diff --git a/.github/workflows/fe-e2e-tests.yml b/.github/workflows/fe-e2e-tests.yml new file mode 100644 index 0000000000..7ee79e63fc --- /dev/null +++ b/.github/workflows/fe-e2e-tests.yml @@ -0,0 +1,47 @@ +# Workflow that runs frontend e2e tests with Playwright +name: Run Frontend E2E Tests + +on: + push: + branches: + - main + pull_request: + paths: + - "frontend/**" + - ".github/workflows/fe-e2e-tests.yml" + +concurrency: + group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }} + cancel-in-progress: true + +jobs: + fe-e2e-test: + name: FE E2E Tests + runs-on: blacksmith-4vcpu-ubuntu-2204 + strategy: + matrix: + node-version: [22] + fail-fast: true + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node.js + uses: useblacksmith/setup-node@v5 + with: + node-version: ${{ matrix.node-version }} + - name: Install dependencies + working-directory: ./frontend + run: npm ci + - name: Install Playwright browsers + working-directory: ./frontend + run: npx playwright install --with-deps chromium + - name: Run Playwright tests + working-directory: ./frontend + run: npx playwright test --project=chromium + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 30 diff --git a/.openhands/microagents/repo.md b/.openhands/microagents/repo.md index ceb87bc2f7..cd3ef33074 100644 --- a/.openhands/microagents/repo.md +++ b/.openhands/microagents/repo.md @@ -63,7 +63,7 @@ Frontend: - We use TanStack Query (fka React Query) for data fetching and cache management - Data Access Layer: API client methods are located in `frontend/src/api` and should never be called directly from UI components - they must always be wrapped with TanStack Query - Custom hooks are located in `frontend/src/hooks/query/` and `frontend/src/hooks/mutation/` - - Query hooks should follow the pattern use[Resource] (e.g., `useConversationMicroagents`) + - Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`) - Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`) - Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints diff --git a/Development.md b/Development.md index bfa057efc1..421959a5ec 100644 --- a/Development.md +++ b/Development.md @@ -161,7 +161,7 @@ poetry run pytest ./tests/unit/test_*.py To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image. -Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik` +Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.0-nikolaik` ## Develop inside Docker container diff --git a/README.md b/README.md index 9fabb37a6e..3928ed32d9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@
MIT License - Benchmark Score + Benchmark Score
Check out the documentation Tech Report diff --git a/containers/dev/compose.yml b/containers/dev/compose.yml index c6168b094f..7ff5042081 100644 --- a/containers/dev/compose.yml +++ b/containers/dev/compose.yml @@ -12,7 +12,7 @@ services: - SANDBOX_API_HOSTNAME=host.docker.internal - DOCKER_HOST_ADDR=host.docker.internal # - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.62-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.0-nikolaik} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/docker-compose.yml b/docker-compose.yml index b663324625..d4aef552c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: image: openhands:latest container_name: openhands-app-${DATE:-} environment: - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.62-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:1.0-nikolaik} #- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/enterprise/allhands-realm-github-provider.json.tmpl b/enterprise/allhands-realm-github-provider.json.tmpl index 6cdaa34383..35ff5f0afc 100644 --- a/enterprise/allhands-realm-github-provider.json.tmpl +++ b/enterprise/allhands-realm-github-provider.json.tmpl @@ -721,6 +721,7 @@ "https://$WEB_HOST/oauth/keycloak/callback", "https://$WEB_HOST/oauth/keycloak/offline/callback", "https://$WEB_HOST/slack/keycloak-callback", + "https://$WEB_HOST/oauth/device/keycloak-callback", "https://$WEB_HOST/api/email/verified", "/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*" ], diff --git a/enterprise/integrations/github/github_view.py b/enterprise/integrations/github/github_view.py index 4d15349dce..a01457f88c 100644 --- a/enterprise/integrations/github/github_view.py +++ b/enterprise/integrations/github/github_view.py @@ -13,6 +13,7 @@ from integrations.resolver_context import ResolverUserContext from integrations.types import ResolverViewInterface, UserData from integrations.utils import ( ENABLE_PROACTIVE_CONVERSATION_STARTERS, + ENABLE_V1_GITHUB_RESOLVER, HOST, HOST_URL, get_oh_labels, @@ -95,7 +96,15 @@ async def get_user_v1_enabled_setting(user_id: str) -> bool: Returns: True if V1 conversations are enabled for this user, False otherwise + + Note: + This function checks both the global environment variable kill switch AND + the user's individual setting. Both must be true for the function to return true. """ + # Check the global environment variable first + if not ENABLE_V1_GITHUB_RESOLVER: + return False + config = get_config() settings_store = SaasSettingsStore( user_id=user_id, session_maker=session_maker, config=config @@ -178,6 +187,19 @@ class GithubIssue(ResolverViewInterface): async def initialize_new_conversation(self) -> ConversationMetadata: # FIXME: Handle if initialize_conversation returns None + + v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id) + logger.info( + f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}' + ) + if v1_enabled: + # Create dummy conversationm metadata + # Don't save to conversation store + # V1 conversations are stored in a separate table + return ConversationMetadata( + conversation_id=uuid4().hex, selected_repository=self.full_repo_name + ) + conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment] user_id=self.user_info.keycloak_user_id, conversation_id=None, @@ -223,7 +245,7 @@ class GithubIssue(ResolverViewInterface): conversation_metadata: ConversationMetadata, ): """Create conversation using the legacy V0 system.""" - logger.info('[GitHub V1]: Creating V0 conversation') + logger.info('[GitHub]: Creating V0 conversation') custom_secrets = await self._get_user_secrets() user_instructions, conversation_instructions = await self._get_instructions( @@ -369,7 +391,18 @@ class GithubPRComment(GithubIssueComment): return user_instructions, conversation_instructions async def initialize_new_conversation(self) -> ConversationMetadata: - # FIXME: Handle if initialize_conversation returns None + v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id) + logger.info( + f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}' + ) + if v1_enabled: + # Create dummy conversationm metadata + # Don't save to conversation store + # V1 conversations are stored in a separate table + return ConversationMetadata( + conversation_id=uuid4().hex, selected_repository=self.full_repo_name + ) + conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment] user_id=self.user_info.keycloak_user_id, conversation_id=None, diff --git a/enterprise/integrations/resolver_context.py b/enterprise/integrations/resolver_context.py index 29840ee9c7..5829122e6f 100644 --- a/enterprise/integrations/resolver_context.py +++ b/enterprise/integrations/resolver_context.py @@ -2,6 +2,7 @@ from openhands.app_server.user.user_context import UserContext from openhands.app_server.user.user_models import UserInfo from openhands.integrations.provider import PROVIDER_TOKEN_TYPE from openhands.integrations.service_types import ProviderType +from openhands.sdk.secret import SecretSource, StaticSecret from openhands.server.user_auth.user_auth import UserAuth @@ -44,11 +45,18 @@ class ResolverUserContext(UserContext): async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None: return await self.saas_user_auth.get_provider_tokens() - async def get_secrets(self) -> dict[str, str]: + async def get_secrets(self) -> dict[str, SecretSource]: """Get secrets for the user, including custom secrets.""" secrets = await self.saas_user_auth.get_secrets() if secrets: - return dict(secrets.custom_secrets) + # Convert custom secrets to StaticSecret objects for SDK compatibility + # secrets.custom_secrets is of type Mapping[str, CustomSecret] + converted_secrets = {} + for key, custom_secret in secrets.custom_secrets.items(): + # Extract the secret value from CustomSecret and convert to StaticSecret + secret_value = custom_secret.secret.get_secret_value() + converted_secrets[key] = StaticSecret(value=secret_value) + return converted_secrets return {} async def get_mcp_api_key(self) -> str | None: diff --git a/enterprise/integrations/utils.py b/enterprise/integrations/utils.py index ffe4f81360..a4721d9ab2 100644 --- a/enterprise/integrations/utils.py +++ b/enterprise/integrations/utils.py @@ -51,6 +51,11 @@ ENABLE_SOLVABILITY_ANALYSIS = ( os.getenv('ENABLE_SOLVABILITY_ANALYSIS', 'false').lower() == 'true' ) +# Toggle for V1 GitHub resolver feature +ENABLE_V1_GITHUB_RESOLVER = ( + os.getenv('ENABLE_V1_GITHUB_RESOLVER', 'false').lower() == 'true' +) + OPENHANDS_RESOLVER_TEMPLATES_DIR = 'openhands/integrations/templates/resolver/' jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR)) diff --git a/enterprise/migrations/versions/084_create_device_codes_table.py b/enterprise/migrations/versions/084_create_device_codes_table.py new file mode 100644 index 0000000000..0898e09ef5 --- /dev/null +++ b/enterprise/migrations/versions/084_create_device_codes_table.py @@ -0,0 +1,49 @@ +"""Create device_codes table for OAuth 2.0 Device Flow + +Revision ID: 084 +Revises: 083 +Create Date: 2024-12-10 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '084' +down_revision = '083' +branch_labels = None +depends_on = None + + +def upgrade(): + """Create device_codes table for OAuth 2.0 Device Flow.""" + op.create_table( + 'device_codes', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('device_code', sa.String(length=128), nullable=False), + sa.Column('user_code', sa.String(length=16), nullable=False), + sa.Column('status', sa.String(length=32), nullable=False), + sa.Column('keycloak_user_id', sa.String(length=255), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('authorized_at', sa.DateTime(timezone=True), nullable=True), + # Rate limiting fields for RFC 8628 section 3.5 compliance + sa.Column('last_poll_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('current_interval', sa.Integer(), nullable=False, default=5), + sa.PrimaryKeyConstraint('id'), + ) + + # Create indexes for efficient lookups + op.create_index( + 'ix_device_codes_device_code', 'device_codes', ['device_code'], unique=True + ) + op.create_index( + 'ix_device_codes_user_code', 'device_codes', ['user_code'], unique=True + ) + + +def downgrade(): + """Drop device_codes table.""" + op.drop_index('ix_device_codes_user_code', table_name='device_codes') + op.drop_index('ix_device_codes_device_code', table_name='device_codes') + op.drop_table('device_codes') diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index ec6552085d..bd2c55c317 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -4624,14 +4624,14 @@ files = [ [[package]] name = "lmnr" -version = "0.7.20" +version = "0.7.24" description = "Python SDK for Laminar" optional = false python-versions = "<4,>=3.10" groups = ["main"] files = [ - {file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"}, - {file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"}, + {file = "lmnr-0.7.24-py3-none-any.whl", hash = "sha256:ad780d4a62ece897048811f3368639c240a9329ab31027da8c96545137a3a08a"}, + {file = "lmnr-0.7.24.tar.gz", hash = "sha256:aa6973f46fc4ba95c9061c1feceb58afc02eb43c9376c21e32545371ff6123d7"}, ] [package.dependencies] @@ -4654,14 +4654,15 @@ tqdm = ">=4.0" [package.extras] alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"] -all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"] +all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"] bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"] chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"] +claude-agent-sdk = ["lmnr-claude-code-proxy (>=0.1.0a5)"] cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"] crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"] haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"] lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"] -langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"] +langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)"] llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"] marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"] mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"] @@ -5835,14 +5836,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0 [[package]] name = "openhands-agent-server" -version = "1.4.1" +version = "1.6.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_agent_server-1.4.1-py3-none-any.whl", hash = "sha256:1e621d15215a48e2398e23c58a791347f06c215c2344053aeb26b562c34a44ee"}, - {file = "openhands_agent_server-1.4.1.tar.gz", hash = "sha256:03010a5c8d63bbd5b088458eb75308ef16559018140d75a3644ae5bbc3531bbf"}, + {file = "openhands_agent_server-1.6.0-py3-none-any.whl", hash = "sha256:e6ae865ac3e7a96b234e10a0faad23f6210e025bbf7721cb66bc7a71d160848c"}, + {file = "openhands_agent_server-1.6.0.tar.gz", hash = "sha256:44ce7694ae2d4bb0666d318ef13e6618bd4dc73022c60354839fe6130e67d02a"}, ] [package.dependencies] @@ -5859,7 +5860,7 @@ wsproto = ">=1.2.0" [[package]] name = "openhands-ai" -version = "0.0.0-post.5625+0a98f165e" +version = "0.0.0-post.5687+7853b41ad" description = "OpenHands: Code Less, Make More" optional = false python-versions = "^3.12,<3.14" @@ -5901,9 +5902,9 @@ memory-profiler = "^0.61.0" numpy = "*" openai = "2.8.0" openhands-aci = "0.3.2" -openhands-agent-server = "1.4.1" -openhands-sdk = "1.4.1" -openhands-tools = "1.4.1" +openhands-agent-server = "1.6.0" +openhands-sdk = "1.6.0" +openhands-tools = "1.6.0" opentelemetry-api = "^1.33.1" opentelemetry-exporter-otlp-proto-grpc = "^1.33.1" pathspec = "^0.12.1" @@ -5959,14 +5960,14 @@ url = ".." [[package]] name = "openhands-sdk" -version = "1.4.1" +version = "1.6.0" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_sdk-1.4.1-py3-none-any.whl", hash = "sha256:70e453eab7f9ab6b705198c2615fdd844b21e14b29d78afaf62724f4a440bcdc"}, - {file = "openhands_sdk-1.4.1.tar.gz", hash = "sha256:37365de25ed57cf8cc2a8003ab4d7a1fe2a40b49c8e8da84a3f1ea2b522eddf2"}, + {file = "openhands_sdk-1.6.0-py3-none-any.whl", hash = "sha256:94d2f87fb35406373da6728ae2d88584137f9e9b67fa0e940444c72f2e44e7d3"}, + {file = "openhands_sdk-1.6.0.tar.gz", hash = "sha256:f45742350e3874a7f5b08befc4a9d5adc7e4454f7ab5f8391c519eee3116090f"}, ] [package.dependencies] @@ -5974,7 +5975,7 @@ deprecation = ">=2.1.0" fastmcp = ">=2.11.3" httpx = ">=0.27.0" litellm = ">=1.80.7" -lmnr = ">=0.7.20" +lmnr = ">=0.7.24" pydantic = ">=2.11.7" python-frontmatter = ">=1.1.0" python-json-logger = ">=3.3.0" @@ -5986,14 +5987,14 @@ boto3 = ["boto3 (>=1.35.0)"] [[package]] name = "openhands-tools" -version = "1.4.1" +version = "1.6.0" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_tools-1.4.1-py3-none-any.whl", hash = "sha256:8f40189a08bf80eb4a33219ee9ccc528f9c6c4f2d5c9ab807b06c3f3fe21a612"}, - {file = "openhands_tools-1.4.1.tar.gz", hash = "sha256:4c0caf87f520a207d9035191c77b7b5c53eeec996350a24ffaf7f740a6566b22"}, + {file = "openhands_tools-1.6.0-py3-none-any.whl", hash = "sha256:176556d44186536751b23fe052d3505492cc2afb8d52db20fb7a2cc0169cd57a"}, + {file = "openhands_tools-1.6.0.tar.gz", hash = "sha256:d07ba31050fd4a7891a4c48388aa53ce9f703e17064ddbd59146d6c77e5980b3"}, ] [package.dependencies] diff --git a/enterprise/saas_server.py b/enterprise/saas_server.py index 4c3c7c49ba..96e19a9815 100644 --- a/enterprise/saas_server.py +++ b/enterprise/saas_server.py @@ -34,6 +34,7 @@ from server.routes.integration.jira_dc import jira_dc_integration_router # noqa from server.routes.integration.linear import linear_integration_router # noqa: E402 from server.routes.integration.slack import slack_router # noqa: E402 from server.routes.mcp_patch import patch_mcp_server # noqa: E402 +from server.routes.oauth_device import oauth_device_router # noqa: E402 from server.routes.readiness import readiness_router # noqa: E402 from server.routes.user import saas_user_router # noqa: E402 @@ -60,6 +61,7 @@ base_app.mount('/internal/metrics', metrics_app()) base_app.include_router(readiness_router) # Add routes for readiness checks base_app.include_router(api_router) # Add additional route for github auth base_app.include_router(oauth_router) # Add additional route for oauth callback +base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes base_app.include_router(saas_user_router) # Add additional route SAAS user calls base_app.include_router( billing_router diff --git a/enterprise/server/legacy_conversation_manager.py b/enterprise/server/legacy_conversation_manager.py deleted file mode 100644 index 5c82b5b420..0000000000 --- a/enterprise/server/legacy_conversation_manager.py +++ /dev/null @@ -1,331 +0,0 @@ -from __future__ import annotations - -import time -from dataclasses import dataclass, field - -import socketio -from server.clustered_conversation_manager import ClusteredConversationManager -from server.saas_nested_conversation_manager import SaasNestedConversationManager - -from openhands.core.config import LLMConfig, OpenHandsConfig -from openhands.events.action import MessageAction -from openhands.server.config.server_config import ServerConfig -from openhands.server.conversation_manager.conversation_manager import ( - ConversationManager, -) -from openhands.server.data_models.agent_loop_info import AgentLoopInfo -from openhands.server.monitoring import MonitoringListener -from openhands.server.session.conversation import ServerConversation -from openhands.storage.data_models.settings import Settings -from openhands.storage.files import FileStore -from openhands.utils.async_utils import wait_all - -_LEGACY_ENTRY_TIMEOUT_SECONDS = 3600 - - -@dataclass -class LegacyCacheEntry: - """Cache entry for legacy mode status.""" - - is_legacy: bool - timestamp: float - - -@dataclass -class LegacyConversationManager(ConversationManager): - """ - Conversation manager for use while migrating - since existing conversations are not nested! - Separate class from SaasNestedConversationManager so it can be easliy removed in a few weeks. - (As of 2025-07-23) - """ - - sio: socketio.AsyncServer - config: OpenHandsConfig - server_config: ServerConfig - file_store: FileStore - conversation_manager: SaasNestedConversationManager - legacy_conversation_manager: ClusteredConversationManager - _legacy_cache: dict[str, LegacyCacheEntry] = field(default_factory=dict) - - async def __aenter__(self): - await wait_all( - [ - self.conversation_manager.__aenter__(), - self.legacy_conversation_manager.__aenter__(), - ] - ) - return self - - async def __aexit__(self, exc_type, exc_value, traceback): - await wait_all( - [ - self.conversation_manager.__aexit__(exc_type, exc_value, traceback), - self.legacy_conversation_manager.__aexit__( - exc_type, exc_value, traceback - ), - ] - ) - - async def request_llm_completion( - self, - sid: str, - service_id: str, - llm_config: LLMConfig, - messages: list[dict[str, str]], - ) -> str: - session = self.get_agent_session(sid) - llm_registry = session.llm_registry - return llm_registry.request_extraneous_completion( - service_id, llm_config, messages - ) - - async def attach_to_conversation( - self, sid: str, user_id: str | None = None - ) -> ServerConversation | None: - if await self.should_start_in_legacy_mode(sid): - return await self.legacy_conversation_manager.attach_to_conversation( - sid, user_id - ) - return await self.conversation_manager.attach_to_conversation(sid, user_id) - - async def detach_from_conversation(self, conversation: ServerConversation): - if await self.should_start_in_legacy_mode(conversation.sid): - return await self.legacy_conversation_manager.detach_from_conversation( - conversation - ) - return await self.conversation_manager.detach_from_conversation(conversation) - - async def join_conversation( - self, - sid: str, - connection_id: str, - settings: Settings, - user_id: str | None, - ) -> AgentLoopInfo: - if await self.should_start_in_legacy_mode(sid): - return await self.legacy_conversation_manager.join_conversation( - sid, connection_id, settings, user_id - ) - return await self.conversation_manager.join_conversation( - sid, connection_id, settings, user_id - ) - - def get_agent_session(self, sid: str): - session = self.legacy_conversation_manager.get_agent_session(sid) - if session is None: - session = self.conversation_manager.get_agent_session(sid) - return session - - async def get_running_agent_loops( - self, user_id: str | None = None, filter_to_sids: set[str] | None = None - ) -> set[str]: - if filter_to_sids and len(filter_to_sids) == 1: - sid = next(iter(filter_to_sids)) - if await self.should_start_in_legacy_mode(sid): - return await self.legacy_conversation_manager.get_running_agent_loops( - user_id, filter_to_sids - ) - return await self.conversation_manager.get_running_agent_loops( - user_id, filter_to_sids - ) - - # Get all running agent loops from both managers - agent_loops, legacy_agent_loops = await wait_all( - [ - self.conversation_manager.get_running_agent_loops( - user_id, filter_to_sids - ), - self.legacy_conversation_manager.get_running_agent_loops( - user_id, filter_to_sids - ), - ] - ) - - # Combine the results - result = set() - for sid in legacy_agent_loops: - if await self.should_start_in_legacy_mode(sid): - result.add(sid) - - for sid in agent_loops: - if not await self.should_start_in_legacy_mode(sid): - result.add(sid) - - return result - - async def is_agent_loop_running(self, sid: str) -> bool: - return bool(await self.get_running_agent_loops(filter_to_sids={sid})) - - async def get_connections( - self, user_id: str | None = None, filter_to_sids: set[str] | None = None - ) -> dict[str, str]: - if filter_to_sids and len(filter_to_sids) == 1: - sid = next(iter(filter_to_sids)) - if await self.should_start_in_legacy_mode(sid): - return await self.legacy_conversation_manager.get_connections( - user_id, filter_to_sids - ) - return await self.conversation_manager.get_connections( - user_id, filter_to_sids - ) - agent_loops, legacy_agent_loops = await wait_all( - [ - self.conversation_manager.get_connections(user_id, filter_to_sids), - self.legacy_conversation_manager.get_connections( - user_id, filter_to_sids - ), - ] - ) - legacy_agent_loops.update(agent_loops) - return legacy_agent_loops - - async def maybe_start_agent_loop( - self, - sid: str, - settings: Settings, - user_id: str, # type: ignore[override] - initial_user_msg: MessageAction | None = None, - replay_json: str | None = None, - ) -> AgentLoopInfo: - if await self.should_start_in_legacy_mode(sid): - return await self.legacy_conversation_manager.maybe_start_agent_loop( - sid, settings, user_id, initial_user_msg, replay_json - ) - return await self.conversation_manager.maybe_start_agent_loop( - sid, settings, user_id, initial_user_msg, replay_json - ) - - async def send_to_event_stream(self, connection_id: str, data: dict): - return await self.legacy_conversation_manager.send_to_event_stream( - connection_id, data - ) - - async def send_event_to_conversation(self, sid: str, data: dict): - if await self.should_start_in_legacy_mode(sid): - await self.legacy_conversation_manager.send_event_to_conversation(sid, data) - await self.conversation_manager.send_event_to_conversation(sid, data) - - async def disconnect_from_session(self, connection_id: str): - return await self.legacy_conversation_manager.disconnect_from_session( - connection_id - ) - - async def close_session(self, sid: str): - if await self.should_start_in_legacy_mode(sid): - await self.legacy_conversation_manager.close_session(sid) - await self.conversation_manager.close_session(sid) - - async def get_agent_loop_info( - self, user_id: str | None = None, filter_to_sids: set[str] | None = None - ) -> list[AgentLoopInfo]: - if filter_to_sids and len(filter_to_sids) == 1: - sid = next(iter(filter_to_sids)) - if await self.should_start_in_legacy_mode(sid): - return await self.legacy_conversation_manager.get_agent_loop_info( - user_id, filter_to_sids - ) - return await self.conversation_manager.get_agent_loop_info( - user_id, filter_to_sids - ) - agent_loops, legacy_agent_loops = await wait_all( - [ - self.conversation_manager.get_agent_loop_info(user_id, filter_to_sids), - self.legacy_conversation_manager.get_agent_loop_info( - user_id, filter_to_sids - ), - ] - ) - - # Combine results - result = [] - legacy_sids = set() - - # Add legacy agent loops - for agent_loop in legacy_agent_loops: - if await self.should_start_in_legacy_mode(agent_loop.conversation_id): - result.append(agent_loop) - legacy_sids.add(agent_loop.conversation_id) - - # Add non-legacy agent loops - for agent_loop in agent_loops: - if ( - agent_loop.conversation_id not in legacy_sids - and not await self.should_start_in_legacy_mode( - agent_loop.conversation_id - ) - ): - result.append(agent_loop) - - return result - - def _cleanup_expired_cache_entries(self): - """Remove expired entries from the local cache.""" - current_time = time.time() - expired_keys = [ - key - for key, entry in self._legacy_cache.items() - if current_time - entry.timestamp > _LEGACY_ENTRY_TIMEOUT_SECONDS - ] - for key in expired_keys: - del self._legacy_cache[key] - - async def should_start_in_legacy_mode(self, conversation_id: str) -> bool: - """ - Check if a conversation should run in legacy mode by directly checking the runtime. - The /list method does not include stopped conversations even though the PVC for these - may not yet have been deleted, so we need to check /sessions/{session_id} directly. - """ - # Clean up expired entries periodically - self._cleanup_expired_cache_entries() - - # First check the local cache - if conversation_id in self._legacy_cache: - cached_entry = self._legacy_cache[conversation_id] - # Check if the cached value is still valid - if time.time() - cached_entry.timestamp <= _LEGACY_ENTRY_TIMEOUT_SECONDS: - return cached_entry.is_legacy - - # If not in cache or expired, check the runtime directly - runtime = await self.conversation_manager._get_runtime(conversation_id) - is_legacy = self.is_legacy_runtime(runtime) - - # Cache the result with current timestamp - self._legacy_cache[conversation_id] = LegacyCacheEntry(is_legacy, time.time()) - - return is_legacy - - def is_legacy_runtime(self, runtime: dict | None) -> bool: - """ - Determine if a runtime is a legacy runtime based on its command. - - Args: - runtime: The runtime dictionary or None if not found - - Returns: - bool: True if this is a legacy runtime, False otherwise - """ - if runtime is None: - return False - return 'openhands.server' not in runtime['command'] - - @classmethod - def get_instance( - cls, - sio: socketio.AsyncServer, - config: OpenHandsConfig, - file_store: FileStore, - server_config: ServerConfig, - monitoring_listener: MonitoringListener, - ) -> ConversationManager: - return LegacyConversationManager( - sio=sio, - config=config, - server_config=server_config, - file_store=file_store, - conversation_manager=SaasNestedConversationManager.get_instance( - sio, config, file_store, server_config, monitoring_listener - ), - legacy_conversation_manager=ClusteredConversationManager.get_instance( - sio, config, file_store, server_config, monitoring_listener - ), - ) diff --git a/enterprise/server/middleware.py b/enterprise/server/middleware.py index 2972c1ec38..54e3319595 100644 --- a/enterprise/server/middleware.py +++ b/enterprise/server/middleware.py @@ -152,17 +152,22 @@ class SetAuthCookieMiddleware: return False path = request.url.path - is_api_that_should_attach = path.startswith('/api') and path not in ( + ignore_paths = ( '/api/options/config', '/api/keycloak/callback', '/api/billing/success', '/api/billing/cancel', '/api/billing/customer-setup-success', '/api/billing/stripe-webhook', + '/oauth/device/authorize', + '/oauth/device/token', ) + if path in ignore_paths: + return False is_mcp = path.startswith('/mcp') - return is_api_that_should_attach or is_mcp + is_api_route = path.startswith('/api') + return is_api_route or is_mcp async def _logout(self, request: Request): # Log out of keycloak - this prevents issues where you did not log in with the idp you believe you used diff --git a/enterprise/server/routes/integration/github.py b/enterprise/server/routes/integration/github.py index d7bf857a3f..204b8297f7 100644 --- a/enterprise/server/routes/integration/github.py +++ b/enterprise/server/routes/integration/github.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import hmac import os @@ -58,7 +59,8 @@ async def github_events( ) try: - payload = await request.body() + # Add timeout to prevent hanging on slow/stalled clients + payload = await asyncio.wait_for(request.body(), timeout=15.0) verify_github_signature(payload, x_hub_signature_256) payload_data = await request.json() @@ -78,6 +80,12 @@ async def github_events( status_code=200, content={'message': 'GitHub events endpoint reached successfully.'}, ) + except asyncio.TimeoutError: + logger.warning('GitHub webhook request timed out waiting for request body') + return JSONResponse( + status_code=408, + content={'error': 'Request timeout - client took too long to send data.'}, + ) except Exception as e: logger.exception(f'Error processing GitHub event: {e}') return JSONResponse(status_code=400, content={'error': 'Invalid payload.'}) diff --git a/enterprise/server/routes/oauth_device.py b/enterprise/server/routes/oauth_device.py new file mode 100644 index 0000000000..39ff9a4081 --- /dev/null +++ b/enterprise/server/routes/oauth_device.py @@ -0,0 +1,324 @@ +"""OAuth 2.0 Device Flow endpoints for CLI authentication.""" + +from datetime import UTC, datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, Form, HTTPException, Request, status +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from storage.api_key_store import ApiKeyStore +from storage.database import session_maker +from storage.device_code_store import DeviceCodeStore + +from openhands.core.logger import openhands_logger as logger +from openhands.server.user_auth import get_user_id + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEVICE_CODE_EXPIRES_IN = 600 # 10 minutes +DEVICE_TOKEN_POLL_INTERVAL = 5 # seconds + +API_KEY_NAME = 'Device Link Access Key' +KEY_EXPIRATION_TIME = timedelta(days=1) # Key expires in 24 hours + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +class DeviceAuthorizationResponse(BaseModel): + device_code: str + user_code: str + verification_uri: str + verification_uri_complete: str + expires_in: int + interval: int + + +class DeviceTokenResponse(BaseModel): + access_token: str # This will be the user's API key + token_type: str = 'Bearer' + expires_in: Optional[int] = None # API keys may not have expiration + + +class DeviceTokenErrorResponse(BaseModel): + error: str + error_description: Optional[str] = None + interval: Optional[int] = None # Required for slow_down error + + +# --------------------------------------------------------------------------- +# Router + stores +# --------------------------------------------------------------------------- + +oauth_device_router = APIRouter(prefix='/oauth/device') +device_code_store = DeviceCodeStore(session_maker) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _oauth_error( + status_code: int, + error: str, + description: str, + interval: Optional[int] = None, +) -> JSONResponse: + """Return a JSON OAuth-style error response.""" + return JSONResponse( + status_code=status_code, + content=DeviceTokenErrorResponse( + error=error, + error_description=description, + interval=interval, + ).model_dump(), + ) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@oauth_device_router.post('/authorize', response_model=DeviceAuthorizationResponse) +async def device_authorization( + http_request: Request, +) -> DeviceAuthorizationResponse: + """Start device flow by generating device and user codes.""" + try: + device_code_entry = device_code_store.create_device_code( + expires_in=DEVICE_CODE_EXPIRES_IN, + ) + + base_url = str(http_request.base_url).rstrip('/') + verification_uri = f'{base_url}/oauth/device/verify' + verification_uri_complete = ( + f'{verification_uri}?user_code={device_code_entry.user_code}' + ) + + logger.info( + 'Device authorization initiated', + extra={'user_code': device_code_entry.user_code}, + ) + + return DeviceAuthorizationResponse( + device_code=device_code_entry.device_code, + user_code=device_code_entry.user_code, + verification_uri=verification_uri, + verification_uri_complete=verification_uri_complete, + expires_in=DEVICE_CODE_EXPIRES_IN, + interval=device_code_entry.current_interval, + ) + except Exception as e: + logger.exception('Error in device authorization: %s', str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Internal server error', + ) from e + + +@oauth_device_router.post('/token') +async def device_token(device_code: str = Form(...)): + """Poll for a token until the user authorizes or the code expires.""" + try: + device_code_entry = device_code_store.get_by_device_code(device_code) + + if not device_code_entry: + return _oauth_error( + status.HTTP_400_BAD_REQUEST, + 'invalid_grant', + 'Invalid device code', + ) + + # Check rate limiting (RFC 8628 section 3.5) + is_too_fast, current_interval = device_code_entry.check_rate_limit() + if is_too_fast: + # Update poll time and increase interval + device_code_store.update_poll_time(device_code, increase_interval=True) + logger.warning( + 'Client polling too fast, returning slow_down error', + extra={ + 'device_code': device_code[:8] + '...', # Log partial for privacy + 'new_interval': current_interval, + }, + ) + return _oauth_error( + status.HTTP_400_BAD_REQUEST, + 'slow_down', + f'Polling too frequently. Wait at least {current_interval} seconds between requests.', + interval=current_interval, + ) + + # Update poll time for successful rate limit check + device_code_store.update_poll_time(device_code, increase_interval=False) + + if device_code_entry.is_expired(): + return _oauth_error( + status.HTTP_400_BAD_REQUEST, + 'expired_token', + 'Device code has expired', + ) + + if device_code_entry.status == 'denied': + return _oauth_error( + status.HTTP_400_BAD_REQUEST, + 'access_denied', + 'User denied the authorization request', + ) + + if device_code_entry.status == 'pending': + return _oauth_error( + status.HTTP_400_BAD_REQUEST, + 'authorization_pending', + 'User has not yet completed authorization', + ) + + if device_code_entry.status == 'authorized': + # Retrieve the specific API key for this device using the user_code + api_key_store = ApiKeyStore.get_instance() + device_key_name = f'{API_KEY_NAME} ({device_code_entry.user_code})' + device_api_key = api_key_store.retrieve_api_key_by_name( + device_code_entry.keycloak_user_id, device_key_name + ) + + if not device_api_key: + logger.error( + 'No device API key found for authorized device', + extra={ + 'user_id': device_code_entry.keycloak_user_id, + 'user_code': device_code_entry.user_code, + }, + ) + return _oauth_error( + status.HTTP_500_INTERNAL_SERVER_ERROR, + 'server_error', + 'API key not found', + ) + + # Return the API key as access_token + return DeviceTokenResponse( + access_token=device_api_key, + ) + + # Fallback for unexpected status values + logger.error( + 'Unknown device code status', + extra={'status': device_code_entry.status}, + ) + return _oauth_error( + status.HTTP_500_INTERNAL_SERVER_ERROR, + 'server_error', + 'Unknown device code status', + ) + + except Exception as e: + logger.exception('Error in device token: %s', str(e)) + return _oauth_error( + status.HTTP_500_INTERNAL_SERVER_ERROR, + 'server_error', + 'Internal server error', + ) + + +@oauth_device_router.post('/verify-authenticated') +async def device_verification_authenticated( + user_code: str = Form(...), + user_id: str = Depends(get_user_id), +): + """Process device verification for authenticated users (called by frontend).""" + try: + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Authentication required', + ) + + # Validate device code + device_code_entry = device_code_store.get_by_user_code(user_code) + if not device_code_entry: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='The device code is invalid or has expired.', + ) + + if not device_code_entry.is_pending(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='This device code has already been processed.', + ) + + # First, authorize the device code + success = device_code_store.authorize_device_code( + user_code=user_code, + user_id=user_id, + ) + + if not success: + logger.error( + 'Failed to authorize device code', + extra={'user_code': user_code, 'user_id': user_id}, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to authorize the device. Please try again.', + ) + + # Only create API key AFTER successful authorization + api_key_store = ApiKeyStore.get_instance() + try: + # Create a unique API key for this device using user_code in the name + device_key_name = f'{API_KEY_NAME} ({user_code})' + api_key_store.create_api_key( + user_id, + name=device_key_name, + expires_at=datetime.now(UTC) + KEY_EXPIRATION_TIME, + ) + logger.info( + 'Created new device API key for user after successful authorization', + extra={'user_id': user_id, 'user_code': user_code}, + ) + except Exception as e: + logger.exception( + 'Failed to create device API key after authorization: %s', str(e) + ) + + # Clean up: revert the device authorization since API key creation failed + # This prevents the device from being in an authorized state without an API key + try: + device_code_store.deny_device_code(user_code) + logger.info( + 'Reverted device authorization due to API key creation failure', + extra={'user_code': user_code, 'user_id': user_id}, + ) + except Exception as cleanup_error: + logger.exception( + 'Failed to revert device authorization during cleanup: %s', + str(cleanup_error), + ) + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create API key for device access.', + ) + + logger.info( + 'Device code authorized with API key successfully', + extra={'user_code': user_code, 'user_id': user_id}, + ) + return JSONResponse( + status_code=status.HTTP_200_OK, + content={'message': 'Device authorized successfully!'}, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception('Error in device verification: %s', str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='An unexpected error occurred. Please try again.', + ) diff --git a/enterprise/server/saas_nested_conversation_manager.py b/enterprise/server/saas_nested_conversation_manager.py index 0c5ece2675..d92e67b9ff 100644 --- a/enterprise/server/saas_nested_conversation_manager.py +++ b/enterprise/server/saas_nested_conversation_manager.py @@ -31,6 +31,7 @@ from openhands.events.event_store import EventStore from openhands.events.serialization.event import event_to_dict from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime +from openhands.runtime.plugins.vscode import VSCodeRequirement from openhands.runtime.runtime_status import RuntimeStatus from openhands.server.config.server_config import ServerConfig from openhands.server.constants import ROOM_KEY @@ -71,10 +72,13 @@ RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + ( ) RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME') + SU_TO_USER = os.getenv('SU_TO_USER', 'false') truthy = {'1', 'true', 't', 'yes', 'y', 'on'} SU_TO_USER = str(SU_TO_USER.lower() in truthy).lower() +DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true' + # Time in seconds before a Redis entry is considered expired if not refreshed _REDIS_ENTRY_TIMEOUT_SECONDS = 300 @@ -799,6 +803,7 @@ class SaasNestedConversationManager(ConversationManager): env_vars['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1' env_vars['ENABLE_V1'] = '0' env_vars['SU_TO_USER'] = SU_TO_USER + env_vars['DISABLE_VSCODE_PLUGIN'] = str(DISABLE_VSCODE_PLUGIN).lower() # We need this for LLM traces tracking to identify the source of the LLM calls env_vars['WEB_HOST'] = WEB_HOST @@ -814,11 +819,18 @@ class SaasNestedConversationManager(ConversationManager): if self._runtime_container_image: config.sandbox.runtime_container_image = self._runtime_container_image + plugins = [ + plugin + for plugin in agent.sandbox_plugins + if not (DISABLE_VSCODE_PLUGIN and isinstance(plugin, VSCodeRequirement)) + ] + logger.info(f'Loaded plugins for runtime {sid}: {plugins}') + runtime = RemoteRuntime( config=config, event_stream=None, # type: ignore[arg-type] sid=sid, - plugins=agent.sandbox_plugins, + plugins=plugins, # env_vars=env_vars, # status_callback: Callable[..., None] | None = None, attach_to_existing=False, diff --git a/enterprise/storage/api_key_store.py b/enterprise/storage/api_key_store.py index 162ed415c1..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 @@ -57,9 +60,15 @@ class ApiKeyStore: return None # Check if the key has expired - if key_record.expires_at and key_record.expires_at < now: - logger.info(f'API key has expired: {key_record.id}') - return None + if key_record.expires_at: + # Handle timezone-naive datetime from database by assuming it's UTC + expires_at = key_record.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=UTC) + + if expires_at < now: + logger.info(f'API key has expired: {key_record.id}') + return None # Update last_used_at timestamp session.execute( @@ -125,6 +134,33 @@ class ApiKeyStore: return None + def retrieve_api_key_by_name(self, user_id: str, name: str) -> str | None: + """Retrieve an API key by name for a specific user.""" + with self.session_maker() as session: + key_record = ( + session.query(ApiKey) + .filter(ApiKey.user_id == user_id, ApiKey.name == name) + .first() + ) + return key_record.key if key_record else None + + def delete_api_key_by_name(self, user_id: str, name: str) -> bool: + """Delete an API key by name for a specific user.""" + with self.session_maker() as session: + key_record = ( + session.query(ApiKey) + .filter(ApiKey.user_id == user_id, ApiKey.name == name) + .first() + ) + + if not key_record: + return False + + session.delete(key_record) + session.commit() + + return True + @classmethod def get_instance(cls) -> ApiKeyStore: """Get an instance of the ApiKeyStore.""" diff --git a/enterprise/storage/device_code.py b/enterprise/storage/device_code.py new file mode 100644 index 0000000000..47e18b51bc --- /dev/null +++ b/enterprise/storage/device_code.py @@ -0,0 +1,109 @@ +"""Device code storage model for OAuth 2.0 Device Flow.""" + +from datetime import datetime, timezone +from enum import Enum + +from sqlalchemy import Column, DateTime, Integer, String +from storage.base import Base + + +class DeviceCodeStatus(Enum): + """Status of a device code authorization request.""" + + PENDING = 'pending' + AUTHORIZED = 'authorized' + EXPIRED = 'expired' + DENIED = 'denied' + + +class DeviceCode(Base): + """Device code for OAuth 2.0 Device Flow. + + This stores the device codes issued during the device authorization flow, + along with their status and associated user information once authorized. + """ + + __tablename__ = 'device_codes' + + id = Column(Integer, primary_key=True, autoincrement=True) + device_code = Column(String(128), unique=True, nullable=False, index=True) + user_code = Column(String(16), unique=True, nullable=False, index=True) + status = Column(String(32), nullable=False, default=DeviceCodeStatus.PENDING.value) + + # Keycloak user ID who authorized the device (set during verification) + keycloak_user_id = Column(String(255), nullable=True) + + # Timestamps + expires_at = Column(DateTime(timezone=True), nullable=False) + authorized_at = Column(DateTime(timezone=True), nullable=True) + + # Rate limiting fields for RFC 8628 section 3.5 compliance + last_poll_time = Column(DateTime(timezone=True), nullable=True) + current_interval = Column(Integer, nullable=False, default=5) + + def __repr__(self) -> str: + return f"" + + def is_expired(self) -> bool: + """Check if the device code has expired.""" + now = datetime.now(timezone.utc) + return now > self.expires_at + + def is_pending(self) -> bool: + """Check if the device code is still pending authorization.""" + return self.status == DeviceCodeStatus.PENDING.value and not self.is_expired() + + def is_authorized(self) -> bool: + """Check if the device code has been authorized.""" + return self.status == DeviceCodeStatus.AUTHORIZED.value + + def authorize(self, user_id: str) -> None: + """Mark the device code as authorized.""" + self.status = DeviceCodeStatus.AUTHORIZED.value + self.keycloak_user_id = user_id # Set the Keycloak user ID during authorization + self.authorized_at = datetime.now(timezone.utc) + + def deny(self) -> None: + """Mark the device code as denied.""" + self.status = DeviceCodeStatus.DENIED.value + + def expire(self) -> None: + """Mark the device code as expired.""" + self.status = DeviceCodeStatus.EXPIRED.value + + def check_rate_limit(self) -> tuple[bool, int]: + """Check if the client is polling too fast. + + Returns: + tuple: (is_too_fast, current_interval) + - is_too_fast: True if client should receive slow_down error + - current_interval: Current polling interval to use + """ + now = datetime.now(timezone.utc) + + # If this is the first poll, allow it + if self.last_poll_time is None: + return False, self.current_interval + + # Calculate time since last poll + time_since_last_poll = (now - self.last_poll_time).total_seconds() + + # Check if polling too fast + if time_since_last_poll < self.current_interval: + # Increase interval for slow_down (RFC 8628 section 3.5) + new_interval = min(self.current_interval + 5, 60) # Cap at 60 seconds + return True, new_interval + + return False, self.current_interval + + def update_poll_time(self, increase_interval: bool = False) -> None: + """Update the last poll time and optionally increase the interval. + + Args: + increase_interval: If True, increase the current interval for slow_down + """ + self.last_poll_time = datetime.now(timezone.utc) + + if increase_interval: + # Increase interval by 5 seconds, cap at 60 seconds (RFC 8628) + self.current_interval = min(self.current_interval + 5, 60) diff --git a/enterprise/storage/device_code_store.py b/enterprise/storage/device_code_store.py new file mode 100644 index 0000000000..de2fe29cc4 --- /dev/null +++ b/enterprise/storage/device_code_store.py @@ -0,0 +1,167 @@ +"""Device code store for OAuth 2.0 Device Flow.""" + +import secrets +import string +from datetime import datetime, timedelta, timezone + +from sqlalchemy.exc import IntegrityError +from storage.device_code import DeviceCode + + +class DeviceCodeStore: + """Store for managing OAuth 2.0 device codes.""" + + def __init__(self, session_maker): + self.session_maker = session_maker + + def generate_user_code(self) -> str: + """Generate a human-readable user code (8 characters, uppercase letters and digits).""" + # Use a mix of uppercase letters and digits, avoiding confusing characters + alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' # No I, O, 0, 1 + return ''.join(secrets.choice(alphabet) for _ in range(8)) + + def generate_device_code(self) -> str: + """Generate a secure device code (128 characters).""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(128)) + + def create_device_code( + self, + expires_in: int = 600, # 10 minutes default + max_attempts: int = 10, + ) -> DeviceCode: + """Create a new device code entry. + + Uses database constraints to ensure uniqueness, avoiding TOCTOU race conditions. + Retries on constraint violations until unique codes are generated. + + Args: + expires_in: Expiration time in seconds + max_attempts: Maximum number of attempts to generate unique codes + + Returns: + The created DeviceCode instance + + Raises: + RuntimeError: If unable to generate unique codes after max_attempts + """ + for attempt in range(max_attempts): + user_code = self.generate_user_code() + device_code = self.generate_device_code() + expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in) + + device_code_entry = DeviceCode( + device_code=device_code, + user_code=user_code, + keycloak_user_id=None, # Will be set during authorization + expires_at=expires_at, + ) + + try: + with self.session_maker() as session: + session.add(device_code_entry) + session.commit() + session.refresh(device_code_entry) + session.expunge(device_code_entry) # Detach from session cleanly + return device_code_entry + except IntegrityError: + # Constraint violation - codes already exist, retry with new codes + continue + + raise RuntimeError( + f'Failed to generate unique device codes after {max_attempts} attempts' + ) + + def get_by_device_code(self, device_code: str) -> DeviceCode | None: + """Get device code entry by device code.""" + with self.session_maker() as session: + result = ( + session.query(DeviceCode).filter_by(device_code=device_code).first() + ) + if result: + session.expunge(result) # Detach from session cleanly + return result + + def get_by_user_code(self, user_code: str) -> DeviceCode | None: + """Get device code entry by user code.""" + with self.session_maker() as session: + result = session.query(DeviceCode).filter_by(user_code=user_code).first() + if result: + session.expunge(result) # Detach from session cleanly + return result + + def authorize_device_code(self, user_code: str, user_id: str) -> bool: + """Authorize a device code. + + Args: + user_code: The user code to authorize + user_id: The user ID from Keycloak + + Returns: + True if authorization was successful, False otherwise + """ + with self.session_maker() as session: + device_code_entry = ( + session.query(DeviceCode).filter_by(user_code=user_code).first() + ) + + if not device_code_entry: + return False + + if not device_code_entry.is_pending(): + return False + + device_code_entry.authorize(user_id) + session.commit() + + return True + + def deny_device_code(self, user_code: str) -> bool: + """Deny a device code authorization. + + Args: + user_code: The user code to deny + + Returns: + True if denial was successful, False otherwise + """ + with self.session_maker() as session: + device_code_entry = ( + session.query(DeviceCode).filter_by(user_code=user_code).first() + ) + + if not device_code_entry: + return False + + if not device_code_entry.is_pending(): + return False + + device_code_entry.deny() + session.commit() + + return True + + def update_poll_time( + self, device_code: str, increase_interval: bool = False + ) -> bool: + """Update the poll time for a device code and optionally increase interval. + + Args: + device_code: The device code to update + increase_interval: If True, increase the polling interval for slow_down + + Returns: + True if update was successful, False otherwise + """ + with self.session_maker() as session: + device_code_entry = ( + session.query(DeviceCode).filter_by(device_code=device_code).first() + ) + + if not device_code_entry: + return False + + device_code_entry.update_poll_time(increase_interval) + session.commit() + + return True diff --git a/enterprise/storage/saas_settings_store.py b/enterprise/storage/saas_settings_store.py index 6cbcb50802..fd64924263 100644 --- a/enterprise/storage/saas_settings_store.py +++ b/enterprise/storage/saas_settings_store.py @@ -94,6 +94,7 @@ class SaasSettingsStore(SettingsStore): } self._decrypt_kwargs(kwargs) settings = Settings(**kwargs) + return settings async def store(self, item: Settings): diff --git a/enterprise/tests/unit/conftest.py b/enterprise/tests/unit/conftest.py index 08516fd813..873f7b775f 100644 --- a/enterprise/tests/unit/conftest.py +++ b/enterprise/tests/unit/conftest.py @@ -12,6 +12,7 @@ from storage.base import Base # Anything not loaded here may not have a table created for it. from storage.billing_session import BillingSession from storage.conversation_work import ConversationWork +from storage.device_code import DeviceCode # noqa: F401 from storage.feedback import Feedback from storage.github_app_installation import GithubAppInstallation from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus diff --git a/enterprise/tests/unit/integrations/test_resolver_context.py b/enterprise/tests/unit/integrations/test_resolver_context.py new file mode 100644 index 0000000000..f1e5f814ba --- /dev/null +++ b/enterprise/tests/unit/integrations/test_resolver_context.py @@ -0,0 +1,133 @@ +"""Test for ResolverUserContext get_secrets conversion logic. + +This test focuses on testing the actual ResolverUserContext implementation. +""" + +from types import MappingProxyType +from unittest.mock import AsyncMock + +import pytest +from pydantic import SecretStr + +from enterprise.integrations.resolver_context import ResolverUserContext + +# Import the real classes we want to test +from openhands.integrations.provider import CustomSecret + +# Import the SDK types we need for testing +from openhands.sdk.secret import SecretSource, StaticSecret +from openhands.storage.data_models.secrets import Secrets + + +@pytest.fixture +def mock_saas_user_auth(): + """Mock SaasUserAuth for testing.""" + return AsyncMock() + + +@pytest.fixture +def resolver_context(mock_saas_user_auth): + """Create a ResolverUserContext instance for testing.""" + return ResolverUserContext(saas_user_auth=mock_saas_user_auth) + + +def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret: + """Helper to create CustomSecret instances.""" + return CustomSecret(secret=SecretStr(value), description=description) + + +def create_secrets(custom_secrets_dict: dict[str, CustomSecret]) -> Secrets: + """Helper to create Secrets instances.""" + return Secrets(custom_secrets=MappingProxyType(custom_secrets_dict)) + + +@pytest.mark.asyncio +async def test_get_secrets_converts_custom_to_static( + resolver_context, mock_saas_user_auth +): + """Test that get_secrets correctly converts CustomSecret objects to StaticSecret objects.""" + # Arrange + secrets = create_secrets( + { + 'TEST_SECRET_1': create_custom_secret('secret_value_1'), + 'TEST_SECRET_2': create_custom_secret('secret_value_2'), + } + ) + mock_saas_user_auth.get_secrets.return_value = secrets + + # Act + result = await resolver_context.get_secrets() + + # Assert + assert len(result) == 2 + assert all(isinstance(secret, StaticSecret) for secret in result.values()) + assert result['TEST_SECRET_1'].value.get_secret_value() == 'secret_value_1' + assert result['TEST_SECRET_2'].value.get_secret_value() == 'secret_value_2' + + +@pytest.mark.asyncio +async def test_get_secrets_with_special_characters( + resolver_context, mock_saas_user_auth +): + """Test that secret values with special characters are preserved during conversion.""" + # Arrange + special_value = 'very_secret_password_123!@#$%^&*()' + secrets = create_secrets({'SPECIAL_SECRET': create_custom_secret(special_value)}) + mock_saas_user_auth.get_secrets.return_value = secrets + + # Act + result = await resolver_context.get_secrets() + + # Assert + assert len(result) == 1 + assert isinstance(result['SPECIAL_SECRET'], StaticSecret) + assert result['SPECIAL_SECRET'].value.get_secret_value() == special_value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'secrets_input,expected_result', + [ + (None, {}), # No secrets available + (create_secrets({}), {}), # Empty custom secrets + ], +) +async def test_get_secrets_empty_cases( + resolver_context, mock_saas_user_auth, secrets_input, expected_result +): + """Test that get_secrets handles empty cases correctly.""" + # Arrange + mock_saas_user_auth.get_secrets.return_value = secrets_input + + # Act + result = await resolver_context.get_secrets() + + # Assert + assert result == expected_result + + +def test_static_secret_is_valid_secret_source(): + """Test that StaticSecret is a valid SecretSource for SDK validation.""" + # Arrange & Act + static_secret = StaticSecret(value='test_secret_123') + + # Assert + assert isinstance(static_secret, StaticSecret) + assert isinstance(static_secret, SecretSource) + assert static_secret.value.get_secret_value() == 'test_secret_123' + + +def test_custom_to_static_conversion(): + """Test the complete conversion flow from CustomSecret to StaticSecret.""" + # Arrange + secret_value = 'conversion_test_secret' + custom_secret = create_custom_secret(secret_value, 'Conversion test') + + # Act - simulate the conversion logic from the actual method + extracted_value = custom_secret.secret.get_secret_value() + static_secret = StaticSecret(value=extracted_value) + + # Assert + assert isinstance(static_secret, StaticSecret) + assert isinstance(static_secret, SecretSource) + assert static_secret.value.get_secret_value() == secret_value diff --git a/enterprise/tests/unit/server/routes/test_oauth_device.py b/enterprise/tests/unit/server/routes/test_oauth_device.py new file mode 100644 index 0000000000..53682e65f0 --- /dev/null +++ b/enterprise/tests/unit/server/routes/test_oauth_device.py @@ -0,0 +1,610 @@ +"""Unit tests for OAuth2 Device Flow endpoints.""" + +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException, Request +from fastapi.responses import JSONResponse +from server.routes.oauth_device import ( + device_authorization, + device_token, + device_verification_authenticated, +) +from storage.device_code import DeviceCode + + +@pytest.fixture +def mock_device_code_store(): + """Mock device code store.""" + return MagicMock() + + +@pytest.fixture +def mock_api_key_store(): + """Mock API key store.""" + return MagicMock() + + +@pytest.fixture +def mock_token_manager(): + """Mock token manager.""" + return MagicMock() + + +@pytest.fixture +def mock_request(): + """Mock FastAPI request.""" + request = MagicMock(spec=Request) + request.base_url = 'https://test.example.com/' + return request + + +class TestDeviceAuthorization: + """Test device authorization endpoint.""" + + @patch('server.routes.oauth_device.device_code_store') + async def test_device_authorization_success(self, mock_store, mock_request): + """Test successful device authorization.""" + mock_device = DeviceCode( + device_code='test-device-code-123', + user_code='ABC12345', + expires_at=datetime.now(UTC) + timedelta(minutes=10), + current_interval=5, # Default interval + ) + mock_store.create_device_code.return_value = mock_device + + result = await device_authorization(mock_request) + + assert result.device_code == 'test-device-code-123' + assert result.user_code == 'ABC12345' + assert result.expires_in == 600 + assert result.interval == 5 # Should match device's current_interval + assert 'verify' in result.verification_uri + assert 'ABC12345' in result.verification_uri_complete + + @patch('server.routes.oauth_device.device_code_store') + async def test_device_authorization_with_increased_interval( + self, mock_store, mock_request + ): + """Test device authorization returns increased interval from rate limiting.""" + mock_device = DeviceCode( + device_code='test-device-code-456', + user_code='XYZ98765', + expires_at=datetime.now(UTC) + timedelta(minutes=10), + current_interval=15, # Increased interval from previous rate limiting + ) + mock_store.create_device_code.return_value = mock_device + + result = await device_authorization(mock_request) + + assert result.device_code == 'test-device-code-456' + assert result.user_code == 'XYZ98765' + assert result.expires_in == 600 + assert result.interval == 15 # Should match device's increased current_interval + assert 'verify' in result.verification_uri + assert 'XYZ98765' in result.verification_uri_complete + + +class TestDeviceToken: + """Test device token endpoint.""" + + @pytest.mark.parametrize( + 'device_exists,status,expected_error', + [ + (False, None, 'invalid_grant'), + (True, 'expired', 'expired_token'), + (True, 'denied', 'access_denied'), + (True, 'pending', 'authorization_pending'), + ], + ) + @patch('server.routes.oauth_device.device_code_store') + async def test_device_token_error_cases( + self, mock_store, device_exists, status, expected_error + ): + """Test various error cases for device token endpoint.""" + device_code = 'test-device-code' + + if device_exists: + mock_device = MagicMock() + mock_device.is_expired.return_value = status == 'expired' + mock_device.status = status + # Mock rate limiting - return False (not too fast) and default interval + mock_device.check_rate_limit.return_value = (False, 5) + mock_store.get_by_device_code.return_value = mock_device + mock_store.update_poll_time.return_value = True + else: + mock_store.get_by_device_code.return_value = None + + result = await device_token(device_code=device_code) + + assert isinstance(result, JSONResponse) + assert result.status_code == 400 + # Check error in response content + content = result.body.decode() + assert expected_error in content + + @patch('server.routes.oauth_device.ApiKeyStore') + @patch('server.routes.oauth_device.device_code_store') + async def test_device_token_success(self, mock_store, mock_api_key_class): + """Test successful device token retrieval.""" + device_code = 'test-device-code' + + # Mock authorized device + mock_device = MagicMock() + mock_device.is_expired.return_value = False + mock_device.status = 'authorized' + mock_device.keycloak_user_id = 'user-123' + mock_device.user_code = ( + 'ABC12345' # Add user_code for device-specific API key lookup + ) + # Mock rate limiting - return False (not too fast) and default interval + mock_device.check_rate_limit.return_value = (False, 5) + mock_store.get_by_device_code.return_value = mock_device + mock_store.update_poll_time.return_value = True + + # Mock API key retrieval + mock_api_key_store = MagicMock() + mock_api_key_store.retrieve_api_key_by_name.return_value = 'test-api-key' + mock_api_key_class.get_instance.return_value = mock_api_key_store + + result = await device_token(device_code=device_code) + + # Check that result is a DeviceTokenResponse + assert result.access_token == 'test-api-key' + assert result.token_type == 'Bearer' + + # Verify that the correct device-specific API key name was used + mock_api_key_store.retrieve_api_key_by_name.assert_called_once_with( + 'user-123', 'Device Link Access Key (ABC12345)' + ) + + +class TestDeviceVerificationAuthenticated: + """Test device verification authenticated endpoint.""" + + async def test_verification_unauthenticated_user(self): + """Test verification with unauthenticated user.""" + with pytest.raises(HTTPException): + await device_verification_authenticated(user_code='ABC12345', user_id=None) + + @patch('server.routes.oauth_device.ApiKeyStore') + @patch('server.routes.oauth_device.device_code_store') + async def test_verification_invalid_device_code( + self, mock_store, mock_api_key_class + ): + """Test verification with invalid device code.""" + mock_store.get_by_user_code.return_value = None + + with pytest.raises(HTTPException): + await device_verification_authenticated( + user_code='INVALID', user_id='user-123' + ) + + @patch('server.routes.oauth_device.ApiKeyStore') + @patch('server.routes.oauth_device.device_code_store') + async def test_verification_already_processed(self, mock_store, mock_api_key_class): + """Test verification with already processed device code.""" + mock_device = MagicMock() + mock_device.is_pending.return_value = False + mock_store.get_by_user_code.return_value = mock_device + + with pytest.raises(HTTPException): + await device_verification_authenticated( + user_code='ABC12345', user_id='user-123' + ) + + @patch('server.routes.oauth_device.ApiKeyStore') + @patch('server.routes.oauth_device.device_code_store') + async def test_verification_success(self, mock_store, mock_api_key_class): + """Test successful device verification.""" + # Mock device code + mock_device = MagicMock() + mock_device.is_pending.return_value = True + mock_store.get_by_user_code.return_value = mock_device + mock_store.authorize_device_code.return_value = True + + # Mock API key store + mock_api_key_store = MagicMock() + mock_api_key_class.get_instance.return_value = mock_api_key_store + + result = await device_verification_authenticated( + user_code='ABC12345', user_id='user-123' + ) + + assert isinstance(result, JSONResponse) + assert result.status_code == 200 + # Should NOT delete existing API keys (multiple devices allowed) + mock_api_key_store.delete_api_key_by_name.assert_not_called() + # Should create a new API key with device-specific name + mock_api_key_store.create_api_key.assert_called_once() + call_args = mock_api_key_store.create_api_key.call_args + assert call_args[1]['name'] == 'Device Link Access Key (ABC12345)' + mock_store.authorize_device_code.assert_called_once_with( + user_code='ABC12345', user_id='user-123' + ) + + @patch('server.routes.oauth_device.ApiKeyStore') + @patch('server.routes.oauth_device.device_code_store') + async def test_multiple_device_authentication(self, mock_store, mock_api_key_class): + """Test that multiple devices can authenticate simultaneously.""" + # Mock API key store + mock_api_key_store = MagicMock() + mock_api_key_class.get_instance.return_value = mock_api_key_store + + # Simulate two different devices + device1_code = 'ABC12345' + device2_code = 'XYZ67890' + user_id = 'user-123' + + # Mock device codes + mock_device1 = MagicMock() + mock_device1.is_pending.return_value = True + mock_device2 = MagicMock() + mock_device2.is_pending.return_value = True + + # Configure mock store to return appropriate device for each user_code + def get_by_user_code_side_effect(user_code): + if user_code == device1_code: + return mock_device1 + elif user_code == device2_code: + return mock_device2 + return None + + mock_store.get_by_user_code.side_effect = get_by_user_code_side_effect + mock_store.authorize_device_code.return_value = True + + # Authenticate first device + result1 = await device_verification_authenticated( + user_code=device1_code, user_id=user_id + ) + + # Authenticate second device + result2 = await device_verification_authenticated( + user_code=device2_code, user_id=user_id + ) + + # Both should succeed + assert isinstance(result1, JSONResponse) + assert result1.status_code == 200 + assert isinstance(result2, JSONResponse) + assert result2.status_code == 200 + + # Should create two separate API keys with different names + assert mock_api_key_store.create_api_key.call_count == 2 + + # Check that each device got a unique API key name + call_args_list = mock_api_key_store.create_api_key.call_args_list + device1_name = call_args_list[0][1]['name'] + device2_name = call_args_list[1][1]['name'] + + assert device1_name == f'Device Link Access Key ({device1_code})' + assert device2_name == f'Device Link Access Key ({device2_code})' + assert device1_name != device2_name # Ensure they're different + + # Should NOT delete any existing API keys + mock_api_key_store.delete_api_key_by_name.assert_not_called() + + +class TestDeviceTokenRateLimiting: + """Test rate limiting for device token polling (RFC 8628 section 3.5).""" + + @patch('server.routes.oauth_device.device_code_store') + async def test_first_poll_allowed(self, mock_store): + """Test that the first poll is always allowed.""" + # Create a device code with no previous poll time + mock_device = DeviceCode( + device_code='test_device_code', + user_code='ABC123', + status='pending', + expires_at=datetime.now(UTC) + timedelta(minutes=10), + last_poll_time=None, # First poll + current_interval=5, + ) + mock_store.get_by_device_code.return_value = mock_device + mock_store.update_poll_time.return_value = True + + device_code = 'test_device_code' + result = await device_token(device_code=device_code) + + # Should return authorization_pending, not slow_down + assert isinstance(result, JSONResponse) + assert result.status_code == 400 + content = result.body.decode() + assert 'authorization_pending' in content + assert 'slow_down' not in content + + # Should update poll time without increasing interval + mock_store.update_poll_time.assert_called_with( + 'test_device_code', increase_interval=False + ) + + @patch('server.routes.oauth_device.device_code_store') + async def test_normal_polling_allowed(self, mock_store): + """Test that normal polling (respecting interval) is allowed.""" + # Create a device code with last poll time 6 seconds ago (interval is 5) + last_poll = datetime.now(UTC) - timedelta(seconds=6) + mock_device = DeviceCode( + device_code='test_device_code', + user_code='ABC123', + status='pending', + expires_at=datetime.now(UTC) + timedelta(minutes=10), + last_poll_time=last_poll, + current_interval=5, + ) + mock_store.get_by_device_code.return_value = mock_device + mock_store.update_poll_time.return_value = True + + device_code = 'test_device_code' + result = await device_token(device_code=device_code) + + # Should return authorization_pending, not slow_down + assert isinstance(result, JSONResponse) + assert result.status_code == 400 + content = result.body.decode() + assert 'authorization_pending' in content + assert 'slow_down' not in content + + # Should update poll time without increasing interval + mock_store.update_poll_time.assert_called_with( + 'test_device_code', increase_interval=False + ) + + @patch('server.routes.oauth_device.device_code_store') + async def test_fast_polling_returns_slow_down(self, mock_store): + """Test that polling too fast returns slow_down error.""" + # Create a device code with last poll time 2 seconds ago (interval is 5) + last_poll = datetime.now(UTC) - timedelta(seconds=2) + mock_device = DeviceCode( + device_code='test_device_code', + user_code='ABC123', + status='pending', + expires_at=datetime.now(UTC) + timedelta(minutes=10), + last_poll_time=last_poll, + current_interval=5, + ) + mock_store.get_by_device_code.return_value = mock_device + mock_store.update_poll_time.return_value = True + + device_code = 'test_device_code' + result = await device_token(device_code=device_code) + + # Should return slow_down error + assert isinstance(result, JSONResponse) + assert result.status_code == 400 + content = result.body.decode() + assert 'slow_down' in content + assert 'interval' in content + assert '10' in content # New interval should be 5 + 5 = 10 + + # Should update poll time and increase interval + mock_store.update_poll_time.assert_called_with( + 'test_device_code', increase_interval=True + ) + + @patch('server.routes.oauth_device.device_code_store') + async def test_interval_increases_with_repeated_fast_polling(self, mock_store): + """Test that interval increases with repeated fast polling.""" + # Create a device code with higher current interval from previous slow_down + last_poll = datetime.now(UTC) - timedelta(seconds=5) # 5 seconds ago + mock_device = DeviceCode( + device_code='test_device_code', + user_code='ABC123', + status='pending', + expires_at=datetime.now(UTC) + timedelta(minutes=10), + last_poll_time=last_poll, + current_interval=15, # Already increased from previous slow_down + ) + mock_store.get_by_device_code.return_value = mock_device + mock_store.update_poll_time.return_value = True + + device_code = 'test_device_code' + result = await device_token(device_code=device_code) + + # Should return slow_down error with increased interval + assert isinstance(result, JSONResponse) + assert result.status_code == 400 + content = result.body.decode() + assert 'slow_down' in content + assert '20' in content # New interval should be 15 + 5 = 20 + + # Should update poll time and increase interval + mock_store.update_poll_time.assert_called_with( + 'test_device_code', increase_interval=True + ) + + @patch('server.routes.oauth_device.device_code_store') + async def test_interval_caps_at_maximum(self, mock_store): + """Test that interval is capped at maximum value.""" + # Create a device code with interval near maximum + last_poll = datetime.now(UTC) - timedelta(seconds=30) + mock_device = DeviceCode( + device_code='test_device_code', + user_code='ABC123', + status='pending', + expires_at=datetime.now(UTC) + timedelta(minutes=10), + last_poll_time=last_poll, + current_interval=58, # Near maximum of 60 + ) + mock_store.get_by_device_code.return_value = mock_device + mock_store.update_poll_time.return_value = True + + device_code = 'test_device_code' + result = await device_token(device_code=device_code) + + # Should return slow_down error with capped interval + assert isinstance(result, JSONResponse) + assert result.status_code == 400 + content = result.body.decode() + assert 'slow_down' in content + assert '60' in content # Should be capped at 60, not 63 + + @patch('server.routes.oauth_device.device_code_store') + async def test_rate_limiting_with_authorized_device(self, mock_store): + """Test that rate limiting still applies to authorized devices.""" + # Create an authorized device code with recent poll + last_poll = datetime.now(UTC) - timedelta(seconds=2) + mock_device = DeviceCode( + device_code='test_device_code', + user_code='ABC123', + status='authorized', # Device is authorized + keycloak_user_id='user123', + expires_at=datetime.now(UTC) + timedelta(minutes=10), + last_poll_time=last_poll, + current_interval=5, + ) + mock_store.get_by_device_code.return_value = mock_device + mock_store.update_poll_time.return_value = True + + device_code = 'test_device_code' + result = await device_token(device_code=device_code) + + # Should still return slow_down error even for authorized device + assert isinstance(result, JSONResponse) + assert result.status_code == 400 + content = result.body.decode() + assert 'slow_down' in content + + # Should update poll time and increase interval + mock_store.update_poll_time.assert_called_with( + 'test_device_code', increase_interval=True + ) + + +class TestDeviceVerificationTransactionIntegrity: + """Test transaction integrity for device verification to prevent orphaned API keys.""" + + @patch('server.routes.oauth_device.ApiKeyStore') + @patch('server.routes.oauth_device.device_code_store') + async def test_authorization_failure_prevents_api_key_creation( + self, mock_store, mock_api_key_class + ): + """Test that if device authorization fails, no API key is created.""" + # Mock device code + mock_device = MagicMock() + mock_device.is_pending.return_value = True + mock_store.get_by_user_code.return_value = mock_device + mock_store.authorize_device_code.return_value = False # Authorization fails + + # Mock API key store + mock_api_key_store = MagicMock() + mock_api_key_class.get_instance.return_value = mock_api_key_store + + # Should raise HTTPException due to authorization failure + with pytest.raises(HTTPException) as exc_info: + await device_verification_authenticated( + user_code='ABC12345', user_id='user-123' + ) + + assert exc_info.value.status_code == 500 + assert 'Failed to authorize the device' in exc_info.value.detail + + # API key should NOT be created since authorization failed + mock_api_key_store.create_api_key.assert_not_called() + mock_store.authorize_device_code.assert_called_once_with( + user_code='ABC12345', user_id='user-123' + ) + + @patch('server.routes.oauth_device.ApiKeyStore') + @patch('server.routes.oauth_device.device_code_store') + async def test_api_key_creation_failure_reverts_authorization( + self, mock_store, mock_api_key_class + ): + """Test that if API key creation fails after authorization, the authorization is reverted.""" + # Mock device code + mock_device = MagicMock() + mock_device.is_pending.return_value = True + mock_store.get_by_user_code.return_value = mock_device + mock_store.authorize_device_code.return_value = True # Authorization succeeds + mock_store.deny_device_code.return_value = True # Cleanup succeeds + + # Mock API key store to fail on creation + mock_api_key_store = MagicMock() + mock_api_key_store.create_api_key.side_effect = Exception('Database error') + mock_api_key_class.get_instance.return_value = mock_api_key_store + + # Should raise HTTPException due to API key creation failure + with pytest.raises(HTTPException) as exc_info: + await device_verification_authenticated( + user_code='ABC12345', user_id='user-123' + ) + + assert exc_info.value.status_code == 500 + assert 'Failed to create API key for device access' in exc_info.value.detail + + # Authorization should have been attempted first + mock_store.authorize_device_code.assert_called_once_with( + user_code='ABC12345', user_id='user-123' + ) + + # API key creation should have been attempted after authorization + mock_api_key_store.create_api_key.assert_called_once() + + # Authorization should be reverted due to API key creation failure + mock_store.deny_device_code.assert_called_once_with('ABC12345') + + @patch('server.routes.oauth_device.ApiKeyStore') + @patch('server.routes.oauth_device.device_code_store') + async def test_api_key_creation_failure_cleanup_failure_logged( + self, mock_store, mock_api_key_class + ): + """Test that cleanup failure is logged but doesn't prevent the main error from being raised.""" + # Mock device code + mock_device = MagicMock() + mock_device.is_pending.return_value = True + mock_store.get_by_user_code.return_value = mock_device + mock_store.authorize_device_code.return_value = True # Authorization succeeds + mock_store.deny_device_code.side_effect = Exception( + 'Cleanup failed' + ) # Cleanup fails + + # Mock API key store to fail on creation + mock_api_key_store = MagicMock() + mock_api_key_store.create_api_key.side_effect = Exception('Database error') + mock_api_key_class.get_instance.return_value = mock_api_key_store + + # Should still raise HTTPException for the original API key creation failure + with pytest.raises(HTTPException) as exc_info: + await device_verification_authenticated( + user_code='ABC12345', user_id='user-123' + ) + + assert exc_info.value.status_code == 500 + assert 'Failed to create API key for device access' in exc_info.value.detail + + # Both operations should have been attempted + mock_store.authorize_device_code.assert_called_once() + mock_api_key_store.create_api_key.assert_called_once() + mock_store.deny_device_code.assert_called_once_with('ABC12345') + + @patch('server.routes.oauth_device.ApiKeyStore') + @patch('server.routes.oauth_device.device_code_store') + async def test_successful_flow_creates_api_key_after_authorization( + self, mock_store, mock_api_key_class + ): + """Test that in the successful flow, API key is created only after authorization.""" + # Mock device code + mock_device = MagicMock() + mock_device.is_pending.return_value = True + mock_store.get_by_user_code.return_value = mock_device + mock_store.authorize_device_code.return_value = True # Authorization succeeds + + # Mock API key store + mock_api_key_store = MagicMock() + mock_api_key_class.get_instance.return_value = mock_api_key_store + + result = await device_verification_authenticated( + user_code='ABC12345', user_id='user-123' + ) + + assert isinstance(result, JSONResponse) + assert result.status_code == 200 + + # Verify the order: authorization first, then API key creation + mock_store.authorize_device_code.assert_called_once_with( + user_code='ABC12345', user_id='user-123' + ) + mock_api_key_store.create_api_key.assert_called_once() + + # No cleanup should be needed in successful case + mock_store.deny_device_code.assert_not_called() diff --git a/enterprise/tests/unit/storage/test_device_code.py b/enterprise/tests/unit/storage/test_device_code.py new file mode 100644 index 0000000000..0d2193075b --- /dev/null +++ b/enterprise/tests/unit/storage/test_device_code.py @@ -0,0 +1,83 @@ +"""Unit tests for DeviceCode model.""" + +from datetime import datetime, timedelta, timezone + +import pytest +from storage.device_code import DeviceCode, DeviceCodeStatus + + +class TestDeviceCode: + """Test cases for DeviceCode model.""" + + @pytest.fixture + def device_code(self): + """Create a test device code.""" + return DeviceCode( + device_code='test-device-code-123', + user_code='ABC12345', + expires_at=datetime.now(timezone.utc) + timedelta(minutes=10), + ) + + @pytest.mark.parametrize( + 'expires_delta,expected', + [ + (timedelta(minutes=5), False), # Future expiry + (timedelta(minutes=-5), True), # Past expiry + (timedelta(seconds=1), False), # Just future (not expired) + ], + ) + def test_is_expired(self, expires_delta, expected): + """Test expiration check with various time deltas.""" + device_code = DeviceCode( + device_code='test-device-code', + user_code='ABC12345', + expires_at=datetime.now(timezone.utc) + expires_delta, + ) + assert device_code.is_expired() == expected + + @pytest.mark.parametrize( + 'status,expired,expected', + [ + (DeviceCodeStatus.PENDING.value, False, True), + (DeviceCodeStatus.PENDING.value, True, False), + (DeviceCodeStatus.AUTHORIZED.value, False, False), + (DeviceCodeStatus.DENIED.value, False, False), + ], + ) + def test_is_pending(self, status, expired, expected): + """Test pending status check.""" + expires_at = ( + datetime.now(timezone.utc) - timedelta(minutes=1) + if expired + else datetime.now(timezone.utc) + timedelta(minutes=10) + ) + device_code = DeviceCode( + device_code='test-device-code', + user_code='ABC12345', + status=status, + expires_at=expires_at, + ) + assert device_code.is_pending() == expected + + def test_authorize(self, device_code): + """Test device authorization.""" + user_id = 'test-user-123' + + device_code.authorize(user_id) + + assert device_code.status == DeviceCodeStatus.AUTHORIZED.value + assert device_code.keycloak_user_id == user_id + assert device_code.authorized_at is not None + assert isinstance(device_code.authorized_at, datetime) + + @pytest.mark.parametrize( + 'method,expected_status', + [ + ('deny', DeviceCodeStatus.DENIED.value), + ('expire', DeviceCodeStatus.EXPIRED.value), + ], + ) + def test_status_changes(self, device_code, method, expected_status): + """Test status change methods.""" + getattr(device_code, method)() + assert device_code.status == expected_status diff --git a/enterprise/tests/unit/storage/test_device_code_store.py b/enterprise/tests/unit/storage/test_device_code_store.py new file mode 100644 index 0000000000..65a58cda8a --- /dev/null +++ b/enterprise/tests/unit/storage/test_device_code_store.py @@ -0,0 +1,193 @@ +"""Unit tests for DeviceCodeStore.""" + +from unittest.mock import MagicMock + +import pytest +from sqlalchemy.exc import IntegrityError +from storage.device_code import DeviceCode +from storage.device_code_store import DeviceCodeStore + + +@pytest.fixture +def mock_session(): + """Mock database session.""" + session = MagicMock() + return session + + +@pytest.fixture +def mock_session_maker(mock_session): + """Mock session maker.""" + session_maker = MagicMock() + session_maker.return_value.__enter__.return_value = mock_session + session_maker.return_value.__exit__.return_value = None + return session_maker + + +@pytest.fixture +def device_code_store(mock_session_maker): + """Create DeviceCodeStore instance.""" + return DeviceCodeStore(mock_session_maker) + + +class TestDeviceCodeStore: + """Test cases for DeviceCodeStore.""" + + def test_generate_user_code(self, device_code_store): + """Test user code generation.""" + code = device_code_store.generate_user_code() + + assert len(code) == 8 + assert code.isupper() + # Should not contain confusing characters + assert not any(char in code for char in 'IO01') + + def test_generate_device_code(self, device_code_store): + """Test device code generation.""" + code = device_code_store.generate_device_code() + + assert len(code) == 128 + assert code.isalnum() + + def test_create_device_code_success(self, device_code_store, mock_session): + """Test successful device code creation.""" + # Mock successful creation (no IntegrityError) + mock_device_code = MagicMock(spec=DeviceCode) + mock_device_code.device_code = 'test-device-code-123' + mock_device_code.user_code = 'TESTCODE' + + # Mock the session to return our mock device code after refresh + def mock_refresh(obj): + obj.device_code = mock_device_code.device_code + obj.user_code = mock_device_code.user_code + + mock_session.refresh.side_effect = mock_refresh + + result = device_code_store.create_device_code(expires_in=600) + + assert isinstance(result, DeviceCode) + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + mock_session.refresh.assert_called_once() + mock_session.expunge.assert_called_once() + + def test_create_device_code_with_retries( + self, device_code_store, mock_session_maker + ): + """Test device code creation with constraint violation retries.""" + mock_session = MagicMock() + mock_session_maker.return_value.__enter__.return_value = mock_session + mock_session_maker.return_value.__exit__.return_value = None + + # First attempt fails with IntegrityError, second succeeds + mock_session.commit.side_effect = [IntegrityError('', '', ''), None] + + mock_device_code = MagicMock(spec=DeviceCode) + mock_device_code.device_code = 'test-device-code-456' + mock_device_code.user_code = 'TESTCD2' + + def mock_refresh(obj): + obj.device_code = mock_device_code.device_code + obj.user_code = mock_device_code.user_code + + mock_session.refresh.side_effect = mock_refresh + + store = DeviceCodeStore(mock_session_maker) + result = store.create_device_code(expires_in=600) + + assert isinstance(result, DeviceCode) + assert mock_session.add.call_count == 2 # Two attempts + assert mock_session.commit.call_count == 2 # Two attempts + + def test_create_device_code_max_attempts_exceeded( + self, device_code_store, mock_session_maker + ): + """Test device code creation failure after max attempts.""" + mock_session = MagicMock() + mock_session_maker.return_value.__enter__.return_value = mock_session + mock_session_maker.return_value.__exit__.return_value = None + + # All attempts fail with IntegrityError + mock_session.commit.side_effect = IntegrityError('', '', '') + + store = DeviceCodeStore(mock_session_maker) + + with pytest.raises( + RuntimeError, + match='Failed to generate unique device codes after 3 attempts', + ): + store.create_device_code(expires_in=600, max_attempts=3) + + @pytest.mark.parametrize( + 'lookup_method,lookup_field', + [ + ('get_by_device_code', 'device_code'), + ('get_by_user_code', 'user_code'), + ], + ) + def test_lookup_methods( + self, device_code_store, mock_session, lookup_method, lookup_field + ): + """Test device code lookup methods.""" + test_code = 'test-code-123' + mock_device_code = MagicMock() + mock_session.query.return_value.filter_by.return_value.first.return_value = ( + mock_device_code + ) + + result = getattr(device_code_store, lookup_method)(test_code) + + assert result == mock_device_code + mock_session.query.assert_called_once_with(DeviceCode) + mock_session.query.return_value.filter_by.assert_called_once_with( + **{lookup_field: test_code} + ) + + @pytest.mark.parametrize( + 'device_exists,is_pending,expected_result', + [ + (True, True, True), # Success case + (False, True, False), # Device not found + (True, False, False), # Device not pending + ], + ) + def test_authorize_device_code( + self, + device_code_store, + mock_session, + device_exists, + is_pending, + expected_result, + ): + """Test device code authorization.""" + user_code = 'ABC12345' + user_id = 'test-user-123' + + if device_exists: + mock_device = MagicMock() + mock_device.is_pending.return_value = is_pending + mock_session.query.return_value.filter_by.return_value.first.return_value = mock_device + else: + mock_session.query.return_value.filter_by.return_value.first.return_value = None + + result = device_code_store.authorize_device_code(user_code, user_id) + + assert result == expected_result + if expected_result: + mock_device.authorize.assert_called_once_with(user_id) + mock_session.commit.assert_called_once() + + def test_deny_device_code(self, device_code_store, mock_session): + """Test device code denial.""" + user_code = 'ABC12345' + mock_device = MagicMock() + mock_device.is_pending.return_value = True + mock_session.query.return_value.filter_by.return_value.first.return_value = ( + mock_device + ) + + result = device_code_store.deny_device_code(user_code) + + assert result is True + mock_device.deny.assert_called_once() + mock_session.commit.assert_called_once() diff --git a/enterprise/tests/unit/test_api_key_store.py b/enterprise/tests/unit/test_api_key_store.py index ea386cb69c..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): @@ -90,6 +92,50 @@ def test_validate_api_key_expired(api_key_store, mock_session): mock_session.commit.assert_not_called() +def test_validate_api_key_expired_timezone_naive(api_key_store, mock_session): + """Test validating an expired API key with timezone-naive datetime from database.""" + # Setup + api_key = 'test-api-key' + mock_key_record = MagicMock() + # Simulate timezone-naive datetime as returned from database + mock_key_record.expires_at = datetime.now() - timedelta(days=1) # No UTC timezone + mock_key_record.id = 1 + mock_session.query.return_value.filter.return_value.first.return_value = ( + mock_key_record + ) + + # Execute + result = api_key_store.validate_api_key(api_key) + + # Verify + assert result is None + mock_session.execute.assert_not_called() + mock_session.commit.assert_not_called() + + +def test_validate_api_key_valid_timezone_naive(api_key_store, mock_session): + """Test validating a valid API key with timezone-naive datetime from database.""" + # Setup + api_key = 'test-api-key' + user_id = 'test-user-123' + mock_key_record = MagicMock() + mock_key_record.user_id = user_id + # Simulate timezone-naive datetime as returned from database (future date) + mock_key_record.expires_at = datetime.now() + timedelta(days=1) # No UTC timezone + mock_key_record.id = 1 + mock_session.query.return_value.filter.return_value.first.return_value = ( + mock_key_record + ) + + # Execute + result = api_key_store.validate_api_key(api_key) + + # Verify + assert result == user_id + mock_session.execute.assert_called_once() + mock_session.commit.assert_called_once() + + def test_validate_api_key_not_found(api_key_store, mock_session): """Test validating a non-existent API key.""" # Setup diff --git a/enterprise/tests/unit/test_get_user_v1_enabled_setting.py b/enterprise/tests/unit/test_get_user_v1_enabled_setting.py new file mode 100644 index 0000000000..bbed7b8ba0 --- /dev/null +++ b/enterprise/tests/unit/test_get_user_v1_enabled_setting.py @@ -0,0 +1,132 @@ +"""Unit tests for get_user_v1_enabled_setting function.""" + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from integrations.github.github_view import get_user_v1_enabled_setting + + +@pytest.fixture +def mock_user_settings(): + """Create a mock user settings object.""" + settings = MagicMock() + settings.v1_enabled = True # Default to True, can be overridden in tests + return settings + + +@pytest.fixture +def mock_settings_store(mock_user_settings): + """Create a mock settings store.""" + store = MagicMock() + store.get_user_settings_by_keycloak_id = AsyncMock(return_value=mock_user_settings) + return store + + +@pytest.fixture +def mock_config(): + """Create a mock config object.""" + return MagicMock() + + +@pytest.fixture +def mock_session_maker(): + """Create a mock session maker.""" + return MagicMock() + + +@pytest.fixture +def mock_dependencies( + mock_settings_store, mock_config, mock_session_maker, mock_user_settings +): + """Fixture that patches all the common dependencies.""" + with patch( + 'integrations.github.github_view.SaasSettingsStore', + return_value=mock_settings_store, + ) as mock_store_class, patch( + 'integrations.github.github_view.get_config', return_value=mock_config + ) as mock_get_config, patch( + 'integrations.github.github_view.session_maker', mock_session_maker + ), patch( + 'integrations.github.github_view.call_sync_from_async', + return_value=mock_user_settings, + ) as mock_call_sync: + yield { + 'store_class': mock_store_class, + 'get_config': mock_get_config, + 'session_maker': mock_session_maker, + 'call_sync': mock_call_sync, + 'settings_store': mock_settings_store, + 'user_settings': mock_user_settings, + } + + +class TestGetUserV1EnabledSetting: + """Test cases for get_user_v1_enabled_setting function.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'env_var_enabled,user_setting_enabled,expected_result', + [ + (False, True, False), # Env var disabled, user enabled -> False + (True, False, False), # Env var enabled, user disabled -> False + (True, True, True), # Both enabled -> True + (False, False, False), # Both disabled -> False + ], + ) + async def test_v1_enabled_combinations( + self, mock_dependencies, env_var_enabled, user_setting_enabled, expected_result + ): + """Test all combinations of environment variable and user setting values.""" + mock_dependencies['user_settings'].v1_enabled = user_setting_enabled + + with patch( + 'integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', env_var_enabled + ): + result = await get_user_v1_enabled_setting('test_user_id') + assert result is expected_result + + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'env_var_value,env_var_bool,expected_result', + [ + ('false', False, False), # Environment variable 'false' -> False + ('true', True, True), # Environment variable 'true' -> True + ], + ) + async def test_environment_variable_integration( + self, mock_dependencies, env_var_value, env_var_bool, expected_result + ): + """Test that the function properly reads the ENABLE_V1_GITHUB_RESOLVER environment variable.""" + mock_dependencies['user_settings'].v1_enabled = True + + with patch.dict( + os.environ, {'ENABLE_V1_GITHUB_RESOLVER': env_var_value} + ), patch('integrations.utils.os.getenv', return_value=env_var_value), patch( + 'integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', env_var_bool + ): + result = await get_user_v1_enabled_setting('test_user_id') + assert result is expected_result + + @pytest.mark.asyncio + async def test_function_calls_correct_methods(self, mock_dependencies): + """Test that the function calls the correct methods with correct parameters.""" + mock_dependencies['user_settings'].v1_enabled = True + + with patch('integrations.github.github_view.ENABLE_V1_GITHUB_RESOLVER', True): + result = await get_user_v1_enabled_setting('test_user_123') + + # Verify the result + assert result is True + + # Verify correct methods were called with correct parameters + mock_dependencies['get_config'].assert_called_once() + mock_dependencies['store_class'].assert_called_once_with( + user_id='test_user_123', + session_maker=mock_dependencies['session_maker'], + config=mock_dependencies['get_config'].return_value, + ) + mock_dependencies['call_sync'].assert_called_once_with( + mock_dependencies['settings_store'].get_user_settings_by_keycloak_id, + 'test_user_123', + ) diff --git a/enterprise/tests/unit/test_legacy_conversation_manager.py b/enterprise/tests/unit/test_legacy_conversation_manager.py deleted file mode 100644 index 55b424dabc..0000000000 --- a/enterprise/tests/unit/test_legacy_conversation_manager.py +++ /dev/null @@ -1,485 +0,0 @@ -import time -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from server.legacy_conversation_manager import ( - _LEGACY_ENTRY_TIMEOUT_SECONDS, - LegacyCacheEntry, - LegacyConversationManager, -) - -from openhands.core.config.openhands_config import OpenHandsConfig -from openhands.server.config.server_config import ServerConfig -from openhands.server.monitoring import MonitoringListener -from openhands.storage.memory import InMemoryFileStore - - -@pytest.fixture -def mock_sio(): - """Create a mock SocketIO server.""" - return MagicMock() - - -@pytest.fixture -def mock_config(): - """Create a mock OpenHands config.""" - return MagicMock(spec=OpenHandsConfig) - - -@pytest.fixture -def mock_server_config(): - """Create a mock server config.""" - return MagicMock(spec=ServerConfig) - - -@pytest.fixture -def mock_file_store(): - """Create a mock file store.""" - return MagicMock(spec=InMemoryFileStore) - - -@pytest.fixture -def mock_monitoring_listener(): - """Create a mock monitoring listener.""" - return MagicMock(spec=MonitoringListener) - - -@pytest.fixture -def mock_conversation_manager(): - """Create a mock SaasNestedConversationManager.""" - mock_cm = MagicMock() - mock_cm._get_runtime = AsyncMock() - return mock_cm - - -@pytest.fixture -def mock_legacy_conversation_manager(): - """Create a mock ClusteredConversationManager.""" - return MagicMock() - - -@pytest.fixture -def legacy_manager( - mock_sio, - mock_config, - mock_server_config, - mock_file_store, - mock_conversation_manager, - mock_legacy_conversation_manager, -): - """Create a LegacyConversationManager instance for testing.""" - return LegacyConversationManager( - sio=mock_sio, - config=mock_config, - server_config=mock_server_config, - file_store=mock_file_store, - conversation_manager=mock_conversation_manager, - legacy_conversation_manager=mock_legacy_conversation_manager, - ) - - -class TestLegacyCacheEntry: - """Test the LegacyCacheEntry dataclass.""" - - def test_cache_entry_creation(self): - """Test creating a cache entry.""" - timestamp = time.time() - entry = LegacyCacheEntry(is_legacy=True, timestamp=timestamp) - - assert entry.is_legacy is True - assert entry.timestamp == timestamp - - def test_cache_entry_false(self): - """Test creating a cache entry with False value.""" - timestamp = time.time() - entry = LegacyCacheEntry(is_legacy=False, timestamp=timestamp) - - assert entry.is_legacy is False - assert entry.timestamp == timestamp - - -class TestLegacyConversationManagerCacheCleanup: - """Test cache cleanup functionality.""" - - def test_cleanup_expired_cache_entries_removes_expired(self, legacy_manager): - """Test that expired entries are removed from cache.""" - current_time = time.time() - expired_time = current_time - _LEGACY_ENTRY_TIMEOUT_SECONDS - 1 - valid_time = current_time - 100 # Well within timeout - - # Add both expired and valid entries - legacy_manager._legacy_cache = { - 'expired_conversation': LegacyCacheEntry(True, expired_time), - 'valid_conversation': LegacyCacheEntry(False, valid_time), - 'another_expired': LegacyCacheEntry(True, expired_time - 100), - } - - legacy_manager._cleanup_expired_cache_entries() - - # Only valid entry should remain - assert len(legacy_manager._legacy_cache) == 1 - assert 'valid_conversation' in legacy_manager._legacy_cache - assert 'expired_conversation' not in legacy_manager._legacy_cache - assert 'another_expired' not in legacy_manager._legacy_cache - - def test_cleanup_expired_cache_entries_keeps_valid(self, legacy_manager): - """Test that valid entries are kept during cleanup.""" - current_time = time.time() - valid_time = current_time - 100 # Well within timeout - - legacy_manager._legacy_cache = { - 'valid_conversation_1': LegacyCacheEntry(True, valid_time), - 'valid_conversation_2': LegacyCacheEntry(False, valid_time - 50), - } - - legacy_manager._cleanup_expired_cache_entries() - - # Both entries should remain - assert len(legacy_manager._legacy_cache) == 2 - assert 'valid_conversation_1' in legacy_manager._legacy_cache - assert 'valid_conversation_2' in legacy_manager._legacy_cache - - def test_cleanup_expired_cache_entries_empty_cache(self, legacy_manager): - """Test cleanup with empty cache.""" - legacy_manager._legacy_cache = {} - - legacy_manager._cleanup_expired_cache_entries() - - assert len(legacy_manager._legacy_cache) == 0 - - -class TestIsLegacyRuntime: - """Test the is_legacy_runtime method.""" - - def test_is_legacy_runtime_none(self, legacy_manager): - """Test with None runtime.""" - result = legacy_manager.is_legacy_runtime(None) - assert result is False - - def test_is_legacy_runtime_legacy_command(self, legacy_manager): - """Test with legacy runtime command.""" - runtime = {'command': 'some_old_legacy_command'} - result = legacy_manager.is_legacy_runtime(runtime) - assert result is True - - def test_is_legacy_runtime_new_command(self, legacy_manager): - """Test with new runtime command containing openhands.server.""" - runtime = {'command': 'python -m openhands.server.listen'} - result = legacy_manager.is_legacy_runtime(runtime) - assert result is False - - def test_is_legacy_runtime_partial_match(self, legacy_manager): - """Test with command that partially matches but is still legacy.""" - runtime = {'command': 'openhands.client.start'} - result = legacy_manager.is_legacy_runtime(runtime) - assert result is True - - def test_is_legacy_runtime_empty_command(self, legacy_manager): - """Test with empty command.""" - runtime = {'command': ''} - result = legacy_manager.is_legacy_runtime(runtime) - assert result is True - - def test_is_legacy_runtime_missing_command_key(self, legacy_manager): - """Test with runtime missing command key.""" - runtime = {'other_key': 'value'} - # This should raise a KeyError - with pytest.raises(KeyError): - legacy_manager.is_legacy_runtime(runtime) - - -class TestShouldStartInLegacyMode: - """Test the should_start_in_legacy_mode method.""" - - @pytest.mark.asyncio - async def test_cache_hit_valid_entry_legacy(self, legacy_manager): - """Test cache hit with valid legacy entry.""" - conversation_id = 'test_conversation' - current_time = time.time() - - # Add valid cache entry - legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry( - True, current_time - 100 - ) - - result = await legacy_manager.should_start_in_legacy_mode(conversation_id) - - assert result is True - # Should not call _get_runtime since we hit cache - legacy_manager.conversation_manager._get_runtime.assert_not_called() - - @pytest.mark.asyncio - async def test_cache_hit_valid_entry_non_legacy(self, legacy_manager): - """Test cache hit with valid non-legacy entry.""" - conversation_id = 'test_conversation' - current_time = time.time() - - # Add valid cache entry - legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry( - False, current_time - 100 - ) - - result = await legacy_manager.should_start_in_legacy_mode(conversation_id) - - assert result is False - # Should not call _get_runtime since we hit cache - legacy_manager.conversation_manager._get_runtime.assert_not_called() - - @pytest.mark.asyncio - async def test_cache_miss_legacy_runtime(self, legacy_manager): - """Test cache miss with legacy runtime.""" - conversation_id = 'test_conversation' - runtime = {'command': 'old_command'} - - legacy_manager.conversation_manager._get_runtime.return_value = runtime - - result = await legacy_manager.should_start_in_legacy_mode(conversation_id) - - assert result is True - # Should call _get_runtime - legacy_manager.conversation_manager._get_runtime.assert_called_once_with( - conversation_id - ) - # Should cache the result - assert conversation_id in legacy_manager._legacy_cache - assert legacy_manager._legacy_cache[conversation_id].is_legacy is True - - @pytest.mark.asyncio - async def test_cache_miss_non_legacy_runtime(self, legacy_manager): - """Test cache miss with non-legacy runtime.""" - conversation_id = 'test_conversation' - runtime = {'command': 'python -m openhands.server.listen'} - - legacy_manager.conversation_manager._get_runtime.return_value = runtime - - result = await legacy_manager.should_start_in_legacy_mode(conversation_id) - - assert result is False - # Should call _get_runtime - legacy_manager.conversation_manager._get_runtime.assert_called_once_with( - conversation_id - ) - # Should cache the result - assert conversation_id in legacy_manager._legacy_cache - assert legacy_manager._legacy_cache[conversation_id].is_legacy is False - - @pytest.mark.asyncio - async def test_cache_expired_entry(self, legacy_manager): - """Test with expired cache entry.""" - conversation_id = 'test_conversation' - expired_time = time.time() - _LEGACY_ENTRY_TIMEOUT_SECONDS - 1 - runtime = {'command': 'python -m openhands.server.listen'} - - # Add expired cache entry - legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry( - True, - expired_time, # This should be considered expired - ) - - legacy_manager.conversation_manager._get_runtime.return_value = runtime - - result = await legacy_manager.should_start_in_legacy_mode(conversation_id) - - assert result is False # Runtime indicates non-legacy - # Should call _get_runtime since cache is expired - legacy_manager.conversation_manager._get_runtime.assert_called_once_with( - conversation_id - ) - # Should update cache with new result - assert legacy_manager._legacy_cache[conversation_id].is_legacy is False - - @pytest.mark.asyncio - async def test_cache_exactly_at_timeout(self, legacy_manager): - """Test with cache entry exactly at timeout boundary.""" - conversation_id = 'test_conversation' - timeout_time = time.time() - _LEGACY_ENTRY_TIMEOUT_SECONDS - runtime = {'command': 'python -m openhands.server.listen'} - - # Add cache entry exactly at timeout - legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry( - True, timeout_time - ) - - legacy_manager.conversation_manager._get_runtime.return_value = runtime - - result = await legacy_manager.should_start_in_legacy_mode(conversation_id) - - # Should treat as expired and fetch from runtime - assert result is False - legacy_manager.conversation_manager._get_runtime.assert_called_once_with( - conversation_id - ) - - @pytest.mark.asyncio - async def test_runtime_returns_none(self, legacy_manager): - """Test when runtime returns None.""" - conversation_id = 'test_conversation' - - legacy_manager.conversation_manager._get_runtime.return_value = None - - result = await legacy_manager.should_start_in_legacy_mode(conversation_id) - - assert result is False - # Should cache the result - assert conversation_id in legacy_manager._legacy_cache - assert legacy_manager._legacy_cache[conversation_id].is_legacy is False - - @pytest.mark.asyncio - async def test_cleanup_called_on_each_invocation(self, legacy_manager): - """Test that cleanup is called on each invocation.""" - conversation_id = 'test_conversation' - runtime = {'command': 'test'} - - legacy_manager.conversation_manager._get_runtime.return_value = runtime - - # Mock the cleanup method to verify it's called - with patch.object( - legacy_manager, '_cleanup_expired_cache_entries' - ) as mock_cleanup: - await legacy_manager.should_start_in_legacy_mode(conversation_id) - mock_cleanup.assert_called_once() - - @pytest.mark.asyncio - async def test_multiple_conversations_cached_independently(self, legacy_manager): - """Test that multiple conversations are cached independently.""" - conv1 = 'conversation_1' - conv2 = 'conversation_2' - - runtime1 = {'command': 'old_command'} # Legacy - runtime2 = {'command': 'python -m openhands.server.listen'} # Non-legacy - - # Mock to return different runtimes based on conversation_id - def mock_get_runtime(conversation_id): - if conversation_id == conv1: - return runtime1 - return runtime2 - - legacy_manager.conversation_manager._get_runtime.side_effect = mock_get_runtime - - result1 = await legacy_manager.should_start_in_legacy_mode(conv1) - result2 = await legacy_manager.should_start_in_legacy_mode(conv2) - - assert result1 is True - assert result2 is False - - # Both should be cached - assert conv1 in legacy_manager._legacy_cache - assert conv2 in legacy_manager._legacy_cache - assert legacy_manager._legacy_cache[conv1].is_legacy is True - assert legacy_manager._legacy_cache[conv2].is_legacy is False - - @pytest.mark.asyncio - async def test_cache_timestamp_updated_on_refresh(self, legacy_manager): - """Test that cache timestamp is updated when entry is refreshed.""" - conversation_id = 'test_conversation' - old_time = time.time() - _LEGACY_ENTRY_TIMEOUT_SECONDS - 1 - runtime = {'command': 'test'} - - # Add expired entry - legacy_manager._legacy_cache[conversation_id] = LegacyCacheEntry(True, old_time) - legacy_manager.conversation_manager._get_runtime.return_value = runtime - - # Record time before call - before_call = time.time() - await legacy_manager.should_start_in_legacy_mode(conversation_id) - after_call = time.time() - - # Timestamp should be updated - cached_entry = legacy_manager._legacy_cache[conversation_id] - assert cached_entry.timestamp >= before_call - assert cached_entry.timestamp <= after_call - - -class TestLegacyConversationManagerIntegration: - """Integration tests for LegacyConversationManager.""" - - @pytest.mark.asyncio - async def test_get_instance_creates_proper_manager( - self, - mock_sio, - mock_config, - mock_file_store, - mock_server_config, - mock_monitoring_listener, - ): - """Test that get_instance creates a properly configured manager.""" - with patch( - 'server.legacy_conversation_manager.SaasNestedConversationManager' - ) as mock_saas, patch( - 'server.legacy_conversation_manager.ClusteredConversationManager' - ) as mock_clustered: - mock_saas.get_instance.return_value = MagicMock() - mock_clustered.get_instance.return_value = MagicMock() - - manager = LegacyConversationManager.get_instance( - mock_sio, - mock_config, - mock_file_store, - mock_server_config, - mock_monitoring_listener, - ) - - assert isinstance(manager, LegacyConversationManager) - assert manager.sio == mock_sio - assert manager.config == mock_config - assert manager.file_store == mock_file_store - assert manager.server_config == mock_server_config - - # Verify that both nested managers are created - mock_saas.get_instance.assert_called_once() - mock_clustered.get_instance.assert_called_once() - - def test_legacy_cache_initialized_empty(self, legacy_manager): - """Test that legacy cache is initialized as empty dict.""" - assert isinstance(legacy_manager._legacy_cache, dict) - assert len(legacy_manager._legacy_cache) == 0 - - -class TestEdgeCases: - """Test edge cases and error scenarios.""" - - @pytest.mark.asyncio - async def test_get_runtime_raises_exception(self, legacy_manager): - """Test behavior when _get_runtime raises an exception.""" - conversation_id = 'test_conversation' - - legacy_manager.conversation_manager._get_runtime.side_effect = Exception( - 'Runtime error' - ) - - # Should propagate the exception - with pytest.raises(Exception, match='Runtime error'): - await legacy_manager.should_start_in_legacy_mode(conversation_id) - - @pytest.mark.asyncio - async def test_very_large_cache(self, legacy_manager): - """Test behavior with a large number of cache entries.""" - current_time = time.time() - - # Add many cache entries - for i in range(1000): - legacy_manager._legacy_cache[f'conversation_{i}'] = LegacyCacheEntry( - i % 2 == 0, current_time - i - ) - - # This should work without issues - await legacy_manager.should_start_in_legacy_mode('new_conversation') - - # Should have added one more entry - assert len(legacy_manager._legacy_cache) == 1001 - - def test_cleanup_with_concurrent_modifications(self, legacy_manager): - """Test cleanup behavior when cache is modified during cleanup.""" - current_time = time.time() - expired_time = current_time - _LEGACY_ENTRY_TIMEOUT_SECONDS - 1 - - # Add expired entries - legacy_manager._legacy_cache = { - f'conversation_{i}': LegacyCacheEntry(True, expired_time) for i in range(10) - } - - # This should work without raising exceptions - legacy_manager._cleanup_expired_cache_entries() - - # All entries should be removed - assert len(legacy_manager._legacy_cache) == 0 diff --git a/evaluation/README.md b/evaluation/README.md index 694623f63d..b4a125b3fc 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -1,5 +1,10 @@ # Evaluation +> [!WARNING] +> **This directory is deprecated.** Our new benchmarks are located at [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks). +> +> If you have already implemented a benchmark in this directory and would like to contribute it, we are happy to have the contribution. However, if you are starting anew, please use the new location. + This folder contains code and resources to run experiments and evaluations. ## For Benchmark Users 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/.npmrc b/frontend/.npmrc deleted file mode 100644 index daecc6941a..0000000000 --- a/frontend/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -public-hoist-pattern[]=*@nextui-org/* -enable-pre-post-scripts=true diff --git a/frontend/__tests__/components/browser.test.tsx b/frontend/__tests__/components/browser.test.tsx index df6aeec640..9466133c34 100644 --- a/frontend/__tests__/components/browser.test.tsx +++ b/frontend/__tests__/components/browser.test.tsx @@ -30,61 +30,33 @@ vi.mock("react-i18next", async () => { }; }); -// Mock Zustand browser store -let mockBrowserState = { - url: "https://example.com", - screenshotSrc: "", - setUrl: vi.fn(), - setScreenshotSrc: vi.fn(), - reset: vi.fn(), -}; - -vi.mock("#/stores/browser-store", () => ({ - useBrowserStore: () => mockBrowserState, -})); - -// Import the component after all mocks are set up import { BrowserPanel } from "#/components/features/browser/browser"; +import { useBrowserStore } from "#/stores/browser-store"; describe("Browser", () => { afterEach(() => { vi.clearAllMocks(); - // Reset the mock state - mockBrowserState = { - url: "https://example.com", - screenshotSrc: "", - setUrl: vi.fn(), - setScreenshotSrc: vi.fn(), - reset: vi.fn(), - }; }); it("renders a message if no screenshotSrc is provided", () => { - // Set the mock state for this test - mockBrowserState = { + useBrowserStore.setState({ url: "https://example.com", screenshotSrc: "", - setUrl: vi.fn(), - setScreenshotSrc: vi.fn(), reset: vi.fn(), - }; + }); render(); - // i18n empty message key expect(screen.getByText("BROWSER$NO_PAGE_LOADED")).toBeInTheDocument(); }); it("renders the url and a screenshot", () => { - // Set the mock state for this test - mockBrowserState = { + useBrowserStore.setState({ url: "https://example.com", screenshotSrc: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==", - setUrl: vi.fn(), - setScreenshotSrc: vi.fn(), reset: vi.fn(), - }; + }); render(); diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index 9a68eb3805..43da7cfae7 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -25,10 +25,7 @@ import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files import { OpenHandsAction } from "#/types/core/actions"; import { useEventStore } from "#/stores/use-event-store"; -// Mock the hooks vi.mock("#/context/ws-client-provider"); -vi.mock("#/stores/error-message-store"); -vi.mock("#/stores/optimistic-user-message-store"); vi.mock("#/hooks/query/use-config"); vi.mock("#/hooks/mutation/use-get-trajectory"); vi.mock("#/hooks/mutation/use-unified-upload-files"); @@ -102,24 +99,20 @@ describe("ChatInterface - Chat Suggestions", () => { }, }); - // Default mock implementations (useWsClient as unknown as ReturnType).mockReturnValue({ send: vi.fn(), isLoadingMessages: false, parsedEvents: [], }); - ( - useOptimisticUserMessageStore as unknown as ReturnType - ).mockReturnValue({ - setOptimisticUserMessage: vi.fn(), - getOptimisticUserMessage: vi.fn(() => null), + + useOptimisticUserMessageStore.setState({ + optimisticUserMessage: null, }); - ( - useErrorMessageStore as unknown as ReturnType - ).mockReturnValue({ - setErrorMessage: vi.fn(), - removeErrorMessage: vi.fn(), + + useErrorMessageStore.setState({ + errorMessage: null, }); + (useConfig as unknown as ReturnType).mockReturnValue({ data: { APP_MODE: "local" }, }); @@ -204,11 +197,8 @@ describe("ChatInterface - Chat Suggestions", () => { }); test("should hide chat suggestions when there is an optimistic user message", () => { - ( - useOptimisticUserMessageStore as unknown as ReturnType - ).mockReturnValue({ - setOptimisticUserMessage: vi.fn(), - getOptimisticUserMessage: vi.fn(() => "Optimistic message"), + useOptimisticUserMessageStore.setState({ + optimisticUserMessage: "Optimistic message", }); renderWithQueryClient(, queryClient); @@ -240,24 +230,19 @@ describe("ChatInterface - Empty state", () => { }); beforeEach(() => { - // Reset mocks to ensure empty state (useWsClient as unknown as ReturnType).mockReturnValue({ send: sendMock, status: "CONNECTED", isLoadingMessages: false, parsedEvents: [], }); - ( - useOptimisticUserMessageStore as unknown as ReturnType - ).mockReturnValue({ - setOptimisticUserMessage: vi.fn(), - getOptimisticUserMessage: vi.fn(() => null), + + useOptimisticUserMessageStore.setState({ + optimisticUserMessage: null, }); - ( - useErrorMessageStore as unknown as ReturnType - ).mockReturnValue({ - setErrorMessage: vi.fn(), - removeErrorMessage: vi.fn(), + + useErrorMessageStore.setState({ + errorMessage: null, }); (useConfig as unknown as ReturnType).mockReturnValue({ data: { APP_MODE: "local" }, diff --git a/frontend/__tests__/components/chat/expandable-message.test.tsx b/frontend/__tests__/components/chat/expandable-message.test.tsx index 0b25ef1f92..4ba839b8af 100644 --- a/frontend/__tests__/components/chat/expandable-message.test.tsx +++ b/frontend/__tests__/components/chat/expandable-message.test.tsx @@ -61,7 +61,7 @@ describe("ExpandableMessage", () => { expect(icon).toHaveClass("fill-success"); }); - it("should render with error icon for failed action messages", () => { + it("should render with no icon for failed action messages", () => { renderWithProviders( { "div.flex.gap-2.items-center.justify-start", ); expect(container).toHaveClass("border-neutral-300"); - const icon = screen.getByTestId("status-icon"); - expect(icon).toHaveClass("fill-danger"); + expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); }); it("should render with neutral border and no icon for action messages without success prop", () => { diff --git a/frontend/__tests__/components/conversation-tab-title.test.tsx b/frontend/__tests__/components/conversation-tab-title.test.tsx new file mode 100644 index 0000000000..4e3a0aa0fe --- /dev/null +++ b/frontend/__tests__/components/conversation-tab-title.test.tsx @@ -0,0 +1,149 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ConversationTabTitle } from "#/components/features/conversation/conversation-tabs/conversation-tab-title"; +import GitService from "#/api/git-service/git-service.api"; +import V1GitService from "#/api/git-service/v1-git-service.api"; + +// Mock the services that the hook depends on +vi.mock("#/api/git-service/git-service.api"); +vi.mock("#/api/git-service/v1-git-service.api"); + +// Mock the hooks that useUnifiedGetGitChanges depends on +vi.mock("#/hooks/use-conversation-id", () => ({ + useConversationId: () => ({ + conversationId: "test-conversation-id", + }), +})); + +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ + data: { + conversation_version: "V0", + url: null, + session_api_key: null, + selected_repository: null, + }, + }), +})); + +vi.mock("#/hooks/use-runtime-is-ready", () => ({ + useRuntimeIsReady: () => true, +})); + +vi.mock("#/utils/get-git-path", () => ({ + getGitPath: () => "/workspace", +})); + +describe("ConversationTabTitle", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + // Mock GitService methods + vi.mocked(GitService.getGitChanges).mockResolvedValue([]); + vi.mocked(V1GitService.getGitChanges).mockResolvedValue([]); + }); + + afterEach(() => { + vi.clearAllMocks(); + queryClient.clear(); + }); + + const renderWithProviders = (ui: React.ReactElement) => { + return render( + {ui}, + ); + }; + + describe("Rendering", () => { + it("should render the title", () => { + // Arrange + const title = "Test Title"; + + // Act + renderWithProviders( + , + ); + + // Assert + expect(screen.getByText(title)).toBeInTheDocument(); + }); + + it("should show refresh button when conversationKey is 'editor'", () => { + // Arrange + const title = "Changes"; + + // Act + renderWithProviders( + , + ); + + // Assert + const refreshButton = screen.getByRole("button"); + expect(refreshButton).toBeInTheDocument(); + }); + + it("should not show refresh button when conversationKey is not 'editor'", () => { + // Arrange + const title = "Browser"; + + // Act + renderWithProviders( + , + ); + + // Assert + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + }); + + describe("User Interactions", () => { + it("should call refetch and trigger GitService.getGitChanges when refresh button is clicked", async () => { + // Arrange + const user = userEvent.setup(); + const title = "Changes"; + const mockGitChanges: Array<{ + path: string; + status: "M" | "A" | "D" | "R" | "U"; + }> = [ + { path: "file1.ts", status: "M" }, + { path: "file2.ts", status: "A" }, + ]; + + vi.mocked(GitService.getGitChanges).mockResolvedValue(mockGitChanges); + + renderWithProviders( + , + ); + + const refreshButton = screen.getByRole("button"); + + // Wait for initial query to complete + await waitFor(() => { + expect(GitService.getGitChanges).toHaveBeenCalled(); + }); + + // Clear the mock to track refetch calls + vi.mocked(GitService.getGitChanges).mockClear(); + + // Act + await user.click(refreshButton); + + // Assert - refetch should trigger another service call + await waitFor(() => { + expect(GitService.getGitChanges).toHaveBeenCalledWith( + "test-conversation-id", + ); + }); + }); + }); +}); diff --git a/frontend/__tests__/components/features/conversation/agent-status.test.tsx b/frontend/__tests__/components/features/conversation/agent-status.test.tsx new file mode 100644 index 0000000000..a121ed37a8 --- /dev/null +++ b/frontend/__tests__/components/features/conversation/agent-status.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router"; +import { AgentStatus } from "#/components/features/controls/agent-status"; +import { AgentState } from "#/types/agent-state"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { useConversationStore } from "#/state/conversation-store"; + +vi.mock("#/hooks/use-agent-state"); + +vi.mock("#/hooks/use-conversation-id", () => ({ + useConversationId: () => ({ conversationId: "test-id" }), +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +); + +const renderAgentStatus = ({ + isPausing = false, +}: { isPausing?: boolean } = {}) => + render( + , + { wrapper }, + ); + +describe("AgentStatus - isLoading logic", () => { + it("should show loading when curAgentState is INIT", () => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.INIT, + }); + + renderAgentStatus(); + + expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument(); + }); + + it("should show loading when isPausing is true, even if shouldShownAgentLoading is false", () => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.AWAITING_USER_INPUT, + }); + + renderAgentStatus({ isPausing: true }); + + expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument(); + }); + + it("should NOT update global shouldShownAgentLoading when only isPausing is true", () => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.AWAITING_USER_INPUT, + }); + + renderAgentStatus({ isPausing: true }); + + // Loading spinner shows (because isPausing) + expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument(); + + // But global state should be false (because shouldShownAgentLoading is false) + const { shouldShownAgentLoading } = useConversationStore.getState(); + expect(shouldShownAgentLoading).toBe(false); + }); +}); diff --git a/frontend/__tests__/components/features/conversation/conversation-name.test.tsx b/frontend/__tests__/components/features/conversation/conversation-name.test.tsx index 572ca590b1..41078b69cb 100644 --- a/frontend/__tests__/components/features/conversation/conversation-name.test.tsx +++ b/frontend/__tests__/components/features/conversation/conversation-name.test.tsx @@ -42,7 +42,7 @@ vi.mock("react-i18next", async () => { BUTTON$EXPORT_CONVERSATION: "Export Conversation", BUTTON$DOWNLOAD_VIA_VSCODE: "Download via VS Code", BUTTON$SHOW_AGENT_TOOLS_AND_METADATA: "Show Agent Tools", - CONVERSATION$SHOW_MICROAGENTS: "Show Microagents", + CONVERSATION$SHOW_SKILLS: "Show Skills", BUTTON$DISPLAY_COST: "Display Cost", COMMON$CLOSE_CONVERSATION_STOP_RUNTIME: "Close Conversation (Stop Runtime)", @@ -290,7 +290,7 @@ describe("ConversationNameContextMenu", () => { onStop: vi.fn(), onDisplayCost: vi.fn(), onShowAgentTools: vi.fn(), - onShowMicroagents: vi.fn(), + onShowSkills: vi.fn(), onExportConversation: vi.fn(), onDownloadViaVSCode: vi.fn(), }; @@ -304,7 +304,7 @@ describe("ConversationNameContextMenu", () => { expect(screen.getByTestId("stop-button")).toBeInTheDocument(); expect(screen.getByTestId("display-cost-button")).toBeInTheDocument(); expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument(); - expect(screen.getByTestId("show-microagents-button")).toBeInTheDocument(); + expect(screen.getByTestId("show-skills-button")).toBeInTheDocument(); expect( screen.getByTestId("export-conversation-button"), ).toBeInTheDocument(); @@ -321,9 +321,7 @@ describe("ConversationNameContextMenu", () => { expect( screen.queryByTestId("show-agent-tools-button"), ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("show-microagents-button"), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId("show-skills-button")).not.toBeInTheDocument(); expect( screen.queryByTestId("export-conversation-button"), ).not.toBeInTheDocument(); @@ -410,19 +408,19 @@ describe("ConversationNameContextMenu", () => { it("should call show microagents handler when show microagents button is clicked", async () => { const user = userEvent.setup(); - const onShowMicroagents = vi.fn(); + const onShowSkills = vi.fn(); renderWithProviders( , ); - const showMicroagentsButton = screen.getByTestId("show-microagents-button"); + const showMicroagentsButton = screen.getByTestId("show-skills-button"); await user.click(showMicroagentsButton); - expect(onShowMicroagents).toHaveBeenCalledTimes(1); + expect(onShowSkills).toHaveBeenCalledTimes(1); }); it("should call export conversation handler when export conversation button is clicked", async () => { @@ -519,7 +517,7 @@ describe("ConversationNameContextMenu", () => { onStop: vi.fn(), onDisplayCost: vi.fn(), onShowAgentTools: vi.fn(), - onShowMicroagents: vi.fn(), + onShowSkills: vi.fn(), onExportConversation: vi.fn(), onDownloadViaVSCode: vi.fn(), }; @@ -541,8 +539,8 @@ describe("ConversationNameContextMenu", () => { expect(screen.getByTestId("show-agent-tools-button")).toHaveTextContent( "Show Agent Tools", ); - expect(screen.getByTestId("show-microagents-button")).toHaveTextContent( - "Show Microagents", + expect(screen.getByTestId("show-skills-button")).toHaveTextContent( + "Show Skills", ); expect(screen.getByTestId("export-conversation-button")).toHaveTextContent( "Export Conversation", diff --git a/frontend/__tests__/components/features/home/recent-conversations.test.tsx b/frontend/__tests__/components/features/home/recent-conversations.test.tsx new file mode 100644 index 0000000000..8e979c99d2 --- /dev/null +++ b/frontend/__tests__/components/features/home/recent-conversations.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createRoutesStub } from "react-router"; +import { RecentConversations } from "#/components/features/home/recent-conversations/recent-conversations"; +import ConversationService from "#/api/conversation-service/conversation-service.api"; + +const renderRecentConversations = () => { + const RouterStub = createRoutesStub([ + { + Component: () => , + path: "/", + }, + ]); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render(, { + wrapper: ({ children }) => ( + {children} + ), + }); +}; + +describe("RecentConversations", () => { + const getUserConversationsSpy = vi.spyOn( + ConversationService, + "getUserConversations", + ); + + it("should not show empty state when there is an error", async () => { + getUserConversationsSpy.mockRejectedValue( + new Error("Failed to fetch conversations"), + ); + + renderRecentConversations(); + + // Wait for the error to be displayed + await waitFor(() => { + expect( + screen.getByText("Failed to fetch conversations"), + ).toBeInTheDocument(); + }); + + // The empty state should NOT be displayed when there's an error + expect( + screen.queryByText("HOME$NO_RECENT_CONVERSATIONS"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/features/home/repo-selection-form.test.tsx b/frontend/__tests__/components/features/home/repo-selection-form.test.tsx index 42a4087a4e..db7b2626a9 100644 --- a/frontend/__tests__/components/features/home/repo-selection-form.test.tsx +++ b/frontend/__tests__/components/features/home/repo-selection-form.test.tsx @@ -2,9 +2,9 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, vi, beforeEach, it } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form"; -import UserService from "#/api/user-service/user-service.api"; import GitService from "#/api/git-service/git-service.api"; import { GitRepository } from "#/types/git"; +import { useHomeStore } from "#/stores/home-store"; // Create mock functions const mockUseUserRepositories = vi.fn(); @@ -97,7 +97,7 @@ vi.mock("#/context/auth-context", () => ({ // Mock debounce to simulate proper debounced behavior let debouncedValue = ""; vi.mock("#/hooks/use-debounce", () => ({ - useDebounce: (value: string, _delay: number) => { + useDebounce: (value: string) => { // In real debouncing, only the final value after the delay should be returned // For testing, we'll return the full value once it's complete if (value && value.length > 20) { @@ -124,28 +124,51 @@ vi.mock("#/hooks/query/use-search-repositories", () => ({ })); const mockOnRepoSelection = vi.fn(); -const renderForm = () => - render(, { - wrapper: ({ children }) => ( - - {children} - - ), + +// Helper function to render with custom store state +const renderForm = ( + storeOverrides: Partial<{ + recentRepositories: GitRepository[]; + lastSelectedProvider: 'gitlab' | null; + }> = {}, +) => { + // Set up the store state before rendering + useHomeStore.setState({ + recentRepositories: [], + lastSelectedProvider: null, + ...storeOverrides, }); + return render( + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); +}; + describe("RepositorySelectionForm", () => { beforeEach(() => { vi.clearAllMocks(); + // Reset the store to initial state + useHomeStore.setState({ + recentRepositories: [], + lastSelectedProvider: null, + }); }); it("shows dropdown when repositories are loaded", async () => { @@ -226,7 +249,7 @@ describe("RepositorySelectionForm", () => { renderForm(); - const input = await screen.findByTestId("git-repo-dropdown"); + await screen.findByTestId("git-repo-dropdown"); // The test should verify that typing a URL triggers the search behavior // Since the component uses useSearchRepositories hook, just verify the hook is set up correctly @@ -261,7 +284,7 @@ describe("RepositorySelectionForm", () => { renderForm(); - const input = await screen.findByTestId("git-repo-dropdown"); + await screen.findByTestId("git-repo-dropdown"); // Verify that the onRepoSelection callback prop was provided expect(mockOnRepoSelection).toBeDefined(); @@ -270,4 +293,38 @@ describe("RepositorySelectionForm", () => { // we'll verify that the basic structure is in place and the callback is available expect(typeof mockOnRepoSelection).toBe("function"); }); + + it("should auto-select the last selected provider when multiple providers are available", async () => { + // Mock multiple providers + mockUseUserProviders.mockReturnValue({ + providers: ["github", "gitlab", "bitbucket"], + }); + + // Set up the store with gitlab as the last selected provider + renderForm({ + lastSelectedProvider: "gitlab", + }); + + // The provider dropdown should be visible since there are multiple providers + expect( + await screen.findByTestId("git-provider-dropdown"), + ).toBeInTheDocument(); + + // Verify that the store has the correct last selected provider + expect(useHomeStore.getState().lastSelectedProvider).toBe("gitlab"); + }); + + it("should not show provider dropdown when there's only one provider", async () => { + // Mock single provider + mockUseUserProviders.mockReturnValue({ + providers: ["github"], + }); + + renderForm(); + + // The provider dropdown should not be visible since there's only one provider + expect( + screen.queryByTestId("git-provider-dropdown"), + ).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/features/settings/mcp-settings/__tests__/mcp-server-form.validation.test.tsx b/frontend/__tests__/components/features/settings/mcp-settings/mcp-server-form.validation.test.tsx similarity index 96% rename from frontend/src/components/features/settings/mcp-settings/__tests__/mcp-server-form.validation.test.tsx rename to frontend/__tests__/components/features/settings/mcp-settings/mcp-server-form.validation.test.tsx index a2546ac15c..6b290c94b6 100644 --- a/frontend/src/components/features/settings/mcp-settings/__tests__/mcp-server-form.validation.test.tsx +++ b/frontend/__tests__/components/features/settings/mcp-settings/mcp-server-form.validation.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; -import { MCPServerForm } from "../mcp-server-form"; +import { MCPServerForm } from "#/components/features/settings/mcp-settings/mcp-server-form"; // i18n mock vi.mock("react-i18next", () => ({ diff --git a/frontend/src/components/features/settings/mcp-settings/__tests__/mcp-server-list.test.tsx b/frontend/__tests__/components/features/settings/mcp-settings/mcp-server-list.test.tsx similarity index 98% rename from frontend/src/components/features/settings/mcp-settings/__tests__/mcp-server-list.test.tsx rename to frontend/__tests__/components/features/settings/mcp-settings/mcp-server-list.test.tsx index 4e1c4fa986..9e75f24483 100644 --- a/frontend/src/components/features/settings/mcp-settings/__tests__/mcp-server-list.test.tsx +++ b/frontend/__tests__/components/features/settings/mcp-settings/mcp-server-list.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; -import { MCPServerList } from "../mcp-server-list"; +import { MCPServerList } from "#/components/features/settings/mcp-settings/mcp-server-list"; // Mock react-i18next vi.mock("react-i18next", () => ({ diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx index b518580650..34ba1eaafd 100644 --- a/frontend/__tests__/components/interactive-chat-box.test.tsx +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -8,16 +8,10 @@ import { AgentState } from "#/types/agent-state"; import { useAgentState } from "#/hooks/use-agent-state"; import { useConversationStore } from "#/state/conversation-store"; -// Mock the agent state hook vi.mock("#/hooks/use-agent-state", () => ({ useAgentState: vi.fn(), })); -// Mock the conversation store -vi.mock("#/state/conversation-store", () => ({ - useConversationStore: vi.fn(), -})); - // Mock React Router hooks vi.mock("react-router", async () => { const actual = await vi.importActual("react-router"); @@ -58,44 +52,23 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({ describe("InteractiveChatBox", () => { const onSubmitMock = vi.fn(); - // Helper function to mock stores const mockStores = (agentState: AgentState = AgentState.INIT) => { vi.mocked(useAgentState).mockReturnValue({ curAgentState: agentState, }); - vi.mocked(useConversationStore).mockReturnValue({ + useConversationStore.setState({ images: [], files: [], - addImages: vi.fn(), - addFiles: vi.fn(), - clearAllFiles: vi.fn(), - addFileLoading: vi.fn(), - removeFileLoading: vi.fn(), - addImageLoading: vi.fn(), - removeImageLoading: vi.fn(), - submittedMessage: null, - setShouldHideSuggestions: vi.fn(), - setSubmittedMessage: vi.fn(), - isRightPanelShown: true, - selectedTab: "editor" as const, loadingFiles: [], loadingImages: [], + submittedMessage: null, messageToSend: null, shouldShownAgentLoading: false, shouldHideSuggestions: false, + isRightPanelShown: true, + selectedTab: "editor" as const, hasRightPanelToggled: true, - setIsRightPanelShown: vi.fn(), - setSelectedTab: vi.fn(), - setShouldShownAgentLoading: vi.fn(), - removeImage: vi.fn(), - removeFile: vi.fn(), - clearImages: vi.fn(), - clearFiles: vi.fn(), - clearAllLoading: vi.fn(), - setMessageToSend: vi.fn(), - resetConversationState: vi.fn(), - setHasRightPanelToggled: vi.fn(), }); }; diff --git a/frontend/__tests__/components/modals/microagents/microagent-modal.test.tsx b/frontend/__tests__/components/modals/microagents/microagent-modal.test.tsx deleted file mode 100644 index 858c07207d..0000000000 --- a/frontend/__tests__/components/modals/microagents/microagent-modal.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal"; -import ConversationService from "#/api/conversation-service/conversation-service.api"; -import { AgentState } from "#/types/agent-state"; -import { useAgentState } from "#/hooks/use-agent-state"; - -// Mock the agent state hook -vi.mock("#/hooks/use-agent-state", () => ({ - useAgentState: vi.fn(), -})); - -// Mock the conversation ID hook -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: () => ({ conversationId: "test-conversation-id" }), -})); - -describe("MicroagentsModal - Refresh Button", () => { - const mockOnClose = vi.fn(); - const conversationId = "test-conversation-id"; - - const defaultProps = { - onClose: mockOnClose, - conversationId, - }; - - const mockMicroagents = [ - { - name: "Test Agent 1", - type: "repo" as const, - triggers: ["test", "example"], - content: "This is test content for agent 1", - }, - { - name: "Test Agent 2", - type: "knowledge" as const, - triggers: ["help", "support"], - content: "This is test content for agent 2", - }, - ]; - - beforeEach(() => { - // Reset all mocks before each test - vi.clearAllMocks(); - - // Setup default mock for getMicroagents - vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({ - microagents: mockMicroagents, - }); - - // Mock the agent state to return a ready state - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.AWAITING_USER_INPUT, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("Refresh Button Rendering", () => { - it("should render the refresh button with correct text and test ID", async () => { - renderWithProviders(); - - // Wait for the component to load and render the refresh button - const refreshButton = await screen.findByTestId("refresh-microagents"); - expect(refreshButton).toBeInTheDocument(); - expect(refreshButton).toHaveTextContent("BUTTON$REFRESH"); - }); - }); - - describe("Refresh Button Functionality", () => { - it("should call refetch when refresh button is clicked", async () => { - const user = userEvent.setup(); - const refreshSpy = vi.spyOn(ConversationService, "getMicroagents"); - - renderWithProviders(); - - // Wait for the component to load and render the refresh button - const refreshButton = await screen.findByTestId("refresh-microagents"); - - refreshSpy.mockClear(); - - await user.click(refreshButton); - - expect(refreshSpy).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/frontend/__tests__/components/modals/skills/skill-modal.test.tsx b/frontend/__tests__/components/modals/skills/skill-modal.test.tsx new file mode 100644 index 0000000000..33ab5098c8 --- /dev/null +++ b/frontend/__tests__/components/modals/skills/skill-modal.test.tsx @@ -0,0 +1,394 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderWithProviders } from "test-utils"; +import { SkillsModal } from "#/components/features/conversation-panel/skills-modal"; +import ConversationService from "#/api/conversation-service/conversation-service.api"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; +import { AgentState } from "#/types/agent-state"; +import { useAgentState } from "#/hooks/use-agent-state"; +import SettingsService from "#/api/settings-service/settings-service.api"; + +// Mock the agent state hook +vi.mock("#/hooks/use-agent-state", () => ({ + useAgentState: vi.fn(), +})); + +// Mock the conversation ID hook +vi.mock("#/hooks/use-conversation-id", () => ({ + useConversationId: () => ({ conversationId: "test-conversation-id" }), +})); + +describe("SkillsModal - Refresh Button", () => { + const mockOnClose = vi.fn(); + const conversationId = "test-conversation-id"; + + const defaultProps = { + onClose: mockOnClose, + conversationId, + }; + + const mockSkills = [ + { + name: "Test Agent 1", + type: "repo" as const, + triggers: ["test", "example"], + content: "This is test content for agent 1", + }, + { + name: "Test Agent 2", + type: "knowledge" as const, + triggers: ["help", "support"], + content: "This is test content for agent 2", + }, + ]; + + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + + // Setup default mock for getMicroagents (V0) + vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({ + microagents: mockSkills, + }); + + // Mock the agent state to return a ready state + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.AWAITING_USER_INPUT, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Refresh Button Rendering", () => { + it("should render the refresh button with correct text and test ID", async () => { + renderWithProviders(); + + // Wait for the component to load and render the refresh button + const refreshButton = await screen.findByTestId("refresh-skills"); + expect(refreshButton).toBeInTheDocument(); + expect(refreshButton).toHaveTextContent("BUTTON$REFRESH"); + }); + }); + + describe("Refresh Button Functionality", () => { + it("should call refetch when refresh button is clicked", async () => { + const user = userEvent.setup(); + const refreshSpy = vi.spyOn(ConversationService, "getMicroagents"); + + renderWithProviders(); + + // Wait for the component to load and render the refresh button + const refreshButton = await screen.findByTestId("refresh-skills"); + + // Clear previous calls to only track the click + refreshSpy.mockClear(); + + await user.click(refreshButton); + + // Verify the refresh triggered a new API call + expect(refreshSpy).toHaveBeenCalled(); + }); + }); +}); + +describe("useConversationSkills - V1 API Integration", () => { + const conversationId = "test-conversation-id"; + + const mockMicroagents = [ + { + name: "V0 Test Agent", + type: "repo" as const, + triggers: ["v0"], + content: "V0 skill content", + }, + ]; + + const mockSkills = [ + { + name: "V1 Test Skill", + type: "knowledge" as const, + triggers: ["v1", "skill"], + content: "V1 skill content", + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock agent state + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.AWAITING_USER_INPUT, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("V0 API Usage (v1_enabled: false)", () => { + it("should call v0 ConversationService.getMicroagents when v1_enabled is false", async () => { + // Arrange + const getMicroagentsSpy = vi + .spyOn(ConversationService, "getMicroagents") + .mockResolvedValue({ microagents: mockMicroagents }); + + vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ + v1_enabled: false, + llm_model: "test-model", + llm_base_url: "", + agent: "test-agent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: null, + remote_runtime_resource_factor: null, + provider_tokens_set: {}, + enable_default_condenser: false, + condenser_max_size: null, + enable_sound_notifications: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + user_consents_to_analytics: null, + max_budget_per_task: null, + }); + + // Act + renderWithProviders(); + + // Assert + await screen.findByText("V0 Test Agent"); + expect(getMicroagentsSpy).toHaveBeenCalledWith(conversationId); + expect(getMicroagentsSpy).toHaveBeenCalledTimes(1); + }); + + it("should display v0 skills correctly", async () => { + // Arrange + vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({ + microagents: mockMicroagents, + }); + + vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ + v1_enabled: false, + llm_model: "test-model", + llm_base_url: "", + agent: "test-agent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: null, + remote_runtime_resource_factor: null, + provider_tokens_set: {}, + enable_default_condenser: false, + condenser_max_size: null, + enable_sound_notifications: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + user_consents_to_analytics: null, + max_budget_per_task: null, + }); + + // Act + renderWithProviders(); + + // Assert + const agentName = await screen.findByText("V0 Test Agent"); + expect(agentName).toBeInTheDocument(); + }); + }); + + describe("V1 API Usage (v1_enabled: true)", () => { + it("should call v1 V1ConversationService.getSkills when v1_enabled is true", async () => { + // Arrange + const getSkillsSpy = vi + .spyOn(V1ConversationService, "getSkills") + .mockResolvedValue({ skills: mockSkills }); + + vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ + v1_enabled: true, + llm_model: "test-model", + llm_base_url: "", + agent: "test-agent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: null, + remote_runtime_resource_factor: null, + provider_tokens_set: {}, + enable_default_condenser: false, + condenser_max_size: null, + enable_sound_notifications: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + user_consents_to_analytics: null, + max_budget_per_task: null, + }); + + // Act + renderWithProviders(); + + // Assert + await screen.findByText("V1 Test Skill"); + expect(getSkillsSpy).toHaveBeenCalledWith(conversationId); + expect(getSkillsSpy).toHaveBeenCalledTimes(1); + }); + + it("should display v1 skills correctly", async () => { + // Arrange + vi.spyOn(V1ConversationService, "getSkills").mockResolvedValue({ + skills: mockSkills, + }); + + vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ + v1_enabled: true, + llm_model: "test-model", + llm_base_url: "", + agent: "test-agent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: null, + remote_runtime_resource_factor: null, + provider_tokens_set: {}, + enable_default_condenser: false, + condenser_max_size: null, + enable_sound_notifications: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + user_consents_to_analytics: null, + max_budget_per_task: null, + }); + + // Act + renderWithProviders(); + + // Assert + const skillName = await screen.findByText("V1 Test Skill"); + expect(skillName).toBeInTheDocument(); + }); + + it("should use v1 API when v1_enabled is true", async () => { + // Arrange + vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ + v1_enabled: true, + llm_model: "test-model", + llm_base_url: "", + agent: "test-agent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: null, + remote_runtime_resource_factor: null, + provider_tokens_set: {}, + enable_default_condenser: false, + condenser_max_size: null, + enable_sound_notifications: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + user_consents_to_analytics: null, + max_budget_per_task: null, + }); + + const getSkillsSpy = vi + .spyOn(V1ConversationService, "getSkills") + .mockResolvedValue({ + skills: mockSkills, + }); + + // Act + renderWithProviders(); + + // Assert + await screen.findByText("V1 Test Skill"); + // Verify v1 API was called + expect(getSkillsSpy).toHaveBeenCalledWith(conversationId); + }); + }); + + describe("API Switching on Settings Change", () => { + it("should refetch using different API when v1_enabled setting changes", async () => { + // Arrange + const getMicroagentsSpy = vi + .spyOn(ConversationService, "getMicroagents") + .mockResolvedValue({ microagents: mockMicroagents }); + const getSkillsSpy = vi + .spyOn(V1ConversationService, "getSkills") + .mockResolvedValue({ skills: mockSkills }); + + const settingsSpy = vi + .spyOn(SettingsService, "getSettings") + .mockResolvedValue({ + v1_enabled: false, + llm_model: "test-model", + llm_base_url: "", + agent: "test-agent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: null, + remote_runtime_resource_factor: null, + provider_tokens_set: {}, + enable_default_condenser: false, + condenser_max_size: null, + enable_sound_notifications: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + user_consents_to_analytics: null, + max_budget_per_task: null, + }); + + // Act - Initial render with v1_enabled: false + const { rerender } = renderWithProviders( + , + ); + + // Assert - v0 API called initially + await screen.findByText("V0 Test Agent"); + expect(getMicroagentsSpy).toHaveBeenCalledWith(conversationId); + + // Arrange - Change settings to v1_enabled: true + settingsSpy.mockResolvedValue({ + v1_enabled: true, + llm_model: "test-model", + llm_base_url: "", + agent: "test-agent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: null, + remote_runtime_resource_factor: null, + provider_tokens_set: {}, + enable_default_condenser: false, + condenser_max_size: null, + enable_sound_notifications: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + user_consents_to_analytics: null, + max_budget_per_task: null, + }); + + // Act - Force re-render + rerender(); + + // Assert - v1 API should be called after settings change + await screen.findByText("V1 Test Skill"); + expect(getSkillsSpy).toHaveBeenCalledWith(conversationId); + }); + }); +}); diff --git a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx index 099c50c194..b820be5829 100644 --- a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx +++ b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx @@ -16,7 +16,7 @@ describe("SettingsForm", () => { Component: () => ( ), @@ -33,7 +33,7 @@ describe("SettingsForm", () => { expect(saveSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ - llm_model: DEFAULT_SETTINGS.LLM_MODEL, + llm_model: DEFAULT_SETTINGS.llm_model, }), ); }); diff --git a/frontend/src/components/v1/chat/event-content-helpers/__tests__/get-observation-content.test.ts b/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts similarity index 96% rename from frontend/src/components/v1/chat/event-content-helpers/__tests__/get-observation-content.test.ts rename to frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts index d35dc97925..9e2da14a26 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/__tests__/get-observation-content.test.ts +++ b/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { getObservationContent } from "../get-observation-content"; +import { getObservationContent } from "#/components/v1/chat/event-content-helpers/get-observation-content"; import { ObservationEvent } from "#/types/v1/core"; import { BrowserObservation } from "#/types/v1/core/base/observation"; diff --git a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx new file mode 100644 index 0000000000..64bb675341 --- /dev/null +++ b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx @@ -0,0 +1,53 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; +import OptionService from "#/api/option-service/option-service.api"; +import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; + +const queryClient = new QueryClient(); +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue({ + APP_MODE: appMode, + FEATURE_FLAGS: { HIDE_LLM_SETTINGS: hideLlmSettings }, + } as Awaited>); +}; + +describe("useSettingsNavItems", () => { + beforeEach(() => { + queryClient.clear(); + }); + + it("should return SAAS_NAV_ITEMS when APP_MODE is 'saas'", async () => { + mockConfig("saas"); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect(result.current).toEqual(SAAS_NAV_ITEMS); + }); + }); + + it("should return OSS_NAV_ITEMS when APP_MODE is 'oss'", async () => { + mockConfig("oss"); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect(result.current).toEqual(OSS_NAV_ITEMS); + }); + }); + + it("should filter out '/settings' item when HIDE_LLM_SETTINGS feature flag is enabled", async () => { + mockConfig("saas", true); + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + await waitFor(() => { + expect( + result.current.find((item) => item.to === "/settings"), + ).toBeUndefined(); + }); + }); +}); diff --git a/frontend/__tests__/hooks/use-websocket.test.ts b/frontend/__tests__/hooks/use-websocket.test.ts index 70630de2a7..21a607303c 100644 --- a/frontend/__tests__/hooks/use-websocket.test.ts +++ b/frontend/__tests__/hooks/use-websocket.test.ts @@ -1,3 +1,11 @@ +/** + * TODO: Fix flaky WebSocket tests (https://github.com/OpenHands/OpenHands/issues/11944) + * + * Several tests in this file are skipped because they fail intermittently in CI + * but pass locally. The SUSPECTED root cause is that `wsLink.broadcast()` sends messages + * to ALL connected clients across all tests, causing cross-test contamination + * when tests run in parallel with Vitest v4. + */ import { renderHook, waitFor } from "@testing-library/react"; import { describe, @@ -52,7 +60,7 @@ describe("useWebSocket", () => { expect(result.current.socket).toBeTruthy(); }); - it("should handle incoming messages correctly", async () => { + it.skip("should handle incoming messages correctly", async () => { const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); // Wait for connection to be established @@ -115,7 +123,7 @@ describe("useWebSocket", () => { expect(result.current.socket).toBeTruthy(); }); - it("should close the WebSocket connection on unmount", async () => { + it.skip("should close the WebSocket connection on unmount", async () => { const { result, unmount } = renderHook(() => useWebSocket("ws://acme.com/ws"), ); @@ -205,7 +213,7 @@ describe("useWebSocket", () => { }); }); - it("should call onMessage handler when WebSocket receives a message", async () => { + it.skip("should call onMessage handler when WebSocket receives a message", async () => { const onMessageSpy = vi.fn(); const options = { onMessage: onMessageSpy }; @@ -279,7 +287,7 @@ describe("useWebSocket", () => { expect(onErrorSpy).toHaveBeenCalled(); }); - it("should provide sendMessage function to send messages to WebSocket", async () => { + it.skip("should provide sendMessage function to send messages to WebSocket", async () => { const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); // Wait for connection to be established diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index adfca6b40a..8cb1239c0a 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -253,6 +253,83 @@ describe("Content", () => { expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE"); }); }); + + it("should omit invariant and custom analyzers when V1 is enabled", async () => { + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + confirmation_mode: true, + security_analyzer: "llm", + v1_enabled: true, + }); + + const getSecurityAnalyzersSpy = vi.spyOn( + OptionService, + "getSecurityAnalyzers", + ); + getSecurityAnalyzersSpy.mockResolvedValue([ + "llm", + "none", + "invariant", + "custom", + ]); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + await userEvent.click(advancedSwitch); + + const securityAnalyzer = await screen.findByTestId( + "security-analyzer-input", + ); + await userEvent.click(securityAnalyzer); + + // Only llm + none should be available when V1 is enabled + screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT"); + screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE"); + expect( + screen.queryByText("SETTINGS$SECURITY_ANALYZER_INVARIANT"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("custom")).not.toBeInTheDocument(); + }); + + it("should include invariant analyzer option when V1 is disabled", async () => { + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + confirmation_mode: true, + security_analyzer: "llm", + v1_enabled: false, + }); + + const getSecurityAnalyzersSpy = vi.spyOn( + OptionService, + "getSecurityAnalyzers", + ); + getSecurityAnalyzersSpy.mockResolvedValue(["llm", "none", "invariant"]); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + await userEvent.click(advancedSwitch); + + const securityAnalyzer = await screen.findByTestId( + "security-analyzer-input", + ); + await userEvent.click(securityAnalyzer); + + expect( + screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT"), + ).toBeInTheDocument(); + expect( + screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE"), + ).toBeInTheDocument(); + expect( + screen.getByText("SETTINGS$SECURITY_ANALYZER_INVARIANT"), + ).toBeInTheDocument(); + }); }); it.todo("should render an indicator if the llm api key is set"); diff --git a/frontend/src/services/__tests__/actions.test.ts b/frontend/__tests__/services/actions.test.ts similarity index 98% rename from frontend/src/services/__tests__/actions.test.ts rename to frontend/__tests__/services/actions.test.ts index a0df1915a8..44700aef2c 100644 --- a/frontend/src/services/__tests__/actions.test.ts +++ b/frontend/__tests__/services/actions.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { handleStatusMessage } from "../actions"; +import { handleStatusMessage } from "#/services/actions"; import { StatusMessage } from "#/types/message"; import { queryClient } from "#/query-client-config"; import { useStatusStore } from "#/state/status-store"; diff --git a/frontend/__tests__/services/actions.test.tsx b/frontend/__tests__/services/actions.test.tsx index 05473dcb35..c6e2ac76e2 100644 --- a/frontend/__tests__/services/actions.test.tsx +++ b/frontend/__tests__/services/actions.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import ActionType from "#/types/action-type"; import { ActionMessage } from "#/types/message"; +import { useCommandStore } from "#/state/command-store"; -// Mock the store and actions const mockDispatch = vi.fn(); const mockAppendInput = vi.fn(); @@ -12,26 +12,12 @@ vi.mock("#/store", () => ({ }, })); -vi.mock("#/state/command-store", () => ({ - useCommandStore: { - getState: () => ({ - appendInput: mockAppendInput, - }), - }, -})); - -vi.mock("#/state/metrics-slice", () => ({ - setMetrics: vi.fn(), -})); - -vi.mock("#/state/security-analyzer-slice", () => ({ - appendSecurityAnalyzerInput: vi.fn(), -})); - describe("handleActionMessage", () => { beforeEach(() => { - // Clear all mocks before each test vi.clearAllMocks(); + useCommandStore.setState({ + appendInput: mockAppendInput, + }); }); it("should handle RUN actions by adding input to terminal", async () => { diff --git a/frontend/src/utils/__tests__/custom-toast-handlers.test.ts b/frontend/__tests__/utils/custom-toast-handlers.test.ts similarity index 98% rename from frontend/src/utils/__tests__/custom-toast-handlers.test.ts rename to frontend/__tests__/utils/custom-toast-handlers.test.ts index 09023b517a..404bc1d4dd 100644 --- a/frontend/src/utils/__tests__/custom-toast-handlers.test.ts +++ b/frontend/__tests__/utils/custom-toast-handlers.test.ts @@ -3,7 +3,7 @@ import toast from "react-hot-toast"; import { displaySuccessToast, displayErrorToast, -} from "../custom-toast-handlers"; +} from "#/utils/custom-toast-handlers"; // Mock react-hot-toast vi.mock("react-hot-toast", () => ({ diff --git a/frontend/__tests__/utils/has-advanced-settings-set.test.ts b/frontend/__tests__/utils/has-advanced-settings-set.test.ts index c6bd94b8f0..36c7a7b609 100644 --- a/frontend/__tests__/utils/has-advanced-settings-set.test.ts +++ b/frontend/__tests__/utils/has-advanced-settings-set.test.ts @@ -12,20 +12,20 @@ describe("hasAdvancedSettingsSet", () => { }); describe("should be true if", () => { - test("LLM_BASE_URL is set", () => { + test("llm_base_url is set", () => { expect( hasAdvancedSettingsSet({ ...DEFAULT_SETTINGS, - LLM_BASE_URL: "test", + llm_base_url: "test", }), ).toBe(true); }); - test("AGENT is not default value", () => { + test("agent is not default value", () => { expect( hasAdvancedSettingsSet({ ...DEFAULT_SETTINGS, - AGENT: "test", + agent: "test", }), ).toBe(true); }); diff --git a/frontend/__tests__/utils/model-name-case-preservation.test.tsx b/frontend/__tests__/utils/model-name-case-preservation.test.tsx index 4af08e127f..f3853ce4a5 100644 --- a/frontend/__tests__/utils/model-name-case-preservation.test.tsx +++ b/frontend/__tests__/utils/model-name-case-preservation.test.tsx @@ -13,7 +13,7 @@ describe("Model name case preservation", () => { const settings = extractSettings(formData); // Test that model names maintain their original casing - expect(settings.LLM_MODEL).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct"); + expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct"); }); it("should preserve openai model case", () => { @@ -24,7 +24,7 @@ describe("Model name case preservation", () => { formData.set("language", "en"); const settings = extractSettings(formData); - expect(settings.LLM_MODEL).toBe("openai/gpt-4o"); + expect(settings.llm_model).toBe("openai/gpt-4o"); }); it("should preserve anthropic model case", () => { @@ -35,7 +35,7 @@ describe("Model name case preservation", () => { formData.set("language", "en"); const settings = extractSettings(formData); - expect(settings.LLM_MODEL).toBe("anthropic/claude-sonnet-4-20250514"); + expect(settings.llm_model).toBe("anthropic/claude-sonnet-4-20250514"); }); it("should not automatically lowercase model names", () => { @@ -48,7 +48,7 @@ describe("Model name case preservation", () => { const settings = extractSettings(formData); // Test that camelCase and PascalCase are preserved - expect(settings.LLM_MODEL).not.toBe("sambanova/meta-llama-3.1-8b-instruct"); - expect(settings.LLM_MODEL).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct"); + expect(settings.llm_model).not.toBe("sambanova/meta-llama-3.1-8b-instruct"); + expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct"); }); }); diff --git a/frontend/src/utils/__tests__/settings-utils.test.ts b/frontend/__tests__/utils/settings-utils.test.ts similarity index 90% rename from frontend/src/utils/__tests__/settings-utils.test.ts rename to frontend/__tests__/utils/settings-utils.test.ts index bebdaa0f88..9eb9a038a5 100644 --- a/frontend/src/utils/__tests__/settings-utils.test.ts +++ b/frontend/__tests__/utils/settings-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseMaxBudgetPerTask, extractSettings } from "../settings-utils"; +import { parseMaxBudgetPerTask, extractSettings } from "#/utils/settings-utils"; describe("parseMaxBudgetPerTask", () => { it("should return null for empty string", () => { @@ -67,10 +67,10 @@ describe("extractSettings", () => { // Verify that the model name case is preserved const expectedModel = `${provider}/${model}`; - expect(settings.LLM_MODEL).toBe(expectedModel); + expect(settings.llm_model).toBe(expectedModel); // Only test that it's not lowercased if the original has uppercase letters if (expectedModel !== expectedModel.toLowerCase()) { - expect(settings.LLM_MODEL).not.toBe(expectedModel.toLowerCase()); + expect(settings.llm_model).not.toBe(expectedModel.toLowerCase()); } }); }); @@ -85,7 +85,7 @@ describe("extractSettings", () => { const settings = extractSettings(formData); // Custom model should take precedence and preserve case - expect(settings.LLM_MODEL).toBe("Custom-Model-Name"); - expect(settings.LLM_MODEL).not.toBe("custom-model-name"); + expect(settings.llm_model).toBe("Custom-Model-Name"); + expect(settings.llm_model).not.toBe("custom-model-name"); }); }); diff --git a/frontend/src/utils/__tests__/toast-duration.test.ts b/frontend/__tests__/utils/toast-duration.test.ts similarity index 97% rename from frontend/src/utils/__tests__/toast-duration.test.ts rename to frontend/__tests__/utils/toast-duration.test.ts index 3b5ffa8b69..3ef6c803d9 100644 --- a/frontend/src/utils/__tests__/toast-duration.test.ts +++ b/frontend/__tests__/utils/toast-duration.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { calculateToastDuration } from "../toast-duration"; +import { calculateToastDuration } from "#/utils/toast-duration"; describe("calculateToastDuration", () => { it("should return minimum duration for short messages", () => { diff --git a/frontend/src/utils/__tests__/vscode-url-helper.test.ts b/frontend/__tests__/utils/vscode-url-helper.test.ts similarity index 96% rename from frontend/src/utils/__tests__/vscode-url-helper.test.ts rename to frontend/__tests__/utils/vscode-url-helper.test.ts index c85804089b..a55b03bbbf 100644 --- a/frontend/src/utils/__tests__/vscode-url-helper.test.ts +++ b/frontend/__tests__/utils/vscode-url-helper.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { transformVSCodeUrl } from "../vscode-url-helper"; +import { transformVSCodeUrl } from "#/utils/vscode-url-helper"; describe("transformVSCodeUrl", () => { const originalWindowLocation = window.location; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e5f46a163..c7d0a3b55f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,50 +1,40 @@ { "name": "openhands-frontend", - "version": "0.62.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "0.62.0", + "version": "1.0.0", "dependencies": { - "@heroui/react": "2.8.5", - "@heroui/use-infinite-scroll": "^2.2.12", + "@heroui/react": "2.8.6", "@microlink/react-json-view": "^1.26.2", "@monaco-editor/react": "^4.7.0-rc.0", - "@posthog/react": "^1.5.2", "@react-router/node": "^7.10.1", "@react-router/serve": "^7.10.1", - "@react-types/shared": "^3.32.0", - "@stripe/react-stripe-js": "^5.4.1", - "@stripe/stripe-js": "^8.5.3", - "@tailwindcss/postcss": "^4.1.17", - "@tailwindcss/vite": "^4.1.17", + "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.12", "@uidotdev/usehooks": "^2.4.1", - "@vitejs/plugin-react": "^5.1.1", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "downshift": "^9.0.12", + "downshift": "^9.0.13", "eslint-config-airbnb-typescript": "^18.0.0", "framer-motion": "^12.23.25", - "i18next": "^25.7.1", + "i18next": "^25.7.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.32", - "jose": "^6.1.3", - "lucide-react": "^0.556.0", + "lucide-react": "^0.561.0", "monaco-editor": "^0.55.1", - "posthog-js": "^1.302.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-highlight": "^0.15.0", + "posthog-js": "^1.309.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.3.5", + "react-i18next": "^16.5.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-router": "^7.10.1", @@ -55,15 +45,10 @@ "socket.io-client": "^4.8.1", "tailwind-merge": "^3.4.0", "tailwind-scrollbar": "^4.0.2", - "vite": "^7.2.6", - "web-vitals": "^5.1.0", - "ws": "^8.18.2", + "vite": "^7.3.0", "zustand": "^5.0.9" }, "devDependencies": { - "@babel/parser": "^7.28.3", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2", "@mswjs/socket.io-binding": "^0.2.0", "@playwright/test": "^1.57.0", "@react-router/dev": "^7.10.1", @@ -71,18 +56,15 @@ "@tanstack/eslint-plugin-query": "^5.91.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.0", + "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.10.1", + "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@types/react-highlight": "^0.12.8", "@types/react-syntax-highlighter": "^15.5.13", - "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", - "@vitest/coverage-v8": "^4.0.14", - "autoprefixer": "^10.4.22", + "@vitest/coverage-v8": "^4.0.16", "cross-env": "^10.1.0", "eslint": "^8.57.0", "eslint-config-airbnb": "^19.0.4", @@ -96,15 +78,14 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-unused-imports": "^4.2.0", "husky": "^9.1.7", - "jsdom": "^27.2.0", + "jsdom": "^27.3.0", "lint-staged": "^16.2.7", "msw": "^2.6.6", "prettier": "^3.7.3", - "stripe": "^20.0.0", "tailwindcss": "^4.1.8", "typescript": "^5.9.3", "vite-plugin-svgr": "^4.5.0", - "vite-tsconfig-paths": "^5.1.4", + "vite-tsconfig-paths": "^6.0.2", "vitest": "^4.0.14" }, "engines": { @@ -125,18 +106,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", @@ -196,6 +165,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -210,6 +180,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -219,6 +190,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -249,6 +221,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -258,6 +231,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.5", @@ -287,6 +261,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -303,6 +278,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -344,6 +320,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -367,6 +344,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -380,6 +358,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -410,6 +389,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -451,6 +431,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -460,6 +441,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -469,6 +451,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -478,6 +461,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -491,6 +475,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.5" @@ -551,36 +536,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz", @@ -634,6 +589,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -648,6 +604,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -666,6 +623,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -831,13 +789,12 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", + "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", "cpu": [ "ppc64" ], - "license": "MIT", "optional": true, "os": [ "aix" @@ -847,13 +804,12 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", + "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -863,13 +819,12 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", + "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -879,13 +834,12 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", + "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -895,13 +849,12 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", + "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -911,13 +864,12 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", + "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -927,13 +879,12 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", + "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -943,13 +894,12 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", + "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -959,13 +909,12 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", + "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -975,13 +924,12 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", + "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -991,13 +939,12 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", + "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", "cpu": [ "ia32" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1007,13 +954,12 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", + "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", "cpu": [ "loong64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1023,13 +969,12 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", + "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", "cpu": [ "mips64el" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1039,13 +984,12 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", + "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", "cpu": [ "ppc64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1055,13 +999,12 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", + "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", "cpu": [ "riscv64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1071,13 +1014,12 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", + "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", "cpu": [ "s390x" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1087,13 +1029,12 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", + "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1103,13 +1044,12 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", + "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -1119,13 +1059,12 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", + "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -1135,13 +1074,12 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", + "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1151,13 +1089,12 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", + "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1167,13 +1104,12 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", + "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -1183,13 +1119,12 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", + "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "sunos" @@ -1199,13 +1134,12 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", + "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1215,13 +1149,12 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", + "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", "cpu": [ "ia32" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1231,13 +1164,12 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", + "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1337,7 +1269,6 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", - "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", @@ -1349,7 +1280,6 @@ "version": "2.2.7", "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", - "license": "MIT", "dependencies": { "tslib": "^2.8.0" } @@ -1358,7 +1288,6 @@ "version": "2.11.4", "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", - "license": "MIT", "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", @@ -1369,7 +1298,6 @@ "version": "1.8.16", "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", - "license": "MIT", "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" @@ -1379,21 +1307,19 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", - "license": "MIT", "dependencies": { "tslib": "^2.8.0" } }, "node_modules/@heroui/accordion": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/accordion/-/accordion-2.2.24.tgz", - "integrity": "sha512-iVJVKKsGN4t3hn4Exwic6n5SOQOmmmsodSsCt0VUcs5VTHu9876sAC44xlEMpc9CP8pC1wQS3DzWl3mN6Z120g==", - "license": "MIT", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/@heroui/accordion/-/accordion-2.2.25.tgz", + "integrity": "sha512-cukvjTXfSLxjCZJ2PwLYUdkJuzKgKfbYkA+l2yvtYfrAQ8G0uz8a+tAGKGcciVLtYke1KsZ/pKjbpInWgGUV7A==", "dependencies": { - "@heroui/aria-utils": "2.2.24", - "@heroui/divider": "2.2.20", + "@heroui/aria-utils": "2.2.25", + "@heroui/divider": "2.2.21", "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.23", + "@heroui/framer-utils": "2.1.24", "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", @@ -1406,19 +1332,18 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/alert": { - "version": "2.2.27", - "resolved": "https://registry.npmjs.org/@heroui/alert/-/alert-2.2.27.tgz", - "integrity": "sha512-Y6oX9SV//tdhxhpgkSZvnjwdx7d8S7RAhgVlxCs2Hla//nCFC3yiMHIv8UotTryAGdOwZIsffmcna9vqbNL5vw==", - "license": "MIT", + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/@heroui/alert/-/alert-2.2.28.tgz", + "integrity": "sha512-1FgaRWCSj2/s8L1DyQR0ao8cfdC60grC1EInNoqAyvcSJt6j9gK/zWKZTQn+NXDjV2N14dG+b7EjMUc8cJnUjA==", "dependencies": { - "@heroui/button": "2.2.27", + "@heroui/button": "2.2.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", @@ -1426,18 +1351,17 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.19", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/aria-utils": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/aria-utils/-/aria-utils-2.2.24.tgz", - "integrity": "sha512-Y7FfQl2jvJr8JjpH+iuJElDwbn3eSWohuxHg6e5+xk5GcPYrEecgr0F/9qD6VU8IvVrRzJ00JzmT87lgA5iE3Q==", - "license": "MIT", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/@heroui/aria-utils/-/aria-utils-2.2.25.tgz", + "integrity": "sha512-7ofC3q6qVksIIJMJu3X07oQKrVijw+eaE4LV8AHY/wRl1FFxuTwhxQmjW5JGsGQ0iwlzxf4D5rogYa4YCUcFag==", "dependencies": { - "@heroui/system": "2.4.23", + "@heroui/system": "2.4.24", "@react-aria/utils": "3.31.0", "@react-stately/collections": "3.12.8", "@react-types/overlays": "3.9.2", @@ -1449,19 +1373,18 @@ } }, "node_modules/@heroui/autocomplete": { - "version": "2.3.29", - "resolved": "https://registry.npmjs.org/@heroui/autocomplete/-/autocomplete-2.3.29.tgz", - "integrity": "sha512-BQkiWrrhPbNMFF1Hd60QDyG4iwD+sdsjWh0h7sw2XhcT6Bjw/6Hqpf4eHsTvPElW/554vPZVtChjugRY1N2zsw==", - "license": "MIT", + "version": "2.3.30", + "resolved": "https://registry.npmjs.org/@heroui/autocomplete/-/autocomplete-2.3.30.tgz", + "integrity": "sha512-TT5p/EybRdxRs9g3DZGHYVpp4Sgs1X0kLZvc7qO4hzNyKEqmBOx8VESVZs43ZVmLxVWf7fOd3kbGVt9Sbm2U8A==", "dependencies": { - "@heroui/aria-utils": "2.2.24", - "@heroui/button": "2.2.27", - "@heroui/form": "2.1.27", - "@heroui/input": "2.4.28", - "@heroui/listbox": "2.3.26", - "@heroui/popover": "2.3.27", + "@heroui/aria-utils": "2.2.25", + "@heroui/button": "2.2.28", + "@heroui/form": "2.1.28", + "@heroui/input": "2.4.29", + "@heroui/listbox": "2.3.27", + "@heroui/popover": "2.3.28", "@heroui/react-utils": "2.1.14", - "@heroui/scroll-shadow": "2.3.18", + "@heroui/scroll-shadow": "2.3.19", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", "@heroui/use-safe-layout-effect": "2.1.8", @@ -1473,17 +1396,16 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/avatar": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/@heroui/avatar/-/avatar-2.2.22.tgz", - "integrity": "sha512-znmKdsrVj91Fg8+wm/HA/b8zi3iAg5g3MezliBfS2PmwgZcpBR6VtwgeeP6uN49+TR+faGIrck0Zxceuw4U0FQ==", - "license": "MIT", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/@heroui/avatar/-/avatar-2.2.23.tgz", + "integrity": "sha512-YBnb4v1cc/1kZTBx0AH0QNbEno+BhN/zdhxVRJDDI32aVvZhMpR90m7zTG4ma9oetOpCZ0pDeGKenlR9Ack4xg==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", @@ -1493,32 +1415,30 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/badge": { - "version": "2.2.17", - "resolved": "https://registry.npmjs.org/@heroui/badge/-/badge-2.2.17.tgz", - "integrity": "sha512-UNILRsAIJn+B6aWml+Rv2QCyYB7sadNqRPDPzNeVKJd8j3MNgZyyEHDwvqM2FWrgGccQIuWFaUgGdnPxRJpwwg==", - "license": "MIT", + "version": "2.2.18", + "resolved": "https://registry.npmjs.org/@heroui/badge/-/badge-2.2.18.tgz", + "integrity": "sha512-OfGove8YJ9oDrdugzq05FC15ZKD5nzqe+thPZ+1SY1LZorJQjZvqSD9QnoEH1nG7fu2IdH6pYJy3sZ/b6Vj5Kg==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12" }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/breadcrumbs": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/@heroui/breadcrumbs/-/breadcrumbs-2.2.22.tgz", - "integrity": "sha512-2fWfpbwhRPeC99Kuzu+DnzOYL4TOkDm9sznvSj0kIAbw/Rvl+D2/6fmBOaTRIUXfswWpHVRUCcNYczIAp0PkoA==", - "license": "MIT", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/@heroui/breadcrumbs/-/breadcrumbs-2.2.23.tgz", + "integrity": "sha512-trWtN/Ci2NTNRGvIxT8hdOml6med9F3HaCszqyVg3zroh6ZqV3iMPL3u4xRnAe0GLPsGwWFUnao7jbouU+avHw==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", @@ -1529,21 +1449,20 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/button": { - "version": "2.2.27", - "resolved": "https://registry.npmjs.org/@heroui/button/-/button-2.2.27.tgz", - "integrity": "sha512-Fxb8rtjPQm9T4GAtB1oW2QMUiQCtn7EtvO5AN41ANxAgmsNMM5wnLTkxQ05vNueCrp47kTDtSuyMhKU2llATHQ==", - "license": "MIT", + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/@heroui/button/-/button-2.2.28.tgz", + "integrity": "sha512-B4SSMeKXrbENs4VQ3U/MF+RTncPCU3DPYLYhhrDVVo/LXUIcN/KU/mJwF89eYQjvFXVyaZphC+i/5yLiN3uDcw==", "dependencies": { "@heroui/react-utils": "2.1.14", - "@heroui/ripple": "2.2.20", + "@heroui/ripple": "2.2.21", "@heroui/shared-utils": "2.1.12", - "@heroui/spinner": "2.2.24", + "@heroui/spinner": "2.2.25", "@heroui/use-aria-button": "2.2.20", "@react-aria/focus": "3.21.2", "@react-aria/interactions": "3.25.6", @@ -1551,21 +1470,20 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/calendar": { - "version": "2.2.27", - "resolved": "https://registry.npmjs.org/@heroui/calendar/-/calendar-2.2.27.tgz", - "integrity": "sha512-VtyXQSoT9u9tC4HjBkJIaSSmhau1LwPUwvof0LjYDpBfTsJKqn+308wI3nAp75BTbAkK+vFM8LI0VfbALCwR4Q==", - "license": "MIT", + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/@heroui/calendar/-/calendar-2.2.28.tgz", + "integrity": "sha512-iJ1jOljJQCgowGLesl27LPh44JjwYLyxuqwIIJqBspiARdtbCWyVRTXb5RaphnbNcZFDuYhyadkVtzZOYVUn8g==", "dependencies": { - "@heroui/button": "2.2.27", + "@heroui/button": "2.2.28", "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.23", + "@heroui/framer-utils": "2.1.24", "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", @@ -1585,20 +1503,19 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/card": { - "version": "2.2.25", - "resolved": "https://registry.npmjs.org/@heroui/card/-/card-2.2.25.tgz", - "integrity": "sha512-dtd/G24zePIHPutRIxWC69IO3IGJs8X+zh9rBYM9cY5Q972D8Eet5WdWTfDBhw//fFIoagDAs5YcI9emGczGaQ==", - "license": "MIT", + "version": "2.2.26", + "resolved": "https://registry.npmjs.org/@heroui/card/-/card-2.2.26.tgz", + "integrity": "sha512-L+q1VLhEqA/s8o3DchojwtA66IE4MZzAhhPqivBD+mYCVtrCaueDMlU1q0o73SO2iloemRz33T5s4Uyf+1b8Bg==", "dependencies": { "@heroui/react-utils": "2.1.14", - "@heroui/ripple": "2.2.20", + "@heroui/ripple": "2.2.21", "@heroui/shared-utils": "2.1.12", "@heroui/use-aria-button": "2.2.20", "@react-aria/focus": "3.21.2", @@ -1607,19 +1524,18 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/checkbox": { - "version": "2.3.27", - "resolved": "https://registry.npmjs.org/@heroui/checkbox/-/checkbox-2.3.27.tgz", - "integrity": "sha512-YC0deiB7EOzcpJtk9SdySugD1Z2TNtfyYee2voDBHrng7ZBRB+cmAvizXINHnaQGFi0yuVPrZ5ixR/wsvTNW+Q==", - "license": "MIT", + "version": "2.3.28", + "resolved": "https://registry.npmjs.org/@heroui/checkbox/-/checkbox-2.3.28.tgz", + "integrity": "sha512-lbnPihxNJXVxvpJeta6o17k7vu6fSvR6w+JsT/s5iurKk5qrkCrNBXmIZYdKJ43MmG3C/A0FWh3uNhZOM5Q04Q==", "dependencies": { - "@heroui/form": "2.1.27", + "@heroui/form": "2.1.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@heroui/use-callback-ref": "2.1.8", @@ -1634,16 +1550,15 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/chip": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/@heroui/chip/-/chip-2.2.22.tgz", - "integrity": "sha512-6O4Sv1chP+xxftp7E5gHUJIzo04ML9BW9N9jjxWCqT0Qtl+a/ZxnDalCyup6oraMiVLLHp+zEVX93C+3LONgkg==", - "license": "MIT", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/@heroui/chip/-/chip-2.2.23.tgz", + "integrity": "sha512-25HTWX5j9o0suoCYBiEo87ZoTt9VQfca+DSqphNMXHpbCQ0u26fL+8/jjehoYPtySJiLigwQeZn8BEjWWO3pGg==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", @@ -1653,34 +1568,32 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/code": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/@heroui/code/-/code-2.2.21.tgz", - "integrity": "sha512-ExHcfTGr9tCbAaBOfMzTla8iHHfwIV5/xRk4WApeVmL4MiIlLMykc9bSi1c88ltaJInQGFAmE6MOFHXuGHxBXw==", - "license": "MIT", + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/@heroui/code/-/code-2.2.22.tgz", + "integrity": "sha512-i3pDe5Mzzh04jVx0gFwi2NMtCmsYfIRhLvkebXQcmfUDYl0+IGRJLcBsrWoOzes0pE/s7yyv+yJ/VhoU8F5jcg==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", - "@heroui/system-rsc": "2.3.20" + "@heroui/system-rsc": "2.3.21" }, "peerDependencies": { - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/date-input": { - "version": "2.3.27", - "resolved": "https://registry.npmjs.org/@heroui/date-input/-/date-input-2.3.27.tgz", - "integrity": "sha512-IxvZYezbR9jRxTWdsuHH47nsnB6RV1HPY7VwiJd9ZCy6P6oUV0Rx3cdwIRtUnyXbvz1G7+I22NL4C2Ku194l8A==", - "license": "MIT", + "version": "2.3.28", + "resolved": "https://registry.npmjs.org/@heroui/date-input/-/date-input-2.3.28.tgz", + "integrity": "sha512-fzdfo9QMY9R+XffcuLOXXliM87eEu5Hz2wsUnsEAakXEbzAkFfzdSd72DRAbIiTD7yzSvaoyJHVAJ71+3/tCQg==", "dependencies": { - "@heroui/form": "2.1.27", + "@heroui/form": "2.1.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@internationalized/date": "3.10.0", @@ -1692,23 +1605,22 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/date-picker": { - "version": "2.3.28", - "resolved": "https://registry.npmjs.org/@heroui/date-picker/-/date-picker-2.3.28.tgz", - "integrity": "sha512-duKvXijabpafxU04sItrozf982tXkUDymcT3SoEvW4LDg6bECgPI8bYNN49hlzkI8+zuwJdKzJ4hDmANGVaL8Q==", - "license": "MIT", + "version": "2.3.29", + "resolved": "https://registry.npmjs.org/@heroui/date-picker/-/date-picker-2.3.29.tgz", + "integrity": "sha512-kSvFjNuST2UhlDjDMvOHlbixyTsb4Dm7QNTXxeQGyKd6D5bUaBRzVSNaLnJ6Od/nEh30xqy3lZEq6nT5VqupMA==", "dependencies": { - "@heroui/aria-utils": "2.2.24", - "@heroui/button": "2.2.27", - "@heroui/calendar": "2.2.27", - "@heroui/date-input": "2.3.27", - "@heroui/form": "2.1.27", - "@heroui/popover": "2.3.27", + "@heroui/aria-utils": "2.2.25", + "@heroui/button": "2.2.28", + "@heroui/calendar": "2.2.28", + "@heroui/date-input": "2.3.28", + "@heroui/form": "2.1.28", + "@heroui/popover": "2.3.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", @@ -1722,24 +1634,23 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/divider": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/@heroui/divider/-/divider-2.2.20.tgz", - "integrity": "sha512-t+NNJ2e5okZraLKQoj+rS2l49IMy5AeXTixjsR+QRZ/WPrETNpMj4lw5cBSxG0i7WhRhlBa+KgqweUUezvCdAg==", - "license": "MIT", + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/@heroui/divider/-/divider-2.2.21.tgz", + "integrity": "sha512-aVvl8/3fWUc+/fHbg+hD/0wrkoMKmXG0yRgyNrJSeu0pkRwhb0eD4ZjnBK1pCYqnstoltNE33J8ko/sU+WlmPw==", "dependencies": { "@heroui/react-rsc-utils": "2.1.9", - "@heroui/system-rsc": "2.3.20", + "@heroui/system-rsc": "2.3.21", "@react-types/shared": "3.32.1" }, "peerDependencies": { - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } @@ -1748,38 +1659,35 @@ "version": "2.1.10", "resolved": "https://registry.npmjs.org/@heroui/dom-animation/-/dom-animation-2.1.10.tgz", "integrity": "sha512-dt+0xdVPbORwNvFT5pnqV2ULLlSgOJeqlg/DMo97s9RWeD6rD4VedNY90c8C9meqWqGegQYBQ9ztsfX32mGEPA==", - "license": "MIT", "peerDependencies": { "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1" } }, "node_modules/@heroui/drawer": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/drawer/-/drawer-2.2.24.tgz", - "integrity": "sha512-gb51Lj9A8jlL1UvUrQ+MLS9tz+Qw+cdXwIJd39RXDkJwDmxqhzkz+WoOPZZwcOAHtATmwlTuxxlv6Cro59iswg==", - "license": "MIT", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/@heroui/drawer/-/drawer-2.2.25.tgz", + "integrity": "sha512-+TFagy61+8dm+EWXLY5NJUGJ4COPL4anRiynw92iSD+arKUGN5b6lJUnjf9NkqwM5jqWKk1vxWdGDZEKZva8Bg==", "dependencies": { - "@heroui/framer-utils": "2.1.23", - "@heroui/modal": "2.2.24", + "@heroui/framer-utils": "2.1.24", + "@heroui/modal": "2.2.25", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12" }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/dropdown": { - "version": "2.3.27", - "resolved": "https://registry.npmjs.org/@heroui/dropdown/-/dropdown-2.3.27.tgz", - "integrity": "sha512-6aedMmxC+St5Ixz9o3s0ERkLOR6ZQE2uRccmRchPCEt7ZJU6TAeJo7fSpxIvdEUjFDe+pNhR2ojIocZEXtBZZg==", - "license": "MIT", + "version": "2.3.28", + "resolved": "https://registry.npmjs.org/@heroui/dropdown/-/dropdown-2.3.28.tgz", + "integrity": "sha512-q+bSLxdsHtauqpQ4529cSkjj8L20UdvbrRGmhRL3YLZyLEzGcCCp6kDRCchkCpTaxK7u869eF9TGSNoFeum92g==", "dependencies": { - "@heroui/aria-utils": "2.2.24", - "@heroui/menu": "2.2.26", - "@heroui/popover": "2.3.27", + "@heroui/aria-utils": "2.2.25", + "@heroui/menu": "2.2.27", + "@heroui/popover": "2.3.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@react-aria/focus": "3.21.2", @@ -1789,39 +1697,37 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/form": { - "version": "2.1.27", - "resolved": "https://registry.npmjs.org/@heroui/form/-/form-2.1.27.tgz", - "integrity": "sha512-vtaBqWhxppkJeWgbAZA/A1bRj6XIudBqJWSkoqYlejtLuvaxNwxQ2Z9u7ewxN96R6QqPrQwChlknIn0NgCWlXQ==", - "license": "MIT", + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/@heroui/form/-/form-2.1.28.tgz", + "integrity": "sha512-skg9GooN1+rgQwM0/7wNqUenq6JBEf3T2tDBItJU/oeNC9oaX00JDpy8rpMz9zS0oUqfbJ0auT11+0FRo2W6CQ==", "dependencies": { "@heroui/shared-utils": "2.1.12", - "@heroui/system": "2.4.23", - "@heroui/theme": "2.4.23", + "@heroui/system": "2.4.24", + "@heroui/theme": "2.4.24", "@react-stately/form": "3.2.2", "@react-types/form": "3.7.16", "@react-types/shared": "3.32.1" }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18", "react-dom": ">=18" } }, "node_modules/@heroui/framer-utils": { - "version": "2.1.23", - "resolved": "https://registry.npmjs.org/@heroui/framer-utils/-/framer-utils-2.1.23.tgz", - "integrity": "sha512-crLLMjRmxs8/fysFv5gwghSGcDmYYkhNfAWh1rFzDy+FRPZN4f/bPH2rt85hdApmuHbWt0QCocqsrjHxLEzrAw==", - "license": "MIT", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@heroui/framer-utils/-/framer-utils-2.1.24.tgz", + "integrity": "sha512-PiHEV8YS3Q0ve3ZnTASVvTeBK0fTFdLtLiPtCuLucC2WGeDFjUerE7++Y+HhWB85Jj/USknEpl0aGsatl3cbgg==", "dependencies": { - "@heroui/system": "2.4.23", + "@heroui/system": "2.4.24", "@heroui/use-measure": "2.1.8" }, "peerDependencies": { @@ -1831,10 +1737,9 @@ } }, "node_modules/@heroui/image": { - "version": "2.2.17", - "resolved": "https://registry.npmjs.org/@heroui/image/-/image-2.2.17.tgz", - "integrity": "sha512-B/MrWafTsiCBFnRc0hPTLDBh7APjb/lRuQf18umuh20/1n6KiQXJ7XGSjnrHaA6HQcrtMGh6mDFZDaXq9rHuoA==", - "license": "MIT", + "version": "2.2.18", + "resolved": "https://registry.npmjs.org/@heroui/image/-/image-2.2.18.tgz", + "integrity": "sha512-hrvj/hDM0+Khb9EqstZOPeO0vIGZvhrJWPMxk7a6i2PqhWWQI+ws+nrwsG5XqAkwE4mqqf9Uw8EMfIG1XE5YYg==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", @@ -1842,18 +1747,17 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/input": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@heroui/input/-/input-2.4.28.tgz", - "integrity": "sha512-uaBubg814YOlVvX13yCAMqsR9HC4jg+asQdukbOvOnFtHY/d53her1BDdXhR9tMcrRTdYWQ3FoHqWbpvd5X4OQ==", - "license": "MIT", + "version": "2.4.29", + "resolved": "https://registry.npmjs.org/@heroui/input/-/input-2.4.29.tgz", + "integrity": "sha512-PIjFmN6BTLvnlI0I9f7PjxvnviauOczRJGaTnlHKDniknoh7mi8j0voXwL/f6BAkVKrgpT5JiFvdjq6og+cfSA==", "dependencies": { - "@heroui/form": "2.1.27", + "@heroui/form": "2.1.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", @@ -1868,18 +1772,17 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.19", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/input-otp": { - "version": "2.1.27", - "resolved": "https://registry.npmjs.org/@heroui/input-otp/-/input-otp-2.1.27.tgz", - "integrity": "sha512-VUzQ1u6/0okE0eqDx/2I/8zpGItSsn7Zml01IVwGM4wY2iJeQA+uRjfP+B1ff9jO/y8n582YU4uv/ZSOmmEQ7A==", - "license": "MIT", + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/@heroui/input-otp/-/input-otp-2.1.28.tgz", + "integrity": "sha512-IHr35WqOHb8SBoMXYt6wxzKQg8iFMdc7iqFa8jqdshfVIS3bvxvJj6PGND3LoZxrRFplCv12lfmp2fWymQLleA==", "dependencies": { - "@heroui/form": "2.1.27", + "@heroui/form": "2.1.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@heroui/use-form-reset": "2.0.1", @@ -1892,32 +1795,30 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18", "react-dom": ">=18" } }, "node_modules/@heroui/kbd": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/@heroui/kbd/-/kbd-2.2.22.tgz", - "integrity": "sha512-PKhgwGB7i53kBuqB1YdFZsg7H9fJ8YESMRRPwRRyPSz5feMdwGidyXs+/ix7lrlYp4mlC3wtPp7L79SEyPCpBA==", - "license": "MIT", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/@heroui/kbd/-/kbd-2.2.23.tgz", + "integrity": "sha512-nKL1Kl044l1Xsk4U8Nib3wFD2NlZCZo6kdqiqUv+DchOo4s3BJcxWSWqHn6fDVmHNyj3DFMYDvA2f/geMasaHQ==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", - "@heroui/system-rsc": "2.3.20" + "@heroui/system-rsc": "2.3.21" }, "peerDependencies": { - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/link": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/@heroui/link/-/link-2.2.23.tgz", - "integrity": "sha512-lObtPRLy8ModlTvJiKhczuAV/CIt31hde6xPGFYRpPsaQN1b7RgQMmai5/Iv/M8WrzFmFZRpgW75RKYIB6hHVQ==", - "license": "MIT", + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/@heroui/link/-/link-2.2.24.tgz", + "integrity": "sha512-rxtSC/8++wCtZs2GqBCukQHtDAbqB5bXT24v03q86oz7VOlbn8pox38LwFKrb/H+A3o+BjSKuTJsYidJcQ5clg==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", @@ -1928,19 +1829,18 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/listbox": { - "version": "2.3.26", - "resolved": "https://registry.npmjs.org/@heroui/listbox/-/listbox-2.3.26.tgz", - "integrity": "sha512-/k3k+xyl2d+aFfT02h+/0njhsDX8vJDEkPK+dl9ETYI9Oz3L+xbHN9yIzuWjBXYkNGlQCjQ46N+0jWjhP5B4pA==", - "license": "MIT", + "version": "2.3.27", + "resolved": "https://registry.npmjs.org/@heroui/listbox/-/listbox-2.3.27.tgz", + "integrity": "sha512-NUBDwP9Xzx3A/0iX/09hhs4/y8Loo+bCTm/vqFqYyufR8AOGLw1Xn0poTybPfE4L5U+6Y1P7GM0VjgZVw9dFQQ==", "dependencies": { - "@heroui/aria-utils": "2.2.24", - "@heroui/divider": "2.2.20", + "@heroui/aria-utils": "2.2.25", + "@heroui/divider": "2.2.21", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@heroui/use-is-mobile": "2.2.12", @@ -1953,19 +1853,18 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/menu": { - "version": "2.2.26", - "resolved": "https://registry.npmjs.org/@heroui/menu/-/menu-2.2.26.tgz", - "integrity": "sha512-raR5pXgEqizKD9GsWS1yKqTm4RPWMrSQlqXLE2zNMQk0TkDqmPVw1z5griMqu2Zt9Vf2Ectf55vh4c0DNOUGlg==", - "license": "MIT", + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/@heroui/menu/-/menu-2.2.27.tgz", + "integrity": "sha512-Ifsb9QBVpAFFcIEEcp3nU28DBtIU0iI7B5HHpblHDJoDtjIbkyNOnyxoEj8eX63QTWQcKrmNnFYdtsrtS9K1RA==", "dependencies": { - "@heroui/aria-utils": "2.2.24", - "@heroui/divider": "2.2.20", + "@heroui/aria-utils": "2.2.25", + "@heroui/divider": "2.2.21", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@heroui/use-is-mobile": "2.2.12", @@ -1978,19 +1877,18 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/modal": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/modal/-/modal-2.2.24.tgz", - "integrity": "sha512-ISbgorNqgps9iUvQdgANxprdN+6H3Sx9TrGKpuW798qjc2f0T4rTbjrEfFPT8tFx6XYF4P5j7T7m3zoKcortHQ==", - "license": "MIT", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/@heroui/modal/-/modal-2.2.25.tgz", + "integrity": "sha512-qoUk0fe/GMbKHUWcW8XThp+TifEG6GgmpBKZ4x8hhM5o/t1cKAD4+F2pKahtih0ba5qjM+tFtwnUV7z7Mt8+xg==", "dependencies": { "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.23", + "@heroui/framer-utils": "2.1.24", "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", @@ -2006,20 +1904,19 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/navbar": { - "version": "2.2.25", - "resolved": "https://registry.npmjs.org/@heroui/navbar/-/navbar-2.2.25.tgz", - "integrity": "sha512-5fNIMDpX2htDTMb/Xgv81qw/FuNWb+0Wpfc6rkFtNYd968I7G6Kjm782QB8WQjZ8DsMugcLEYUN4lpbJHRSdwg==", - "license": "MIT", + "version": "2.2.26", + "resolved": "https://registry.npmjs.org/@heroui/navbar/-/navbar-2.2.26.tgz", + "integrity": "sha512-uQhISgbQgea1ki0et3hDJ8+IXc35zMNowRQTKgWeEF8T3yS5X2fKuLzJc7/cf0vUGnxH0FPB3Z5Cb7o1nwjr9A==", "dependencies": { "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.23", + "@heroui/framer-utils": "2.1.24", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@heroui/use-resize": "2.1.8", @@ -2033,20 +1930,19 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/number-input": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/@heroui/number-input/-/number-input-2.0.18.tgz", - "integrity": "sha512-28v0/0FABs+yy3CcJimcr5uNlhaJSyKt1ENMSXfzPxdN2WgIs14+6NLMT+KV7ibcJl7kmqG0uc8vuIDLVrM5bQ==", - "license": "MIT", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/@heroui/number-input/-/number-input-2.0.19.tgz", + "integrity": "sha512-5UHdznU9XIqjRH17dG277YQrTnUeifWmHdU76Jzf78+SVsJgQdLqcRINHPVj382q0kd6vLMzc4Hyb2fQ0g2WXg==", "dependencies": { - "@heroui/button": "2.2.27", - "@heroui/form": "2.1.27", + "@heroui/button": "2.2.28", + "@heroui/form": "2.1.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", @@ -2062,16 +1958,15 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.19", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/pagination": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/pagination/-/pagination-2.2.24.tgz", - "integrity": "sha512-5ObSJ1PzB9D1CjHV0MfDNzLR69vSYpx/rNQLBo/D4g5puaAR7kkGgw5ncf5eirhdKuy9y8VGAhjwhBxO4NUdpQ==", - "license": "MIT", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/@heroui/pagination/-/pagination-2.2.25.tgz", + "integrity": "sha512-PQZMNQ7wiv++cLEpEXDAdID3IQE2FlG1UkcuYhVYLPJgGSxoKKcM81wmE/HYMgmIMXySiZ+9E/UM8HATrpvTzA==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", @@ -2086,21 +1981,20 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/popover": { - "version": "2.3.27", - "resolved": "https://registry.npmjs.org/@heroui/popover/-/popover-2.3.27.tgz", - "integrity": "sha512-PmSCKQcAvKIegK59Flr9cglbsEu7OAegQMtwNIjqWHsPT18NNphimmUSJrtuD78rcfKekrZ+Uo9qJEUf0zGZDw==", - "license": "MIT", + "version": "2.3.28", + "resolved": "https://registry.npmjs.org/@heroui/popover/-/popover-2.3.28.tgz", + "integrity": "sha512-0KHClVQVhLTCqUOtsKEZQ3dqPpNjd7qTISD2Ud3vACdLXprSLWmOzo2ItT6PAh881oIZnPS8l/0/jZ1ON/izdA==", "dependencies": { - "@heroui/aria-utils": "2.2.24", - "@heroui/button": "2.2.27", + "@heroui/aria-utils": "2.2.25", + "@heroui/button": "2.2.28", "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.23", + "@heroui/framer-utils": "2.1.24", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@heroui/use-aria-button": "2.2.20", @@ -2114,17 +2008,16 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/progress": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/@heroui/progress/-/progress-2.2.22.tgz", - "integrity": "sha512-ch+iWEDo8d+Owz81vu4+Kj6CLfxi0nUlivQBhXeOzgU3VZbRmxJyW8S6l7wk6GyKJZxsCbYbjV1wPSjZhKJXCg==", - "license": "MIT", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/@heroui/progress/-/progress-2.2.23.tgz", + "integrity": "sha512-5mfFPv5oW69yD5m/Y1cz0R+s4W8cwvLCZXzVtevoqyzkInNks8w2FKeGptkXcDeXVxqfhwDmNU4DXUmc4nRx3w==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", @@ -2134,18 +2027,17 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/radio": { - "version": "2.3.27", - "resolved": "https://registry.npmjs.org/@heroui/radio/-/radio-2.3.27.tgz", - "integrity": "sha512-kfDxzPR0u4++lZX2Gf6wbEe/hGbFnoXI4XLbe4e+ZDjGdBSakNuJlcDvWHVoDFZH1xXyOO9w/dHfZuE6O2VGLA==", - "license": "MIT", + "version": "2.3.28", + "resolved": "https://registry.npmjs.org/@heroui/radio/-/radio-2.3.28.tgz", + "integrity": "sha512-qrzZpEXRl4EH3zKeCujyKeK2yvcvaOaosxdZnMrT2O7wxX9LeOp6ZPMwIdMFmJYj7iyPym2nUwFfQBne7JNuvA==", "dependencies": { - "@heroui/form": "2.1.27", + "@heroui/form": "2.1.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@react-aria/focus": "3.21.2", @@ -2158,66 +2050,65 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/react": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/@heroui/react/-/react-2.8.5.tgz", - "integrity": "sha512-cGiG0/DCPsYopa+zACFDmtx9LQDfY5KU58Tt82ELANhmKRyYAesAq9tSa01dG+MjOXUTUR6cxp5i5RmRn8rPYg==", - "license": "MIT", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/@heroui/react/-/react-2.8.6.tgz", + "integrity": "sha512-iDmmkqZZtBqVqsSSZiV6PIWN3AEOZLQFXwt9Lob2Oy7gQQuFDP+iljg/ARc3fZ9JBNbJTfgGFdNjrnaFpPtRyw==", "dependencies": { - "@heroui/accordion": "2.2.24", - "@heroui/alert": "2.2.27", - "@heroui/autocomplete": "2.3.29", - "@heroui/avatar": "2.2.22", - "@heroui/badge": "2.2.17", - "@heroui/breadcrumbs": "2.2.22", - "@heroui/button": "2.2.27", - "@heroui/calendar": "2.2.27", - "@heroui/card": "2.2.25", - "@heroui/checkbox": "2.3.27", - "@heroui/chip": "2.2.22", - "@heroui/code": "2.2.21", - "@heroui/date-input": "2.3.27", - "@heroui/date-picker": "2.3.28", - "@heroui/divider": "2.2.20", - "@heroui/drawer": "2.2.24", - "@heroui/dropdown": "2.3.27", - "@heroui/form": "2.1.27", - "@heroui/framer-utils": "2.1.23", - "@heroui/image": "2.2.17", - "@heroui/input": "2.4.28", - "@heroui/input-otp": "2.1.27", - "@heroui/kbd": "2.2.22", - "@heroui/link": "2.2.23", - "@heroui/listbox": "2.3.26", - "@heroui/menu": "2.2.26", - "@heroui/modal": "2.2.24", - "@heroui/navbar": "2.2.25", - "@heroui/number-input": "2.0.18", - "@heroui/pagination": "2.2.24", - "@heroui/popover": "2.3.27", - "@heroui/progress": "2.2.22", - "@heroui/radio": "2.3.27", - "@heroui/ripple": "2.2.20", - "@heroui/scroll-shadow": "2.3.18", - "@heroui/select": "2.4.28", - "@heroui/skeleton": "2.2.17", - "@heroui/slider": "2.4.24", - "@heroui/snippet": "2.2.28", - "@heroui/spacer": "2.2.21", - "@heroui/spinner": "2.2.24", - "@heroui/switch": "2.2.24", - "@heroui/system": "2.4.23", - "@heroui/table": "2.2.27", - "@heroui/tabs": "2.2.24", - "@heroui/theme": "2.4.23", - "@heroui/toast": "2.0.17", - "@heroui/tooltip": "2.2.24", - "@heroui/user": "2.2.22", + "@heroui/accordion": "2.2.25", + "@heroui/alert": "2.2.28", + "@heroui/autocomplete": "2.3.30", + "@heroui/avatar": "2.2.23", + "@heroui/badge": "2.2.18", + "@heroui/breadcrumbs": "2.2.23", + "@heroui/button": "2.2.28", + "@heroui/calendar": "2.2.28", + "@heroui/card": "2.2.26", + "@heroui/checkbox": "2.3.28", + "@heroui/chip": "2.2.23", + "@heroui/code": "2.2.22", + "@heroui/date-input": "2.3.28", + "@heroui/date-picker": "2.3.29", + "@heroui/divider": "2.2.21", + "@heroui/drawer": "2.2.25", + "@heroui/dropdown": "2.3.28", + "@heroui/form": "2.1.28", + "@heroui/framer-utils": "2.1.24", + "@heroui/image": "2.2.18", + "@heroui/input": "2.4.29", + "@heroui/input-otp": "2.1.28", + "@heroui/kbd": "2.2.23", + "@heroui/link": "2.2.24", + "@heroui/listbox": "2.3.27", + "@heroui/menu": "2.2.27", + "@heroui/modal": "2.2.25", + "@heroui/navbar": "2.2.26", + "@heroui/number-input": "2.0.19", + "@heroui/pagination": "2.2.25", + "@heroui/popover": "2.3.28", + "@heroui/progress": "2.2.23", + "@heroui/radio": "2.3.28", + "@heroui/ripple": "2.2.21", + "@heroui/scroll-shadow": "2.3.19", + "@heroui/select": "2.4.29", + "@heroui/skeleton": "2.2.18", + "@heroui/slider": "2.4.25", + "@heroui/snippet": "2.2.29", + "@heroui/spacer": "2.2.22", + "@heroui/spinner": "2.2.25", + "@heroui/switch": "2.2.25", + "@heroui/system": "2.4.24", + "@heroui/table": "2.2.28", + "@heroui/tabs": "2.2.25", + "@heroui/theme": "2.4.24", + "@heroui/toast": "2.0.18", + "@heroui/tooltip": "2.2.25", + "@heroui/user": "2.2.23", "@react-aria/visually-hidden": "3.8.28" }, "peerDependencies": { @@ -2230,7 +2121,6 @@ "version": "2.1.9", "resolved": "https://registry.npmjs.org/@heroui/react-rsc-utils/-/react-rsc-utils-2.1.9.tgz", "integrity": "sha512-e77OEjNCmQxE9/pnLDDb93qWkX58/CcgIqdNAczT/zUP+a48NxGq2A2WRimvc1uviwaNL2StriE2DmyZPyYW7Q==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2239,7 +2129,6 @@ "version": "2.1.14", "resolved": "https://registry.npmjs.org/@heroui/react-utils/-/react-utils-2.1.14.tgz", "integrity": "sha512-hhKklYKy9sRH52C9A8P0jWQ79W4MkIvOnKBIuxEMHhigjfracy0o0lMnAUdEsJni4oZKVJYqNGdQl+UVgcmeDA==", - "license": "MIT", "dependencies": { "@heroui/react-rsc-utils": "2.1.9", "@heroui/shared-utils": "2.1.12" @@ -2249,27 +2138,25 @@ } }, "node_modules/@heroui/ripple": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/@heroui/ripple/-/ripple-2.2.20.tgz", - "integrity": "sha512-3+fBx5jO7l8SE84ZG0vB5BOxKKr23Ay180AeIWcf8m8lhXXd4iShVz2S+keW9PewqVHv52YBaxLoSVQ93Ddcxw==", - "license": "MIT", + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/@heroui/ripple/-/ripple-2.2.21.tgz", + "integrity": "sha512-wairSq9LnhbIqTCJmUlJAQURQ1wcRK/L8pjg2s3R/XnvZlPXHy4ZzfphiwIlTI21z/f6tH3arxv/g1uXd1RY0g==", "dependencies": { "@heroui/dom-animation": "2.1.10", "@heroui/shared-utils": "2.1.12" }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/scroll-shadow": { - "version": "2.3.18", - "resolved": "https://registry.npmjs.org/@heroui/scroll-shadow/-/scroll-shadow-2.3.18.tgz", - "integrity": "sha512-P/nLQbFPOlbTLRjO2tKoZCljJtU7iq81wsp7C8wZ1rZI1RmkTx3UgLLeoFWgmAp3ZlUIYgaewTnejt6eRx+28w==", - "license": "MIT", + "version": "2.3.19", + "resolved": "https://registry.npmjs.org/@heroui/scroll-shadow/-/scroll-shadow-2.3.19.tgz", + "integrity": "sha512-y5mdBlhiITVrFnQTDqEphYj7p5pHqoFSFtVuRRvl9wUec2lMxEpD85uMGsfL8OgQTKIAqGh2s6M360+VJm7ajQ==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", @@ -2277,26 +2164,25 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/select": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@heroui/select/-/select-2.4.28.tgz", - "integrity": "sha512-Dg3jv248Tu+g2WJMWseDjWA0FAG356elZIcE0OufVAIzQoWjLhgbkTqY9ths0HkcHy0nDwQWvyrrwkbif1kNqA==", - "license": "MIT", + "version": "2.4.29", + "resolved": "https://registry.npmjs.org/@heroui/select/-/select-2.4.29.tgz", + "integrity": "sha512-rFsI+UNUtK6WTm6oDM8A45tu8rDqt1zHoSoBQ8RJDkRITDcKRBTaTnvJI/Ez+kMRNH4fQ45LgoSPxw/JOOMg4w==", "dependencies": { - "@heroui/aria-utils": "2.2.24", - "@heroui/form": "2.1.27", - "@heroui/listbox": "2.3.26", - "@heroui/popover": "2.3.27", + "@heroui/aria-utils": "2.2.25", + "@heroui/form": "2.1.28", + "@heroui/listbox": "2.3.27", + "@heroui/popover": "2.3.28", "@heroui/react-utils": "2.1.14", - "@heroui/scroll-shadow": "2.3.18", + "@heroui/scroll-shadow": "2.3.19", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", - "@heroui/spinner": "2.2.24", + "@heroui/spinner": "2.2.25", "@heroui/use-aria-button": "2.2.20", "@heroui/use-aria-multiselect": "2.4.19", "@heroui/use-form-reset": "2.0.1", @@ -2310,7 +2196,7 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" @@ -2320,7 +2206,6 @@ "version": "2.1.10", "resolved": "https://registry.npmjs.org/@heroui/shared-icons/-/shared-icons-2.1.10.tgz", "integrity": "sha512-ePo60GjEpM0SEyZBGOeySsLueNDCqLsVL79Fq+5BphzlrBAcaKY7kUp74964ImtkXvknTxAWzuuTr3kCRqj6jg==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2329,33 +2214,30 @@ "version": "2.1.12", "resolved": "https://registry.npmjs.org/@heroui/shared-utils/-/shared-utils-2.1.12.tgz", "integrity": "sha512-0iCnxVAkIPtrHQo26Qa5g0UTqMTpugTbClNOrEPsrQuyRAq7Syux998cPwGlneTfB5E5xcU3LiEdA9GUyeK2cQ==", - "hasInstallScript": true, - "license": "MIT" + "hasInstallScript": true }, "node_modules/@heroui/skeleton": { - "version": "2.2.17", - "resolved": "https://registry.npmjs.org/@heroui/skeleton/-/skeleton-2.2.17.tgz", - "integrity": "sha512-WDzwODs+jW+GgMr3oOdLtXXfv8ScXuuWgxN2iPWWyDBcQYXX2XCKGVjCpM5lSKf1UG4Yp3iXuqKzH1m+E+m7kg==", - "license": "MIT", + "version": "2.2.18", + "resolved": "https://registry.npmjs.org/@heroui/skeleton/-/skeleton-2.2.18.tgz", + "integrity": "sha512-7AjU5kjk9rqrKP9mWQiAVj0dow4/vbK5/ejh4jqdb3DZm7bM2+DGzfnQPiS0c2eWR606CgOuuoImpwDS82HJtA==", "dependencies": { "@heroui/shared-utils": "2.1.12" }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/slider": { - "version": "2.4.24", - "resolved": "https://registry.npmjs.org/@heroui/slider/-/slider-2.4.24.tgz", - "integrity": "sha512-GKdqFTCe9O8tT3HEZ/W4TEWkz7ADtUBzuOBXw779Oqqf02HNg9vSnISlNvI6G0ymYjY42EanwA+dChHbPBIVJw==", - "license": "MIT", + "version": "2.4.25", + "resolved": "https://registry.npmjs.org/@heroui/slider/-/slider-2.4.25.tgz", + "integrity": "sha512-1ULgaqsu1Vzyyx6S7TGs+13PX5BGArZhLiApQfKwiA3TFvT0MNzTVoWVgyFZ8XLqh4esSUnqddhivqQhbRzrHw==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", - "@heroui/tooltip": "2.2.24", + "@heroui/tooltip": "2.2.25", "@react-aria/focus": "3.21.2", "@react-aria/i18n": "3.12.13", "@react-aria/interactions": "3.25.6", @@ -2365,70 +2247,66 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.19", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/snippet": { - "version": "2.2.28", - "resolved": "https://registry.npmjs.org/@heroui/snippet/-/snippet-2.2.28.tgz", - "integrity": "sha512-UfC/ZcYpmOutAcazxkizJWlhvqzr077szDyQ85thyUC5yhuRRLrsOHDIhyLWQrEKIcWw5+CaEGS2VLwAFlgfzw==", - "license": "MIT", + "version": "2.2.29", + "resolved": "https://registry.npmjs.org/@heroui/snippet/-/snippet-2.2.29.tgz", + "integrity": "sha512-RuyK/DldxvVYb6ToPk5cNNYeDkL+phKZPYHrUxBJK/PzuAkqi3AzQV7zHd+3IfTNxQbevRjzCXENE5F3GKP/MQ==", "dependencies": { - "@heroui/button": "2.2.27", + "@heroui/button": "2.2.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", - "@heroui/tooltip": "2.2.24", + "@heroui/tooltip": "2.2.25", "@heroui/use-clipboard": "2.1.9", "@react-aria/focus": "3.21.2" }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/spacer": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/@heroui/spacer/-/spacer-2.2.21.tgz", - "integrity": "sha512-WKD+BlgHfqJ8lrkkg/6cvzSWNsbRjzr24HpZnv6cDeWX95wVLTOco9HVR8ohwStMqwu5zYeUd1bw6yCDVTo53w==", - "license": "MIT", + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/@heroui/spacer/-/spacer-2.2.22.tgz", + "integrity": "sha512-BJ7RauvSY3gx10ntqZkCcyTy9K2FS4AeeryQUE9RgkMKQxP4t5TbeYLPEyomjWK+cCL/ERQCCruW16D3vKyWmw==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", - "@heroui/system-rsc": "2.3.20" + "@heroui/system-rsc": "2.3.21" }, "peerDependencies": { - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/spinner": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/spinner/-/spinner-2.2.24.tgz", - "integrity": "sha512-HfKkFffrIN9UdJY2UaenlB8xEwIzolCCFCwU0j3wVnLMX+Dw+ixwaELdAxX14Z6gPQYec6AROKetkWWit14rlw==", - "license": "MIT", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/@heroui/spinner/-/spinner-2.2.25.tgz", + "integrity": "sha512-zDuLJicUL51vGLEBbHWy/t6DlOvs9YILM4YLmzS/o84ExTgfrCycXNs6JkoteFiNu570qqZMeAA2aYneGfl/PQ==", "dependencies": { "@heroui/shared-utils": "2.1.12", - "@heroui/system": "2.4.23", - "@heroui/system-rsc": "2.3.20" + "@heroui/system": "2.4.24", + "@heroui/system-rsc": "2.3.21" }, "peerDependencies": { - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/switch": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/switch/-/switch-2.2.24.tgz", - "integrity": "sha512-RbV+MECncBKsthX3D8r+CGoQRu8Q3AAYUEdm/7ody6+bMZFmBilm695yLiqziMI33Ct/WQ0WkpvrTClIcmxU/A==", - "license": "MIT", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/@heroui/switch/-/switch-2.2.25.tgz", + "integrity": "sha512-F0Yj+kgVfD2bdy6REFvNySeGuYg1OT2phwMPwSZGUl7ZFeGSvvWSnbYS4/wS3JIM5PyEibSaB8QIPc8r00xq1A==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", @@ -2441,19 +2319,18 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/system": { - "version": "2.4.23", - "resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.23.tgz", - "integrity": "sha512-kgYvfkIOQKM6CCBIlNSE2tXMtNrS1mvEUbvwnaU3pEYbMlceBtwA5v7SlpaJy/5dqKcTbfmVMUCmXnY/Kw4vaQ==", - "license": "MIT", + "version": "2.4.24", + "resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.24.tgz", + "integrity": "sha512-9GKQgUc91otQfwmq6TLE72QKxtB341aK5NpBHS3gRoWYEuNN714Zl3OXwIZNvdXPJpsTaUo1ID1ibJU9tfgwdg==", "dependencies": { "@heroui/react-utils": "2.1.14", - "@heroui/system-rsc": "2.3.20", + "@heroui/system-rsc": "2.3.21", "@react-aria/i18n": "3.12.13", "@react-aria/overlays": "3.30.0", "@react-aria/utils": "3.31.0" @@ -2465,39 +2342,26 @@ } }, "node_modules/@heroui/system-rsc": { - "version": "2.3.20", - "resolved": "https://registry.npmjs.org/@heroui/system-rsc/-/system-rsc-2.3.20.tgz", - "integrity": "sha512-uZwQErEud/lAX7KRXEdsDcGLyygBffHcgnbCDrLvmTf3cyBE84YziG7AjM7Ts8ZcrF+wBXX4+a1IqnKGlsGEdQ==", - "license": "MIT", + "version": "2.3.21", + "resolved": "https://registry.npmjs.org/@heroui/system-rsc/-/system-rsc-2.3.21.tgz", + "integrity": "sha512-icB7njbNgkI3dcfZhY5LP7VFspaVgWL1lcg9Q7uJMAaj6gGFqqSSnHkSMwpR9AGLxVRKTHey0TUx8CeZDe8XDw==", "dependencies": { - "@react-types/shared": "3.32.1", - "clsx": "^1.2.1" + "@react-types/shared": "3.32.1" }, "peerDependencies": { - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0" } }, - "node_modules/@heroui/system-rsc/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/@heroui/table": { - "version": "2.2.27", - "resolved": "https://registry.npmjs.org/@heroui/table/-/table-2.2.27.tgz", - "integrity": "sha512-XFmbEgBzf89WH1VzmnwENxVzK4JrHV5jdlzyM3snNhk8uDSjfecnUY33qR62cpdZsKiCFFcYf7kQPkCnJGnD0Q==", - "license": "MIT", + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/@heroui/table/-/table-2.2.28.tgz", + "integrity": "sha512-0z3xs0kxDXvvd9gy/uHgvK0/bmpJF0m9t3omNMnB0I0EUx+gJ/CnaaPiF9M5veg/128rc45J7X2FgY3fPAKcmA==", "dependencies": { - "@heroui/checkbox": "2.3.27", + "@heroui/checkbox": "2.3.28", "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", - "@heroui/spacer": "2.2.21", "@react-aria/focus": "3.21.2", "@react-aria/interactions": "3.25.6", "@react-aria/table": "3.17.8", @@ -2510,18 +2374,17 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/tabs": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/tabs/-/tabs-2.2.24.tgz", - "integrity": "sha512-2SfxzAXe1t2Zz0v16kqkb7DR2wW86XoDwRUpLex6zhEN4/uT5ILeynxIVSUyAvVN3z95cnaQt0XPQBfUjAIQhQ==", - "license": "MIT", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/@heroui/tabs/-/tabs-2.2.25.tgz", + "integrity": "sha512-bIpz/8TTNMabmzObN2zs+3WhQXbKyr9tZUPkk3rMQxIshpg9oyyEWOS8XiMBxrEzSByLfPNypl5sX1au6Dw2Ew==", "dependencies": { - "@heroui/aria-utils": "2.2.24", + "@heroui/aria-utils": "2.2.25", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@heroui/use-is-mounted": "2.1.8", @@ -2534,60 +2397,38 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.22", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/theme": { - "version": "2.4.23", - "resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.23.tgz", - "integrity": "sha512-5hoaRWG+/d/t06p7Pfhz70DUP0Uggjids7/z2Ytgup4A8KAOvDIXxvHUDlk6rRHKiN1wDMNA5H+EWsSXB/m03Q==", - "license": "MIT", + "version": "2.4.24", + "resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.24.tgz", + "integrity": "sha512-lL+anmY4GGWwKyTbJ2PEBZE4talIZ3hu4yGpku9TktCVG2nC2YTwiWQFJ+Jcbf8Cf9vuLzI1sla5bz2jUqiBRA==", "dependencies": { "@heroui/shared-utils": "2.1.12", - "clsx": "^1.2.1", "color": "^4.2.3", "color2k": "^2.0.3", "deepmerge": "4.3.1", "flat": "^5.0.2", - "tailwind-merge": "3.3.1", - "tailwind-variants": "3.1.1" + "tailwind-merge": "3.4.0", + "tailwind-variants": "3.2.2" }, "peerDependencies": { "tailwindcss": ">=4.0.0" } }, - "node_modules/@heroui/theme/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@heroui/theme/node_modules/tailwind-merge": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", - "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/@heroui/toast": { - "version": "2.0.17", - "resolved": "https://registry.npmjs.org/@heroui/toast/-/toast-2.0.17.tgz", - "integrity": "sha512-w3TaA1DYLcwdDjpwf9xw5YSr+odo9GGHsObsrMmLEQDS0JQhmKyK5sQqXUzb9d27EC6KVwGjeVg0hUHYQBK2JA==", - "license": "MIT", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@heroui/toast/-/toast-2.0.18.tgz", + "integrity": "sha512-5IoqEq10W/AaUgKWKIR7bbTB6U+rHMkikzGwW+IndsvFLR3meyb5l4K5cmVCmDsMHubUaRa9UFDeAokyNXvpWA==", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/shared-icons": "2.1.10", "@heroui/shared-utils": "2.1.12", - "@heroui/spinner": "2.2.24", + "@heroui/spinner": "2.2.25", "@heroui/use-is-mobile": "2.2.12", "@react-aria/interactions": "3.25.6", "@react-aria/toast": "3.0.8", @@ -2595,21 +2436,20 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/tooltip": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/tooltip/-/tooltip-2.2.24.tgz", - "integrity": "sha512-H+0STFea2/Z4obDdk+ZPoDzJxJQHIWGSjnW/jieThJbJ5zow/qBfcg5DqzIdiC+FCJ4dDD5jEDZ4W4H/fQUKQA==", - "license": "MIT", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/@heroui/tooltip/-/tooltip-2.2.25.tgz", + "integrity": "sha512-f+WxkQy0YBzzE6VhzVgA/CeD7nvo0hhOapx0UScU8zsQ1J+n5Kr5YY/7CgMHmFLyC/Amrqlf7WSgljRl4iWivQ==", "dependencies": { - "@heroui/aria-utils": "2.2.24", + "@heroui/aria-utils": "2.2.25", "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.23", + "@heroui/framer-utils": "2.1.24", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@heroui/use-aria-overlay": "2.0.4", @@ -2622,7 +2462,7 @@ }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" @@ -2632,7 +2472,6 @@ "version": "2.2.18", "resolved": "https://registry.npmjs.org/@heroui/use-aria-accordion/-/use-aria-accordion-2.2.18.tgz", "integrity": "sha512-qjRkae2p4MFDrNqO6v6YCor0BtVi3idMd1dsI82XM16bxLQ2stqG4Ajrg60xV0AN+WKZUq10oetqkJuY6MYg0w==", - "license": "MIT", "dependencies": { "@react-aria/button": "3.14.2", "@react-aria/focus": "3.21.2", @@ -2649,7 +2488,6 @@ "version": "2.2.20", "resolved": "https://registry.npmjs.org/@heroui/use-aria-button/-/use-aria-button-2.2.20.tgz", "integrity": "sha512-Y0Bmze/pxEACKsHMbA1sYA3ghMJ+9fSnWvZBwlUxqiVXDEy2YrrK2JmXEgsuHGQdKD9RqU2Od3V4VqIIiaHiMA==", - "license": "MIT", "dependencies": { "@react-aria/focus": "3.21.2", "@react-aria/interactions": "3.25.6", @@ -2665,7 +2503,6 @@ "version": "2.2.21", "resolved": "https://registry.npmjs.org/@heroui/use-aria-link/-/use-aria-link-2.2.21.tgz", "integrity": "sha512-sG2rUutT/E/FYguzZmg715cXcM6+ue9wRfs2Gi6epWJwIVpS51uEagJKY0wIutJDfuCPfQ9AuxXfJek4CnxjKw==", - "license": "MIT", "dependencies": { "@react-aria/focus": "3.21.2", "@react-aria/interactions": "3.25.6", @@ -2681,7 +2518,6 @@ "version": "2.2.19", "resolved": "https://registry.npmjs.org/@heroui/use-aria-modal-overlay/-/use-aria-modal-overlay-2.2.19.tgz", "integrity": "sha512-MPvszNrt+1DauiSyOAwb0pKbYahpEVi9hrmidnO8cd1SA7B2ES0fNRBeNMAwcaeR/Nzsv+Cw1hRXt3egwqi0lg==", - "license": "MIT", "dependencies": { "@heroui/use-aria-overlay": "2.0.4", "@react-aria/overlays": "3.30.0", @@ -2697,7 +2533,6 @@ "version": "2.4.19", "resolved": "https://registry.npmjs.org/@heroui/use-aria-multiselect/-/use-aria-multiselect-2.4.19.tgz", "integrity": "sha512-RLDSpOLJqNESn6OK/zKuyTriK6sqMby76si/4kTMCs+4lmMPOyFKP3fREywu+zyJjRUCuZPa6xYuN2OHKQRDow==", - "license": "MIT", "dependencies": { "@react-aria/i18n": "3.12.13", "@react-aria/interactions": "3.25.6", @@ -2722,7 +2557,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/@heroui/use-aria-overlay/-/use-aria-overlay-2.0.4.tgz", "integrity": "sha512-iv+y0+OvQd1eWiZftPI07JE3c5AdK85W5k3rDlhk5MFEI3dllkIpu8z8zLh3ge/BQGFiGkySVC5iXl8w84gMUQ==", - "license": "MIT", "dependencies": { "@react-aria/focus": "3.21.2", "@react-aria/interactions": "3.25.6", @@ -2738,7 +2572,6 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/@heroui/use-callback-ref/-/use-callback-ref-2.1.8.tgz", "integrity": "sha512-D1JDo9YyFAprYpLID97xxQvf86NvyWLay30BeVVZT9kWmar6O9MbCRc7ACi7Ngko60beonj6+amTWkTm7QuY/Q==", - "license": "MIT", "dependencies": { "@heroui/use-safe-layout-effect": "2.1.8" }, @@ -2750,7 +2583,6 @@ "version": "2.1.9", "resolved": "https://registry.npmjs.org/@heroui/use-clipboard/-/use-clipboard-2.1.9.tgz", "integrity": "sha512-lkBq5RpXHiPvk1BXKJG8gMM0f7jRMIGnxAXDjAUzZyXKBuWLoM+XlaUWmZHtmkkjVFMX1L4vzA+vxi9rZbenEQ==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2759,7 +2591,6 @@ "version": "2.2.13", "resolved": "https://registry.npmjs.org/@heroui/use-data-scroll-overflow/-/use-data-scroll-overflow-2.2.13.tgz", "integrity": "sha512-zboLXO1pgYdzMUahDcVt5jf+l1jAQ/D9dFqr7AxWLfn6tn7/EgY0f6xIrgWDgJnM0U3hKxVeY13pAeB4AFTqTw==", - "license": "MIT", "dependencies": { "@heroui/shared-utils": "2.1.12" }, @@ -2771,7 +2602,6 @@ "version": "2.2.17", "resolved": "https://registry.npmjs.org/@heroui/use-disclosure/-/use-disclosure-2.2.17.tgz", "integrity": "sha512-S3pN0WmpcTTZuQHcXw4RcTVsxLaCZ95H5qi/JPN83ahhWTCC+pN8lwE37vSahbMTM1YriiHyTM6AWpv/E3Jq7w==", - "license": "MIT", "dependencies": { "@heroui/use-callback-ref": "2.1.8", "@react-aria/utils": "3.31.0", @@ -2785,7 +2615,6 @@ "version": "2.1.18", "resolved": "https://registry.npmjs.org/@heroui/use-draggable/-/use-draggable-2.1.18.tgz", "integrity": "sha512-ihQdmLGYJ6aTEaJ0/yCXYn6VRdrRV2eO03XD2A3KANZPb1Bj/n4r298xNMql5VnGq5ZNDJB9nTv8NNCu9pmPdg==", - "license": "MIT", "dependencies": { "@react-aria/interactions": "3.25.6" }, @@ -2797,7 +2626,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/@heroui/use-form-reset/-/use-form-reset-2.0.1.tgz", "integrity": "sha512-6slKWiLtVfgZnVeHVkM9eXgjwI07u0CUaLt2kQpfKPqTSTGfbHgCYJFduijtThhTdKBhdH6HCmzTcnbVlAxBXw==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2806,7 +2634,6 @@ "version": "2.1.13", "resolved": "https://registry.npmjs.org/@heroui/use-image/-/use-image-2.1.13.tgz", "integrity": "sha512-NLApz+xin2bKHEXr+eSrtB0lN8geKP5VOea5QGbOCiHq4DBXu4QctpRkSfCHGIQzWdBVaLPoV+5wd0lR2S2Egg==", - "license": "MIT", "dependencies": { "@heroui/react-utils": "2.1.14", "@heroui/use-safe-layout-effect": "2.1.8" @@ -2815,23 +2642,10 @@ "react": ">=18 || >=19.0.0-rc.0" } }, - "node_modules/@heroui/use-infinite-scroll": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/@heroui/use-infinite-scroll/-/use-infinite-scroll-2.2.12.tgz", - "integrity": "sha512-5yIrw6aP9eH6iU+bQmfixb6QM4qvcwrW3g8jaZJ5ce94nebglLs131B7rSqF/UK8Bp7OXsBM3j1pdBZM7lo/MA==", - "license": "MIT", - "dependencies": { - "@heroui/shared-utils": "2.1.12" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, "node_modules/@heroui/use-intersection-observer": { "version": "2.2.14", "resolved": "https://registry.npmjs.org/@heroui/use-intersection-observer/-/use-intersection-observer-2.2.14.tgz", "integrity": "sha512-qYJeMk4cTsF+xIckRctazCgWQ4BVOpJu+bhhkB1NrN+MItx19Lcb7ksOqMdN5AiSf85HzDcAEPIQ9w9RBlt5sg==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2840,7 +2654,6 @@ "version": "2.2.12", "resolved": "https://registry.npmjs.org/@heroui/use-is-mobile/-/use-is-mobile-2.2.12.tgz", "integrity": "sha512-2UKa4v1xbvFwerWKoMTrg4q9ZfP9MVIVfCl1a7JuKQlXq3jcyV6z1as5bZ41pCsTOT+wUVOFnlr6rzzQwT9ZOA==", - "license": "MIT", "dependencies": { "@react-aria/ssr": "3.9.10" }, @@ -2852,7 +2665,6 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/@heroui/use-is-mounted/-/use-is-mounted-2.1.8.tgz", "integrity": "sha512-DO/Th1vD4Uy8KGhd17oGlNA4wtdg91dzga+VMpmt94gSZe1WjsangFwoUBxF2uhlzwensCX9voye3kerP/lskg==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2861,7 +2673,6 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/@heroui/use-measure/-/use-measure-2.1.8.tgz", "integrity": "sha512-GjT9tIgluqYMZWfAX6+FFdRQBqyHeuqUMGzAXMTH9kBXHU0U5C5XU2c8WFORkNDoZIg1h13h1QdV+Vy4LE1dEA==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2870,7 +2681,6 @@ "version": "2.2.18", "resolved": "https://registry.npmjs.org/@heroui/use-pagination/-/use-pagination-2.2.18.tgz", "integrity": "sha512-qm1mUe5UgV0kPZItcs/jiX/BxzdDagmcxaJkYR6DkhfMRoCuOdoJhcoh8ncbCAgHpzPESPn1VxsOcG4/Y+Jkdw==", - "license": "MIT", "dependencies": { "@heroui/shared-utils": "2.1.12", "@react-aria/i18n": "3.12.13" @@ -2883,7 +2693,6 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/@heroui/use-resize/-/use-resize-2.1.8.tgz", "integrity": "sha512-htF3DND5GmrSiMGnzRbISeKcH+BqhQ/NcsP9sBTIl7ewvFaWiDhEDiUHdJxflmJGd/c5qZq2nYQM/uluaqIkKA==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2892,7 +2701,6 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/@heroui/use-safe-layout-effect/-/use-safe-layout-effect-2.1.8.tgz", "integrity": "sha512-wbnZxVWCYqk10XRMu0veSOiVsEnLcmGUmJiapqgaz0fF8XcpSScmqjTSoWjHIEWaHjQZ6xr+oscD761D6QJN+Q==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2901,7 +2709,6 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/@heroui/use-scroll-position/-/use-scroll-position-2.1.8.tgz", "integrity": "sha512-NxanHKObxVfWaPpNRyBR8v7RfokxrzcHyTyQfbgQgAGYGHTMaOGkJGqF8kBzInc3zJi+F0zbX7Nb0QjUgsLNUQ==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } @@ -2910,25 +2717,23 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/@heroui/use-viewport-size/-/use-viewport-size-2.0.1.tgz", "integrity": "sha512-blv8BEB/QdLePLWODPRzRS2eELJ2eyHbdOIADbL0KcfLzOUEg9EiuVk90hcSUDAFqYiJ3YZ5Z0up8sdPcR8Y7g==", - "license": "MIT", "peerDependencies": { "react": ">=18 || >=19.0.0-rc.0" } }, "node_modules/@heroui/user": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/@heroui/user/-/user-2.2.22.tgz", - "integrity": "sha512-kOLxh9Bjgl/ya/f+W7/eKVO/n1GPsU5TPzwocC9+FU/+MbCOrmkevhAGGUrb259KCnp9WCv7WGRIcf8rrsreDw==", - "license": "MIT", + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/@heroui/user/-/user-2.2.23.tgz", + "integrity": "sha512-o/ngJ4yTD4svjYKSP3hJNwhyWLhHk5g/wjqGvH81INfpeV7wPlzpM/C6LIezGB3rZjGM9d4ozSofv6spbCKCiA==", "dependencies": { - "@heroui/avatar": "2.2.22", + "@heroui/avatar": "2.2.23", "@heroui/react-utils": "2.1.14", "@heroui/shared-utils": "2.1.12", "@react-aria/focus": "3.21.2" }, "peerDependencies": { "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.17", + "@heroui/theme": ">=2.4.23", "react": ">=18 || >=19.0.0-rc.0", "react-dom": ">=18 || >=19.0.0-rc.0" } @@ -3134,7 +2939,6 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz", "integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==", - "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -3143,7 +2947,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/@internationalized/message/-/message-3.1.8.tgz", "integrity": "sha512-Rwk3j/TlYZhn3HQ6PyXUV0XP9Uv42jqZGNegt0BXlxjE6G3+LwHjbQZAGHhCnCPdaA6Tvd3ma/7QzLlLkJxAWA==", - "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0", "intl-messageformat": "^10.1.0" @@ -3153,7 +2956,6 @@ "version": "3.6.5", "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", "integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==", - "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -3162,7 +2964,6 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.7.tgz", "integrity": "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A==", - "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -3391,35 +3192,18 @@ "license": "MIT" }, "node_modules/@posthog/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.7.1.tgz", - "integrity": "sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.8.1.tgz", + "integrity": "sha512-jfzBtQIk9auRi/biO+G/gumK5KxqsD5wOr7XpYMROE/I3pazjP4zIziinp21iQuIQJMXrDvwt9Af3njgOGwtew==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6" } }, - "node_modules/@posthog/react": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@posthog/react/-/react-1.5.2.tgz", - "integrity": "sha512-KHdXbV1yba7Y2l8BVmwXlySWxqKVLNQ5ZiVvWOf7r3Eo7GIFxCM4CaNK/z83kKWn8KTskmKy7AGF6Hl6INWK3g==", - "license": "MIT", - "peerDependencies": { - "@types/react": ">=16.8.0", - "posthog-js": ">=1.257.2", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@react-aria/breadcrumbs": { "version": "3.5.29", "resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.29.tgz", "integrity": "sha512-rKS0dryllaZJqrr3f/EAf2liz8CBEfmL5XACj+Z1TAig6GIYe1QuA3BtkX0cV9OkMugXdX8e3cbA7nD10ORRqg==", - "license": "Apache-2.0", "dependencies": { "@react-aria/i18n": "^3.12.13", "@react-aria/link": "^3.8.6", @@ -3437,7 +3221,6 @@ "version": "3.14.2", "resolved": "https://registry.npmjs.org/@react-aria/button/-/button-3.14.2.tgz", "integrity": "sha512-VbLIA+Kd6f/MDjd+TJBUg2+vNDw66pnvsj2E4RLomjI9dfBuN7d+Yo2UnsqKVyhePjCUZ6xxa2yDuD63IOSIYA==", - "license": "Apache-2.0", "dependencies": { "@react-aria/interactions": "^3.25.6", "@react-aria/toolbar": "3.0.0-beta.21", @@ -3456,7 +3239,6 @@ "version": "3.9.2", "resolved": "https://registry.npmjs.org/@react-aria/calendar/-/calendar-3.9.2.tgz", "integrity": "sha512-uSLxLgOPRnEU4Jg59lAhUVA+uDx/55NBg4lpfsP2ynazyiJ5LCXmYceJi+VuOqMml7d9W0dB87OldOeLdIxYVA==", - "license": "Apache-2.0", "dependencies": { "@internationalized/date": "^3.10.0", "@react-aria/i18n": "^3.12.13", @@ -3478,7 +3260,6 @@ "version": "3.16.2", "resolved": "https://registry.npmjs.org/@react-aria/checkbox/-/checkbox-3.16.2.tgz", "integrity": "sha512-29Mj9ZqXioJ0bcMnNGooHztnTau5pikZqX3qCRj5bYR3by/ZFFavYoMroh9F7s/MbFm/tsKX+Sf02lYFEdXRjA==", - "license": "Apache-2.0", "dependencies": { "@react-aria/form": "^3.1.2", "@react-aria/interactions": "^3.25.6", @@ -3501,7 +3282,6 @@ "version": "3.14.0", "resolved": "https://registry.npmjs.org/@react-aria/combobox/-/combobox-3.14.0.tgz", "integrity": "sha512-z4ro0Hma//p4nL2IJx5iUa7NwxeXbzSoZ0se5uTYjG1rUUMszg+wqQh/AQoL+eiULn7rs18JY9wwNbVIkRNKWA==", - "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.2", "@react-aria/i18n": "^3.12.13", @@ -3529,7 +3309,6 @@ "version": "3.15.2", "resolved": "https://registry.npmjs.org/@react-aria/datepicker/-/datepicker-3.15.2.tgz", "integrity": "sha512-th078hyNqPf4P2K10su/y32zPDjs3lOYVdHvsL9/+5K1dnTvLHCK5vgUyLuyn8FchhF7cmHV49D+LZVv65PEpQ==", - "license": "Apache-2.0", "dependencies": { "@internationalized/date": "^3.10.0", "@internationalized/number": "^3.6.5", @@ -3559,7 +3338,6 @@ "version": "3.5.31", "resolved": "https://registry.npmjs.org/@react-aria/dialog/-/dialog-3.5.31.tgz", "integrity": "sha512-inxQMyrzX0UBW9Mhraq0nZ4HjHdygQvllzloT1E/RlDd61lr3RbmJR6pLsrbKOTtSvDIBJpCso1xEdHCFNmA0Q==", - "license": "Apache-2.0", "dependencies": { "@react-aria/interactions": "^3.25.6", "@react-aria/overlays": "^3.30.0", @@ -3577,7 +3355,6 @@ "version": "3.21.2", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", - "license": "Apache-2.0", "dependencies": { "@react-aria/interactions": "^3.25.6", "@react-aria/utils": "^3.31.0", @@ -3594,7 +3371,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-aria/form/-/form-3.1.2.tgz", "integrity": "sha512-R3i7L7Ci61PqZQvOrnL9xJeWEbh28UkTVgkj72EvBBn39y4h7ReH++0stv7rRs8p5ozETSKezBbGfu4UsBewWw==", - "license": "Apache-2.0", "dependencies": { "@react-aria/interactions": "^3.25.6", "@react-aria/utils": "^3.31.0", @@ -3611,7 +3387,6 @@ "version": "3.14.5", "resolved": "https://registry.npmjs.org/@react-aria/grid/-/grid-3.14.5.tgz", "integrity": "sha512-XHw6rgjlTqc85e3zjsWo3U0EVwjN5MOYtrolCKc/lc2ItNdcY3OlMhpsU9+6jHwg/U3VCSWkGvwAz9hg7krd8Q==", - "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.2", "@react-aria/i18n": "^3.12.13", @@ -3636,7 +3411,6 @@ "version": "3.12.13", "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.13.tgz", "integrity": "sha512-YTM2BPg0v1RvmP8keHenJBmlx8FXUKsdYIEX7x6QWRd1hKlcDwphfjzvt0InX9wiLiPHsT5EoBTpuUk8SXc0Mg==", - "license": "Apache-2.0", "dependencies": { "@internationalized/date": "^3.10.0", "@internationalized/message": "^3.1.8", @@ -3656,7 +3430,6 @@ "version": "3.25.6", "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", - "license": "Apache-2.0", "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.31.0", @@ -3673,7 +3446,6 @@ "version": "3.7.22", "resolved": "https://registry.npmjs.org/@react-aria/label/-/label-3.7.22.tgz", "integrity": "sha512-jLquJeA5ZNqDT64UpTc9XJ7kQYltUlNcgxZ37/v4mHe0UZ7QohCKdKQhXHONb0h2jjNUpp2HOZI8J9++jOpzxA==", - "license": "Apache-2.0", "dependencies": { "@react-aria/utils": "^3.31.0", "@react-types/shared": "^3.32.1", @@ -3688,7 +3460,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.7.tgz", "integrity": "sha512-t8c610b8hPLS6Vwv+rbuSyljZosI1s5+Tosfa0Fk4q7d+Ex6Yj7hLfUFy59GxZAufhUYfGX396fT0gPqAbU1tg==", - "license": "Apache-2.0", "dependencies": { "@react-aria/utils": "^3.31.0", "@react-types/shared": "^3.32.1", @@ -3704,7 +3475,6 @@ "version": "3.8.6", "resolved": "https://registry.npmjs.org/@react-aria/link/-/link-3.8.6.tgz", "integrity": "sha512-7F7UDJnwbU9IjfoAdl6f3Hho5/WB7rwcydUOjUux0p7YVWh/fTjIFjfAGyIir7MJhPapun1D0t97QQ3+8jXVcg==", - "license": "Apache-2.0", "dependencies": { "@react-aria/interactions": "^3.25.6", "@react-aria/utils": "^3.31.0", @@ -3721,7 +3491,6 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/@react-aria/listbox/-/listbox-3.15.0.tgz", "integrity": "sha512-Ub1Wu79R9sgxM7h4HeEdjOgOKDHwduvYcnDqsSddGXgpkL8ADjsy2YUQ0hHY5VnzA4BxK36bLp4mzSna8Qvj1w==", - "license": "Apache-2.0", "dependencies": { "@react-aria/interactions": "^3.25.6", "@react-aria/label": "^3.7.22", @@ -3742,7 +3511,6 @@ "version": "3.4.4", "resolved": "https://registry.npmjs.org/@react-aria/live-announcer/-/live-announcer-3.4.4.tgz", "integrity": "sha512-PTTBIjNRnrdJOIRTDGNifY2d//kA7GUAwRFJNOEwSNG4FW+Bq9awqLiflw0JkpyB0VNIwou6lqKPHZVLsGWOXA==", - "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -3751,7 +3519,6 @@ "version": "3.19.3", "resolved": "https://registry.npmjs.org/@react-aria/menu/-/menu-3.19.3.tgz", "integrity": "sha512-52fh8y8b2776R2VrfZPpUBJYC9oTP7XDy+zZuZTxPEd7Ywk0JNUl5F92y6ru22yPkS13sdhrNM/Op+V/KulmAg==", - "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.2", "@react-aria/i18n": "^3.12.13", @@ -3777,7 +3544,6 @@ "version": "3.12.2", "resolved": "https://registry.npmjs.org/@react-aria/numberfield/-/numberfield-3.12.2.tgz", "integrity": "sha512-M2b+z0HIXiXpGAWOQkO2kpIjaLNUXJ5Q3/GMa3Fkr+B1piFX0VuOynYrtddKVrmXCe+r5t+XcGb0KS29uqv7nQ==", - "license": "Apache-2.0", "dependencies": { "@react-aria/i18n": "^3.12.13", "@react-aria/interactions": "^3.25.6", @@ -3800,7 +3566,6 @@ "version": "3.30.0", "resolved": "https://registry.npmjs.org/@react-aria/overlays/-/overlays-3.30.0.tgz", "integrity": "sha512-UpjqSjYZx5FAhceWCRVsW6fX1sEwya1fQ/TKkL53FAlLFR8QKuoKqFlmiL43YUFTcGK3UdEOy3cWTleLQwdSmQ==", - "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.2", "@react-aria/i18n": "^3.12.13", @@ -3823,7 +3588,6 @@ "version": "3.4.27", "resolved": "https://registry.npmjs.org/@react-aria/progress/-/progress-3.4.27.tgz", "integrity": "sha512-0OA1shs1575g1zmO8+rWozdbTnxThFFhOfuoL1m7UV5Dley6FHpueoKB1ECv7B+Qm4dQt6DoEqLg7wsbbQDhmg==", - "license": "Apache-2.0", "dependencies": { "@react-aria/i18n": "^3.12.13", "@react-aria/label": "^3.7.22", @@ -3841,7 +3605,6 @@ "version": "3.12.2", "resolved": "https://registry.npmjs.org/@react-aria/radio/-/radio-3.12.2.tgz", "integrity": "sha512-I11f6I90neCh56rT/6ieAs3XyDKvEfbj/QmbU5cX3p+SJpRRPN0vxQi5D1hkh0uxDpeClxygSr31NmZsd4sqfg==", - "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.2", "@react-aria/form": "^3.1.2", @@ -3863,7 +3626,6 @@ "version": "3.26.0", "resolved": "https://registry.npmjs.org/@react-aria/selection/-/selection-3.26.0.tgz", "integrity": "sha512-ZBH3EfWZ+RfhTj01dH8L17uT7iNbXWS8u77/fUpHgtrm0pwNVhx0TYVnLU1YpazQ/3WVpvWhmBB8sWwD1FlD/g==", - "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.2", "@react-aria/i18n": "^3.12.13", @@ -3882,7 +3644,6 @@ "version": "3.8.2", "resolved": "https://registry.npmjs.org/@react-aria/slider/-/slider-3.8.2.tgz", "integrity": "sha512-6KyUGaVzRE4xAz1LKHbNh1q5wzxe58pdTHFSnxNe6nk1SCoHw7NfI4h2s2m6LgJ0megFxsT0Ir8aHaFyyxmbgg==", - "license": "Apache-2.0", "dependencies": { "@react-aria/i18n": "^3.12.13", "@react-aria/interactions": "^3.25.6", @@ -3902,7 +3663,6 @@ "version": "3.6.19", "resolved": "https://registry.npmjs.org/@react-aria/spinbutton/-/spinbutton-3.6.19.tgz", "integrity": "sha512-xOIXegDpts9t3RSHdIN0iYQpdts0FZ3LbpYJIYVvdEHo9OpDS+ElnDzCGtwZLguvZlwc5s1LAKuKopDUsAEMkw==", - "license": "Apache-2.0", "dependencies": { "@react-aria/i18n": "^3.12.13", "@react-aria/live-announcer": "^3.4.4", @@ -3920,7 +3680,6 @@ "version": "3.9.10", "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", - "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" }, @@ -3935,7 +3694,6 @@ "version": "3.7.8", "resolved": "https://registry.npmjs.org/@react-aria/switch/-/switch-3.7.8.tgz", "integrity": "sha512-AfsUq1/YiuoprhcBUD9vDPyWaigAwctQNW1fMb8dROL+i/12B+Zekj8Ml+jbU69/kIVtfL0Jl7/0Bo9KK3X0xQ==", - "license": "Apache-2.0", "dependencies": { "@react-aria/toggle": "^3.12.2", "@react-stately/toggle": "^3.9.2", @@ -3952,7 +3710,6 @@ "version": "3.17.8", "resolved": "https://registry.npmjs.org/@react-aria/table/-/table-3.17.8.tgz", "integrity": "sha512-bXiZoxTMbsqUJsYDhHPzKc3jw0HFJ/xMsJ49a0f7mp5r9zACxNLeIU0wJ4Uvx37dnYOHKzGliG+rj5l4sph7MA==", - "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.2", "@react-aria/grid": "^3.14.5", @@ -3979,7 +3736,6 @@ "version": "3.10.8", "resolved": "https://registry.npmjs.org/@react-aria/tabs/-/tabs-3.10.8.tgz", "integrity": "sha512-sPPJyTyoAqsBh76JinBAxStOcbjZvyWFYKpJ9Uqw+XT0ObshAPPFSGeh8DiQemPs02RwJdrfARPMhyqiX8t59A==", - "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.2", "@react-aria/i18n": "^3.12.13", @@ -3999,7 +3755,6 @@ "version": "3.18.2", "resolved": "https://registry.npmjs.org/@react-aria/textfield/-/textfield-3.18.2.tgz", "integrity": "sha512-G+lM8VYSor6g9Yptc6hLZ6BF+0cq0pYol1z6wdQUQgJN8tg4HPtzq75lsZtlCSIznL3amgRAxJtd0dUrsAnvaQ==", - "license": "Apache-2.0", "dependencies": { "@react-aria/form": "^3.1.2", "@react-aria/interactions": "^3.25.6", @@ -4020,7 +3775,6 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.8.tgz", "integrity": "sha512-rfJIms6AkMyQ7ZgKrMZgGfPwGcB/t1JoEwbc1PAmXcAvFI/hzF6YF7ZFDXiq38ucFsP9PnHmbXIzM9w4ccl18A==", - "license": "Apache-2.0", "dependencies": { "@react-aria/i18n": "^3.12.13", "@react-aria/interactions": "^3.25.6", @@ -4040,7 +3794,6 @@ "version": "3.12.2", "resolved": "https://registry.npmjs.org/@react-aria/toggle/-/toggle-3.12.2.tgz", "integrity": "sha512-g25XLYqJuJpt0/YoYz2Rab8ax+hBfbssllcEFh0v0jiwfk2gwTWfRU9KAZUvxIqbV8Nm8EBmrYychDpDcvW1kw==", - "license": "Apache-2.0", "dependencies": { "@react-aria/interactions": "^3.25.6", "@react-aria/utils": "^3.31.0", @@ -4058,7 +3811,6 @@ "version": "3.0.0-beta.21", "resolved": "https://registry.npmjs.org/@react-aria/toolbar/-/toolbar-3.0.0-beta.21.tgz", "integrity": "sha512-yRCk/GD8g+BhdDgxd3I0a0c8Ni4Wyo6ERzfSoBkPkwQ4X2E2nkopmraM9D0fXw4UcIr4bnmvADzkHXtBN0XrBg==", - "license": "Apache-2.0", "dependencies": { "@react-aria/focus": "^3.21.2", "@react-aria/i18n": "^3.12.13", @@ -4075,7 +3827,6 @@ "version": "3.8.8", "resolved": "https://registry.npmjs.org/@react-aria/tooltip/-/tooltip-3.8.8.tgz", "integrity": "sha512-CmHUqtXtFWmG4AHMEr9hIVex+oscK6xcM2V47gq9ijNInxe3M6UBu/dBdkgGP/jYv9N7tzCAjTR8nNIHQXwvWw==", - "license": "Apache-2.0", "dependencies": { "@react-aria/interactions": "^3.25.6", "@react-aria/utils": "^3.31.0", @@ -4093,7 +3844,6 @@ "version": "3.31.0", "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", - "license": "Apache-2.0", "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-stately/flags": "^3.1.2", @@ -4111,7 +3861,6 @@ "version": "3.8.28", "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.8.28.tgz", "integrity": "sha512-KRRjbVVob2CeBidF24dzufMxBveEUtUu7IM+hpdZKB+gxVROoh4XRLPv9SFmaH89Z7D9To3QoykVZoWD0lan6Q==", - "license": "Apache-2.0", "dependencies": { "@react-aria/interactions": "^3.25.6", "@react-aria/utils": "^3.31.0", @@ -4188,19 +3937,6 @@ } } }, - "node_modules/@react-router/dev/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@react-router/express": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.10.1.tgz", @@ -4273,7 +4009,6 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.9.0.tgz", "integrity": "sha512-U5Nf2kx9gDhJRxdDUm5gjfyUlt/uUfOvM1vDW2UA62cA6+2k2cavMLc2wNlXOb/twFtl6p0joYKHG7T4xnEFkg==", - "license": "Apache-2.0", "dependencies": { "@internationalized/date": "^3.10.0", "@react-stately/utils": "^3.10.8", @@ -4289,7 +4024,6 @@ "version": "3.7.2", "resolved": "https://registry.npmjs.org/@react-stately/checkbox/-/checkbox-3.7.2.tgz", "integrity": "sha512-j1ycUVz5JmqhaL6mDZgDNZqBilOB8PBW096sDPFaTtuYreDx2HOd1igxiIvwlvPESZwsJP7FVM3mYnaoXtpKPA==", - "license": "Apache-2.0", "dependencies": { "@react-stately/form": "^3.2.2", "@react-stately/utils": "^3.10.8", @@ -4305,7 +4039,6 @@ "version": "3.12.8", "resolved": "https://registry.npmjs.org/@react-stately/collections/-/collections-3.12.8.tgz", "integrity": "sha512-AceJYLLXt1Y2XIcOPi6LEJSs4G/ubeYW3LqOCQbhfIgMaNqKfQMIfagDnPeJX9FVmPFSlgoCBxb1pTJW2vjCAQ==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" @@ -4318,7 +4051,6 @@ "version": "3.12.0", "resolved": "https://registry.npmjs.org/@react-stately/combobox/-/combobox-3.12.0.tgz", "integrity": "sha512-A6q9R/7cEa/qoQsBkdslXWvD7ztNLLQ9AhBhVN9QvzrmrH5B4ymUwcTU8lWl22ykH7RRwfonLeLXJL4C+/L2oQ==", - "license": "Apache-2.0", "dependencies": { "@react-stately/collections": "^3.12.8", "@react-stately/form": "^3.2.2", @@ -4337,7 +4069,6 @@ "version": "3.15.2", "resolved": "https://registry.npmjs.org/@react-stately/datepicker/-/datepicker-3.15.2.tgz", "integrity": "sha512-S5GL+W37chvV8knv9v0JRv0L6hKo732qqabCCHXzOpYxkLIkV4f/y3cHdEzFWzpZ0O0Gkg7WgeYo160xOdBKYg==", - "license": "Apache-2.0", "dependencies": { "@internationalized/date": "^3.10.0", "@internationalized/string": "^3.2.7", @@ -4356,7 +4087,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", - "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -4365,7 +4095,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@react-stately/form/-/form-3.2.2.tgz", "integrity": "sha512-soAheOd7oaTO6eNs6LXnfn0tTqvOoe3zN9FvtIhhrErKz9XPc5sUmh3QWwR45+zKbitOi1HOjfA/gifKhZcfWw==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" @@ -4378,7 +4107,6 @@ "version": "3.11.6", "resolved": "https://registry.npmjs.org/@react-stately/grid/-/grid-3.11.6.tgz", "integrity": "sha512-vWPAkzpeTIsrurHfMubzMuqEw7vKzFhIJeEK5sEcLunyr1rlADwTzeWrHNbPMl66NAIAi70Dr1yNq+kahQyvMA==", - "license": "Apache-2.0", "dependencies": { "@react-stately/collections": "^3.12.8", "@react-stately/selection": "^3.20.6", @@ -4394,7 +4122,6 @@ "version": "3.13.1", "resolved": "https://registry.npmjs.org/@react-stately/list/-/list-3.13.1.tgz", "integrity": "sha512-eHaoauh21twbcl0kkwULhVJ+CzYcy1jUjMikNVMHOQdhr4WIBdExf7PmSgKHKqsSPhpGg6IpTCY2dUX3RycjDg==", - "license": "Apache-2.0", "dependencies": { "@react-stately/collections": "^3.12.8", "@react-stately/selection": "^3.20.6", @@ -4410,7 +4137,6 @@ "version": "3.9.8", "resolved": "https://registry.npmjs.org/@react-stately/menu/-/menu-3.9.8.tgz", "integrity": "sha512-bo0NOhofnTHLESiYfsSSw6gyXiPVJJ0UlN2igUXtJk5PmyhWjFzUzTzcnd7B028OB0si9w3LIWM3stqz5271Eg==", - "license": "Apache-2.0", "dependencies": { "@react-stately/overlays": "^3.6.20", "@react-types/menu": "^3.10.5", @@ -4425,7 +4151,6 @@ "version": "3.10.2", "resolved": "https://registry.npmjs.org/@react-stately/numberfield/-/numberfield-3.10.2.tgz", "integrity": "sha512-jlKVFYaH3RX5KvQ7a+SAMQuPccZCzxLkeYkBE64u1Zvi7YhJ8hkTMHG/fmZMbk1rHlseE2wfBdk0Rlya3MvoNQ==", - "license": "Apache-2.0", "dependencies": { "@internationalized/number": "^3.6.5", "@react-stately/form": "^3.2.2", @@ -4441,7 +4166,6 @@ "version": "3.6.20", "resolved": "https://registry.npmjs.org/@react-stately/overlays/-/overlays-3.6.20.tgz", "integrity": "sha512-YAIe+uI8GUXX8F/0Pzr53YeC5c/bjqbzDFlV8NKfdlCPa6+Jp4B/IlYVjIooBj9+94QvbQdjylegvYWK/iPwlg==", - "license": "Apache-2.0", "dependencies": { "@react-stately/utils": "^3.10.8", "@react-types/overlays": "^3.9.2", @@ -4455,7 +4179,6 @@ "version": "3.11.2", "resolved": "https://registry.npmjs.org/@react-stately/radio/-/radio-3.11.2.tgz", "integrity": "sha512-UM7L6AW+k8edhSBUEPZAqiWNRNadfOKK7BrCXyBiG79zTz0zPcXRR+N+gzkDn7EMSawDeyK1SHYUuoSltTactg==", - "license": "Apache-2.0", "dependencies": { "@react-stately/form": "^3.2.2", "@react-stately/utils": "^3.10.8", @@ -4471,7 +4194,6 @@ "version": "3.20.6", "resolved": "https://registry.npmjs.org/@react-stately/selection/-/selection-3.20.6.tgz", "integrity": "sha512-a0bjuP2pJYPKEiedz2Us1W1aSz0iHRuyeQEdBOyL6Z6VUa6hIMq9H60kvseir2T85cOa4QggizuRV7mcO6bU5w==", - "license": "Apache-2.0", "dependencies": { "@react-stately/collections": "^3.12.8", "@react-stately/utils": "^3.10.8", @@ -4486,7 +4208,6 @@ "version": "3.7.2", "resolved": "https://registry.npmjs.org/@react-stately/slider/-/slider-3.7.2.tgz", "integrity": "sha512-EVBHUdUYwj++XqAEiQg2fGi8Reccznba0uyQ3gPejF0pAc390Q/J5aqiTEDfiCM7uJ6WHxTM6lcCqHQBISk2dQ==", - "license": "Apache-2.0", "dependencies": { "@react-stately/utils": "^3.10.8", "@react-types/shared": "^3.32.1", @@ -4501,7 +4222,6 @@ "version": "3.15.1", "resolved": "https://registry.npmjs.org/@react-stately/table/-/table-3.15.1.tgz", "integrity": "sha512-MhMAgE/LgAzHcAn1P3p/nQErzJ6DiixSJ1AOt2JlnAKEb5YJg4ATKWCb2IjBLwywt9ZCzfm3KMUzkctZqAoxwA==", - "license": "Apache-2.0", "dependencies": { "@react-stately/collections": "^3.12.8", "@react-stately/flags": "^3.1.2", @@ -4521,7 +4241,6 @@ "version": "3.8.6", "resolved": "https://registry.npmjs.org/@react-stately/tabs/-/tabs-3.8.6.tgz", "integrity": "sha512-9RYxmgjVIxUpIsGKPIF7uRoHWOEz8muwaYiStCVeyiYBPmarvZoIYtTXcwSMN/vEs7heVN5uGCL6/bfdY4+WiA==", - "license": "Apache-2.0", "dependencies": { "@react-stately/list": "^3.13.1", "@react-types/shared": "^3.32.1", @@ -4536,7 +4255,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.2.tgz", "integrity": "sha512-HiInm7bck32khFBHZThTQaAF6e6/qm57F4mYRWdTq8IVeGDzpkbUYibnLxRhk0UZ5ybc6me+nqqPkG/lVmM42Q==", - "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.4.0" @@ -4549,7 +4267,6 @@ "version": "3.9.2", "resolved": "https://registry.npmjs.org/@react-stately/toggle/-/toggle-3.9.2.tgz", "integrity": "sha512-dOxs9wrVXHUmA7lc8l+N9NbTJMAaXcYsnNGsMwfXIXQ3rdq+IjWGNYJ52UmNQyRYFcg0jrzRrU16TyGbNjOdNQ==", - "license": "Apache-2.0", "dependencies": { "@react-stately/utils": "^3.10.8", "@react-types/checkbox": "^3.10.2", @@ -4564,7 +4281,6 @@ "version": "3.5.8", "resolved": "https://registry.npmjs.org/@react-stately/tooltip/-/tooltip-3.5.8.tgz", "integrity": "sha512-gkcUx2ROhCiGNAYd2BaTejakXUUNLPnnoJ5+V/mN480pN+OrO8/2V9pqb/IQmpqxLsso93zkM3A4wFHHLBBmPQ==", - "license": "Apache-2.0", "dependencies": { "@react-stately/overlays": "^3.6.20", "@react-types/tooltip": "^3.4.21", @@ -4578,7 +4294,6 @@ "version": "3.9.3", "resolved": "https://registry.npmjs.org/@react-stately/tree/-/tree-3.9.3.tgz", "integrity": "sha512-ZngG79nLFxE/GYmpwX6E/Rma2MMkzdoJPRI3iWk3dgqnGMMzpPnUp/cvjDsU3UHF7xDVusC5BT6pjWN0uxCIFQ==", - "license": "Apache-2.0", "dependencies": { "@react-stately/collections": "^3.12.8", "@react-stately/selection": "^3.20.6", @@ -4594,7 +4309,6 @@ "version": "3.10.8", "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", - "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" }, @@ -4606,7 +4320,6 @@ "version": "4.4.4", "resolved": "https://registry.npmjs.org/@react-stately/virtualizer/-/virtualizer-4.4.4.tgz", "integrity": "sha512-ri8giqXSZOrznZDCCOE4U36wSkOhy+hrFK7yo/YVcpxTqqp3d3eisfKMqbDsgqBW+XTHycTU/xeAf0u9NqrfpQ==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0" @@ -4620,7 +4333,6 @@ "version": "3.0.0-alpha.26", "resolved": "https://registry.npmjs.org/@react-types/accordion/-/accordion-3.0.0-alpha.26.tgz", "integrity": "sha512-OXf/kXcD2vFlEnkcZy/GG+a/1xO9BN7Uh3/5/Ceuj9z2E/WwD55YwU3GFM5zzkZ4+DMkdowHnZX37XnmbyD3Mg==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.27.0" }, @@ -4632,7 +4344,6 @@ "version": "3.7.17", "resolved": "https://registry.npmjs.org/@react-types/breadcrumbs/-/breadcrumbs-3.7.17.tgz", "integrity": "sha512-IhvVTcfli5o/UDlGACXxjlor2afGlMQA8pNR3faH0bBUay1Fmm3IWktVw9Xwmk+KraV2RTAg9e+E6p8DOQZfiw==", - "license": "Apache-2.0", "dependencies": { "@react-types/link": "^3.6.5", "@react-types/shared": "^3.32.1" @@ -4645,7 +4356,6 @@ "version": "3.14.1", "resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.14.1.tgz", "integrity": "sha512-D8C4IEwKB7zEtiWYVJ3WE/5HDcWlze9mLWQ5hfsBfpePyWCgO3bT/+wjb/7pJvcAocrkXo90QrMm85LcpBtrpg==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4657,7 +4367,6 @@ "version": "3.8.0", "resolved": "https://registry.npmjs.org/@react-types/calendar/-/calendar-3.8.0.tgz", "integrity": "sha512-ZDZgfZgbz1ydWOFs1mH7QFfX3ioJrmb3Y/lkoubQE0HWXLZzyYNvhhKyFJRS1QJ40IofLSBHriwbQb/tsUnGlw==", - "license": "Apache-2.0", "dependencies": { "@internationalized/date": "^3.10.0", "@react-types/shared": "^3.32.1" @@ -4670,7 +4379,6 @@ "version": "3.10.2", "resolved": "https://registry.npmjs.org/@react-types/checkbox/-/checkbox-3.10.2.tgz", "integrity": "sha512-ktPkl6ZfIdGS1tIaGSU/2S5Agf2NvXI9qAgtdMDNva0oLyAZ4RLQb6WecPvofw1J7YKXu0VA5Mu7nlX+FM2weQ==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4682,7 +4390,6 @@ "version": "3.13.9", "resolved": "https://registry.npmjs.org/@react-types/combobox/-/combobox-3.13.9.tgz", "integrity": "sha512-G6GmLbzVkLW6VScxPAr/RtliEyPhBClfYaIllK1IZv+Z42SVnOpKzhnoe79BpmiFqy1AaC3+LjZX783mrsHCwA==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4694,7 +4401,6 @@ "version": "3.13.2", "resolved": "https://registry.npmjs.org/@react-types/datepicker/-/datepicker-3.13.2.tgz", "integrity": "sha512-+M6UZxJnejYY8kz0spbY/hP08QJ5rsZ3aNarRQQHc48xV2oelFLX5MhAqizfLEsvyfb0JYrhWoh4z1xZtAmYCg==", - "license": "Apache-2.0", "dependencies": { "@internationalized/date": "^3.10.0", "@react-types/calendar": "^3.8.0", @@ -4709,7 +4415,6 @@ "version": "3.5.22", "resolved": "https://registry.npmjs.org/@react-types/dialog/-/dialog-3.5.22.tgz", "integrity": "sha512-smSvzOcqKE196rWk0oqJDnz+ox5JM5+OT0PmmJXiUD4q7P5g32O6W5Bg7hMIFUI9clBtngo8kLaX2iMg+GqAzg==", - "license": "Apache-2.0", "dependencies": { "@react-types/overlays": "^3.9.2", "@react-types/shared": "^3.32.1" @@ -4722,7 +4427,6 @@ "version": "3.7.16", "resolved": "https://registry.npmjs.org/@react-types/form/-/form-3.7.16.tgz", "integrity": "sha512-Sb7KJoWEaQ/e4XIY+xRbjKvbP1luome98ZXevpD+zVSyGjEcfIroebizP6K1yMHCWP/043xH6GUkgEqWPoVGjg==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4734,7 +4438,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/@react-types/grid/-/grid-3.3.6.tgz", "integrity": "sha512-vIZJlYTii2n1We9nAugXwM2wpcpsC6JigJFBd6vGhStRdRWRoU4yv1Gc98Usbx0FQ/J7GLVIgeG8+1VMTKBdxw==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4746,7 +4449,6 @@ "version": "3.6.5", "resolved": "https://registry.npmjs.org/@react-types/link/-/link-3.6.5.tgz", "integrity": "sha512-+I2s3XWBEvLrzts0GnNeA84mUkwo+a7kLUWoaJkW0TOBDG7my95HFYxF9WnqKye7NgpOkCqz4s3oW96xPdIniQ==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4758,7 +4460,6 @@ "version": "3.7.4", "resolved": "https://registry.npmjs.org/@react-types/listbox/-/listbox-3.7.4.tgz", "integrity": "sha512-p4YEpTl/VQGrqVE8GIfqTS5LkT5jtjDTbVeZgrkPnX/fiPhsfbTPiZ6g0FNap4+aOGJFGEEZUv2q4vx+rCORww==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4770,7 +4471,6 @@ "version": "3.10.5", "resolved": "https://registry.npmjs.org/@react-types/menu/-/menu-3.10.5.tgz", "integrity": "sha512-HBTrKll2hm0VKJNM4ubIv1L9MNo8JuOnm2G3M+wXvb6EYIyDNxxJkhjsqsGpUXJdAOSkacHBDcNh2HsZABNX4A==", - "license": "Apache-2.0", "dependencies": { "@react-types/overlays": "^3.9.2", "@react-types/shared": "^3.32.1" @@ -4783,7 +4483,6 @@ "version": "3.8.15", "resolved": "https://registry.npmjs.org/@react-types/numberfield/-/numberfield-3.8.15.tgz", "integrity": "sha512-97r92D23GKCOjGIGMeW9nt+/KlfM3GeWH39Czcmd2/D5y3k6z4j0avbsfx2OttCtJszrnENjw3GraYGYI2KosQ==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4795,7 +4494,6 @@ "version": "3.9.2", "resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.2.tgz", "integrity": "sha512-Q0cRPcBGzNGmC8dBuHyoPR7N3057KTS5g+vZfQ53k8WwmilXBtemFJPLsogJbspuewQ/QJ3o2HYsp2pne7/iNw==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4807,7 +4505,6 @@ "version": "3.5.16", "resolved": "https://registry.npmjs.org/@react-types/progress/-/progress-3.5.16.tgz", "integrity": "sha512-I9tSdCFfvQ7gHJtm90VAKgwdTWXQgVNvLRStEc0z9h+bXBxdvZb+QuiRPERChwFQ9VkK4p4rDqaFo69nDqWkpw==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4819,7 +4516,6 @@ "version": "3.9.2", "resolved": "https://registry.npmjs.org/@react-types/radio/-/radio-3.9.2.tgz", "integrity": "sha512-3UcJXu37JrTkRyP4GJPDBU7NmDTInrEdOe+bVzA1j4EegzdkJmLBkLg5cLDAbpiEHB+xIsvbJdx6dxeMuc+H3g==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4831,7 +4527,6 @@ "version": "3.32.1", "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", - "license": "Apache-2.0", "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } @@ -4840,7 +4535,6 @@ "version": "3.8.2", "resolved": "https://registry.npmjs.org/@react-types/slider/-/slider-3.8.2.tgz", "integrity": "sha512-MQYZP76OEOYe7/yA2To+Dl0LNb0cKKnvh5JtvNvDnAvEprn1RuLiay8Oi/rTtXmc2KmBa4VdTcsXsmkbbkeN2Q==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4852,7 +4546,6 @@ "version": "3.5.15", "resolved": "https://registry.npmjs.org/@react-types/switch/-/switch-3.5.15.tgz", "integrity": "sha512-r/ouGWQmIeHyYSP1e5luET+oiR7N7cLrAlWsrAfYRWHxqXOSNQloQnZJ3PLHrKFT02fsrQhx2rHaK2LfKeyN3A==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4864,7 +4557,6 @@ "version": "3.13.4", "resolved": "https://registry.npmjs.org/@react-types/table/-/table-3.13.4.tgz", "integrity": "sha512-I/DYiZQl6aNbMmjk90J9SOhkzVDZvyA3Vn3wMWCiajkMNjvubFhTfda5DDf2SgFP5l0Yh6TGGH5XumRv9LqL5Q==", - "license": "Apache-2.0", "dependencies": { "@react-types/grid": "^3.3.6", "@react-types/shared": "^3.32.1" @@ -4877,7 +4569,6 @@ "version": "3.3.19", "resolved": "https://registry.npmjs.org/@react-types/tabs/-/tabs-3.3.19.tgz", "integrity": "sha512-fE+qI43yR5pAMpeqPxGqQq9jDHXEPqXskuxNHERMW0PYMdPyem2Cw6goc5F4qeZO3Hf6uPZgHkvJz2OAq7TbBw==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4889,7 +4580,6 @@ "version": "3.12.6", "resolved": "https://registry.npmjs.org/@react-types/textfield/-/textfield-3.12.6.tgz", "integrity": "sha512-hpEVKE+M3uUkTjw2WrX1NrH/B3rqDJFUa+ViNK2eVranLY4ZwFqbqaYXSzHupOF3ecSjJJv2C103JrwFvx6TPQ==", - "license": "Apache-2.0", "dependencies": { "@react-types/shared": "^3.32.1" }, @@ -4901,7 +4591,6 @@ "version": "3.4.21", "resolved": "https://registry.npmjs.org/@react-types/tooltip/-/tooltip-3.4.21.tgz", "integrity": "sha512-ugGHOZU6WbOdeTdbjnaEc+Ms7/WhsUCg+T3PCOIeOT9FG02Ce189yJ/+hd7oqL/tVwIhEMYJIqSCgSELFox+QA==", - "license": "Apache-2.0", "dependencies": { "@react-types/overlays": "^3.9.2", "@react-types/shared": "^3.32.1" @@ -4917,12 +4606,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "license": "MIT" - }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -5266,34 +4949,10 @@ "license": "MIT" }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@stripe/react-stripe-js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz", - "integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.7.2" - }, - "peerDependencies": { - "@stripe/stripe-js": ">=8.0.0 <9.0.0", - "react": ">=16.8.0 <20.0.0", - "react-dom": ">=16.8.0 <20.0.0" - } - }, - "node_modules/@stripe/stripe-js": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.5.3.tgz", - "integrity": "sha512-UM0GHAxlTN7v0lCK2P6t0VOlvBIdApIQxhnM3yZ2kupQ4PpSrLsK/n/NyYKtw2NJGMaNRRD1IicWS7fSL2sFtA==", - "license": "MIT", - "engines": { - "node": ">=12.16" - } + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", @@ -5537,16 +5196,14 @@ "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" } }, "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", - "license": "MIT", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", @@ -5554,40 +5211,38 @@ "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" + "tailwindcss": "4.1.18" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", - "license": "MIT", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -5597,13 +5252,12 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -5613,13 +5267,12 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -5629,13 +5282,12 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -5645,13 +5297,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5661,13 +5312,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5677,13 +5327,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5693,13 +5342,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5709,13 +5357,12 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -5725,9 +5372,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -5739,13 +5386,12 @@ "cpu": [ "wasm32" ], - "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", + "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, @@ -5753,14 +5399,67 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -5770,13 +5469,12 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -5785,19 +5483,6 @@ "node": ">= 10" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", - "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "postcss": "^8.4.41", - "tailwindcss": "4.1.17" - } - }, "node_modules/@tailwindcss/typography": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", @@ -5812,14 +5497,13 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", - "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", - "license": "MIT", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", "dependencies": { - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "tailwindcss": "4.1.17" + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -5872,7 +5556,6 @@ "version": "3.11.3", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.3.tgz", "integrity": "sha512-vCU+OTylXN3hdC8RKg68tPlBPjjxtzon7Ys46MgrSLE+JhSjSTPvoQifV6DQJeJmA8Q3KT6CphJbejupx85vFw==", - "license": "MIT", "dependencies": { "@tanstack/virtual-core": "3.11.3" }, @@ -5889,7 +5572,6 @@ "version": "3.11.3", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.3.tgz", "integrity": "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w==", - "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -5943,11 +5625,10 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -5991,47 +5672,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/base16": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.5.tgz", @@ -6043,7 +5683,6 @@ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, - "license": "MIT", "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -6062,8 +5701,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/estree": { "version": "1.0.8", @@ -6118,11 +5756,10 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, - "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } @@ -6152,16 +5789,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/react-highlight": { - "version": "0.12.8", - "resolved": "https://registry.npmjs.org/@types/react-highlight/-/react-highlight-0.12.8.tgz", - "integrity": "sha512-V7O7zwXUw8WSPd//YUO8sz489J/EeobJljASGhP0rClrvq+1Y1qWEpToGu+Pp7YuChxhAXSgkLkrOYpZX5A62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/react-syntax-highlighter": { "version": "15.5.13", "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", @@ -6192,16 +5819,6 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -6289,14 +5906,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", - "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.1", - "@typescript-eslint/types": "^8.48.1", + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "engines": { @@ -6311,9 +5928,9 @@ } }, "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", - "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", "engines": { @@ -6343,9 +5960,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", - "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", "dev": true, "license": "MIT", "engines": { @@ -6454,16 +6071,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", - "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1" + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6478,14 +6095,14 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", - "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1" + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6496,9 +6113,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", - "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", "engines": { @@ -6510,16 +6127,16 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", - "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.1", - "@typescript-eslint/tsconfig-utils": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -6538,13 +6155,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -6618,44 +6235,14 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.5", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitejs/plugin-react/node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.15.tgz", - "integrity": "sha512-FUJ+1RkpTFW7rQITdgTi93qOCWJobWhBirEPCeXh2SW2wsTlFxy51apDz5gzG+ZEYt/THvWeNmhdAoS9DTwpCw==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz", + "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==", "dev": true, - "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.15", + "@vitest/utils": "4.0.16", "ast-v8-to-istanbul": "^0.3.8", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -6670,8 +6257,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.15", - "vitest": "4.0.15" + "@vitest/browser": "4.0.16", + "vitest": "4.0.16" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6680,16 +6267,15 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", - "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "dev": true, - "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.15", - "@vitest/utils": "4.0.15", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, @@ -6698,13 +6284,12 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", - "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.15", + "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -6725,11 +6310,10 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", - "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "dev": true, - "license": "MIT", "dependencies": { "tinyrainbow": "^3.0.3" }, @@ -6738,13 +6322,12 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", - "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.15", + "@vitest/utils": "4.0.16", "pathe": "^2.0.3" }, "funding": { @@ -6755,17 +6338,15 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@vitest/snapshot": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", - "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.15", + "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -6777,27 +6358,24 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@vitest/spy": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", - "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", "dev": true, - "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", - "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.15", + "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" }, "funding": { @@ -7138,7 +6716,6 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" } @@ -7185,44 +6762,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/autoprefixer": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", - "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.27.0", - "caniuse-lite": "^1.0.30001754", - "fraction.js": "^5.3.4", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7310,6 +6849,7 @@ "version": "2.9.5", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -7409,6 +6949,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -7535,9 +7076,10 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001759", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", - "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -7569,7 +7111,6 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" } @@ -7780,7 +7321,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" @@ -7820,8 +7360,7 @@ "node_modules/color2k": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz", - "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==", - "license": "MIT" + "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==" }, "node_modules/colorette": { "version": "2.0.20", @@ -7968,6 +7507,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -8194,16 +7734,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -8266,7 +7796,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8459,6 +7988,7 @@ "version": "1.5.266", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { @@ -8538,10 +8068,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "license": "MIT", + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -8774,11 +8303,10 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", + "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -8786,38 +8314,39 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.1", + "@esbuild/android-arm": "0.27.1", + "@esbuild/android-arm64": "0.27.1", + "@esbuild/android-x64": "0.27.1", + "@esbuild/darwin-arm64": "0.27.1", + "@esbuild/darwin-x64": "0.27.1", + "@esbuild/freebsd-arm64": "0.27.1", + "@esbuild/freebsd-x64": "0.27.1", + "@esbuild/linux-arm": "0.27.1", + "@esbuild/linux-arm64": "0.27.1", + "@esbuild/linux-ia32": "0.27.1", + "@esbuild/linux-loong64": "0.27.1", + "@esbuild/linux-mips64el": "0.27.1", + "@esbuild/linux-ppc64": "0.27.1", + "@esbuild/linux-riscv64": "0.27.1", + "@esbuild/linux-s390x": "0.27.1", + "@esbuild/linux-x64": "0.27.1", + "@esbuild/netbsd-arm64": "0.27.1", + "@esbuild/netbsd-x64": "0.27.1", + "@esbuild/openbsd-arm64": "0.27.1", + "@esbuild/openbsd-x64": "0.27.1", + "@esbuild/openharmony-arm64": "0.27.1", + "@esbuild/sunos-x64": "0.27.1", + "@esbuild/win32-arm64": "0.27.1", + "@esbuild/win32-ia32": "0.27.1", + "@esbuild/win32-x64": "0.27.1" } }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9777,7 +9306,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "license": "BSD-3-Clause", "bin": { "flat": "cli.js" } @@ -9873,24 +9401,10 @@ "node": ">= 0.6" } }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/framer-motion": { - "version": "12.23.25", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.25.tgz", - "integrity": "sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==", + "version": "12.23.26", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", + "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", "license": "MIT", "dependencies": { "motion-dom": "^12.23.23", @@ -9998,6 +9512,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -10237,8 +9752,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -10544,9 +10058,9 @@ } }, "node_modules/i18next": { - "version": "25.7.2", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz", - "integrity": "sha512-58b4kmLpLv1buWUEwegMDUqZVR5J+rT+WTRFaBGL7lxDuJQQ0NrJFrq+eT2N94aYVR1k1Sr13QITNOL88tZCuw==", + "version": "25.7.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz", + "integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==", "funding": [ { "type": "individual", @@ -10679,7 +10193,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.1.tgz", "integrity": "sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==", - "license": "MIT", "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" @@ -10704,7 +10217,6 @@ "version": "10.7.18", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", - "license": "BSD-3-Clause", "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", @@ -11318,15 +10830,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11387,9 +10890,10 @@ } }, "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -11430,6 +10934,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -11950,15 +11455,16 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" } }, "node_modules/lucide-react": { - "version": "0.556.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.556.0.tgz", - "integrity": "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==", + "version": "0.561.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", + "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -13356,17 +12862,8 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", @@ -13908,32 +13405,19 @@ "node": ">=4" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/posthog-js": { - "version": "1.302.2", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.302.2.tgz", - "integrity": "sha512-4voih22zQe7yHA7DynlQ3B7kgzJOaKIjzV7K3jJ2Qf+UDXd1ZgO7xYmLWYVtuKEvD1OXHbKk/fPhUTZeHEWpBw==", + "version": "1.309.1", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.309.1.tgz", + "integrity": "sha512-JUJcQhYzNNKO0cgnSbowCsVi2RTu75XGZ2EmnTQti4tMGRCTOv/HCnZasdFniBGZ0rLugQkaScYca/84Ta2u5Q==", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@posthog/core": "1.7.1", + "@posthog/core": "1.8.1", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" } }, - "node_modules/posthog-js/node_modules/web-vitals": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", - "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", - "license": "Apache-2.0" - }, "node_modules/preact": { "version": "10.28.0", "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz", @@ -14157,10 +13641,9 @@ } }, "node_modules/react": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", - "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", - "license": "MIT", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "engines": { "node": ">=0.10.0" } @@ -14206,24 +13689,14 @@ "license": "MIT" }, "node_modules/react-dom": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", - "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", - "license": "MIT", + "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==", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.1" - } - }, - "node_modules/react-highlight": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/react-highlight/-/react-highlight-0.15.0.tgz", - "integrity": "sha512-5uV/b/N4Z421GSVVe05fz+OfTsJtFzx/fJBdafZyw4LS70XjIZwgEx3Lrkfc01W/RzZ2Dtfb0DApoaJFAIKBtA==", - "license": "MIT", - "dependencies": { - "highlight.js": "^10.5.0" + "react": "^19.2.3" } }, "node_modules/react-hot-toast": { @@ -14244,9 +13717,9 @@ } }, "node_modules/react-i18next": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.4.0.tgz", - "integrity": "sha512-bxVeBA8Ky2UeItNhF4JRxHCFIrpEJHGFG/mOAa4CR0JkqaDEYSLmlEgmC4Os63SBlZ+E5U0YyrNJOSVl2mtVqQ==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.0.tgz", + "integrity": "sha512-IMpPTyCTKxEj8klCrLKUTIUa8uYTd851+jcu2fJuUB9Agkk9Qq8asw4omyeHVnOXHrLgQJGTm5zTvn8HpaPiqw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.27.6", @@ -14870,7 +14343,6 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.0.10.tgz", "integrity": "sha512-t44QCeDKAPf1mtQH3fYpWz8IM/DyvHLjs8wUvvwMYxk5moOqCzrMSxK6HQVD0QVmVjXFavoFIPRVrMuJPKAvtg==", - "license": "MIT", "dependencies": { "compute-scroll-into-view": "^3.0.2" } @@ -15701,27 +15173,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stripe": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.0.0.tgz", - "integrity": "sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "qs": "^6.11.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@types/node": ">=16" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -15835,10 +15286,9 @@ } }, "node_modules/tailwind-variants": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.1.1.tgz", - "integrity": "sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==", - "license": "MIT", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", "engines": { "node": ">=16.x", "pnpm": ">=7.x" @@ -15854,16 +15304,14 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==" }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "license": "MIT", "engines": { "node": ">=6" }, @@ -16390,6 +15838,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -16549,12 +15998,12 @@ } }, "node_modules/vite": { - "version": "7.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", - "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -16668,11 +16117,10 @@ } }, "node_modules/vite-tsconfig-paths": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", - "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "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==", "dev": true, - "license": "MIT", "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", @@ -16731,19 +16179,18 @@ } }, "node_modules/vitest": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", - "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.15", - "@vitest/mocker": "4.0.15", - "@vitest/pretty-format": "4.0.15", - "@vitest/runner": "4.0.15", - "@vitest/snapshot": "4.0.15", - "@vitest/spy": "4.0.15", - "@vitest/utils": "4.0.15", + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", @@ -16771,10 +16218,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.15", - "@vitest/browser-preview": "4.0.15", - "@vitest/browser-webdriverio": "4.0.15", - "@vitest/ui": "4.0.15", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, @@ -16851,9 +16298,9 @@ } }, "node_modules/web-vitals": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", - "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", "license": "Apache-2.0" }, "node_modules/webidl-conversions": { @@ -17143,6 +16590,7 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -17199,6 +16647,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, "license": "ISC" }, "node_modules/yaml": { diff --git a/frontend/package.json b/frontend/package.json index a0b1601f25..90636fed77 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,49 +1,39 @@ { "name": "openhands-frontend", - "version": "0.62.0", + "version": "1.0.0", "private": true, "type": "module", "engines": { "node": ">=22.0.0" }, "dependencies": { - "@heroui/react": "2.8.5", - "@heroui/use-infinite-scroll": "^2.2.12", + "@heroui/react": "2.8.6", "@microlink/react-json-view": "^1.26.2", "@monaco-editor/react": "^4.7.0-rc.0", - "@posthog/react": "^1.5.2", "@react-router/node": "^7.10.1", "@react-router/serve": "^7.10.1", - "@react-types/shared": "^3.32.0", - "@stripe/react-stripe-js": "^5.4.1", - "@stripe/stripe-js": "^8.5.3", - "@tailwindcss/postcss": "^4.1.17", - "@tailwindcss/vite": "^4.1.17", + "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.12", "@uidotdev/usehooks": "^2.4.1", - "@vitejs/plugin-react": "^5.1.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "downshift": "^9.0.12", + "downshift": "^9.0.13", "eslint-config-airbnb-typescript": "^18.0.0", "framer-motion": "^12.23.25", - "i18next": "^25.7.2", + "i18next": "^25.7.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.32", - "jose": "^6.1.3", - "lucide-react": "^0.556.0", + "lucide-react": "^0.561.0", "monaco-editor": "^0.55.1", - "posthog-js": "^1.302.2", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-highlight": "^0.15.0", + "posthog-js": "^1.309.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.4.0", + "react-i18next": "^16.5.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-router": "^7.10.1", @@ -54,9 +44,7 @@ "socket.io-client": "^4.8.1", "tailwind-merge": "^3.4.0", "tailwind-scrollbar": "^4.0.2", - "vite": "^7.2.7", - "web-vitals": "^5.1.0", - "ws": "^8.18.2", + "vite": "^7.3.0", "zustand": "^5.0.9" }, "scripts": { @@ -92,9 +80,6 @@ ] }, "devDependencies": { - "@babel/parser": "^7.28.3", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2", "@mswjs/socket.io-binding": "^0.2.0", "@playwright/test": "^1.57.0", "@react-router/dev": "^7.10.1", @@ -102,18 +87,15 @@ "@tanstack/eslint-plugin-query": "^5.91.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.0", + "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.10.1", + "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@types/react-highlight": "^0.12.8", "@types/react-syntax-highlighter": "^15.5.13", - "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", - "@vitest/coverage-v8": "^4.0.14", - "autoprefixer": "^10.4.22", + "@vitest/coverage-v8": "^4.0.16", "cross-env": "^10.1.0", "eslint": "^8.57.0", "eslint-config-airbnb": "^19.0.4", @@ -131,11 +113,10 @@ "lint-staged": "^16.2.7", "msw": "^2.6.6", "prettier": "^3.7.3", - "stripe": "^20.0.0", "tailwindcss": "^4.1.8", "typescript": "^5.9.3", "vite-plugin-svgr": "^4.5.0", - "vite-tsconfig-paths": "^5.1.4", + "vite-tsconfig-paths": "^6.0.2", "vitest": "^4.0.14" }, "packageManager": "npm@10.5.0", diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index bd37fa8180..d2f8f51ff5 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -11,6 +11,7 @@ import type { V1AppConversationStartTask, V1AppConversationStartTaskPage, V1AppConversation, + GetSkillsResponse, } from "./v1-conversation-service.types"; class V1ConversationService { @@ -315,6 +316,18 @@ class V1ConversationService { ); return data; } + + /** + * Get all skills associated with a V1 conversation + * @param conversationId The conversation ID + * @returns The available skills associated with the conversation + */ + static async getSkills(conversationId: string): Promise { + const { data } = await openHands.get( + `/api/v1/app-conversations/${conversationId}/skills`, + ); + return data; + } } export default V1ConversationService; diff --git a/frontend/src/api/conversation-service/v1-conversation-service.types.ts b/frontend/src/api/conversation-service/v1-conversation-service.types.ts index 621283c274..7c8b04ccbf 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.types.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.types.ts @@ -99,3 +99,14 @@ export interface V1AppConversation { conversation_url: string | null; session_api_key: string | null; } + +export interface Skill { + name: string; + type: "repo" | "knowledge"; + content: string; + triggers: string[]; +} + +export interface GetSkillsResponse { + skills: Skill[]; +} diff --git a/frontend/src/api/event-service/event-service.api.ts b/frontend/src/api/event-service/event-service.api.ts index 3e7a42666b..7464480d5c 100644 --- a/frontend/src/api/event-service/event-service.api.ts +++ b/frontend/src/api/event-service/event-service.api.ts @@ -5,7 +5,6 @@ import type { ConfirmationResponseRequest, ConfirmationResponseResponse, } from "./event-service.types"; -import { openHands } from "../open-hands-axios"; class EventService { /** @@ -38,11 +37,27 @@ class EventService { return data; } - static async getEventCount(conversationId: string): Promise { - const params = new URLSearchParams(); - params.append("conversation_id__eq", conversationId); - const { data } = await openHands.get( - `/api/v1/events/count?${params.toString()}`, + /** + * Get event count for a V1 conversation + * @param conversationId The conversation ID + * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") + * @param sessionApiKey Session API key for authentication (required for V1) + * @returns The event count + */ + static async getEventCount( + conversationId: string, + conversationUrl: string, + sessionApiKey?: string | null, + ): Promise { + // Build the runtime URL using the conversation URL + const runtimeUrl = buildHttpBaseUrl(conversationUrl); + + // Build session headers for authentication + const headers = buildSessionHeaders(sessionApiKey); + + const { data } = await axios.get( + `${runtimeUrl}/api/conversations/${conversationId}/events/count`, + { headers }, ); return data; } 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/api/settings-service/settings-service.api.ts b/frontend/src/api/settings-service/settings-service.api.ts index f75e10c3e6..1b0d1d5e0e 100644 --- a/frontend/src/api/settings-service/settings-service.api.ts +++ b/frontend/src/api/settings-service/settings-service.api.ts @@ -1,5 +1,5 @@ import { openHands } from "../open-hands-axios"; -import { ApiSettings, PostApiSettings } from "./settings.types"; +import { Settings } from "#/types/settings"; /** * Settings service for managing application settings @@ -8,8 +8,8 @@ class SettingsService { /** * Get the settings from the server or use the default settings if not found */ - static async getSettings(): Promise { - const { data } = await openHands.get("/api/settings"); + static async getSettings(): Promise { + const { data } = await openHands.get("/api/settings"); return data; } @@ -17,9 +17,7 @@ class SettingsService { * Save the settings to the server. Only valid settings are saved. * @param settings - the settings to save */ - static async saveSettings( - settings: Partial, - ): Promise { + static async saveSettings(settings: Partial): Promise { const data = await openHands.post("/api/settings", settings); return data.status === 200; } diff --git a/frontend/src/api/settings-service/settings.types.ts b/frontend/src/api/settings-service/settings.types.ts deleted file mode 100644 index c6d33a7ee5..0000000000 --- a/frontend/src/api/settings-service/settings.types.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Provider } from "#/types/settings"; - -export type ApiSettings = { - llm_model: string; - llm_base_url: string; - agent: string; - language: string; - llm_api_key: string | null; - llm_api_key_set: boolean; - search_api_key_set: boolean; - confirmation_mode: boolean; - security_analyzer: string | null; - remote_runtime_resource_factor: number | null; - enable_default_condenser: boolean; - // Max size for condenser in backend settings - condenser_max_size: number | null; - enable_sound_notifications: boolean; - enable_proactive_conversation_starters: boolean; - enable_solvability_analysis: boolean; - user_consents_to_analytics: boolean | null; - search_api_key?: string; - provider_tokens_set: Partial>; - max_budget_per_task: number | null; - mcp_config?: { - sse_servers: (string | { url: string; api_key?: string })[]; - stdio_servers: { - name: string; - command: string; - args?: string[]; - env?: Record; - }[]; - shttp_servers: (string | { url: string; api_key?: string })[]; - }; - email?: string; - email_verified?: boolean; - git_user_name?: string; - git_user_email?: string; - v1_enabled?: boolean; -}; - -export type PostApiSettings = ApiSettings & { - user_consents_to_analytics: boolean | null; - search_api_key?: string; - mcp_config?: { - sse_servers: (string | { url: string; api_key?: string })[]; - stdio_servers: { - name: string; - command: string; - args?: string[]; - env?: Record; - }[]; - shttp_servers: (string | { url: string; api_key?: string })[]; - }; -}; 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/confirmation-mode-enabled.tsx b/frontend/src/components/features/chat/confirmation-mode-enabled.tsx index 6094d9a4c3..0e7df1afb8 100644 --- a/frontend/src/components/features/chat/confirmation-mode-enabled.tsx +++ b/frontend/src/components/features/chat/confirmation-mode-enabled.tsx @@ -9,7 +9,7 @@ function ConfirmationModeEnabled() { const { data: settings } = useSettings(); - if (!settings?.CONFIRMATION_MODE) { + if (!settings?.confirmation_mode) { return null; } 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/expandable-message.tsx b/frontend/src/components/features/chat/expandable-message.tsx index 12942498a2..f1f7fe6869 100644 --- a/frontend/src/components/features/chat/expandable-message.tsx +++ b/frontend/src/components/features/chat/expandable-message.tsx @@ -6,7 +6,6 @@ import { I18nKey } from "#/i18n/declaration"; import ArrowDown from "#/icons/angle-down-solid.svg?react"; import ArrowUp from "#/icons/angle-up-solid.svg?react"; import CheckCircle from "#/icons/check-circle-solid.svg?react"; -import XCircle from "#/icons/x-circle-solid.svg?react"; import { OpenHandsAction } from "#/types/core/actions"; import { OpenHandsObservation } from "#/types/core/observations"; import { cn } from "#/utils/utils"; @@ -169,19 +168,12 @@ export function ExpandableMessage({ )} - {type === "action" && success !== undefined && ( + {type === "action" && success && ( - {success ? ( - - ) : ( - - )} + )}
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/success-indicator.tsx b/frontend/src/components/features/chat/success-indicator.tsx index 4e5ac4779a..12e16d67fe 100644 --- a/frontend/src/components/features/chat/success-indicator.tsx +++ b/frontend/src/components/features/chat/success-indicator.tsx @@ -1,6 +1,5 @@ import { FaClock } from "react-icons/fa"; import CheckCircle from "#/icons/check-circle-solid.svg?react"; -import XCircle from "#/icons/x-circle-solid.svg?react"; import { ObservationResultStatus } from "./event-content-helpers/get-observation-result"; interface SuccessIndicatorProps { @@ -17,13 +16,6 @@ export function SuccessIndicator({ status }: SuccessIndicatorProps) { /> )} - {status === "error" && ( - - )} - {status === "timeout" && ( ; case "in_progress": - return ; + return ; case "done": return ; default: diff --git a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx index a30fe5f816..0c2541237d 100644 --- a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx +++ b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx @@ -5,11 +5,10 @@ import { ContextMenu } from "#/ui/context-menu"; import { ContextMenuListItem } from "./context-menu-list-item"; import { Divider } from "#/ui/divider"; import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; -import { useConfig } from "#/hooks/query/use-config"; import { I18nKey } from "#/i18n/declaration"; import LogOutIcon from "#/icons/log-out.svg?react"; import DocumentIcon from "#/icons/document.svg?react"; -import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; +import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; interface AccountSettingsContextMenuProps { onLogout: () => void; @@ -22,15 +21,8 @@ export function AccountSettingsContextMenu({ }: AccountSettingsContextMenuProps) { const ref = useClickOutsideElement(onClose); const { t } = useTranslation(); - const { data: config } = useConfig(); - - const isSaas = config?.APP_MODE === "saas"; - // Get navigation items and filter out LLM settings if the feature flag is enabled - let items = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; - if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS) { - items = items.filter((item) => item.to !== "/settings"); - } + const items = useSettingsNavItems(); const navItems = items.map((item) => ({ ...item, @@ -39,11 +31,7 @@ export function AccountSettingsContextMenu({ height: 16, } as React.SVGProps), })); - - const handleNavigationClick = () => { - onClose(); - // The Link component will handle the actual navigation - }; + const handleNavigationClick = () => onClose(); return ( ( handleNavigationClick()} + onClick={handleNavigationClick} className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]" > {icon} diff --git a/frontend/src/components/features/controls/agent-status.tsx b/frontend/src/components/features/controls/agent-status.tsx index 078eb5f40f..675b5881a3 100644 --- a/frontend/src/components/features/controls/agent-status.tsx +++ b/frontend/src/components/features/controls/agent-status.tsx @@ -59,13 +59,15 @@ export function AgentStatus({ ); const shouldShownAgentLoading = - isPausing || curAgentState === AgentState.INIT || curAgentState === AgentState.LOADING || (webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") || isTaskPolling(taskStatus) || isTaskPolling(subConversationTaskStatus); + // For UI rendering - includes pause state + const isLoading = shouldShownAgentLoading || isPausing; + const shouldShownAgentError = curAgentState === AgentState.ERROR || curAgentState === AgentState.RATE_LIMITED || @@ -93,25 +95,28 @@ export function AgentStatus({
- {shouldShownAgentLoading && } - {!shouldShownAgentLoading && shouldShownAgentStop && ( + {isLoading && } + {!isLoading && shouldShownAgentStop && ( )} - {!shouldShownAgentLoading && shouldShownAgentResume && ( + {!isLoading && shouldShownAgentResume && ( )} - {!shouldShownAgentLoading && shouldShownAgentError && ( - + {!isLoading && shouldShownAgentError && ( + )} - {!shouldShownAgentLoading && + {!isLoading && !shouldShownAgentStop && !shouldShownAgentResume && !shouldShownAgentError && } diff --git a/frontend/src/components/features/controls/tools-context-menu.tsx b/frontend/src/components/features/controls/tools-context-menu.tsx index 39330e25e4..2089f95111 100644 --- a/frontend/src/components/features/controls/tools-context-menu.tsx +++ b/frontend/src/components/features/controls/tools-context-menu.tsx @@ -26,14 +26,14 @@ const contextMenuListItemClassName = cn( interface ToolsContextMenuProps { onClose: () => void; - onShowMicroagents: (event: React.MouseEvent) => void; + onShowSkills: (event: React.MouseEvent) => void; onShowAgentTools: (event: React.MouseEvent) => void; shouldShowAgentTools?: boolean; } export function ToolsContextMenu({ onClose, - onShowMicroagents, + onShowSkills, onShowAgentTools, shouldShowAgentTools = true, }: ToolsContextMenuProps) { @@ -41,7 +41,6 @@ export function ToolsContextMenu({ const { data: conversation } = useActiveConversation(); const { providers } = useUserProviders(); - // TODO: Hide microagent menu items for V1 conversations // This is a temporary measure and may be re-enabled in the future const isV1Conversation = conversation?.conversation_version === "V1"; @@ -130,20 +129,17 @@ export function ToolsContextMenu({ {(!isV1Conversation || shouldShowAgentTools) && } - {/* Show Available Microagents - Hidden for V1 conversations */} - {!isV1Conversation && ( - - } - text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)} - className={CONTEXT_MENU_ICON_TEXT_CLASSNAME} - /> - - )} + + } + text={t(I18nKey.CONVERSATION$SHOW_SKILLS)} + className={CONTEXT_MENU_ICON_TEXT_CLASSNAME} + /> + {/* Show Agent Tools and Metadata - Only show if system message is available */} {shouldShowAgentTools && ( diff --git a/frontend/src/components/features/controls/tools.tsx b/frontend/src/components/features/controls/tools.tsx index 56ef58bc8e..80994cbe65 100644 --- a/frontend/src/components/features/controls/tools.tsx +++ b/frontend/src/components/features/controls/tools.tsx @@ -7,7 +7,7 @@ import { ToolsContextMenu } from "./tools-context-menu"; import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { SystemMessageModal } from "../conversation-panel/system-message-modal"; -import { MicroagentsModal } from "../conversation-panel/microagents-modal"; +import { SkillsModal } from "../conversation-panel/skills-modal"; export function Tools() { const { t } = useTranslation(); @@ -17,11 +17,11 @@ export function Tools() { const { handleShowAgentTools, - handleShowMicroagents, + handleShowSkills, systemModalVisible, setSystemModalVisible, - microagentsModalVisible, - setMicroagentsModalVisible, + skillsModalVisible, + setSkillsModalVisible, systemMessage, shouldShowAgentTools, } = useConversationNameContextMenu({ @@ -51,7 +51,7 @@ export function Tools() { {contextMenuOpen && ( setContextMenuOpen(false)} - onShowMicroagents={handleShowMicroagents} + onShowSkills={handleShowSkills} onShowAgentTools={handleShowAgentTools} shouldShowAgentTools={shouldShowAgentTools} /> @@ -64,9 +64,9 @@ export function Tools() { systemMessage={systemMessage ? systemMessage.args : null} /> - {/* Microagents Modal */} - {microagentsModalVisible && ( - setMicroagentsModalVisible(false)} /> + {/* Skills Modal */} + {skillsModalVisible && ( + setSkillsModalVisible(false)} /> )}
); diff --git a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx b/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx deleted file mode 100644 index 63ea33152b..0000000000 --- a/frontend/src/components/features/conversation-panel/conversation-card-context-menu.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { - Trash, - Power, - Pencil, - Download, - Wallet, - Wrench, - Bot, -} from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; -import { cn } from "#/utils/utils"; -import { ContextMenu } from "#/ui/context-menu"; -import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; -import { Divider } from "#/ui/divider"; -import { I18nKey } from "#/i18n/declaration"; -import { ContextMenuIconText } from "../context-menu/context-menu-icon-text"; -import { useActiveConversation } from "#/hooks/query/use-active-conversation"; - -interface ConversationCardContextMenuProps { - onClose: () => void; - onDelete?: (event: React.MouseEvent) => void; - onStop?: (event: React.MouseEvent) => void; - onEdit?: (event: React.MouseEvent) => void; - onDisplayCost?: (event: React.MouseEvent) => void; - onShowAgentTools?: (event: React.MouseEvent) => void; - onShowMicroagents?: (event: React.MouseEvent) => void; - onDownloadViaVSCode?: (event: React.MouseEvent) => void; - position?: "top" | "bottom"; -} - -export function ConversationCardContextMenu({ - onClose, - onDelete, - onStop, - onEdit, - onDisplayCost, - onShowAgentTools, - onShowMicroagents, - onDownloadViaVSCode, - position = "bottom", -}: ConversationCardContextMenuProps) { - const { t } = useTranslation(); - const ref = useClickOutsideElement(onClose); - const { data: conversation } = useActiveConversation(); - - // TODO: Hide microagent menu items for V1 conversations - // This is a temporary measure and may be re-enabled in the future - const isV1Conversation = conversation?.conversation_version === "V1"; - - const hasEdit = Boolean(onEdit); - const hasDownload = Boolean(onDownloadViaVSCode); - const hasTools = Boolean(onShowAgentTools || onShowMicroagents); - const hasInfo = Boolean(onDisplayCost); - const hasControl = Boolean(onStop || onDelete); - - return ( - - {onEdit && ( - - - - )} - - {hasEdit && (hasDownload || hasTools || hasInfo || hasControl) && ( - - )} - - {onDownloadViaVSCode && ( - - - - )} - - {hasDownload && (hasTools || hasInfo || hasControl) && } - - {onShowAgentTools && ( - - - - )} - - {onShowMicroagents && !isV1Conversation && ( - - - - )} - - {hasTools && (hasInfo || hasControl) && } - - {onDisplayCost && ( - - - - )} - - {hasInfo && hasControl && } - - {onStop && ( - - - - )} - - {onDelete && ( - - - - )} - - ); -} diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx index 6565a83a10..30a7ec42cb 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-context-menu.tsx @@ -22,7 +22,7 @@ interface ConversationCardContextMenuProps { onEdit?: (event: React.MouseEvent) => void; onDisplayCost?: (event: React.MouseEvent) => void; onShowAgentTools?: (event: React.MouseEvent) => void; - onShowMicroagents?: (event: React.MouseEvent) => void; + onShowSkills?: (event: React.MouseEvent) => void; onDownloadViaVSCode?: (event: React.MouseEvent) => void; position?: "top" | "bottom"; } @@ -37,7 +37,7 @@ export function ConversationCardContextMenu({ onEdit, onDisplayCost, onShowAgentTools, - onShowMicroagents, + onShowSkills, onDownloadViaVSCode, position = "bottom", }: ConversationCardContextMenuProps) { @@ -96,15 +96,15 @@ export function ConversationCardContextMenu({ />
), - onShowMicroagents && ( + onShowSkills && ( } - text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)} + text={t(I18nKey.CONVERSATION$SHOW_SKILLS)} /> ), 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-panel/microagent-content.tsx b/frontend/src/components/features/conversation-panel/skill-content.tsx similarity index 76% rename from frontend/src/components/features/conversation-panel/microagent-content.tsx rename to frontend/src/components/features/conversation-panel/skill-content.tsx index fad0485607..9303047e3a 100644 --- a/frontend/src/components/features/conversation-panel/microagent-content.tsx +++ b/frontend/src/components/features/conversation-panel/skill-content.tsx @@ -3,17 +3,17 @@ import { I18nKey } from "#/i18n/declaration"; import { Typography } from "#/ui/typography"; import { Pre } from "#/ui/pre"; -interface MicroagentContentProps { +interface SkillContentProps { content: string; } -export function MicroagentContent({ content }: MicroagentContentProps) { +export function SkillContent({ content }: SkillContentProps) { const { t } = useTranslation(); return (
- {t(I18nKey.MICROAGENTS_MODAL$CONTENT)} + {t(I18nKey.COMMON$CONTENT)}
-        {content || t(I18nKey.MICROAGENTS_MODAL$NO_CONTENT)}
+        {content || t(I18nKey.SKILLS_MODAL$NO_CONTENT)}
       
); diff --git a/frontend/src/components/features/conversation-panel/microagent-item.tsx b/frontend/src/components/features/conversation-panel/skill-item.tsx similarity index 65% rename from frontend/src/components/features/conversation-panel/microagent-item.tsx rename to frontend/src/components/features/conversation-panel/skill-item.tsx index d23febb099..c76bf10be9 100644 --- a/frontend/src/components/features/conversation-panel/microagent-item.tsx +++ b/frontend/src/components/features/conversation-panel/skill-item.tsx @@ -1,35 +1,31 @@ import { ChevronDown, ChevronRight } from "lucide-react"; -import { Microagent } from "#/api/open-hands.types"; import { Typography } from "#/ui/typography"; -import { MicroagentTriggers } from "./microagent-triggers"; -import { MicroagentContent } from "./microagent-content"; +import { SkillTriggers } from "./skill-triggers"; +import { SkillContent } from "./skill-content"; +import { Skill } from "#/api/conversation-service/v1-conversation-service.types"; -interface MicroagentItemProps { - agent: Microagent; +interface SkillItemProps { + skill: Skill; isExpanded: boolean; onToggle: (agentName: string) => void; } -export function MicroagentItem({ - agent, - isExpanded, - onToggle, -}: MicroagentItemProps) { +export function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) { return (
+ )}
); } diff --git a/frontend/src/components/features/guards/email-verification-guard.tsx b/frontend/src/components/features/guards/email-verification-guard.tsx index b212016503..3fbb774842 100644 --- a/frontend/src/components/features/guards/email-verification-guard.tsx +++ b/frontend/src/components/features/guards/email-verification-guard.tsx @@ -20,13 +20,13 @@ export function EmailVerificationGuard({ if (isLoading) return; // If EMAIL_VERIFIED is explicitly false (not undefined or null) - if (settings?.EMAIL_VERIFIED === false) { + if (settings?.email_verified === false) { // Allow access to /settings/user but redirect from any other page if (pathname !== "/settings/user") { navigate("/settings/user", { replace: true }); } } - }, [settings?.EMAIL_VERIFIED, pathname, navigate, isLoading]); + }, [settings?.email_verified, pathname, navigate, isLoading]); return children; } 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/recent-conversations/recent-conversations.tsx b/frontend/src/components/features/home/recent-conversations/recent-conversations.tsx index 3d6bc64410..d0bd560c7d 100644 --- a/frontend/src/components/features/home/recent-conversations/recent-conversations.tsx +++ b/frontend/src/components/features/home/recent-conversations/recent-conversations.tsx @@ -78,7 +78,7 @@ export function RecentConversations() { )}
- {!isInitialLoading && displayedConversations?.length === 0 && ( + {!isInitialLoading && !error && displayedConversations?.length === 0 && ( {t(I18nKey.HOME$NO_RECENT_CONVERSATIONS)} diff --git a/frontend/src/components/features/home/repo-selection-form.tsx b/frontend/src/components/features/home/repo-selection-form.tsx index f891f25d1f..f70364975a 100644 --- a/frontend/src/components/features/home/repo-selection-form.tsx +++ b/frontend/src/components/features/home/repo-selection-form.tsx @@ -35,7 +35,11 @@ export function RepositorySelectionForm({ React.useState(null); const { providers } = useUserProviders(); - const { addRecentRepository } = useHomeStore(); + const { + addRecentRepository, + setLastSelectedProvider, + getLastSelectedProvider, + } = useHomeStore(); const { mutate: createConversation, isPending, @@ -46,12 +50,24 @@ export function RepositorySelectionForm({ const { t } = useTranslation(); - // Auto-select provider if there's only one + // Auto-select provider logic React.useEffect(() => { + if (providers.length === 0) return; + + // If there's only one provider, auto-select it if (providers.length === 1 && !selectedProvider) { setSelectedProvider(providers[0]); + return; } - }, [providers, selectedProvider]); + + // If there are multiple providers and none is selected, try to use the last selected one + if (providers.length > 1 && !selectedProvider) { + const lastSelected = getLastSelectedProvider(); + if (lastSelected && providers.includes(lastSelected)) { + setSelectedProvider(lastSelected); + } + } + }, [providers, selectedProvider, getLastSelectedProvider]); // We check for isSuccess because the app might require time to render // into the new conversation screen after the conversation is created. @@ -66,6 +82,7 @@ export function RepositorySelectionForm({ } setSelectedProvider(provider); + setLastSelectedProvider(provider); // Store the selected provider setSelectedRepository(null); // Reset repository selection when provider changes setSelectedBranch(null); // Reset branch selection when provider changes onRepoSelection(null); // Reset parent component's selected repo 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/payment/setup-payment-modal.tsx b/frontend/src/components/features/payment/setup-payment-modal.tsx index 30cb0a4e54..7d8883a719 100644 --- a/frontend/src/components/features/payment/setup-payment-modal.tsx +++ b/frontend/src/components/features/payment/setup-payment-modal.tsx @@ -1,24 +1,14 @@ -import { useMutation } from "@tanstack/react-query"; import { Trans, useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalBody } from "#/components/shared/modals/modal-body"; -import BillingService from "#/api/billing-service/billing-service.api"; import { BrandButton } from "../settings/brand-button"; -import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { useCreateBillingSession } from "#/hooks/mutation/use-create-billing-session"; export function SetupPaymentModal() { const { t } = useTranslation(); - const { mutate, isPending } = useMutation({ - mutationFn: BillingService.createBillingSessionResponse, - onSuccess: (data) => { - window.location.href = data; - }, - onError: () => { - displayErrorToast(t(I18nKey.BILLING$ERROR_WHILE_CREATING_SESSION)); - }, - }); + const { mutate, isPending } = useCreateBillingSession(); return ( diff --git a/frontend/src/components/features/settings/api-keys-manager.tsx b/frontend/src/components/features/settings/api-keys-manager.tsx index 82d86fb4a9..20a8807aa0 100644 --- a/frontend/src/components/features/settings/api-keys-manager.tsx +++ b/frontend/src/components/features/settings/api-keys-manager.tsx @@ -13,10 +13,8 @@ import { CreateApiKeyModal } from "./create-api-key-modal"; import { DeleteApiKeyModal } from "./delete-api-key-modal"; import { NewApiKeyModal } from "./new-api-key-modal"; import { useApiKeys } from "#/hooks/query/use-api-keys"; -import { - useLlmApiKey, - useRefreshLlmApiKey, -} from "#/hooks/query/use-llm-api-key"; +import { useLlmApiKey } from "#/hooks/query/use-llm-api-key"; +import { useRefreshLlmApiKey } from "#/hooks/mutation/use-refresh-llm-api-key"; interface LlmApiKeyManagerProps { llmApiKey: { key: string | null } | undefined; diff --git a/frontend/src/components/features/settings/settings-layout.tsx b/frontend/src/components/features/settings/settings-layout.tsx index 6ac82cf8d0..7d00ab2596 100644 --- a/frontend/src/components/features/settings/settings-layout.tsx +++ b/frontend/src/components/features/settings/settings-layout.tsx @@ -1,16 +1,11 @@ import { useState } from "react"; import { MobileHeader } from "./mobile-header"; import { SettingsNavigation } from "./settings-navigation"; - -interface NavigationItem { - to: string; - icon: React.ReactNode; - text: string; -} +import { SettingsNavItem } from "#/constants/settings-nav"; interface SettingsLayoutProps { children: React.ReactNode; - navigationItems: NavigationItem[]; + navigationItems: SettingsNavItem[]; } export function SettingsLayout({ @@ -19,13 +14,8 @@ export function SettingsLayout({ }: SettingsLayoutProps) { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const toggleMobileMenu = () => { - setIsMobileMenuOpen(!isMobileMenuOpen); - }; - - const closeMobileMenu = () => { - setIsMobileMenuOpen(false); - }; + const toggleMobileMenu = () => setIsMobileMenuOpen(!isMobileMenuOpen); + const closeMobileMenu = () => setIsMobileMenuOpen(false); return (
    @@ -34,7 +24,6 @@ export function SettingsLayout({ isMobileMenuOpen={isMobileMenuOpen} onToggleMenu={toggleMobileMenu} /> - {/* Desktop layout with navigation and main content */}
    {/* Navigation */} @@ -43,7 +32,6 @@ export function SettingsLayout({ onCloseMobileMenu={closeMobileMenu} navigationItems={navigationItems} /> - {/* Main content */}
    {children} diff --git a/frontend/src/components/features/settings/settings-navigation.tsx b/frontend/src/components/features/settings/settings-navigation.tsx index ce9e49aa09..5a35f01495 100644 --- a/frontend/src/components/features/settings/settings-navigation.tsx +++ b/frontend/src/components/features/settings/settings-navigation.tsx @@ -5,17 +5,12 @@ import { Typography } from "#/ui/typography"; import { I18nKey } from "#/i18n/declaration"; import SettingsIcon from "#/icons/settings-gear.svg?react"; import CloseIcon from "#/icons/close.svg?react"; - -interface NavigationItem { - to: string; - icon: React.ReactNode; - text: string; -} +import { SettingsNavItem } from "#/constants/settings-nav"; interface SettingsNavigationProps { isMobileMenuOpen: boolean; onCloseMobileMenu: () => void; - navigationItems: NavigationItem[]; + navigationItems: SettingsNavItem[]; } export function SettingsNavigation({ @@ -34,7 +29,6 @@ export function SettingsNavigation({ onClick={onCloseMobileMenu} /> )} - {/* Navigation sidebar */}
    - +
    - settings?.EMAIL_VERIFIED === false + settings?.email_verified === false ? null : setConversationPanelIsOpen((prev) => !prev) } - disabled={settings?.EMAIL_VERIFIED === false} + disabled={settings?.email_verified === false} />
    diff --git a/frontend/src/components/features/sidebar/user-actions.tsx b/frontend/src/components/features/sidebar/user-actions.tsx index aaa766c885..27b1543b07 100644 --- a/frontend/src/components/features/sidebar/user-actions.tsx +++ b/frontend/src/components/features/sidebar/user-actions.tsx @@ -58,6 +58,9 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) { className={cn( "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto", showMenu && "opacity-100 pointer-events-auto", + // Invisible hover bridge: extends hover zone to create a "safe corridor" + // for diagonal mouse movement to the menu (only active when menu is visible) + "group-hover:before:absolute group-hover:before:bottom-0 group-hover:before:right-0 group-hover:before:w-[200px] group-hover:before:h-[300px]", )} > @@ -80,7 +80,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
    diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx index d9b9bf2d2d..dec57f385f 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx +++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx @@ -159,6 +159,9 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => { } break; } + case "ThinkObservation": + observationKey = "OBSERVATION_MESSAGE$THINK"; + break; default: // For unknown observations, use the type name return observationType.replace("Observation", "").toUpperCase(); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts index bf443ea71c..7fb1c2ce1c 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts @@ -190,7 +190,13 @@ const getThinkObservationContent = ( event: ObservationEvent, ): string => { const { observation } = event; - return observation.content || ""; + + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return textContent || ""; }; const getFinishObservationContent = ( 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/components/v1/chat/event-content-helpers/should-render-event.ts b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts index a5fdc62252..1171c21c92 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts @@ -18,6 +18,10 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => { // For V1, action is an object with kind property const actionType = event.action.kind; + if (!actionType) { + return false; + } + // Hide user commands from the chat interface if (actionType === "ExecuteBashAction" && event.source === "user") { return false; diff --git a/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx index aa0bbc09b4..221d758dd6 100644 --- a/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx +++ b/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx @@ -34,7 +34,12 @@ export function ObservationPairEventMessage({ .map((t) => t.text) .join("\n"); - if (thoughtContent && event.action.kind !== "ThinkAction") { + // Defensive check: ensure action exists and has kind property + if ( + thoughtContent && + event.action?.kind && + event.action.kind !== "ThinkAction" + ) { return (
    diff --git a/frontend/src/components/v1/chat/task-tracking/task-item.tsx b/frontend/src/components/v1/chat/task-tracking/task-item.tsx index b25664a611..a50b6829d3 100644 --- a/frontend/src/components/v1/chat/task-tracking/task-item.tsx +++ b/frontend/src/components/v1/chat/task-tracking/task-item.tsx @@ -20,9 +20,7 @@ export function TaskItem({ task }: TaskItemProps) { case "todo": return ; case "in_progress": - return ( - - ); + return ; case "done": return ; default: diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index 68c50f9499..0cf43b49ce 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -7,6 +7,7 @@ import React, { useMemo, useRef, } from "react"; +import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket"; import { useEventStore } from "#/stores/use-event-store"; @@ -44,6 +45,7 @@ import { isBudgetOrCreditError } from "#/utils/error-handler"; import { useTracking } from "#/hooks/use-tracking"; import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation-file"; import useMetricsStore from "#/stores/metrics-store"; +import { I18nKey } from "#/i18n/declaration"; // eslint-disable-next-line @typescript-eslint/naming-convention export type V1_WebSocketConnectionState = @@ -123,6 +125,8 @@ export function ConversationWebSocketProvider({ conversationId: string; } | null>(null); + const { t } = useTranslation(); + // Helper function to update metrics from stats event const updateMetricsFromStats = useCallback( (event: ConversationStateUpdateEventStats) => { @@ -578,9 +582,13 @@ export function ConversationWebSocketProvider({ removeErrorMessage(); // Clear any previous error messages on successful connection // Fetch expected event count for history loading detection - if (conversationId) { + if (conversationId && conversationUrl) { try { - const count = await EventService.getEventCount(conversationId); + const count = await EventService.getEventCount( + conversationId, + conversationUrl, + sessionApiKey, + ); setExpectedEventCountMain(count); // If no events expected, mark as loaded immediately @@ -599,7 +607,7 @@ export function ConversationWebSocketProvider({ // This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation) if (event.code !== 1000 && hasConnectedRefMain.current) { setErrorMessage( - `Connection lost: ${event.reason || "Unexpected disconnect"}`, + `${t(I18nKey.STATUS$CONNECTION_LOST)}: ${event.reason || t(I18nKey.STATUS$DISCONNECTED_REFRESH_PAGE)}`, ); } }, @@ -618,6 +626,7 @@ export function ConversationWebSocketProvider({ removeErrorMessage, sessionApiKey, conversationId, + conversationUrl, ]); // Separate WebSocket options for planning agent connection @@ -642,10 +651,15 @@ export function ConversationWebSocketProvider({ removeErrorMessage(); // Clear any previous error messages on successful connection // Fetch expected event count for history loading detection - if (planningAgentConversation?.id) { + if ( + planningAgentConversation?.id && + planningAgentConversation.conversation_url + ) { try { const count = await EventService.getEventCount( planningAgentConversation.id, + planningAgentConversation.conversation_url, + planningAgentConversation.session_api_key, ); setExpectedEventCountPlanning(count); @@ -665,7 +679,7 @@ export function ConversationWebSocketProvider({ // This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation) if (event.code !== 1000 && hasConnectedRefPlanning.current) { setErrorMessage( - `Connection lost: ${event.reason || "Unexpected disconnect"}`, + `${t(I18nKey.STATUS$CONNECTION_LOST)}: ${event.reason || t(I18nKey.STATUS$DISCONNECTED_REFRESH_PAGE)}`, ); } }, diff --git a/frontend/src/hooks/mutation/use-accept-tos.ts b/frontend/src/hooks/mutation/use-accept-tos.ts new file mode 100644 index 0000000000..a159b1458c --- /dev/null +++ b/frontend/src/hooks/mutation/use-accept-tos.ts @@ -0,0 +1,54 @@ +import { useMutation } from "@tanstack/react-query"; +import { usePostHog } from "posthog-js/react"; +import { useNavigate } from "react-router"; +import { openHands } from "#/api/open-hands-axios"; +import { handleCaptureConsent } from "#/utils/handle-capture-consent"; +import { useTracking } from "#/hooks/use-tracking"; + +interface AcceptTosVariables { + redirectUrl: string; +} + +interface AcceptTosResponse { + redirect_url?: string; +} + +export const useAcceptTos = () => { + const posthog = usePostHog(); + const navigate = useNavigate(); + const { trackUserSignupCompleted } = useTracking(); + + return useMutation({ + mutationFn: async ({ redirectUrl }: AcceptTosVariables) => { + // Set consent for analytics + handleCaptureConsent(posthog, true); + + // Call the API to record TOS acceptance in the database + return openHands.post("/api/accept_tos", { + redirect_url: redirectUrl, + }); + }, + onSuccess: (response, { redirectUrl }) => { + // Track user signup completion + trackUserSignupCompleted(); + + // Get the redirect URL from the response + const finalRedirectUrl = response.data.redirect_url || redirectUrl; + + // Check if the redirect URL is an external URL (starts with http or https) + if ( + finalRedirectUrl.startsWith("http://") || + finalRedirectUrl.startsWith("https://") + ) { + // For external URLs, redirect using window.location + window.location.href = finalRedirectUrl; + } else { + // For internal routes, use navigate + navigate(finalRedirectUrl); + } + }, + onError: () => { + window.location.href = "/"; + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-add-mcp-server.ts b/frontend/src/hooks/mutation/use-add-mcp-server.ts index 25581cbdaf..c9aaf4e446 100644 --- a/frontend/src/hooks/mutation/use-add-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-add-mcp-server.ts @@ -24,7 +24,7 @@ export function useAddMcpServer() { mutationFn: async (server: MCPServerConfig): Promise => { if (!settings) return; - const currentConfig = settings.MCP_CONFIG || { + const currentConfig = settings.mcp_config || { sse_servers: [], stdio_servers: [], shttp_servers: [], @@ -57,7 +57,7 @@ export function useAddMcpServer() { const apiSettings = { mcp_config: newConfig, - v1_enabled: settings.V1_ENABLED, + v1_enabled: settings.v1_enabled, }; await SettingsService.saveSettings(apiSettings); diff --git a/frontend/src/hooks/mutation/use-create-billing-session.ts b/frontend/src/hooks/mutation/use-create-billing-session.ts new file mode 100644 index 0000000000..f8f0716cb2 --- /dev/null +++ b/frontend/src/hooks/mutation/use-create-billing-session.ts @@ -0,0 +1,19 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import BillingService from "#/api/billing-service/billing-service.api"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; + +export const useCreateBillingSession = () => { + const { t } = useTranslation(); + + return useMutation({ + mutationFn: BillingService.createBillingSessionResponse, + onSuccess: (data) => { + window.location.href = data; + }, + onError: () => { + displayErrorToast(t(I18nKey.BILLING$ERROR_WHILE_CREATING_SESSION)); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-create-conversation.ts b/frontend/src/hooks/mutation/use-create-conversation.ts index 8f6df2c272..85e8dd880c 100644 --- a/frontend/src/hooks/mutation/use-create-conversation.ts +++ b/frontend/src/hooks/mutation/use-create-conversation.ts @@ -51,7 +51,7 @@ export const useCreateConversation = () => { agentType, } = variables; - const useV1 = !!settings?.V1_ENABLED && !createMicroagent; + const useV1 = !!settings?.v1_enabled && !createMicroagent; if (useV1) { // Use V1 API - creates a conversation start task diff --git a/frontend/src/hooks/mutation/use-delete-mcp-server.ts b/frontend/src/hooks/mutation/use-delete-mcp-server.ts index 42ee01601f..43d1b2a7cc 100644 --- a/frontend/src/hooks/mutation/use-delete-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-delete-mcp-server.ts @@ -9,9 +9,9 @@ export function useDeleteMcpServer() { return useMutation({ mutationFn: async (serverId: string): Promise => { - if (!settings?.MCP_CONFIG) return; + if (!settings?.mcp_config) return; - const newConfig: MCPConfig = { ...settings.MCP_CONFIG }; + const newConfig: MCPConfig = { ...settings.mcp_config }; const [serverType, indexStr] = serverId.split("-"); const index = parseInt(indexStr, 10); @@ -25,7 +25,7 @@ export function useDeleteMcpServer() { const apiSettings = { mcp_config: newConfig, - v1_enabled: settings.V1_ENABLED, + v1_enabled: settings.v1_enabled, }; await SettingsService.saveSettings(apiSettings); diff --git a/frontend/src/hooks/mutation/use-refresh-llm-api-key.ts b/frontend/src/hooks/mutation/use-refresh-llm-api-key.ts new file mode 100644 index 0000000000..11a112e182 --- /dev/null +++ b/frontend/src/hooks/mutation/use-refresh-llm-api-key.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { openHands } from "#/api/open-hands-axios"; +import { + LLM_API_KEY_QUERY_KEY, + LlmApiKeyResponse, +} from "#/hooks/query/use-llm-api-key"; + +export function useRefreshLlmApiKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const { data } = await openHands.post( + "/api/keys/llm/byor/refresh", + ); + return data; + }, + onSuccess: () => { + // Invalidate the LLM API key query to trigger a refetch + queryClient.invalidateQueries({ queryKey: [LLM_API_KEY_QUERY_KEY] }); + }, + }); +} diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index 168c1d11f1..f335fd83ec 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -2,43 +2,28 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { usePostHog } from "posthog-js/react"; import { DEFAULT_SETTINGS } from "#/services/settings"; import SettingsService from "#/api/settings-service/settings-service.api"; -import { PostSettings } from "#/types/settings"; -import { PostApiSettings } from "#/api/settings-service/settings.types"; +import { Settings } from "#/types/settings"; import { useSettings } from "../query/use-settings"; -const saveSettingsMutationFn = async (settings: Partial) => { - const apiSettings: Partial = { - llm_model: settings.LLM_MODEL, - llm_base_url: settings.LLM_BASE_URL, - agent: settings.AGENT || DEFAULT_SETTINGS.AGENT, - language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE, - confirmation_mode: settings.CONFIRMATION_MODE, - security_analyzer: settings.SECURITY_ANALYZER, +const saveSettingsMutationFn = async (settings: Partial) => { + const settingsToSave: Partial = { + ...settings, + agent: settings.agent || DEFAULT_SETTINGS.agent, + language: settings.language || DEFAULT_SETTINGS.language, llm_api_key: settings.llm_api_key === "" ? "" : settings.llm_api_key?.trim() || undefined, - remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR, - enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER, condenser_max_size: - settings.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE, - enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS, - user_consents_to_analytics: settings.user_consents_to_analytics, - provider_tokens_set: settings.PROVIDER_TOKENS_SET, - mcp_config: settings.MCP_CONFIG, - enable_proactive_conversation_starters: - settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS, - enable_solvability_analysis: settings.ENABLE_SOLVABILITY_ANALYSIS, - search_api_key: settings.SEARCH_API_KEY?.trim() || "", - max_budget_per_task: settings.MAX_BUDGET_PER_TASK, + settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size, + search_api_key: settings.search_api_key?.trim() || "", git_user_name: - settings.GIT_USER_NAME?.trim() || DEFAULT_SETTINGS.GIT_USER_NAME, + settings.git_user_name?.trim() || DEFAULT_SETTINGS.git_user_name, git_user_email: - settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL, - v1_enabled: settings.V1_ENABLED, + settings.git_user_email?.trim() || DEFAULT_SETTINGS.git_user_email, }; - await SettingsService.saveSettings(apiSettings); + await SettingsService.saveSettings(settingsToSave); }; export const useSaveSettings = () => { @@ -47,18 +32,18 @@ export const useSaveSettings = () => { const { data: currentSettings } = useSettings(); return useMutation({ - mutationFn: async (settings: Partial) => { + mutationFn: async (settings: Partial) => { const newSettings = { ...currentSettings, ...settings }; // Track MCP configuration changes if ( - settings.MCP_CONFIG && - currentSettings?.MCP_CONFIG !== settings.MCP_CONFIG + settings.mcp_config && + currentSettings?.mcp_config !== settings.mcp_config ) { - const hasMcpConfig = !!settings.MCP_CONFIG; - const sseServersCount = settings.MCP_CONFIG?.sse_servers?.length || 0; + const hasMcpConfig = !!settings.mcp_config; + const sseServersCount = settings.mcp_config?.sse_servers?.length || 0; const stdioServersCount = - settings.MCP_CONFIG?.stdio_servers?.length || 0; + settings.mcp_config?.stdio_servers?.length || 0; // Track MCP configuration usage posthog.capture("mcp_config_updated", { diff --git a/frontend/src/hooks/mutation/use-update-mcp-server.ts b/frontend/src/hooks/mutation/use-update-mcp-server.ts index 7d7b7c9fd4..558997b500 100644 --- a/frontend/src/hooks/mutation/use-update-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-update-mcp-server.ts @@ -28,9 +28,9 @@ export function useUpdateMcpServer() { serverId: string; server: MCPServerConfig; }): Promise => { - if (!settings?.MCP_CONFIG) return; + if (!settings?.mcp_config) return; - const newConfig = { ...settings.MCP_CONFIG }; + const newConfig = { ...settings.mcp_config }; const [serverType, indexStr] = serverId.split("-"); const index = parseInt(indexStr, 10); @@ -59,7 +59,7 @@ export function useUpdateMcpServer() { const apiSettings = { mcp_config: newConfig, - v1_enabled: settings.V1_ENABLED, + v1_enabled: settings.v1_enabled, }; await SettingsService.saveSettings(apiSettings); diff --git a/frontend/src/hooks/query/use-conversation-microagents.ts b/frontend/src/hooks/query/use-conversation-skills.ts similarity index 62% rename from frontend/src/hooks/query/use-conversation-microagents.ts rename to frontend/src/hooks/query/use-conversation-skills.ts index d51b2b311d..43cf23bd37 100644 --- a/frontend/src/hooks/query/use-conversation-microagents.ts +++ b/frontend/src/hooks/query/use-conversation-skills.ts @@ -1,19 +1,29 @@ import { useQuery } from "@tanstack/react-query"; import ConversationService from "#/api/conversation-service/conversation-service.api"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; import { useConversationId } from "../use-conversation-id"; import { AgentState } from "#/types/agent-state"; import { useAgentState } from "#/hooks/use-agent-state"; +import { useSettings } from "./use-settings"; -export const useConversationMicroagents = () => { +export const useConversationSkills = () => { const { conversationId } = useConversationId(); const { curAgentState } = useAgentState(); + const { data: settings } = useSettings(); return useQuery({ - queryKey: ["conversation", conversationId, "microagents"], + queryKey: ["conversation", conversationId, "skills", settings?.v1_enabled], queryFn: async () => { if (!conversationId) { throw new Error("No conversation ID provided"); } + + // Check if V1 is enabled and use the appropriate API + if (settings?.v1_enabled) { + const data = await V1ConversationService.getSkills(conversationId); + return data.skills; + } + const data = await ConversationService.getMicroagents(conversationId); return data.microagents; }, diff --git a/frontend/src/hooks/query/use-llm-api-key.ts b/frontend/src/hooks/query/use-llm-api-key.ts index 5dcea9f714..58dee11411 100644 --- a/frontend/src/hooks/query/use-llm-api-key.ts +++ b/frontend/src/hooks/query/use-llm-api-key.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { openHands } from "#/api/open-hands-axios"; import { useConfig } from "./use-config"; @@ -23,20 +23,3 @@ export function useLlmApiKey() { gcTime: 1000 * 60 * 15, // 15 minutes }); } - -export function useRefreshLlmApiKey() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async () => { - const { data } = await openHands.post( - "/api/keys/llm/byor/refresh", - ); - return data; - }, - onSuccess: () => { - // Invalidate the LLM API key query to trigger a refetch - queryClient.invalidateQueries({ queryKey: [LLM_API_KEY_QUERY_KEY] }); - }, - }); -} 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/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index 3f2e57c90d..faf34d5dae 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -6,37 +6,18 @@ import { Settings } from "#/types/settings"; import { useIsAuthed } from "./use-is-authed"; const getSettingsQueryFn = async (): Promise => { - const apiSettings = await SettingsService.getSettings(); + const settings = await SettingsService.getSettings(); return { - LLM_MODEL: apiSettings.llm_model, - LLM_BASE_URL: apiSettings.llm_base_url, - AGENT: apiSettings.agent, - LANGUAGE: apiSettings.language, - CONFIRMATION_MODE: apiSettings.confirmation_mode, - SECURITY_ANALYZER: apiSettings.security_analyzer, - LLM_API_KEY_SET: apiSettings.llm_api_key_set, - SEARCH_API_KEY_SET: apiSettings.search_api_key_set, - REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor, - PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set, - ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser, - CONDENSER_MAX_SIZE: - apiSettings.condenser_max_size ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE, - ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications, - ENABLE_PROACTIVE_CONVERSATION_STARTERS: - apiSettings.enable_proactive_conversation_starters, - ENABLE_SOLVABILITY_ANALYSIS: apiSettings.enable_solvability_analysis, - USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics, - SEARCH_API_KEY: apiSettings.search_api_key || "", - MAX_BUDGET_PER_TASK: apiSettings.max_budget_per_task, - EMAIL: apiSettings.email || "", - EMAIL_VERIFIED: apiSettings.email_verified, - MCP_CONFIG: apiSettings.mcp_config, - GIT_USER_NAME: apiSettings.git_user_name || DEFAULT_SETTINGS.GIT_USER_NAME, - GIT_USER_EMAIL: - apiSettings.git_user_email || DEFAULT_SETTINGS.GIT_USER_EMAIL, - IS_NEW_USER: false, - V1_ENABLED: apiSettings.v1_enabled ?? DEFAULT_SETTINGS.V1_ENABLED, + ...settings, + condenser_max_size: + settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size, + search_api_key: settings.search_api_key || "", + email: settings.email || "", + git_user_name: settings.git_user_name || DEFAULT_SETTINGS.git_user_name, + git_user_email: settings.git_user_email || DEFAULT_SETTINGS.git_user_email, + is_new_user: false, + v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled, }; }; diff --git a/frontend/src/hooks/query/use-start-tasks.ts b/frontend/src/hooks/query/use-start-tasks.ts index 6af56f2296..3fb1e8d47d 100644 --- a/frontend/src/hooks/query/use-start-tasks.ts +++ b/frontend/src/hooks/query/use-start-tasks.ts @@ -15,7 +15,7 @@ import { useSettings } from "#/hooks/query/use-settings"; */ export const useStartTasks = (limit = 10) => { const { data: settings } = useSettings(); - const isV1Enabled = settings?.V1_ENABLED; + const isV1Enabled = settings?.v1_enabled; return useQuery({ queryKey: ["start-tasks", "search", limit], diff --git a/frontend/src/hooks/query/use-unified-get-git-changes.ts b/frontend/src/hooks/query/use-unified-get-git-changes.ts index ae5600469a..6b0856031c 100644 --- a/frontend/src/hooks/query/use-unified-get-git-changes.ts +++ b/frontend/src/hooks/query/use-unified-get-git-changes.ts @@ -103,5 +103,6 @@ export const useUnifiedGetGitChanges = () => { isSuccess: result.isSuccess, isError: result.isError, error: result.error, + refetch: result.refetch, }; }; diff --git a/frontend/src/hooks/use-conversation-name-context-menu.ts b/frontend/src/hooks/use-conversation-name-context-menu.ts index 0e2c3e837e..6072d5331e 100644 --- a/frontend/src/hooks/use-conversation-name-context-menu.ts +++ b/frontend/src/hooks/use-conversation-name-context-menu.ts @@ -41,8 +41,7 @@ export function useConversationNameContextMenu({ const [metricsModalVisible, setMetricsModalVisible] = React.useState(false); const [systemModalVisible, setSystemModalVisible] = React.useState(false); - const [microagentsModalVisible, setMicroagentsModalVisible] = - React.useState(false); + const [skillsModalVisible, setSkillsModalVisible] = React.useState(false); const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = React.useState(false); const [confirmStopModalVisible, setConfirmStopModalVisible] = @@ -161,11 +160,9 @@ export function useConversationNameContextMenu({ onContextMenuToggle?.(false); }; - const handleShowMicroagents = ( - event: React.MouseEvent, - ) => { + const handleShowSkills = (event: React.MouseEvent) => { event.stopPropagation(); - setMicroagentsModalVisible(true); + setSkillsModalVisible(true); onContextMenuToggle?.(false); }; @@ -178,7 +175,7 @@ export function useConversationNameContextMenu({ handleDownloadViaVSCode, handleDisplayCost, handleShowAgentTools, - handleShowMicroagents, + handleShowSkills, handleConfirmDelete, handleConfirmStop, @@ -187,8 +184,8 @@ export function useConversationNameContextMenu({ setMetricsModalVisible, systemModalVisible, setSystemModalVisible, - microagentsModalVisible, - setMicroagentsModalVisible, + skillsModalVisible, + setSkillsModalVisible, confirmDeleteModalVisible, setConfirmDeleteModalVisible, confirmStopModalVisible, @@ -204,6 +201,6 @@ export function useConversationNameContextMenu({ shouldShowExport: Boolean(conversationId && showOptions), shouldShowDisplayCost: showOptions, shouldShowAgentTools: Boolean(showOptions && systemMessage), - shouldShowMicroagents: Boolean(showOptions && conversationId), + shouldShowSkills: Boolean(showOptions && conversationId), }; } diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts new file mode 100644 index 0000000000..aa67e8cb9a --- /dev/null +++ b/frontend/src/hooks/use-settings-nav-items.ts @@ -0,0 +1,15 @@ +import { useConfig } from "#/hooks/query/use-config"; +import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; + +export function useSettingsNavItems() { + const { data: config } = useConfig(); + + const shouldHideLlmSettings = !!config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS; + const isSaasMode = config?.APP_MODE === "saas"; + + const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; + + return shouldHideLlmSettings + ? items.filter((item) => item.to !== "/settings") + : items; +} diff --git a/frontend/src/hooks/use-sync-posthog-consent.ts b/frontend/src/hooks/use-sync-posthog-consent.ts index 615aa9a1bf..5032122794 100644 --- a/frontend/src/hooks/use-sync-posthog-consent.ts +++ b/frontend/src/hooks/use-sync-posthog-consent.ts @@ -19,7 +19,7 @@ export const useSyncPostHogConsent = () => { return; } - const backendConsent = settings.USER_CONSENTS_TO_ANALYTICS; + const backendConsent = settings.user_consents_to_analytics; // Only sync if there's a backend preference set if (backendConsent !== null) { diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts index 0dfc0f0705..d04cdbb81a 100644 --- a/frontend/src/hooks/use-tracking.ts +++ b/frontend/src/hooks/use-tracking.ts @@ -17,7 +17,7 @@ export const useTracking = () => { app_surface: config?.APP_MODE || "unknown", plan_tier: null, current_url: window.location.href, - user_email: settings?.EMAIL || settings?.GIT_USER_EMAIL || null, + user_email: settings?.email || settings?.git_user_email || null, }; const trackLoginButtonClick = ({ provider }: { provider: Provider }) => { diff --git a/frontend/src/hooks/use-user-providers.ts b/frontend/src/hooks/use-user-providers.ts index d60102c2e0..c09130990b 100644 --- a/frontend/src/hooks/use-user-providers.ts +++ b/frontend/src/hooks/use-user-providers.ts @@ -6,8 +6,8 @@ export const useUserProviders = () => { const { data: settings, isLoading: isLoadingSettings } = useSettings(); const providers = React.useMemo( - () => convertRawProvidersToList(settings?.PROVIDER_TOKENS_SET), - [settings?.PROVIDER_TOKENS_SET], + () => convertRawProvidersToList(settings?.provider_tokens_set), + [settings?.provider_tokens_set], ); return { diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 420709ef9b..2f99b1aef6 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -532,6 +532,8 @@ export enum I18nKey { SUGGESTIONS$ADD_DOCS = "SUGGESTIONS$ADD_DOCS", SUGGESTIONS$ADD_DOCKERFILE = "SUGGESTIONS$ADD_DOCKERFILE", STATUS$CONNECTED = "STATUS$CONNECTED", + STATUS$CONNECTION_LOST = "STATUS$CONNECTION_LOST", + STATUS$DISCONNECTED_REFRESH_PAGE = "STATUS$DISCONNECTED_REFRESH_PAGE", BROWSER$NO_PAGE_LOADED = "BROWSER$NO_PAGE_LOADED", USER$AVATAR_PLACEHOLDER = "USER$AVATAR_PLACEHOLDER", ACCOUNT_SETTINGS$LOGOUT = "ACCOUNT_SETTINGS$LOGOUT", @@ -638,17 +640,16 @@ export enum I18nKey { TOS$CONTINUE = "TOS$CONTINUE", TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING", TIPS$CUSTOMIZE_MICROAGENT = "TIPS$CUSTOMIZE_MICROAGENT", - CONVERSATION$SHOW_MICROAGENTS = "CONVERSATION$SHOW_MICROAGENTS", - CONVERSATION$NO_MICROAGENTS = "CONVERSATION$NO_MICROAGENTS", + CONVERSATION$NO_SKILLS = "CONVERSATION$NO_SKILLS", CONVERSATION$FAILED_TO_FETCH_MICROAGENTS = "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS", MICROAGENTS_MODAL$TITLE = "MICROAGENTS_MODAL$TITLE", - MICROAGENTS_MODAL$WARNING = "MICROAGENTS_MODAL$WARNING", - MICROAGENTS_MODAL$TRIGGERS = "MICROAGENTS_MODAL$TRIGGERS", + SKILLS_MODAL$WARNING = "SKILLS_MODAL$WARNING", + COMMON$TRIGGERS = "COMMON$TRIGGERS", MICROAGENTS_MODAL$INPUTS = "MICROAGENTS_MODAL$INPUTS", MICROAGENTS_MODAL$TOOLS = "MICROAGENTS_MODAL$TOOLS", - MICROAGENTS_MODAL$CONTENT = "MICROAGENTS_MODAL$CONTENT", - MICROAGENTS_MODAL$NO_CONTENT = "MICROAGENTS_MODAL$NO_CONTENT", - MICROAGENTS_MODAL$FETCH_ERROR = "MICROAGENTS_MODAL$FETCH_ERROR", + COMMON$CONTENT = "COMMON$CONTENT", + SKILLS_MODAL$NO_CONTENT = "SKILLS_MODAL$NO_CONTENT", + COMMON$FETCH_ERROR = "COMMON$FETCH_ERROR", TIPS$SETUP_SCRIPT = "TIPS$SETUP_SCRIPT", TIPS$VSCODE_INSTANCE = "TIPS$VSCODE_INSTANCE", TIPS$SAVE_WORK = "TIPS$SAVE_WORK", @@ -955,4 +956,6 @@ export enum I18nKey { COMMON$PLAN_AGENT_DESCRIPTION = "COMMON$PLAN_AGENT_DESCRIPTION", PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED = "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED", OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY", + CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS", + SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 2278092e8e..fc4ca89dbc 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -8511,6 +8511,38 @@ "tr": "Bağlandı", "uk": "Підключено" }, + "STATUS$CONNECTION_LOST": { + "en": "Connection lost", + "ja": "接続が切断されました", + "zh-CN": "连接已断开", + "zh-TW": "連接已斷開", + "ko-KR": "연결이 끊어졌습니다", + "de": "Verbindung verloren", + "no": "Tilkobling mistet", + "it": "Connessione persa", + "pt": "Conexão perdida", + "es": "Conexión perdida", + "ar": "فُقد الاتصال", + "fr": "Connexion perdue", + "tr": "Bağlantı kesildi", + "uk": "Втрачено з'єднання" + }, + "STATUS$DISCONNECTED_REFRESH_PAGE": { + "en": "Disconnected. Please refresh the page", + "ja": "切断されました。ページを更新してください", + "zh-CN": "已断开连接。请刷新页面", + "zh-TW": "已斷開連接。請重新整理頁面", + "ko-KR": "연결이 끊어졌습니다. 페이지를 새로고침하세요", + "de": "Getrennt. Bitte aktualisieren Sie die Seite", + "no": "Koblet fra. Vennligst oppdater siden", + "it": "Disconnesso. Si prega di aggiornare la pagina", + "pt": "Desconectado. Por favor, atualize a página", + "es": "Desconectado. Por favor, actualice la página", + "ar": "تم قطع الاتصال. يرجى تحديث الصفحة", + "fr": "Déconnecté. Veuillez actualiser la page", + "tr": "Bağlantı kesildi. Lütfen sayfayı yenileyin", + "uk": "Відключено. Будь ласка, оновіть сторінку" + }, "BROWSER$NO_PAGE_LOADED": { "en": "No page loaded", "ja": "ブラウザは空です", @@ -10207,37 +10239,21 @@ "tr": "Kullanılabilir bir mikro ajan kullanarak OpenHands'i deponuz için özelleştirebilirsiniz. OpenHands'ten deponun açıklamasını, kodun nasıl çalıştırılacağı dahil, .openhands/microagents/repo.md dosyasına koymasını isteyin.", "uk": "Ви можете налаштувати OpenHands для свого репозиторію за допомогою доступного мікроагента. Попросіть OpenHands розмістити опис репозиторію, включаючи інформацію про те, як запустити код, у файлі .openhands/microagents/repo.md." }, - "CONVERSATION$SHOW_MICROAGENTS": { - "en": "Show Available Microagents", - "ja": "利用可能なマイクロエージェントを表示", - "zh-CN": "显示可用微代理", - "zh-TW": "顯示可用微代理", - "ko-KR": "사용 가능한 마이크로에이전트 표시", - "no": "Vis tilgjengelige mikroagenter", - "ar": "عرض الوكلاء المصغرين المتاحة", - "de": "Verfügbare Mikroagenten anzeigen", - "fr": "Afficher les micro-agents disponibles", - "it": "Mostra microagenti disponibili", - "pt": "Mostrar microagentes disponíveis", - "es": "Mostrar microagentes disponibles", - "tr": "Kullanılabilir mikro ajanları göster", - "uk": "Показати доступних мікроагентів" - }, - "CONVERSATION$NO_MICROAGENTS": { - "en": "No available microagents found for this conversation.", - "ja": "この会話用の利用可能なマイクロエージェントが見つかりませんでした。", - "zh-CN": "未找到此对话的可用微代理。", - "zh-TW": "未找到此對話的可用微代理。", - "ko-KR": "이 대화에 대한 사용 가능한 마이크로에이전트를 찾을 수 없습니다.", - "no": "Ingen tilgjengelige mikroagenter funnet for denne samtalen.", - "ar": "لم يتم العثور على وكلاء مصغرين متاحة لهذه المحادثة.", - "de": "Keine verfügbaren Mikroagenten für dieses Gespräch gefunden.", - "fr": "Aucun micro-agent disponible trouvé pour cette conversation.", - "it": "Nessun microagente disponibile trovato per questa conversazione.", - "pt": "Nenhum microagente disponível encontrado para esta conversa.", - "es": "No se encontraron microagentes disponibles para esta conversación.", - "tr": "Bu konuşma için kullanılabilir mikro ajan bulunamadı.", - "uk": "Для цієї розмови не знайдено доступних мікроагентів." + "CONVERSATION$NO_SKILLS": { + "en": "No available skills found for this conversation.", + "ja": "この会話には利用可能なスキルが見つかりません。", + "zh-CN": "本会话未找到可用技能。", + "zh-TW": "此對話中未找到可用技能。", + "ko-KR": "이 대화에서 사용 가능한 스킬을 찾을 수 없습니다.", + "no": "Ingen tilgjengelige ferdigheter ble funnet for denne samtalen.", + "ar": "لم يتم العثور على مهارات متاحة لهذه المحادثة.", + "de": "Für diese Unterhaltung wurden keine verfügbaren Skills gefunden.", + "fr": "Aucune compétence disponible trouvée pour cette conversation.", + "it": "Nessuna abilità disponibile trovata per questa conversazione.", + "pt": "Nenhuma habilidade disponível encontrada para esta conversa.", + "es": "No se encontraron habilidades disponibles para esta conversación.", + "tr": "Bu sohbet için kullanılabilir yetenek bulunamadı.", + "uk": "У цій розмові не знайдено доступних навичок." }, "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS": { "en": "Failed to fetch available microagents", @@ -10271,23 +10287,23 @@ "tr": "Kullanılabilir mikro ajanlar", "uk": "Доступні мікроагенти" }, - "MICROAGENTS_MODAL$WARNING": { - "en": "If you update the microagents, you will need to stop the conversation and then click on the refresh button to see the changes.", - "ja": "マイクロエージェントを更新する場合、会話を停止してから更新ボタンをクリックして変更を確認する必要があります。", - "zh-CN": "如果您更新微代理,您需要停止对话,然后点击刷新按钮以查看更改。", - "zh-TW": "如果您更新微代理,您需要停止對話,然後點擊重新整理按鈕以查看更改。", - "ko-KR": "마이크로에이전트를 업데이트하는 경우 대화를 중지한 후 새로고침 버튼을 클릭하여 변경사항을 확인해야 합니다.", - "no": "Hvis du oppdaterer mikroagentene, må du stoppe samtalen og deretter klikke på oppdater-knappen for å se endringene.", - "ar": "إذا قمت بتحديث الوكلاء المصغرين، فستحتاج إلى إيقاف المحادثة ثم النقر على زر التحديث لرؤية التغييرات.", - "de": "Wenn Sie die Mikroagenten aktualisieren, müssen Sie das Gespräch beenden und dann auf die Aktualisieren-Schaltfläche klicken, um die Änderungen zu sehen.", - "fr": "Si vous mettez à jour les micro-agents, vous devrez arrêter la conversation puis cliquer sur le bouton actualiser pour voir les changements.", - "it": "Se aggiorni i microagenti, dovrai fermare la conversazione e poi cliccare sul pulsante aggiorna per vedere le modifiche.", - "pt": "Se você atualizar os microagentes, precisará parar a conversa e depois clicar no botão atualizar para ver as alterações.", - "es": "Si actualiza los microagentes, necesitará detener la conversación y luego hacer clic en el botón actualizar para ver los cambios.", - "tr": "Mikro ajanları güncellerseniz, konuşmayı durdurmanız ve ardından değişiklikleri görmek için yenile düğmesine tıklamanız gerekecektir.", - "uk": "Якщо ви оновите мікроагенти, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни." + "SKILLS_MODAL$WARNING": { + "en": "If you update the skills, you will need to stop the conversation and then click on the refresh button to see the changes.", + "ja": "スキルを更新する場合、会話を停止し、その後、更新ボタンをクリックして変更を反映させる必要があります。", + "zh-CN": "如果您更新技能,需要先停止对话,然后点击刷新按钮以查看更改。", + "zh-TW": "如果您更新技能,需要先停止對話,然後點擊刷新按鈕以查看更改。", + "ko-KR": "스킬을 업데이트하면 대화를 중단한 후 새로 고침 버튼을 클릭해야 변경 사항을 볼 수 있습니다.", + "no": "Hvis du oppdaterer ferdighetene, må du stoppe samtalen og deretter klikke på oppdateringsknappen for å se endringene.", + "ar": "إذا قمت بتحديث المهارات، ستحتاج إلى إيقاف المحادثة ثم النقر على زر التحديث لرؤية التغييرات.", + "de": "Wenn Sie die Fähigkeiten aktualisieren, müssen Sie das Gespräch beenden und dann auf die Schaltfläche 'Aktualisieren' klicken, um die Änderungen zu sehen.", + "fr": "Si vous mettez à jour les compétences, vous devrez arrêter la conversation, puis cliquer sur le bouton d’actualisation pour voir les modifications.", + "it": "Se aggiorni le competenze, dovrai interrompere la conversazione e poi cliccare sul pulsante di aggiornamento per vedere le modifiche.", + "pt": "Se você atualizar as habilidades, precisará interromper a conversa e clicar no botão de atualizar para ver as mudanças.", + "es": "Si actualizas las habilidades, deberás detener la conversación y luego hacer clic en el botón de actualizar para ver los cambios.", + "tr": "Yetenekleri güncellerseniz, değişiklikleri görmek için sohbeti durdurmalı ve ardından yenile düğmesine tıklamalısınız.", + "uk": "Якщо ви оновите навички, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни." }, - "MICROAGENTS_MODAL$TRIGGERS": { + "COMMON$TRIGGERS": { "en": "Triggers", "ja": "トリガー", "zh-CN": "触发器", @@ -10335,7 +10351,7 @@ "tr": "Araçlar", "uk": "Інструменти" }, - "MICROAGENTS_MODAL$CONTENT": { + "COMMON$CONTENT": { "en": "Content", "ja": "コンテンツ", "zh-CN": "内容", @@ -10351,37 +10367,37 @@ "tr": "İçerik", "uk": "Вміст" }, - "MICROAGENTS_MODAL$NO_CONTENT": { - "en": "Microagent has no content", - "ja": "マイクロエージェントにコンテンツがありません", - "zh-CN": "微代理没有内容", - "zh-TW": "微代理沒有內容", - "ko-KR": "마이크로에이전트에 콘텐츠가 없습니다", - "no": "Mikroagenten har ikke innhold", - "ar": "الوكيل المصغر ليس لديه محتوى", - "de": "Mikroagent hat keinen Inhalt", - "fr": "Le micro-agent n'a pas de contenu", - "it": "Il microagente non ha contenuto", - "pt": "Microagente não tem conteúdo", - "es": "El microagente no tiene contenido", - "tr": "Mikroajanın içeriği yok", - "uk": "Мікроагент не має вмісту" + "SKILLS_MODAL$NO_CONTENT": { + "en": "Skill has no content", + "ja": "スキルにはコンテンツがありません", + "zh-CN": "技能没有内容", + "zh-TW": "技能沒有內容", + "ko-KR": "스킬에 컨텐츠가 없습니다", + "no": "Ferdighet har ikke noe innhold", + "ar": "المهارة ليس لديها محتوى", + "de": "Die Fähigkeit hat keinen Inhalt", + "fr": "La compétence n'a pas de contenu", + "it": "La competenza non ha contenuti", + "pt": "A habilidade não possui conteúdo", + "es": "La habilidad no tiene contenido", + "tr": "Beceride içerik yok", + "uk": "У навички немає вмісту" }, - "MICROAGENTS_MODAL$FETCH_ERROR": { - "en": "Failed to fetch microagents. Please try again later.", - "ja": "マイクロエージェントの取得に失敗しました。後でもう一度お試しください。", - "zh-CN": "获取微代理失败。请稍后再试。", - "zh-TW": "獲取微代理失敗。請稍後再試。", - "ko-KR": "마이크로에이전트를 가져오지 못했습니다. 나중에 다시 시도해 주세요.", - "no": "Kunne ikke hente mikroagenter. Prøv igjen senere.", - "ar": "فشل في جلب الوكلاء المصغرين. يرجى المحاولة مرة أخرى لاحقًا.", - "de": "Mikroagenten konnten nicht abgerufen werden. Bitte versuchen Sie es später erneut.", - "fr": "Échec de la récupération des micro-agents. Veuillez réessayer plus tard.", - "it": "Impossibile recuperare i microagenti. Riprova più tardi.", - "pt": "Falha ao buscar microagentes. Por favor, tente novamente mais tarde.", - "es": "Error al obtener microagentes. Por favor, inténtelo de nuevo más tarde.", - "tr": "Mikroajanlar getirilemedi. Lütfen daha sonra tekrar deneyin.", - "uk": "Не вдалося отримати мікроагентів. Будь ласка, спробуйте пізніше." + "COMMON$FETCH_ERROR": { + "en": "Failed to fetch skills. Please try again later.", + "ja": "スキルの取得に失敗しました。後でもう一度お試しください。", + "zh-CN": "获取技能失败。请稍后再试。", + "zh-TW": "取得技能失敗。請稍後再試。", + "ko-KR": "스킬을 가져오지 못했습니다. 나중에 다시 시도해주세요.", + "no": "Kunne ikke hente ferdigheter. Prøv igjen senere.", + "ar": "فشل في جلب المهارات. يرجى المحاولة لاحقًا.", + "de": "Die Fähigkeiten konnten nicht abgerufen werden. Bitte versuchen Sie es später erneut.", + "fr": "Échec de la récupération des compétences. Veuillez réessayer plus tard.", + "it": "Impossibile recuperare le competenze. Riprova più tardi.", + "pt": "Falha ao buscar as habilidades. Por favor, tente novamente mais tarde.", + "es": "No se pudieron obtener las habilidades. Por favor, inténtalo de nuevo más tarde.", + "tr": "Beceriler alınamadı. Lütfen daha sonra tekrar deneyin.", + "uk": "Не вдалося отримати навички. Будь ласка, спробуйте пізніше." }, "TIPS$SETUP_SCRIPT": { "en": "You can add .openhands/setup.sh to your repository to automatically run a setup script every time you start an OpenHands conversation.", @@ -15278,5 +15294,37 @@ "tr": "Yetenek hazır", "de": "Fähigkeit bereit", "uk": "Навичка готова" + }, + "CONVERSATION$SHOW_SKILLS": { + "en": "Show Available Skills", + "ja": "利用可能なスキルを表示", + "zh-CN": "显示可用技能", + "zh-TW": "顯示可用技能", + "ko-KR": "사용 가능한 스킬 표시", + "no": "Vis tilgjengelige ferdigheter", + "ar": "عرض المهارات المتاحة", + "de": "Verfügbare Fähigkeiten anzeigen", + "fr": "Afficher les compétences disponibles", + "it": "Mostra abilità disponibili", + "pt": "Mostrar habilidades disponíveis", + "es": "Mostrar habilidades disponibles", + "tr": "Kullanılabilir yetenekleri göster", + "uk": "Показати доступні навички" + }, + "SKILLS_MODAL$TITLE": { + "en": "Available Skills", + "ja": "利用可能なスキル", + "zh-CN": "可用技能", + "zh-TW": "可用技能", + "ko-KR": "사용 가능한 스킬", + "no": "Tilgjengelige ferdigheter", + "ar": "المهارات المتاحة", + "de": "Verfügbare Fähigkeiten", + "fr": "Compétences disponibles", + "it": "Abilità disponibili", + "pt": "Habilidades disponíveis", + "es": "Habilidades disponibles", + "tr": "Kullanılabilir yetenekler", + "uk": "Доступні навички" } } diff --git a/frontend/src/icons/loading.svg b/frontend/src/icons/loading.svg index 2da678957f..a5217fd608 100644 --- a/frontend/src/icons/loading.svg +++ b/frontend/src/icons/loading.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/src/icons/u-refresh.svg b/frontend/src/icons/u-refresh.svg new file mode 100644 index 0000000000..9e3a2051d2 --- /dev/null +++ b/frontend/src/icons/u-refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/mocks/analytics-handlers.ts b/frontend/src/mocks/analytics-handlers.ts new file mode 100644 index 0000000000..09b3ac0c60 --- /dev/null +++ b/frontend/src/mocks/analytics-handlers.ts @@ -0,0 +1,7 @@ +import { http, HttpResponse } from "msw"; + +export const ANALYTICS_HANDLERS = [ + http.post("https://us.i.posthog.com/e", async () => + HttpResponse.json(null, { status: 200 }), + ), +]; diff --git a/frontend/src/mocks/auth-handlers.ts b/frontend/src/mocks/auth-handlers.ts new file mode 100644 index 0000000000..bb4baf2397 --- /dev/null +++ b/frontend/src/mocks/auth-handlers.ts @@ -0,0 +1,23 @@ +import { http, HttpResponse } from "msw"; +import { GitUser } from "#/types/git"; + +export const AUTH_HANDLERS = [ + http.get("/api/user/info", () => { + const user: GitUser = { + id: "1", + login: "octocat", + avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4", + company: "GitHub", + email: "placeholder@placeholder.placeholder", + name: "monalisa octocat", + }; + + return HttpResponse.json(user); + }), + + http.post("/api/authenticate", async () => + HttpResponse.json({ message: "Authenticated" }), + ), + + http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })), +]; diff --git a/frontend/src/mocks/conversation-handlers.ts b/frontend/src/mocks/conversation-handlers.ts new file mode 100644 index 0000000000..1ec536fd92 --- /dev/null +++ b/frontend/src/mocks/conversation-handlers.ts @@ -0,0 +1,118 @@ +import { http, delay, HttpResponse } from "msw"; +import { Conversation, ResultSet } from "#/api/open-hands.types"; + +const conversations: Conversation[] = [ + { + conversation_id: "1", + title: "My New Project", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: new Date().toISOString(), + created_at: new Date().toISOString(), + status: "RUNNING", + runtime_status: "STATUS$READY", + url: null, + session_api_key: null, + }, + { + conversation_id: "2", + title: "Repo Testing", + selected_repository: "octocat/hello-world", + git_provider: "github", + selected_branch: null, + last_updated_at: new Date( + Date.now() - 2 * 24 * 60 * 60 * 1000, + ).toISOString(), + created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + status: "STOPPED", + runtime_status: null, + url: null, + session_api_key: null, + }, + { + conversation_id: "3", + title: "Another Project", + selected_repository: "octocat/earth", + git_provider: null, + selected_branch: "main", + last_updated_at: new Date( + Date.now() - 5 * 24 * 60 * 60 * 1000, + ).toISOString(), + created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + status: "STOPPED", + runtime_status: null, + url: null, + session_api_key: null, + }, +]; + +const CONVERSATIONS = new Map( + conversations.map((c) => [c.conversation_id, c]), +); + +export const CONVERSATION_HANDLERS = [ + http.get("/api/conversations", async () => { + const values = Array.from(CONVERSATIONS.values()); + const results: ResultSet = { + results: values, + next_page_id: null, + }; + return HttpResponse.json(results); + }), + + http.get("/api/conversations/:conversationId", async ({ params }) => { + const conversationId = params.conversationId as string; + const project = CONVERSATIONS.get(conversationId); + if (project) return HttpResponse.json(project); + return HttpResponse.json(null, { status: 404 }); + }), + + http.post("/api/conversations", async () => { + await delay(); + const conversation: Conversation = { + conversation_id: (Math.random() * 100).toString(), + title: "New Conversation", + selected_repository: null, + git_provider: null, + selected_branch: null, + last_updated_at: new Date().toISOString(), + created_at: new Date().toISOString(), + status: "RUNNING", + runtime_status: "STATUS$READY", + url: null, + session_api_key: null, + }; + CONVERSATIONS.set(conversation.conversation_id, conversation); + return HttpResponse.json(conversation, { status: 201 }); + }), + + http.patch( + "/api/conversations/:conversationId", + async ({ params, request }) => { + const conversationId = params.conversationId as string; + const conversation = CONVERSATIONS.get(conversationId); + + if (conversation) { + const body = await request.json(); + if (typeof body === "object" && body?.title) { + CONVERSATIONS.set(conversationId, { + ...conversation, + title: body.title, + }); + return HttpResponse.json(null, { status: 200 }); + } + } + return HttpResponse.json(null, { status: 404 }); + }, + ), + + http.delete("/api/conversations/:conversationId", async ({ params }) => { + const conversationId = params.conversationId as string; + if (CONVERSATIONS.has(conversationId)) { + CONVERSATIONS.delete(conversationId); + return HttpResponse.json(null, { status: 200 }); + } + return HttpResponse.json(null, { status: 404 }); + }), +]; diff --git a/frontend/src/mocks/feedback-handlers.ts b/frontend/src/mocks/feedback-handlers.ts new file mode 100644 index 0000000000..8e4e602b33 --- /dev/null +++ b/frontend/src/mocks/feedback-handlers.ts @@ -0,0 +1,15 @@ +import { http, delay, HttpResponse } from "msw"; + +export const FEEDBACK_HANDLERS = [ + http.post("/api/submit-feedback", async () => { + await delay(1200); + return HttpResponse.json({ + statusCode: 200, + body: { message: "Success", link: "fake-url.com", password: "abc123" }, + }); + }), + + http.post("/api/submit-feedback", async () => + HttpResponse.json({ statusCode: 200 }, { status: 200 }), + ), +]; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 09053bfc31..999903ba93 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1,146 +1,17 @@ -import { delay, http, HttpResponse } from "msw"; -import { GetConfigResponse } from "#/api/option-service/option.types"; -import { Conversation, ResultSet } from "#/api/open-hands.types"; -import { DEFAULT_SETTINGS } from "#/services/settings"; import { STRIPE_BILLING_HANDLERS } from "./billing-handlers"; -import { Provider } from "#/types/settings"; -import { - ApiSettings, - PostApiSettings, -} from "#/api/settings-service/settings.types"; import { FILE_SERVICE_HANDLERS } from "./file-service-handlers"; -import { GitUser } from "#/types/git"; import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers"; import { SECRETS_HANDLERS } from "./secrets-handlers"; import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers"; - -export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = { - llm_model: DEFAULT_SETTINGS.LLM_MODEL, - llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL, - llm_api_key: null, - llm_api_key_set: DEFAULT_SETTINGS.LLM_API_KEY_SET, - search_api_key_set: DEFAULT_SETTINGS.SEARCH_API_KEY_SET, - agent: DEFAULT_SETTINGS.AGENT, - language: DEFAULT_SETTINGS.LANGUAGE, - confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE, - security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER, - remote_runtime_resource_factor: - DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR, - provider_tokens_set: {}, - enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER, - condenser_max_size: DEFAULT_SETTINGS.CONDENSER_MAX_SIZE, - enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS, - enable_proactive_conversation_starters: - DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS, - enable_solvability_analysis: DEFAULT_SETTINGS.ENABLE_SOLVABILITY_ANALYSIS, - user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS, - max_budget_per_task: DEFAULT_SETTINGS.MAX_BUDGET_PER_TASK, -}; - -const MOCK_USER_PREFERENCES: { - settings: ApiSettings | PostApiSettings | null; -} = { - settings: null, -}; - -/** - * Set the user settings to the default settings - * - * Useful for resetting the settings in tests - */ -export const resetTestHandlersMockSettings = () => { - MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS; -}; - -const conversations: Conversation[] = [ - { - conversation_id: "1", - title: "My New Project", - selected_repository: null, - git_provider: null, - selected_branch: null, - last_updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - status: "RUNNING", - runtime_status: "STATUS$READY", - url: null, - session_api_key: null, - }, - { - conversation_id: "2", - title: "Repo Testing", - selected_repository: "octocat/hello-world", - git_provider: "github", - selected_branch: null, - // 2 days ago - last_updated_at: new Date( - Date.now() - 2 * 24 * 60 * 60 * 1000, - ).toISOString(), - created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), - status: "STOPPED", - runtime_status: null, - url: null, - session_api_key: null, - }, - { - conversation_id: "3", - title: "Another Project", - selected_repository: "octocat/earth", - git_provider: null, - selected_branch: "main", - // 5 days ago - last_updated_at: new Date( - Date.now() - 5 * 24 * 60 * 60 * 1000, - ).toISOString(), - created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), - status: "STOPPED", - runtime_status: null, - url: null, - session_api_key: null, - }, -]; - -const CONVERSATIONS = new Map( - conversations.map((conversation) => [ - conversation.conversation_id, - conversation, - ]), -); - -const openHandsHandlers = [ - http.get("/api/options/models", async () => - HttpResponse.json([ - "gpt-3.5-turbo", - "gpt-4o", - "gpt-4o-mini", - "anthropic/claude-3.5", - "anthropic/claude-sonnet-4-20250514", - "anthropic/claude-sonnet-4-5-20250929", - "anthropic/claude-haiku-4-5-20251001", - "openhands/claude-sonnet-4-20250514", - "openhands/claude-sonnet-4-5-20250929", - "openhands/claude-haiku-4-5-20251001", - "sambanova/Meta-Llama-3.1-8B-Instruct", - ]), - ), - - http.get("/api/options/agents", async () => - HttpResponse.json(["CodeActAgent", "CoActAgent"]), - ), - - http.get("/api/options/security-analyzers", async () => - HttpResponse.json(["llm", "none"]), - ), - - http.post("http://localhost:3001/api/submit-feedback", async () => { - await delay(1200); - - return HttpResponse.json({ - statusCode: 200, - body: { message: "Success", link: "fake-url.com", password: "abc123" }, - }); - }), -]; +import { + SETTINGS_HANDLERS, + MOCK_DEFAULT_USER_SETTINGS, + resetTestHandlersMockSettings, +} from "./settings-handlers"; +import { CONVERSATION_HANDLERS } from "./conversation-handlers"; +import { AUTH_HANDLERS } from "./auth-handlers"; +import { FEEDBACK_HANDLERS } from "./feedback-handlers"; +import { ANALYTICS_HANDLERS } from "./analytics-handlers"; export const handlers = [ ...STRIPE_BILLING_HANDLERS, @@ -148,192 +19,11 @@ export const handlers = [ ...TASK_SUGGESTIONS_HANDLERS, ...SECRETS_HANDLERS, ...GIT_REPOSITORY_HANDLERS, - ...openHandsHandlers, - http.get("/api/user/info", () => { - const user: GitUser = { - id: "1", - login: "octocat", - avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4", - company: "GitHub", - email: "placeholder@placeholder.placeholder", - name: "monalisa octocat", - }; - - return HttpResponse.json(user); - }), - http.post("http://localhost:3001/api/submit-feedback", async () => - HttpResponse.json({ statusCode: 200 }, { status: 200 }), - ), - http.post("https://us.i.posthog.com/e", async () => - HttpResponse.json(null, { status: 200 }), - ), - http.get("/api/options/config", () => { - const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true"; - - const config: GetConfigResponse = { - APP_MODE: mockSaas ? "saas" : "oss", - GITHUB_CLIENT_ID: "fake-github-client-id", - POSTHOG_CLIENT_KEY: "fake-posthog-client-key", - FEATURE_FLAGS: { - ENABLE_BILLING: false, - HIDE_LLM_SETTINGS: mockSaas, - ENABLE_JIRA: false, - ENABLE_JIRA_DC: false, - ENABLE_LINEAR: false, - }, - // Uncomment the following to test the maintenance banner - // MAINTENANCE: { - // startTime: "2024-01-15T10:00:00-05:00", // EST timestamp - // }, - }; - - return HttpResponse.json(config); - }), - http.get("/api/settings", async () => { - await delay(); - - const { settings } = MOCK_USER_PREFERENCES; - - if (!settings) return HttpResponse.json(null, { status: 404 }); - - return HttpResponse.json(settings); - }), - http.post("/api/settings", async ({ request }) => { - await delay(); - const body = await request.json(); - - if (body) { - const current = MOCK_USER_PREFERENCES.settings || { - ...MOCK_DEFAULT_USER_SETTINGS, - }; - // Persist new values over current/mock defaults - MOCK_USER_PREFERENCES.settings = { - ...current, - ...(body as Partial), - }; - return HttpResponse.json(null, { status: 200 }); - } - - return HttpResponse.json(null, { status: 400 }); - }), - - http.post("/api/authenticate", async () => - HttpResponse.json({ message: "Authenticated" }), - ), - - http.get("/api/conversations", async () => { - const values = Array.from(CONVERSATIONS.values()); - const results: ResultSet = { - results: values, - next_page_id: null, - }; - - return HttpResponse.json(results, { status: 200 }); - }), - - http.delete("/api/conversations/:conversationId", async ({ params }) => { - const { conversationId } = params; - - if (typeof conversationId === "string") { - CONVERSATIONS.delete(conversationId); - return HttpResponse.json(null, { status: 200 }); - } - - return HttpResponse.json(null, { status: 404 }); - }), - - http.patch( - "/api/conversations/:conversationId", - async ({ params, request }) => { - const { conversationId } = params; - - if (typeof conversationId === "string") { - const conversation = CONVERSATIONS.get(conversationId); - - if (conversation) { - const body = await request.json(); - if (typeof body === "object" && body?.title) { - CONVERSATIONS.set(conversationId, { - ...conversation, - title: body.title, - }); - return HttpResponse.json(null, { status: 200 }); - } - } - } - - return HttpResponse.json(null, { status: 404 }); - }, - ), - - http.post("/api/conversations", async () => { - await delay(); - - const conversation: Conversation = { - conversation_id: (Math.random() * 100).toString(), - title: "New Conversation", - selected_repository: null, - git_provider: null, - selected_branch: null, - last_updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - status: "RUNNING", - runtime_status: "STATUS$READY", - url: null, - session_api_key: null, - }; - - CONVERSATIONS.set(conversation.conversation_id, conversation); - return HttpResponse.json(conversation, { status: 201 }); - }), - - http.get("/api/conversations/:conversationId", async ({ params }) => { - const { conversationId } = params; - - if (typeof conversationId === "string") { - const project = CONVERSATIONS.get(conversationId); - - if (project) { - return HttpResponse.json(project, { status: 200 }); - } - } - - return HttpResponse.json(null, { status: 404 }); - }), - - http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })), - - http.post("/api/reset-settings", async () => { - await delay(); - MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS }; - return HttpResponse.json(null, { status: 200 }); - }), - - http.post("/api/add-git-providers", async ({ request }) => { - const body = await request.json(); - - if (typeof body === "object" && body?.provider_tokens) { - const rawTokens = body.provider_tokens as Record< - string, - { token?: string } - >; - - const providerTokensSet: Partial> = - Object.fromEntries( - Object.entries(rawTokens) - .filter(([, val]) => val && val.token) - .map(([provider]) => [provider as Provider, ""]), - ); - - const newSettings = { - ...(MOCK_USER_PREFERENCES.settings ?? MOCK_DEFAULT_USER_SETTINGS), - provider_tokens_set: providerTokensSet, - }; - MOCK_USER_PREFERENCES.settings = newSettings; - - return HttpResponse.json(true, { status: 200 }); - } - - return HttpResponse.json(null, { status: 400 }); - }), + ...SETTINGS_HANDLERS, + ...CONVERSATION_HANDLERS, + ...AUTH_HANDLERS, + ...FEEDBACK_HANDLERS, + ...ANALYTICS_HANDLERS, ]; + +export { MOCK_DEFAULT_USER_SETTINGS, resetTestHandlersMockSettings }; 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 new file mode 100644 index 0000000000..e0d7b1ed11 --- /dev/null +++ b/frontend/src/mocks/settings-handlers.ts @@ -0,0 +1,151 @@ +import { http, delay, HttpResponse } from "msw"; +import { GetConfigResponse } from "#/api/option-service/option.types"; +import { DEFAULT_SETTINGS } from "#/services/settings"; +import { Provider, Settings } from "#/types/settings"; + +export const MOCK_DEFAULT_USER_SETTINGS: Settings = { + llm_model: DEFAULT_SETTINGS.llm_model, + llm_base_url: DEFAULT_SETTINGS.llm_base_url, + llm_api_key: null, + llm_api_key_set: DEFAULT_SETTINGS.llm_api_key_set, + search_api_key_set: DEFAULT_SETTINGS.search_api_key_set, + agent: DEFAULT_SETTINGS.agent, + language: DEFAULT_SETTINGS.language, + confirmation_mode: DEFAULT_SETTINGS.confirmation_mode, + security_analyzer: DEFAULT_SETTINGS.security_analyzer, + remote_runtime_resource_factor: + DEFAULT_SETTINGS.remote_runtime_resource_factor, + provider_tokens_set: {}, + enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser, + condenser_max_size: DEFAULT_SETTINGS.condenser_max_size, + enable_sound_notifications: DEFAULT_SETTINGS.enable_sound_notifications, + enable_proactive_conversation_starters: + DEFAULT_SETTINGS.enable_proactive_conversation_starters, + enable_solvability_analysis: DEFAULT_SETTINGS.enable_solvability_analysis, + user_consents_to_analytics: DEFAULT_SETTINGS.user_consents_to_analytics, + max_budget_per_task: DEFAULT_SETTINGS.max_budget_per_task, +}; + +const MOCK_USER_PREFERENCES: { + settings: Settings | null; +} = { + settings: null, +}; + +// Reset mock +export const resetTestHandlersMockSettings = () => { + MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS; +}; + +// --- Handlers for options/config/settings --- + +export const SETTINGS_HANDLERS = [ + http.get("/api/options/models", async () => + HttpResponse.json([ + "gpt-3.5-turbo", + "gpt-4o", + "gpt-4o-mini", + "anthropic/claude-3.5", + "anthropic/claude-sonnet-4-20250514", + "anthropic/claude-sonnet-4-5-20250929", + "anthropic/claude-haiku-4-5-20251001", + "openhands/claude-sonnet-4-20250514", + "openhands/claude-sonnet-4-5-20250929", + "openhands/claude-haiku-4-5-20251001", + "sambanova/Meta-Llama-3.1-8B-Instruct", + ]), + ), + + http.get("/api/options/agents", async () => + HttpResponse.json(["CodeActAgent", "CoActAgent"]), + ), + + http.get("/api/options/security-analyzers", async () => + HttpResponse.json(["llm", "none"]), + ), + + http.get("/api/options/config", () => { + const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true"; + + const config: GetConfigResponse = { + APP_MODE: mockSaas ? "saas" : "oss", + GITHUB_CLIENT_ID: "fake-github-client-id", + POSTHOG_CLIENT_KEY: "fake-posthog-client-key", + FEATURE_FLAGS: { + ENABLE_BILLING: false, + HIDE_LLM_SETTINGS: mockSaas, + ENABLE_JIRA: false, + ENABLE_JIRA_DC: false, + ENABLE_LINEAR: false, + }, + // Uncomment the following to test the maintenance banner + // MAINTENANCE: { + // startTime: "2024-01-15T10:00:00-05:00", // EST timestamp + // }, + }; + + return HttpResponse.json(config); + }), + + http.get("/api/settings", async () => { + await delay(); + const { settings } = MOCK_USER_PREFERENCES; + + if (!settings) return HttpResponse.json(null, { status: 404 }); + + return HttpResponse.json(settings); + }), + + http.post("/api/settings", async ({ request }) => { + await delay(); + const body = await request.json(); + + if (body) { + const current = MOCK_USER_PREFERENCES.settings || { + ...MOCK_DEFAULT_USER_SETTINGS, + }; + + MOCK_USER_PREFERENCES.settings = { + ...current, + ...(body as Partial), + }; + + return HttpResponse.json(null, { status: 200 }); + } + + return HttpResponse.json(null, { status: 400 }); + }), + + http.post("/api/reset-settings", async () => { + await delay(); + MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS }; + return HttpResponse.json(null, { status: 200 }); + }), + + http.post("/api/add-git-providers", async ({ request }) => { + const body = await request.json(); + + if (typeof body === "object" && body?.provider_tokens) { + const rawTokens = body.provider_tokens as Record< + string, + { token?: string } + >; + + const providerTokensSet: Partial> = + Object.fromEntries( + Object.entries(rawTokens) + .filter(([, val]) => val?.token) + .map(([provider]) => [provider as Provider, ""]), + ); + + MOCK_USER_PREFERENCES.settings = { + ...(MOCK_USER_PREFERENCES.settings || MOCK_DEFAULT_USER_SETTINGS), + provider_tokens_set: providerTokensSet, + }; + + return HttpResponse.json(true, { status: 200 }); + } + + return HttpResponse.json(null, { status: 400 }); + }), +]; diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 4c3c48adc5..ecee511688 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -21,5 +21,6 @@ export default [ ]), route("conversations/:conversationId", "routes/conversation.tsx"), route("microagent-management", "routes/microagent-management.tsx"), + route("oauth/device/verify", "routes/device-verify.tsx"), ]), ] satisfies RouteConfig; diff --git a/frontend/src/routes/accept-tos.tsx b/frontend/src/routes/accept-tos.tsx index f723f2a5f6..a3732273e3 100644 --- a/frontend/src/routes/accept-tos.tsx +++ b/frontend/src/routes/accept-tos.tsx @@ -1,66 +1,27 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate, useSearchParams } from "react-router"; -import { useMutation } from "@tanstack/react-query"; -import { usePostHog } from "posthog-js/react"; +import { useSearchParams } from "react-router"; import { I18nKey } from "#/i18n/declaration"; import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react"; import { TOSCheckbox } from "#/components/features/waitlist/tos-checkbox"; import { BrandButton } from "#/components/features/settings/brand-button"; -import { handleCaptureConsent } from "#/utils/handle-capture-consent"; -import { openHands } from "#/api/open-hands-axios"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; -import { useTracking } from "#/hooks/use-tracking"; +import { useAcceptTos } from "#/hooks/mutation/use-accept-tos"; export default function AcceptTOS() { - const posthog = usePostHog(); const { t } = useTranslation(); - const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [isTosAccepted, setIsTosAccepted] = React.useState(false); - const { trackUserSignupCompleted } = useTracking(); // Get the redirect URL from the query parameters const redirectUrl = searchParams.get("redirect_url") || "/"; // Use mutation for accepting TOS - const { mutate: acceptTOS, isPending: isSubmitting } = useMutation({ - mutationFn: async () => { - // Set consent for analytics - handleCaptureConsent(posthog, true); - - // Call the API to record TOS acceptance in the database - return openHands.post("/api/accept_tos", { - redirect_url: redirectUrl, - }); - }, - onSuccess: (response) => { - // Track user signup completion - trackUserSignupCompleted(); - - // Get the redirect URL from the response - const finalRedirectUrl = response.data.redirect_url || redirectUrl; - - // Check if the redirect URL is an external URL (starts with http or https) - if ( - finalRedirectUrl.startsWith("http://") || - finalRedirectUrl.startsWith("https://") - ) { - // For external URLs, redirect using window.location - window.location.href = finalRedirectUrl; - } else { - // For internal routes, use navigate - navigate(finalRedirectUrl); - } - }, - onError: () => { - window.location.href = "/"; - }, - }); + const { mutate: acceptTOS, isPending: isSubmitting } = useAcceptTos(); const handleAcceptTOS = () => { if (isTosAccepted && !isSubmitting) { - acceptTOS(); + acceptTOS({ redirectUrl }); } }; diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index e825bb3e0f..a8524cc989 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -56,7 +56,7 @@ function AppSettingsScreen() { const languageValue = AvailableLanguages.find( ({ label }) => label === languageLabel, )?.value; - const language = languageValue || DEFAULT_SETTINGS.LANGUAGE; + const language = languageValue || DEFAULT_SETTINGS.language; const enableAnalytics = formData.get("enable-analytics-switch")?.toString() === "on"; @@ -77,21 +77,21 @@ function AppSettingsScreen() { const gitUserName = formData.get("git-user-name-input")?.toString() || - DEFAULT_SETTINGS.GIT_USER_NAME; + DEFAULT_SETTINGS.git_user_name; const gitUserEmail = formData.get("git-user-email-input")?.toString() || - DEFAULT_SETTINGS.GIT_USER_EMAIL; + DEFAULT_SETTINGS.git_user_email; saveSettings( { - LANGUAGE: language, + language, user_consents_to_analytics: enableAnalytics, - ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications, - ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations, - ENABLE_SOLVABILITY_ANALYSIS: enableSolvabilityAnalysis, - MAX_BUDGET_PER_TASK: maxBudgetPerTask, - GIT_USER_NAME: gitUserName, - GIT_USER_EMAIL: gitUserEmail, + enable_sound_notifications: enableSoundNotifications, + enable_proactive_conversation_starters: enableProactiveConversations, + enable_solvability_analysis: enableSolvabilityAnalysis, + max_budget_per_task: maxBudgetPerTask, + git_user_name: gitUserName, + git_user_email: gitUserEmail, }, { onSuccess: () => { @@ -120,7 +120,7 @@ function AppSettingsScreen() { ({ label: langValue }) => langValue === value, )?.label; const currentLanguage = AvailableLanguages.find( - ({ value: langValue }) => langValue === settings?.LANGUAGE, + ({ value: langValue }) => langValue === settings?.language, )?.label; setLanguageInputHasChanged(selectedLanguage !== currentLanguage); @@ -128,12 +128,12 @@ function AppSettingsScreen() { const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => { // Treat null as true since analytics is opt-in by default - const currentAnalytics = settings?.USER_CONSENTS_TO_ANALYTICS ?? true; + const currentAnalytics = settings?.user_consents_to_analytics ?? true; setAnalyticsSwitchHasChanged(checked !== currentAnalytics); }; const checkIfSoundNotificationsSwitchHasChanged = (checked: boolean) => { - const currentSoundNotifications = !!settings?.ENABLE_SOUND_NOTIFICATIONS; + const currentSoundNotifications = !!settings?.enable_sound_notifications; setSoundNotificationsSwitchHasChanged( checked !== currentSoundNotifications, ); @@ -141,14 +141,14 @@ function AppSettingsScreen() { const checkIfProactiveConversationsSwitchHasChanged = (checked: boolean) => { const currentProactiveConversations = - !!settings?.ENABLE_PROACTIVE_CONVERSATION_STARTERS; + !!settings?.enable_proactive_conversation_starters; setProactiveConversationsSwitchHasChanged( checked !== currentProactiveConversations, ); }; const checkIfSolvabilityAnalysisSwitchHasChanged = (checked: boolean) => { - const currentSolvabilityAnalysis = !!settings?.ENABLE_SOLVABILITY_ANALYSIS; + const currentSolvabilityAnalysis = !!settings?.enable_solvability_analysis; setSolvabilityAnalysisSwitchHasChanged( checked !== currentSolvabilityAnalysis, ); @@ -156,17 +156,17 @@ function AppSettingsScreen() { const checkIfMaxBudgetPerTaskHasChanged = (value: string) => { const newValue = parseMaxBudgetPerTask(value); - const currentValue = settings?.MAX_BUDGET_PER_TASK; + const currentValue = settings?.max_budget_per_task; setMaxBudgetPerTaskHasChanged(newValue !== currentValue); }; const checkIfGitUserNameHasChanged = (value: string) => { - const currentValue = settings?.GIT_USER_NAME; + const currentValue = settings?.git_user_name; setGitUserNameHasChanged(value !== currentValue); }; const checkIfGitUserEmailHasChanged = (value: string) => { - const currentValue = settings?.GIT_USER_EMAIL; + const currentValue = settings?.git_user_email; setGitUserEmailHasChanged(value !== currentValue); }; @@ -193,14 +193,14 @@ function AppSettingsScreen() {
    {t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)} @@ -209,7 +209,7 @@ function AppSettingsScreen() { {t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)} @@ -220,7 +220,7 @@ function AppSettingsScreen() { testId="enable-proactive-conversations-switch" name="enable-proactive-conversations-switch" defaultIsToggled={ - !!settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS + !!settings.enable_proactive_conversation_starters } onToggle={checkIfProactiveConversationsSwitchHasChanged} > @@ -232,20 +232,20 @@ function AppSettingsScreen() { {t(I18nKey.SETTINGS$SOLVABILITY_ANALYSIS)} )} - {!settings?.V1_ENABLED && ( + {!settings?.v1_enabled && ( (null); + const [isProcessing, setIsProcessing] = useState(false); + + // Get user_code from URL parameters + const userCode = searchParams.get("user_code"); + + const processDeviceVerification = async (code: string) => { + try { + setIsProcessing(true); + + // Call the backend API endpoint to process device verification + const response = await fetch("/oauth/device/verify-authenticated", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: `user_code=${encodeURIComponent(code)}`, + credentials: "include", // Include cookies for authentication + }); + + if (response.ok) { + // Show success message + setVerificationResult({ + success: true, + message: + "Device authorized successfully! You can now return to your CLI and close this window.", + }); + } else { + const errorText = await response.text(); + setVerificationResult({ + success: false, + message: errorText || "Failed to authorize device. Please try again.", + }); + } + } catch (error) { + setVerificationResult({ + success: false, + message: + "An error occurred while authorizing the device. Please try again.", + }); + } finally { + setIsProcessing(false); + } + }; + + // Remove automatic verification - require explicit user consent + + const handleManualSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const code = formData.get("user_code") as string; + if (code && isAuthed) { + processDeviceVerification(code); + } + }; + + // Show verification result if we have one + if (verificationResult) { + return ( +
    +
    +
    +
    + {verificationResult.success ? ( + + + + ) : ( + + + + )} +
    +

    + {verificationResult.success ? "Success!" : "Error"} +

    +

    + {verificationResult.message} +

    + {!verificationResult.success && ( + + )} +
    +
    +
    + ); + } + + // Show processing state + if (isProcessing) { + return ( +
    +
    +
    +
    +

    + Processing device verification... +

    +
    +
    +
    + ); + } + + // Show device authorization confirmation if user is authenticated and code is provided + if (isAuthed && userCode) { + return ( +
    +
    +

    + Device Authorization Request +

    +
    +

    Device Code:

    +

    + {userCode} +

    +
    +
    +
    + + + +
    +

    + Security Notice +

    +

    + Only authorize this device if you initiated this request from + your CLI or application. +

    +
    +
    +
    +

    + Do you want to authorize this device to access your OpenHands + account? +

    +
    + + +
    +
    +
    + ); + } + + // Show manual code entry form if no code in URL but user is authenticated + if (isAuthed && !userCode) { + return ( +
    +
    +

    + Device Authorization +

    +

    + Enter the code displayed on your device: +

    +
    +
    + + +
    + +
    +
    +
    + ); + } + + // Show loading state while checking authentication + if (isAuthLoading) { + return ( +
    +
    +
    +

    + Processing device verification... +

    +
    +
    + ); + } + + // Show authentication required message (this will trigger the auth modal via root layout) + return ( +
    +
    +

    Authentication Required

    +

    + Please sign in to authorize your device. +

    +
    +
    + ); +} diff --git a/frontend/src/routes/git-settings.tsx b/frontend/src/routes/git-settings.tsx index aff64afb55..69a7838c10 100644 --- a/frontend/src/routes/git-settings.tsx +++ b/frontend/src/routes/git-settings.tsx @@ -50,10 +50,10 @@ function GitSettingsScreen() { const [azureDevOpsHostInputHasValue, setAzureDevOpsHostInputHasValue] = React.useState(false); - const existingGithubHost = settings?.PROVIDER_TOKENS_SET.github; - const existingGitlabHost = settings?.PROVIDER_TOKENS_SET.gitlab; - const existingBitbucketHost = settings?.PROVIDER_TOKENS_SET.bitbucket; - const existingAzureDevOpsHost = settings?.PROVIDER_TOKENS_SET.azure_devops; + const existingGithubHost = settings?.provider_tokens_set.github; + const existingGitlabHost = settings?.provider_tokens_set.gitlab; + const existingBitbucketHost = settings?.provider_tokens_set.bitbucket; + const existingAzureDevOpsHost = settings?.provider_tokens_set.azure_devops; const isSaas = config?.APP_MODE === "saas"; const isGitHubTokenSet = providers.includes("github"); diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index 056bf28c2c..d793e56876 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -91,15 +91,15 @@ function LlmSettingsScreen() { // Track confirmation mode state to control security analyzer visibility const [confirmationModeEnabled, setConfirmationModeEnabled] = React.useState( - settings?.CONFIRMATION_MODE ?? DEFAULT_SETTINGS.CONFIRMATION_MODE, + settings?.confirmation_mode ?? DEFAULT_SETTINGS.confirmation_mode, ); // Track selected security analyzer for form submission const [selectedSecurityAnalyzer, setSelectedSecurityAnalyzer] = React.useState( - settings?.SECURITY_ANALYZER === null + settings?.security_analyzer === null ? "none" - : (settings?.SECURITY_ANALYZER ?? DEFAULT_SETTINGS.SECURITY_ANALYZER), + : (settings?.security_analyzer ?? DEFAULT_SETTINGS.security_analyzer), ); const [selectedProvider, setSelectedProvider] = React.useState( @@ -111,7 +111,7 @@ function LlmSettingsScreen() { ); // Determine if we should hide the API key input and use OpenHands-managed key (when using OpenHands provider in SaaS mode) - const currentModel = currentSelectedModel || settings?.LLM_MODEL; + const currentModel = currentSelectedModel || settings?.llm_model; const isSaasMode = config?.APP_MODE === "saas"; @@ -124,7 +124,7 @@ function LlmSettingsScreen() { if (dirtyInputs.model) { return currentModel?.startsWith("openhands/"); } - return settings?.LLM_MODEL?.startsWith("openhands/"); + return settings?.llm_model?.startsWith("openhands/"); } return false; @@ -133,13 +133,13 @@ function LlmSettingsScreen() { const shouldUseOpenHandsKey = isOpenHandsProvider() && isSaasMode; // Determine if we should hide the agent dropdown when V1 conversation API is enabled - const isV1Enabled = settings?.V1_ENABLED; + const isV1Enabled = settings?.v1_enabled; React.useEffect(() => { const determineWhetherToToggleAdvancedSettings = () => { if (resources && settings) { return ( - isCustomModel(resources.models, settings.LLM_MODEL) || + isCustomModel(resources.models, settings.llm_model) || hasAdvancedSettingsSet({ ...settings, }) @@ -157,24 +157,24 @@ function LlmSettingsScreen() { // Initialize currentSelectedModel with the current settings React.useEffect(() => { - if (settings?.LLM_MODEL) { - setCurrentSelectedModel(settings.LLM_MODEL); + if (settings?.llm_model) { + setCurrentSelectedModel(settings.llm_model); } - }, [settings?.LLM_MODEL]); + }, [settings?.llm_model]); // Update confirmation mode state when settings change React.useEffect(() => { - if (settings?.CONFIRMATION_MODE !== undefined) { - setConfirmationModeEnabled(settings.CONFIRMATION_MODE); + if (settings?.confirmation_mode !== undefined) { + setConfirmationModeEnabled(settings.confirmation_mode); } - }, [settings?.CONFIRMATION_MODE]); + }, [settings?.confirmation_mode]); // Update selected security analyzer state when settings change React.useEffect(() => { - if (settings?.SECURITY_ANALYZER !== undefined) { - setSelectedSecurityAnalyzer(settings.SECURITY_ANALYZER || "none"); + if (settings?.security_analyzer !== undefined) { + setSelectedSecurityAnalyzer(settings.security_analyzer || "none"); } - }, [settings?.SECURITY_ANALYZER]); + }, [settings?.security_analyzer]); // Handle URL parameters for SaaS subscription redirects React.useEffect(() => { @@ -230,19 +230,19 @@ function LlmSettingsScreen() { saveSettings( { - LLM_MODEL: fullLlmModel, + llm_model: fullLlmModel, llm_api_key: finalApiKey || null, - SEARCH_API_KEY: searchApiKey || "", - CONFIRMATION_MODE: confirmationMode, - SECURITY_ANALYZER: + search_api_key: searchApiKey || "", + confirmation_mode: confirmationMode, + security_analyzer: securityAnalyzer === "none" ? null - : securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER, + : securityAnalyzer || DEFAULT_SETTINGS.security_analyzer, // reset advanced settings - LLM_BASE_URL: DEFAULT_SETTINGS.LLM_BASE_URL, - AGENT: DEFAULT_SETTINGS.AGENT, - ENABLE_DEFAULT_CONDENSER: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER, + llm_base_url: DEFAULT_SETTINGS.llm_base_url, + agent: DEFAULT_SETTINGS.agent, + enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser, }, { onSuccess: handleSuccessfulMutation, @@ -281,19 +281,19 @@ function LlmSettingsScreen() { saveSettings( { - LLM_MODEL: model, - LLM_BASE_URL: baseUrl, + llm_model: model, + llm_base_url: baseUrl, llm_api_key: finalApiKey || null, - SEARCH_API_KEY: searchApiKey || "", - AGENT: agent, - CONFIRMATION_MODE: confirmationMode, - ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser, - CONDENSER_MAX_SIZE: - condenserMaxSize ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE, - SECURITY_ANALYZER: + search_api_key: searchApiKey || "", + agent, + confirmation_mode: confirmationMode, + enable_default_condenser: enableDefaultCondenser, + condenser_max_size: + condenserMaxSize ?? DEFAULT_SETTINGS.condenser_max_size, + security_analyzer: securityAnalyzer === "none" ? null - : securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER, + : securityAnalyzer || DEFAULT_SETTINGS.security_analyzer, }, { onSuccess: handleSuccessfulMutation, @@ -323,7 +323,7 @@ function LlmSettingsScreen() { ) => { // openai providers are special case; see ModelSelector // component for details - const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", ""); + const modelIsDirty = model !== settings?.llm_model.replace("openai/", ""); setDirtyInputs((prev) => ({ ...prev, model: modelIsDirty, @@ -351,7 +351,7 @@ function LlmSettingsScreen() { }; const handleSearchApiKeyIsDirty = (searchApiKey: string) => { - const searchApiKeyIsDirty = searchApiKey !== settings?.SEARCH_API_KEY; + const searchApiKeyIsDirty = searchApiKey !== settings?.search_api_key; setDirtyInputs((prev) => ({ ...prev, searchApiKey: searchApiKeyIsDirty, @@ -359,7 +359,7 @@ function LlmSettingsScreen() { }; const handleCustomModelIsDirty = (model: string) => { - const modelIsDirty = model !== settings?.LLM_MODEL && model !== ""; + const modelIsDirty = model !== settings?.llm_model && model !== ""; setDirtyInputs((prev) => ({ ...prev, model: modelIsDirty, @@ -370,7 +370,7 @@ function LlmSettingsScreen() { }; const handleBaseUrlIsDirty = (baseUrl: string) => { - const baseUrlIsDirty = baseUrl !== settings?.LLM_BASE_URL; + const baseUrlIsDirty = baseUrl !== settings?.llm_base_url; setDirtyInputs((prev) => ({ ...prev, baseUrl: baseUrlIsDirty, @@ -378,7 +378,7 @@ function LlmSettingsScreen() { }; const handleAgentIsDirty = (agent: string) => { - const agentIsDirty = agent !== settings?.AGENT && agent !== ""; + const agentIsDirty = agent !== settings?.agent && agent !== ""; setDirtyInputs((prev) => ({ ...prev, agent: agentIsDirty, @@ -386,7 +386,7 @@ function LlmSettingsScreen() { }; const handleConfirmationModeIsDirty = (isToggled: boolean) => { - const confirmationModeIsDirty = isToggled !== settings?.CONFIRMATION_MODE; + const confirmationModeIsDirty = isToggled !== settings?.confirmation_mode; setDirtyInputs((prev) => ({ ...prev, confirmationMode: confirmationModeIsDirty, @@ -395,7 +395,7 @@ function LlmSettingsScreen() { // When confirmation mode is enabled, set default security analyzer to "llm" if not already set if (isToggled && !selectedSecurityAnalyzer) { - setSelectedSecurityAnalyzer(DEFAULT_SETTINGS.SECURITY_ANALYZER); + setSelectedSecurityAnalyzer(DEFAULT_SETTINGS.security_analyzer); setDirtyInputs((prev) => ({ ...prev, securityAnalyzer: true, @@ -405,7 +405,7 @@ function LlmSettingsScreen() { const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => { const enableDefaultCondenserIsDirty = - isToggled !== settings?.ENABLE_DEFAULT_CONDENSER; + isToggled !== settings?.enable_default_condenser; setDirtyInputs((prev) => ({ ...prev, enableDefaultCondenser: enableDefaultCondenserIsDirty, @@ -416,8 +416,8 @@ function LlmSettingsScreen() { const parsed = value ? Number.parseInt(value, 10) : undefined; const bounded = parsed !== undefined ? Math.max(20, parsed) : undefined; const condenserMaxSizeIsDirty = - (bounded ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !== - (settings?.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE); + (bounded ?? DEFAULT_SETTINGS.condenser_max_size) !== + (settings?.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size); setDirtyInputs((prev) => ({ ...prev, condenserMaxSize: condenserMaxSizeIsDirty, @@ -426,7 +426,7 @@ function LlmSettingsScreen() { const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => { const securityAnalyzerIsDirty = - securityAnalyzer !== settings?.SECURITY_ANALYZER; + securityAnalyzer !== settings?.security_analyzer; setDirtyInputs((prev) => ({ ...prev, securityAnalyzer: securityAnalyzerIsDirty, @@ -453,6 +453,10 @@ function LlmSettingsScreen() { label: t(I18nKey.SETTINGS$SECURITY_ANALYZER_NONE), }); + if (isV1Enabled) { + return orderedItems; + } + // Add Invariant analyzer third if (analyzers.includes("invariant")) { orderedItems.push({ @@ -508,12 +512,12 @@ function LlmSettingsScreen() { <> - {(settings.LLM_MODEL?.startsWith("openhands/") || + {(settings.llm_model?.startsWith("openhands/") || currentSelectedModel?.startsWith("openhands/")) && ( )} @@ -528,11 +532,11 @@ function LlmSettingsScreen() { label={t(I18nKey.SETTINGS_FORM$API_KEY)} type="password" className="w-full max-w-[680px]" - placeholder={settings.LLM_API_KEY_SET ? "" : ""} + placeholder={settings.llm_api_key_set ? "" : ""} onChange={handleApiKeyIsDirty} startContent={ - settings.LLM_API_KEY_SET && ( - + settings.llm_api_key_set && ( + ) } /> @@ -557,13 +561,13 @@ function LlmSettingsScreen() { testId="llm-custom-model-input" name="llm-custom-model-input" label={t(I18nKey.SETTINGS$CUSTOM_MODEL)} - defaultValue={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL} + defaultValue={settings.llm_model || DEFAULT_OPENHANDS_MODEL} placeholder={DEFAULT_OPENHANDS_MODEL} type="text" className="w-full max-w-[680px]" onChange={handleCustomModelIsDirty} /> - {(settings.LLM_MODEL?.startsWith("openhands/") || + {(settings.llm_model?.startsWith("openhands/") || currentSelectedModel?.startsWith("openhands/")) && ( )} @@ -572,7 +576,7 @@ function LlmSettingsScreen() { testId="base-url-input" name="base-url-input" label={t(I18nKey.SETTINGS$BASE_URL)} - defaultValue={settings.LLM_BASE_URL} + defaultValue={settings.llm_base_url} placeholder="https://api.openai.com" type="text" className="w-full max-w-[680px]" @@ -587,11 +591,11 @@ function LlmSettingsScreen() { label={t(I18nKey.SETTINGS_FORM$API_KEY)} type="password" className="w-full max-w-[680px]" - placeholder={settings.LLM_API_KEY_SET ? "" : ""} + placeholder={settings.llm_api_key_set ? "" : ""} onChange={handleApiKeyIsDirty} startContent={ - settings.LLM_API_KEY_SET && ( - + settings.llm_api_key_set && ( + ) } /> @@ -612,12 +616,12 @@ function LlmSettingsScreen() { label={t(I18nKey.SETTINGS$SEARCH_API_KEY)} type="password" className="w-full max-w-[680px]" - defaultValue={settings.SEARCH_API_KEY || ""} + defaultValue={settings.search_api_key || ""} onChange={handleSearchApiKeyIsDirty} placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)} startContent={ - settings.SEARCH_API_KEY_SET && ( - + settings.search_api_key_set && ( + ) } /> @@ -640,7 +644,7 @@ function LlmSettingsScreen() { label: agent, // TODO: Add i18n support for agent names })) || [] } - defaultSelectedKey={settings.AGENT} + defaultSelectedKey={settings.agent} isClearable={false} onInputChange={handleAgentIsDirty} wrapperClassName="w-full max-w-[680px]" @@ -658,11 +662,11 @@ function LlmSettingsScreen() { step={1} label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)} defaultValue={( - settings.CONDENSER_MAX_SIZE ?? - DEFAULT_SETTINGS.CONDENSER_MAX_SIZE + settings.condenser_max_size ?? + DEFAULT_SETTINGS.condenser_max_size )?.toString()} onChange={(value) => handleCondenserMaxSizeIsDirty(value)} - isDisabled={!settings.ENABLE_DEFAULT_CONDENSER} + isDisabled={!settings.enable_default_condenser} />

    {t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP)} @@ -672,7 +676,7 @@ function LlmSettingsScreen() { {t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)} @@ -684,7 +688,7 @@ function LlmSettingsScreen() { testId="enable-confirmation-mode-switch" name="enable-confirmation-mode-switch" onToggle={handleConfirmationModeIsDirty} - defaultIsToggled={settings.CONFIRMATION_MODE} + defaultIsToggled={settings.confirmation_mode} isBeta > {t(I18nKey.SETTINGS$CONFIRMATION_MODE)} diff --git a/frontend/src/routes/mcp-settings.tsx b/frontend/src/routes/mcp-settings.tsx index 0a4224182b..e308b45228 100644 --- a/frontend/src/routes/mcp-settings.tsx +++ b/frontend/src/routes/mcp-settings.tsx @@ -41,7 +41,7 @@ function MCPSettingsScreen() { useState(false); const [serverToDelete, setServerToDelete] = useState(null); - const mcpConfig: MCPConfig = settings?.MCP_CONFIG || { + const mcpConfig: MCPConfig = settings?.mcp_config || { sse_servers: [], stdio_servers: [], shttp_servers: [], diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index bcf7ed3409..3f8ec1085a 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -117,16 +117,16 @@ export default function MainApp() { React.useEffect(() => { // Don't change language when on TOS page - if (!isOnTosPage && settings?.LANGUAGE) { - i18n.changeLanguage(settings.LANGUAGE); + if (!isOnTosPage && settings?.language) { + i18n.changeLanguage(settings.language); } - }, [settings?.LANGUAGE, isOnTosPage]); + }, [settings?.language, isOnTosPage]); React.useEffect(() => { // Don't show consent form when on TOS page if (!isOnTosPage) { const consentFormModalIsOpen = - settings?.USER_CONSENTS_TO_ANALYTICS === null; + settings?.user_consents_to_analytics === null; setConsentFormIsOpen(consentFormModalIsOpen); } @@ -145,10 +145,10 @@ export default function MainApp() { }, [isOnTosPage]); React.useEffect(() => { - if (settings?.IS_NEW_USER && config.data?.APP_MODE === "saas") { + if (settings?.is_new_user && config.data?.APP_MODE === "saas") { displaySuccessToast(t(I18nKey.BILLING$YOURE_IN)); } - }, [settings?.IS_NEW_USER, config.data?.APP_MODE]); + }, [settings?.is_new_user, config.data?.APP_MODE]); React.useEffect(() => { // Don't do any redirects when on TOS page @@ -260,7 +260,7 @@ export default function MainApp() { {config.data?.FEATURE_FLAGS.ENABLE_BILLING && config.data?.APP_MODE === "saas" && - settings?.IS_NEW_USER && } + settings?.is_new_user && }

    ); } diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index 2d7f7cb6b6..4f35595d13 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -1,14 +1,13 @@ import { useMemo } from "react"; import { Outlet, redirect, useLocation } from "react-router"; import { useTranslation } from "react-i18next"; -import { useConfig } from "#/hooks/query/use-config"; import { Route } from "./+types/settings"; import OptionService from "#/api/option-service/option-service.api"; import { queryClient } from "#/query-client-config"; import { GetConfigResponse } from "#/api/option-service/option.types"; -import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; -import { Typography } from "#/ui/typography"; import { SettingsLayout } from "#/components/features/settings/settings-layout"; +import { Typography } from "#/ui/typography"; +import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; const SAAS_ONLY_PATHS = [ "/settings/user", @@ -33,14 +32,10 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { // if in OSS mode, do not allow access to saas-only paths return redirect("/settings"); } - // If LLM settings are hidden and user tries to access the LLM settings page if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS && pathname === "/settings") { // Redirect to the first available settings page - if (isSaas) { - return redirect("/settings/user"); - } - return redirect("/settings/mcp"); + return isSaas ? redirect("/settings/user") : redirect("/settings/mcp"); } return null; @@ -48,37 +43,15 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { function SettingsScreen() { const { t } = useTranslation(); - const { data: config } = useConfig(); const location = useLocation(); - - const isSaas = config?.APP_MODE === "saas"; - - // Navigation items configuration - const navItems = useMemo(() => { - const items = []; - if (isSaas) { - items.push(...SAAS_NAV_ITEMS); - } else { - items.push(...OSS_NAV_ITEMS); - } - - // Filter out LLM settings if the feature flag is enabled - if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS) { - return items.filter((item) => item.to !== "/settings"); - } - - return items; - }, [isSaas, config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS]); - + const navItems = useSettingsNavItems(); // Current section title for the main content area const currentSectionTitle = useMemo(() => { const currentItem = navItems.find((item) => item.to === location.pathname); - if (currentItem) { - return currentItem.text; - } - // Default to the first available navigation item if current page is not found - return navItems.length > 0 ? navItems[0].text : "SETTINGS$TITLE"; + return currentItem + ? currentItem.text + : (navItems[0]?.text ?? "SETTINGS$TITLE"); }, [navItems, location.pathname]); return ( diff --git a/frontend/src/routes/user-settings.tsx b/frontend/src/routes/user-settings.tsx index 93366574b0..cddc38466e 100644 --- a/frontend/src/routes/user-settings.tsx +++ b/frontend/src/routes/user-settings.tsx @@ -122,12 +122,12 @@ function UserSettingsScreen() { const prevVerificationStatusRef = useRef(undefined); useEffect(() => { - if (settings?.EMAIL) { - setEmail(settings.EMAIL); - setOriginalEmail(settings.EMAIL); - setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL)); + if (settings?.email) { + setEmail(settings.email); + setOriginalEmail(settings.email); + setIsEmailValid(EMAIL_REGEX.test(settings.email)); } - }, [settings?.EMAIL]); + }, [settings?.email]); useEffect(() => { if (pollingIntervalRef.current) { @@ -137,7 +137,7 @@ function UserSettingsScreen() { if ( prevVerificationStatusRef.current === false && - settings?.EMAIL_VERIFIED === true + settings?.email_verified === true ) { // Display toast notification instead of setting state displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY")); @@ -146,9 +146,9 @@ function UserSettingsScreen() { }, 2000); } - prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED; + prevVerificationStatusRef.current = settings?.email_verified; - if (settings?.EMAIL_VERIFIED === false) { + if (settings?.email_verified === false) { pollingIntervalRef.current = window.setInterval(() => { refetch(); }, 5000); @@ -160,7 +160,7 @@ function UserSettingsScreen() { pollingIntervalRef.current = null; } }; - }, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]); + }, [settings?.email_verified, refetch, queryClient, t]); const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value; @@ -215,10 +215,10 @@ function UserSettingsScreen() { isSaving={isSaving} isResendingVerification={isResendingVerification} isEmailChanged={isEmailChanged} - emailVerified={settings?.EMAIL_VERIFIED} + emailVerified={settings?.email_verified} isEmailValid={isEmailValid} > - {settings?.EMAIL_VERIFIED === false && } + {settings?.email_verified === false && } )}
    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/settings.ts b/frontend/src/services/settings.ts index 7c648247d6..1191e0ea68 100644 --- a/frontend/src/services/settings.ts +++ b/frontend/src/services/settings.ts @@ -3,35 +3,36 @@ import { Settings } from "#/types/settings"; export const LATEST_SETTINGS_VERSION = 5; export const DEFAULT_SETTINGS: Settings = { - LLM_MODEL: "openhands/claude-sonnet-4-20250514", - LLM_BASE_URL: "", - AGENT: "CodeActAgent", - LANGUAGE: "en", - LLM_API_KEY_SET: false, - SEARCH_API_KEY_SET: false, - CONFIRMATION_MODE: false, - SECURITY_ANALYZER: "llm", - REMOTE_RUNTIME_RESOURCE_FACTOR: 1, - PROVIDER_TOKENS_SET: {}, - ENABLE_DEFAULT_CONDENSER: true, - CONDENSER_MAX_SIZE: 120, - ENABLE_SOUND_NOTIFICATIONS: false, - USER_CONSENTS_TO_ANALYTICS: false, - ENABLE_PROACTIVE_CONVERSATION_STARTERS: false, - ENABLE_SOLVABILITY_ANALYSIS: false, - SEARCH_API_KEY: "", - IS_NEW_USER: true, - MAX_BUDGET_PER_TASK: null, - EMAIL: "", - EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily - MCP_CONFIG: { + llm_model: "openhands/claude-sonnet-4-20250514", + llm_base_url: "", + agent: "CodeActAgent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: "llm", + remote_runtime_resource_factor: 1, + provider_tokens_set: {}, + enable_default_condenser: true, + condenser_max_size: 120, + enable_sound_notifications: false, + user_consents_to_analytics: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + search_api_key: "", + is_new_user: true, + max_budget_per_task: null, + email: "", + email_verified: true, // Default to true to avoid restricting access unnecessarily + mcp_config: { sse_servers: [], stdio_servers: [], shttp_servers: [], }, - GIT_USER_NAME: "openhands", - GIT_USER_EMAIL: "openhands@all-hands.dev", - V1_ENABLED: false, + git_user_name: "openhands", + git_user_email: "openhands@all-hands.dev", + v1_enabled: false, }; /** diff --git a/frontend/src/stores/home-store.ts b/frontend/src/stores/home-store.ts index 3ec2ed2c26..6289f65f01 100644 --- a/frontend/src/stores/home-store.ts +++ b/frontend/src/stores/home-store.ts @@ -1,21 +1,26 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import { GitRepository } from "#/types/git"; +import { Provider } from "#/types/settings"; interface HomeState { recentRepositories: GitRepository[]; + lastSelectedProvider: Provider | null; } interface HomeActions { addRecentRepository: (repository: GitRepository) => void; clearRecentRepositories: () => void; getRecentRepositories: () => GitRepository[]; + setLastSelectedProvider: (provider: Provider | null) => void; + getLastSelectedProvider: () => Provider | null; } type HomeStore = HomeState & HomeActions; const initialState: HomeState = { recentRepositories: [], + lastSelectedProvider: null, }; export const useHomeStore = create()( @@ -44,6 +49,13 @@ export const useHomeStore = create()( })), getRecentRepositories: () => get().recentRepositories, + + setLastSelectedProvider: (provider: Provider | null) => + set(() => ({ + lastSelectedProvider: provider, + })), + + getLastSelectedProvider: () => get().lastSelectedProvider, }), { name: "home-store", // unique name for localStorage diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css index 8228f6b154..1673288564 100644 --- a/frontend/src/tailwind.css +++ b/frontend/src/tailwind.css @@ -318,8 +318,8 @@ background: transparent !important; } -/* Ensure all xterm elements have transparent backgrounds */ -.xterm * { +/* Ensure all xterm DOM elements have transparent backgrounds. Exclude canvas elements */ +.xterm { background: transparent !important; } diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 2299288132..e5db0296bd 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -38,37 +38,31 @@ export type MCPConfig = { }; export type Settings = { - LLM_MODEL: string; - LLM_BASE_URL: string; - AGENT: string; - LANGUAGE: string; - LLM_API_KEY_SET: boolean; - SEARCH_API_KEY_SET: boolean; - CONFIRMATION_MODE: boolean; - SECURITY_ANALYZER: string | null; - REMOTE_RUNTIME_RESOURCE_FACTOR: number | null; - PROVIDER_TOKENS_SET: Partial>; - ENABLE_DEFAULT_CONDENSER: boolean; + llm_model: string; + llm_base_url: string; + agent: string; + language: string; + llm_api_key: string | null; + llm_api_key_set: boolean; + search_api_key_set: boolean; + confirmation_mode: boolean; + security_analyzer: string | null; + remote_runtime_resource_factor: number | null; + provider_tokens_set: Partial>; + enable_default_condenser: boolean; // Maximum number of events before the condenser runs - CONDENSER_MAX_SIZE: number | null; - ENABLE_SOUND_NOTIFICATIONS: boolean; - ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean; - ENABLE_SOLVABILITY_ANALYSIS: boolean; - USER_CONSENTS_TO_ANALYTICS: boolean | null; - SEARCH_API_KEY?: string; - IS_NEW_USER?: boolean; - MCP_CONFIG?: MCPConfig; - MAX_BUDGET_PER_TASK: number | null; - EMAIL?: string; - EMAIL_VERIFIED?: boolean; - GIT_USER_NAME?: string; - GIT_USER_EMAIL?: string; - V1_ENABLED?: boolean; -}; - -export type PostSettings = Settings & { + condenser_max_size: number | null; + enable_sound_notifications: boolean; + enable_proactive_conversation_starters: boolean; + enable_solvability_analysis: boolean; user_consents_to_analytics: boolean | null; - llm_api_key?: string | null; search_api_key?: string; + is_new_user?: boolean; mcp_config?: MCPConfig; + max_budget_per_task: number | null; + email?: string; + email_verified?: boolean; + git_user_name?: string; + git_user_email?: string; + v1_enabled?: boolean; }; diff --git a/frontend/src/types/v1/core/base/observation.ts b/frontend/src/types/v1/core/base/observation.ts index 42726c2b32..a1c8a1a48d 100644 --- a/frontend/src/types/v1/core/base/observation.ts +++ b/frontend/src/types/v1/core/base/observation.ts @@ -36,7 +36,7 @@ export interface ThinkObservation extends ObservationBase<"ThinkObservation"> { /** * Confirmation message. DEFAULT: "Your thought has been logged." */ - content: string; + content: Array; } export interface BrowserObservation extends ObservationBase<"BrowserObservation"> { diff --git a/frontend/src/types/v1/type-guards.ts b/frontend/src/types/v1/type-guards.ts index ee831ea489..dec1816209 100644 --- a/frontend/src/types/v1/type-guards.ts +++ b/frontend/src/types/v1/type-guards.ts @@ -54,7 +54,10 @@ export const isObservationEvent = ( ): event is ObservationEvent => event.source === "environment" && "action_id" in event && - "observation" in event; + "observation" in event && + event.observation !== null && + typeof event.observation === "object" && + "kind" in event.observation; /** * Type guard function to check if an event is an agent error event @@ -94,6 +97,9 @@ export const isUserMessageEvent = ( export const isActionEvent = (event: OpenHandsEvent): event is ActionEvent => event.source === "agent" && "action" in event && + event.action !== null && + typeof event.action === "object" && + "kind" in event.action && "tool_name" in event && "tool_call_id" in event && typeof event.tool_name === "string" && 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 8cf3f10a39..b873425239 100644 --- a/frontend/src/utils/has-advanced-settings-set.ts +++ b/frontend/src/utils/has-advanced-settings-set.ts @@ -3,4 +3,4 @@ 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); + (!!settings.llm_base_url || settings.agent !== DEFAULT_SETTINGS.agent); diff --git a/frontend/src/utils/parse-terminal-output.ts b/frontend/src/utils/parse-terminal-output.ts index a6ccc73cfc..1cd54eb858 100644 --- a/frontend/src/utils/parse-terminal-output.ts +++ b/frontend/src/utils/parse-terminal-output.ts @@ -1,3 +1,5 @@ +const START = "[Python Interpreter: "; + /** * Parses the raw output from the terminal into the command and symbol * @param raw The raw output to be displayed in the terminal @@ -13,9 +15,14 @@ * console.log(parsed.symbol); // openhands@659478cb008c:/workspace $ */ export const parseTerminalOutput = (raw: string) => { - const envRegex = /(.*)\[Python Interpreter: (.*)\]/s; - const match = raw.match(envRegex); - - if (!match) return raw; - return match[1]?.trim() || ""; + const start = raw.indexOf(START); + if (start < 0) { + return raw; + } + const offset = start + START.length; + const end = raw.indexOf("]", offset); + if (end <= offset) { + return raw; + } + return raw.substring(0, start).trim(); }; diff --git a/frontend/src/utils/settings-utils.ts b/frontend/src/utils/settings-utils.ts index ca56b25170..4259226d77 100644 --- a/frontend/src/utils/settings-utils.ts +++ b/frontend/src/utils/settings-utils.ts @@ -67,9 +67,7 @@ export const parseMaxBudgetPerTask = (value: string): number | null => { : null; }; -export const extractSettings = ( - formData: FormData, -): Partial & { llm_api_key?: string | null } => { +export const extractSettings = (formData: FormData): Partial => { const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } = extractBasicFormData(formData); @@ -82,14 +80,14 @@ export const extractSettings = ( } = extractAdvancedFormData(formData); return { - LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL, - LLM_API_KEY_SET: !!LLM_API_KEY, - AGENT, - LANGUAGE, - LLM_BASE_URL, - CONFIRMATION_MODE, - SECURITY_ANALYZER, - ENABLE_DEFAULT_CONDENSER, + llm_model: CUSTOM_LLM_MODEL || LLM_MODEL, + llm_api_key_set: !!LLM_API_KEY, + agent: AGENT, + language: LANGUAGE, + llm_base_url: LLM_BASE_URL, + confirmation_mode: CONFIRMATION_MODE, + security_analyzer: SECURITY_ANALYZER, + enable_default_condenser: ENABLE_DEFAULT_CONDENSER, llm_api_key: LLM_API_KEY, }; }; diff --git a/frontend/tests/avatar-menu.spec.ts b/frontend/tests/avatar-menu.spec.ts new file mode 100644 index 0000000000..a7ef4efe40 --- /dev/null +++ b/frontend/tests/avatar-menu.spec.ts @@ -0,0 +1,48 @@ +import test, { expect } from "@playwright/test"; + +/** + * Test for issue #11933: Avatar context menu closes when moving cursor diagonally + * + * This test verifies that the user can move their cursor diagonally from the + * avatar to the context menu without the menu closing unexpectedly. + */ +test("avatar context menu stays open when moving cursor diagonally to menu", async ({ + page, + browserName, +}) => { + // Skip on WebKit - Playwright's mouse.move() doesn't reliably trigger CSS hover states + test.skip(browserName === "webkit", "Playwright hover simulation unreliable"); + + await page.goto("/"); + + // Get the user avatar button + const userAvatar = page.getByTestId("user-avatar"); + await expect(userAvatar).toBeVisible(); + + // Get avatar bounding box first + const avatarBox = await userAvatar.boundingBox(); + if (!avatarBox) { + throw new Error("Could not get bounding box for avatar"); + } + + // Use mouse.move to hover (not .hover() which may trigger click) + const avatarCenterX = avatarBox.x + avatarBox.width / 2; + const avatarCenterY = avatarBox.y + avatarBox.height / 2; + await page.mouse.move(avatarCenterX, avatarCenterY); + + // The context menu should appear via CSS group-hover + const contextMenu = page.getByTestId("account-settings-context-menu"); + await expect(contextMenu).toBeVisible(); + + // Move UP from the LEFT side of the avatar - simulating diagonal movement + // toward the menu (which is to the right). This exits the hover zone. + const leftX = avatarBox.x + 2; + const aboveY = avatarBox.y - 50; + await page.mouse.move(leftX, aboveY); + + // The menu uses opacity-0/opacity-100 for visibility via CSS. + // Use toHaveCSS which auto-retries, avoiding flaky waitForTimeout. + // The menu should remain visible (opacity 1) to allow diagonal access to it. + const menuWrapper = contextMenu.locator(".."); + await expect(menuWrapper).toHaveCSS("opacity", "1"); +}); diff --git a/frontend/tests/conversation-panel.test.ts b/frontend/tests/conversation-panel.test.ts deleted file mode 100644 index 6e3f58cd45..0000000000 --- a/frontend/tests/conversation-panel.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import test, { expect, Page } from "@playwright/test"; - -const toggleConversationPanel = async (page: Page) => { - const panel = page.getByTestId("conversation-panel"); - await page.waitForTimeout(1000); // Wait for state to stabilize - const panelIsVisible = await panel.isVisible(); - - if (!panelIsVisible) { - const conversationPanelButton = page.getByTestId( - "toggle-conversation-panel", - ); - await conversationPanelButton.click(); - } - - return page.getByTestId("conversation-panel"); -}; - -const selectConversationCard = async (page: Page, index: number) => { - const panel = await toggleConversationPanel(page); - - // select a conversation - const conversationItem = panel.getByTestId("conversation-card").nth(index); - await conversationItem.click(); - - // panel should close - await expect(panel).not.toBeVisible(); - - await page.waitForURL(`/conversations/${index + 1}`); - expect(page.url()).toBe(`http://localhost:3001/conversations/${index + 1}`); -}; - -test.beforeEach(async ({ page }) => { - await page.goto("/"); -}); - -test("should only display the create new conversation button when in a conversation", async ({ - page, -}) => { - const panel = page.getByTestId("conversation-panel"); - - const newProjectButton = panel.getByTestId("new-conversation-button"); - await expect(newProjectButton).not.toBeVisible(); - - await page.goto("/conversations/1"); - await expect(newProjectButton).toBeVisible(); -}); - -test("redirect to /conversation with the session id as a path param when clicking on a conversation card", async ({ - page, -}) => { - const panel = page.getByTestId("conversation-panel"); - - // select a conversation - const conversationItem = panel.getByTestId("conversation-card").first(); - await conversationItem.click(); - - // panel should close - expect(panel).not.toBeVisible(); - - await page.waitForURL("/conversations/1"); - expect(page.url()).toBe("http://localhost:3001/conversations/1"); -}); - -test("redirect to the home screen if the current session was deleted", async ({ - page, -}) => { - await page.goto("/conversations/1"); - await page.waitForURL("/conversations/1"); - - const panel = page.getByTestId("conversation-panel"); - const firstCard = panel.getByTestId("conversation-card").first(); - - const ellipsisButton = firstCard.getByTestId("ellipsis-button"); - await ellipsisButton.click(); - - const deleteButton = firstCard.getByTestId("delete-button"); - await deleteButton.click(); - - // confirm modal - const confirmButton = page.getByText("Confirm"); - await confirmButton.click(); - - await page.waitForURL("/"); -}); - -test("load relevant files in the file explorer", async ({ page }) => { - await selectConversationCard(page, 0); - - // check if the file explorer has the correct files - const fileExplorer = page.getByTestId("file-explorer"); - - await expect(fileExplorer.getByText("file1.txt")).toBeVisible(); - await expect(fileExplorer.getByText("file2.txt")).toBeVisible(); - await expect(fileExplorer.getByText("file3.txt")).toBeVisible(); - - await selectConversationCard(page, 2); - - // check if the file explorer has the correct files - expect(fileExplorer.getByText("reboot_skynet.exe")).toBeVisible(); - expect(fileExplorer.getByText("target_list.txt")).toBeVisible(); - expect(fileExplorer.getByText("terminator_blueprint.txt")).toBeVisible(); -}); - -test("should redirect to home screen if conversation deos not exist", async ({ - page, -}) => { - await page.goto("/conversations/9999"); - await page.waitForURL("/"); -}); - -test("display the conversation details during a conversation", async ({ - page, -}) => { - const conversationPanelButton = page.getByTestId("toggle-conversation-panel"); - await expect(conversationPanelButton).toBeVisible(); - await conversationPanelButton.click(); - - const panel = page.getByTestId("conversation-panel"); - - // select a conversation - const conversationItem = panel.getByTestId("conversation-card").first(); - await conversationItem.click(); - - // panel should close - await expect(panel).not.toBeVisible(); - - await page.waitForURL("/conversations/1"); - expect(page.url()).toBe("http://localhost:3001/conversations/1"); - - const conversationDetails = page.getByTestId("conversation-card"); - - await expect(conversationDetails).toBeVisible(); - await expect(conversationDetails).toHaveText("Conversation 1"); -}); diff --git a/frontend/tests/fixtures/project.zip b/frontend/tests/fixtures/project.zip deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/tests/helpers/confirm-settings.ts b/frontend/tests/helpers/confirm-settings.ts deleted file mode 100644 index ca82edd35a..0000000000 --- a/frontend/tests/helpers/confirm-settings.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Page } from "@playwright/test"; - -export const confirmSettings = async (page: Page) => { - const confirmPreferenceButton = page.getByRole("button", { - name: /confirm preferences/i, - }); - await confirmPreferenceButton.click(); - - const configSaveButton = page - .getByRole("button", { - name: /save/i, - }) - .first(); - await configSaveButton.click(); - - const confirmChanges = page.getByRole("button", { - name: /yes, close settings/i, - }); - await confirmChanges.click(); -}; diff --git a/frontend/tests/placeholder.spec.ts b/frontend/tests/placeholder.spec.ts new file mode 100644 index 0000000000..48e76b587e --- /dev/null +++ b/frontend/tests/placeholder.spec.ts @@ -0,0 +1,4 @@ +import { test } from "@playwright/test"; + +// Placeholder test to ensure CI passes until real E2E tests are added +test("placeholder", () => {}); diff --git a/frontend/tests/redirect.spec.ts b/frontend/tests/redirect.spec.ts deleted file mode 100644 index 8425345ba6..0000000000 --- a/frontend/tests/redirect.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { expect, test } from "@playwright/test"; -import path from "path"; -import { fileURLToPath } from "url"; - -const filename = fileURLToPath(import.meta.url); -const dirname = path.dirname(filename); - -test.beforeEach(async ({ page }) => { - await page.goto("/"); -}); - -test("should redirect to /conversations after uploading a project zip", async ({ - page, -}) => { - const fileInput = page.getByLabel("Upload a .zip"); - const filePath = path.join(dirname, "fixtures/project.zip"); - await fileInput.setInputFiles(filePath); - - await page.waitForURL(/\/conversations\/\d+/); -}); - -test("should redirect to /conversations after selecting a repo", async ({ - page, -}) => { - // enter a github token to view the repositories - const connectToGitHubButton = page.getByRole("button", { - name: /connect to github/i, - }); - await connectToGitHubButton.click(); - const tokenInput = page.getByLabel(/github token\*/i); - await tokenInput.fill("fake-token"); - - const submitButton = page.getByTestId("connect-to-github"); - await submitButton.click(); - - // select a repository - const repoDropdown = page.getByLabel(/github repository/i); - await repoDropdown.click(); - - const repoItem = page.getByTestId("github-repo-item").first(); - await repoItem.click(); - - await page.waitForURL(/\/conversations\/\d+/); -}); - -// FIXME: This fails because the MSW WS mocks change state too quickly, -// missing the OPENING status where the initial query is rendered. -test.skip("should redirect the user to /conversation with their initial query after selecting a project", async ({ - page, -}) => { - // enter query - const testQuery = "this is my test query"; - const textbox = page.getByPlaceholder(/what do you want to build/i); - expect(textbox).not.toBeNull(); - await textbox.fill(testQuery); - - const fileInput = page.getByLabel("Upload a .zip"); - const filePath = path.join(dirname, "fixtures/project.zip"); - await fileInput.setInputFiles(filePath); - - await page.waitForURL("/conversation"); - - // get user message - const userMessage = page.getByTestId("user-message"); - expect(await userMessage.textContent()).toBe(testQuery); -}); diff --git a/frontend/tests/repo-selection-form.test.tsx b/frontend/tests/repo-selection-form.test.tsx deleted file mode 100644 index 24666d49fc..0000000000 --- a/frontend/tests/repo-selection-form.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { RepositorySelectionForm } from "../src/components/features/home/repo-selection-form"; -import { useUserRepositories } from "../src/hooks/query/use-user-repositories"; -import { useRepositoryBranches } from "../src/hooks/query/use-repository-branches"; -import { useCreateConversation } from "../src/hooks/mutation/use-create-conversation"; -import { useIsCreatingConversation } from "../src/hooks/use-is-creating-conversation"; - -// Mock the hooks -vi.mock("../src/hooks/query/use-user-repositories"); -vi.mock("../src/hooks/query/use-repository-branches"); -vi.mock("../src/hooks/mutation/use-create-conversation"); -vi.mock("../src/hooks/use-is-creating-conversation"); -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -describe("RepositorySelectionForm", () => { - const mockOnRepoSelection = vi.fn(); - - beforeEach(() => { - vi.resetAllMocks(); - - // Mock the hooks with default values - (useUserRepositories as any).mockReturnValue({ - data: [ - { id: "1", full_name: "test/repo1" }, - { id: "2", full_name: "test/repo2" } - ], - isLoading: false, - isError: false, - }); - - (useRepositoryBranches as any).mockReturnValue({ - data: [ - { name: "main" }, - { name: "develop" } - ], - isLoading: false, - isError: false, - }); - - (useCreateConversation as any).mockReturnValue({ - mutate: vi.fn(() => (useIsCreatingConversation as any).mockReturnValue(true)), - isPending: false, - isSuccess: false, - }); - - (useIsCreatingConversation as any).mockReturnValue(false); - }); - - it("should clear selected branch when input is empty", async () => { - render(); - - // First select a repository to enable the branch dropdown - const repoDropdown = screen.getByTestId("repository-dropdown"); - fireEvent.change(repoDropdown, { target: { value: "test/repo1" } }); - - // Get the branch dropdown and verify it's enabled - const branchDropdown = screen.getByTestId("branch-dropdown"); - expect(branchDropdown).not.toBeDisabled(); - - // Simulate deleting all text in the branch input - fireEvent.change(branchDropdown, { target: { value: "" } }); - - // Verify the branch input is cleared (no selected branch) - expect(branchDropdown).toHaveValue(""); - }); - - it("should clear selected branch when input contains only whitespace", async () => { - render(); - - // First select a repository to enable the branch dropdown - const repoDropdown = screen.getByTestId("repository-dropdown"); - fireEvent.change(repoDropdown, { target: { value: "test/repo1" } }); - - // Get the branch dropdown and verify it's enabled - const branchDropdown = screen.getByTestId("branch-dropdown"); - expect(branchDropdown).not.toBeDisabled(); - - // Simulate entering only whitespace in the branch input - fireEvent.change(branchDropdown, { target: { value: " " } }); - - // Verify the branch input is cleared (no selected branch) - expect(branchDropdown).toHaveValue(""); - }); - - it("should keep branch empty after being cleared even with auto-selection", async () => { - render(); - - // First select a repository to enable the branch dropdown - const repoDropdown = screen.getByTestId("repository-dropdown"); - fireEvent.change(repoDropdown, { target: { value: "test/repo1" } }); - - // Get the branch dropdown and verify it's enabled - const branchDropdown = screen.getByTestId("branch-dropdown"); - expect(branchDropdown).not.toBeDisabled(); - - // The branch should be auto-selected to "main" initially - expect(branchDropdown).toHaveValue("main"); - - // Simulate deleting all text in the branch input - fireEvent.change(branchDropdown, { target: { value: "" } }); - - // Verify the branch input is cleared (no selected branch) - expect(branchDropdown).toHaveValue(""); - - // Trigger a re-render by changing something else - fireEvent.change(repoDropdown, { target: { value: "test/repo2" } }); - fireEvent.change(repoDropdown, { target: { value: "test/repo1" } }); - - // The branch should be auto-selected to "main" again after repo change - expect(branchDropdown).toHaveValue("main"); - - // Clear it again - fireEvent.change(branchDropdown, { target: { value: "" } }); - - // Verify it stays empty - expect(branchDropdown).toHaveValue(""); - - // Simulate a component update without changing repos - // This would normally trigger the useEffect if our fix wasn't working - fireEvent.blur(branchDropdown); - - // Verify it still stays empty - expect(branchDropdown).toHaveValue(""); - }); -}); diff --git a/frontend/tests/settings.spec.ts b/frontend/tests/settings.spec.ts deleted file mode 100644 index e4c4ce3b35..0000000000 --- a/frontend/tests/settings.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import test, { expect } from "@playwright/test"; - -test("do not navigate to /settings/billing if not SaaS mode", async ({ - page, -}) => { - await page.goto("/settings/billing"); - await expect(page.getByTestId("settings-screen")).toBeVisible(); - expect(page.url()).toBe("http://localhost:3001/settings"); -}); - -// FIXME: This test is failing because the config is not being set to SaaS mode -// since MSW is always returning APP_MODE as "oss" -test.skip("navigate to /settings/billing if SaaS mode", async ({ page }) => { - await page.goto("/settings/billing"); - await expect(page.getByTestId("settings-screen")).toBeVisible(); - expect(page.url()).toBe("http://localhost:3001/settings/billing"); -}); diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py index 85e5f88cbc..9dd814e9cf 100644 --- a/openhands/agenthub/codeact_agent/codeact_agent.py +++ b/openhands/agenthub/codeact_agent/codeact_agent.py @@ -194,9 +194,12 @@ class CodeActAgent(Agent): # event we'll just return that instead of an action. The controller will # immediately ask the agent to step again with the new view. condensed_history: list[Event] = [] + # Track which event IDs have been forgotten/condensed + forgotten_event_ids: set[int] = set() match self.condenser.condensed_history(state): - case View(events=events): + case View(events=events, forgotten_event_ids=forgotten_ids): condensed_history = events + forgotten_event_ids = forgotten_ids case Condensation(action=condensation_action): return condensation_action @@ -206,7 +209,9 @@ class CodeActAgent(Agent): ) initial_user_message = self._get_initial_user_message(state.history) - messages = self._get_messages(condensed_history, initial_user_message) + messages = self._get_messages( + condensed_history, initial_user_message, forgotten_event_ids + ) params: dict = { 'messages': messages, } @@ -245,7 +250,10 @@ class CodeActAgent(Agent): return initial_user_message def _get_messages( - self, events: list[Event], initial_user_message: MessageAction + self, + events: list[Event], + initial_user_message: MessageAction, + forgotten_event_ids: set[int], ) -> list[Message]: """Constructs the message history for the LLM conversation. @@ -284,6 +292,7 @@ class CodeActAgent(Agent): messages = self.conversation_memory.process_events( condensed_history=events, initial_user_action=initial_user_message, + forgotten_event_ids=forgotten_event_ids, max_message_chars=self.llm.config.max_message_chars, vision_is_active=self.llm.vision_is_active(), ) diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py index 0c7ef99ce5..1c0ba914cb 100644 --- a/openhands/app_server/app_conversation/app_conversation_models.py +++ b/openhands/app_server/app_conversation/app_conversation_models.py @@ -1,5 +1,6 @@ from datetime import datetime from enum import Enum +from typing import Literal from uuid import UUID, uuid4 from pydantic import BaseModel, Field @@ -161,3 +162,12 @@ class AppConversationStartTask(BaseModel): class AppConversationStartTaskPage(BaseModel): items: list[AppConversationStartTask] next_page_id: str | None = None + + +class SkillResponse(BaseModel): + """Response model for skills endpoint.""" + + name: str + type: Literal['repo', 'knowledge'] + content: str + triggers: list[str] = [] diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index bf82840e96..a7a0414e31 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -1,11 +1,12 @@ """Sandboxed Conversation router for OpenHands Server.""" import asyncio +import logging import os import sys import tempfile from datetime import datetime -from typing import Annotated, AsyncGenerator +from typing import Annotated, AsyncGenerator, Literal from uuid import UUID import httpx @@ -28,8 +29,8 @@ else: return await async_iterator.__anext__() -from fastapi import APIRouter, Query, Request -from fastapi.responses import StreamingResponse +from fastapi import APIRouter, Query, Request, status +from fastapi.responses import JSONResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from openhands.app_server.app_conversation.app_conversation_models import ( @@ -39,10 +40,14 @@ from openhands.app_server.app_conversation.app_conversation_models import ( AppConversationStartTask, AppConversationStartTaskPage, AppConversationStartTaskSortOrder, + SkillResponse, ) from openhands.app_server.app_conversation.app_conversation_service import ( AppConversationService, ) +from openhands.app_server.app_conversation.app_conversation_service_base import ( + AppConversationServiceBase, +) from openhands.app_server.app_conversation.app_conversation_start_task_service import ( AppConversationStartTaskService, ) @@ -65,9 +70,11 @@ from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService from openhands.app_server.utils.docker_utils import ( replace_localhost_hostname_for_docker, ) +from openhands.sdk.context.skills import KeywordTrigger, TaskTrigger from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace router = APIRouter(prefix='/app-conversations', tags=['Conversations']) +logger = logging.getLogger(__name__) app_conversation_service_dependency = depends_app_conversation_service() app_conversation_start_task_service_dependency = ( depends_app_conversation_start_task_service() @@ -400,6 +407,145 @@ async def read_conversation_file( return '' +@router.get('/{conversation_id}/skills') +async def get_conversation_skills( + conversation_id: UUID, + app_conversation_service: AppConversationService = ( + app_conversation_service_dependency + ), + sandbox_service: SandboxService = sandbox_service_dependency, + sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency, +) -> JSONResponse: + """Get all skills associated with the conversation. + + This endpoint returns all skills that are loaded for the v1 conversation. + Skills are loaded from multiple sources: + - Sandbox skills (exposed URLs) + - Global skills (OpenHands/skills/) + - User skills (~/.openhands/skills/) + - Organization skills (org/.openhands repository) + - Repository skills (repo/.openhands/skills/ or .openhands/microagents/) + + Returns: + JSONResponse: A JSON response containing the list of skills. + """ + try: + # Get the conversation info + conversation = await app_conversation_service.get_app_conversation( + conversation_id + ) + if not conversation: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': f'Conversation {conversation_id} not found'}, + ) + + # Get the sandbox info + sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id) + if not sandbox or sandbox.status != SandboxStatus.RUNNING: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={ + 'error': f'Sandbox not found or not running for conversation {conversation_id}' + }, + ) + + # Get the sandbox spec to find the working directory + sandbox_spec = await sandbox_spec_service.get_sandbox_spec( + sandbox.sandbox_spec_id + ) + if not sandbox_spec: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': 'Sandbox spec not found'}, + ) + + # Get the agent server URL + if not sandbox.exposed_urls: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': 'No agent server URL found for sandbox'}, + ) + + agent_server_url = None + for exposed_url in sandbox.exposed_urls: + if exposed_url.name == AGENT_SERVER: + agent_server_url = exposed_url.url + break + + if not agent_server_url: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': 'Agent server URL not found in sandbox'}, + ) + + agent_server_url = replace_localhost_hostname_for_docker(agent_server_url) + + # Create remote workspace + remote_workspace = AsyncRemoteWorkspace( + host=agent_server_url, + api_key=sandbox.session_api_key, + working_dir=sandbox_spec.working_dir, + ) + + # Load skills from all sources + logger.info(f'Loading skills for conversation {conversation_id}') + + # Prefer the shared loader to avoid duplication; otherwise return empty list. + all_skills: list = [] + if isinstance(app_conversation_service, AppConversationServiceBase): + all_skills = await app_conversation_service.load_and_merge_all_skills( + sandbox, + remote_workspace, + conversation.selected_repository, + sandbox_spec.working_dir, + ) + + logger.info( + f'Loaded {len(all_skills)} skills for conversation {conversation_id}: ' + f'{[s.name for s in all_skills]}' + ) + + # Transform skills to response format + skills_response = [] + for skill in all_skills: + # Determine type based on trigger + skill_type: Literal['repo', 'knowledge'] + if skill.trigger is None: + skill_type = 'repo' + else: + skill_type = 'knowledge' + + # Extract triggers + triggers = [] + if isinstance(skill.trigger, (KeywordTrigger, TaskTrigger)): + if hasattr(skill.trigger, 'keywords'): + triggers = skill.trigger.keywords + elif hasattr(skill.trigger, 'triggers'): + triggers = skill.trigger.triggers + + skills_response.append( + SkillResponse( + name=skill.name, + type=skill_type, + content=skill.content, + triggers=triggers, + ) + ) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content={'skills': [s.model_dump() for s in skills_response]}, + ) + + except Exception as e: + logger.error(f'Error getting skills for conversation {conversation_id}: {e}') + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={'error': f'Error getting skills: {str(e)}'}, + ) + + async def _consume_remaining( async_iter, db_session: AsyncSession, httpx_client: httpx.AsyncClient ): diff --git a/openhands/app_server/app_conversation/app_conversation_service_base.py b/openhands/app_server/app_conversation/app_conversation_service_base.py index f524167524..aa6add73fe 100644 --- a/openhands/app_server/app_conversation/app_conversation_service_base.py +++ b/openhands/app_server/app_conversation/app_conversation_service_base.py @@ -4,7 +4,11 @@ import tempfile from abc import ABC from dataclasses import dataclass from pathlib import Path -from typing import AsyncGenerator +from typing import TYPE_CHECKING, AsyncGenerator +from uuid import UUID + +if TYPE_CHECKING: + import httpx import base62 @@ -18,6 +22,7 @@ from openhands.app_server.app_conversation.app_conversation_service import ( ) from openhands.app_server.app_conversation.skill_loader import ( load_global_skills, + load_org_skills, load_repo_skills, load_sandbox_skills, merge_skills, @@ -29,6 +34,14 @@ from openhands.sdk.context.agent_context import AgentContext from openhands.sdk.context.condenser import LLMSummarizingCondenser from openhands.sdk.context.skills import load_user_skills from openhands.sdk.llm import LLM +from openhands.sdk.security.analyzer import SecurityAnalyzerBase +from openhands.sdk.security.confirmation_policy import ( + AlwaysConfirm, + ConfirmationPolicyBase, + ConfirmRisky, + NeverConfirm, +) +from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace _logger = logging.getLogger(__name__) @@ -45,7 +58,7 @@ class AppConversationServiceBase(AppConversationService, ABC): init_git_in_empty_workspace: bool user_context: UserContext - async def _load_and_merge_all_skills( + async def load_and_merge_all_skills( self, sandbox: SandboxInfo, remote_workspace: AsyncRemoteWorkspace, @@ -82,13 +95,20 @@ class AppConversationServiceBase(AppConversationService, ABC): except Exception as e: _logger.warning(f'Failed to load user skills: {str(e)}') user_skills = [] + + # Load organization-level skills + org_skills = await load_org_skills( + remote_workspace, selected_repository, working_dir, self.user_context + ) + repo_skills = await load_repo_skills( remote_workspace, selected_repository, working_dir ) # Merge all skills (later lists override earlier ones) + # Precedence: sandbox < global < user < org < repo all_skills = merge_skills( - [sandbox_skills, global_skills, user_skills, repo_skills] + [sandbox_skills, global_skills, user_skills, org_skills, repo_skills] ) _logger.info( @@ -149,7 +169,7 @@ class AppConversationServiceBase(AppConversationService, ABC): Updated agent with skills loaded into context """ # Load and merge all skills - all_skills = await self._load_and_merge_all_skills( + all_skills = await self.load_and_merge_all_skills( sandbox, remote_workspace, selected_repository, working_dir ) @@ -178,7 +198,7 @@ class AppConversationServiceBase(AppConversationService, ABC): task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS yield task - await self._load_and_merge_all_skills( + await self.load_and_merge_all_skills( sandbox, workspace, task.request.selected_repository, @@ -379,3 +399,95 @@ class AppConversationServiceBase(AppConversationService, ABC): condenser = LLMSummarizingCondenser(**condenser_kwargs) return condenser + + def _create_security_analyzer_from_string( + self, security_analyzer_str: str | None + ) -> SecurityAnalyzerBase | None: + """Convert security analyzer string from settings to SecurityAnalyzerBase instance. + + Args: + security_analyzer_str: String value from settings. Valid values: + - "llm" -> LLMSecurityAnalyzer + - "none" or None -> None + - Other values -> None (unsupported analyzers are ignored) + + Returns: + SecurityAnalyzerBase instance or None + """ + if not security_analyzer_str or security_analyzer_str.lower() == 'none': + return None + + if security_analyzer_str.lower() == 'llm': + return LLMSecurityAnalyzer() + + # For unknown values, log a warning and return None + _logger.warning( + f'Unknown security analyzer value: {security_analyzer_str}. ' + 'Supported values: "llm", "none". Defaulting to None.' + ) + return None + + def _select_confirmation_policy( + self, confirmation_mode: bool, security_analyzer: str | None + ) -> ConfirmationPolicyBase: + """Choose confirmation policy using only mode flag and analyzer string.""" + if not confirmation_mode: + return NeverConfirm() + + analyzer_kind = (security_analyzer or '').lower() + if analyzer_kind == 'llm': + return ConfirmRisky() + + return AlwaysConfirm() + + async def _set_security_analyzer_from_settings( + self, + agent_server_url: str, + session_api_key: str | None, + conversation_id: UUID, + security_analyzer_str: str | None, + httpx_client: 'httpx.AsyncClient', + ) -> None: + """Set security analyzer on conversation using only the analyzer string. + + Args: + agent_server_url: URL of the agent server + session_api_key: Session API key for authentication + conversation_id: ID of the conversation to update + security_analyzer_str: String value from settings + httpx_client: HTTP client for making API requests + """ + + if session_api_key is None: + return + + security_analyzer = self._create_security_analyzer_from_string( + security_analyzer_str + ) + + # Only make API call if we have a security analyzer to set + # (None is the default, so we can skip the call if it's None) + if security_analyzer is None: + return + + try: + # Prepare the request payload + payload = {'security_analyzer': security_analyzer.model_dump()} + + # Call agent server API to set security analyzer + response = await httpx_client.post( + f'{agent_server_url}/api/conversations/{conversation_id}/security_analyzer', + json=payload, + headers={'X-Session-API-Key': session_api_key}, + timeout=30.0, + ) + response.raise_for_status() + _logger.info( + f'Successfully set security analyzer for conversation {conversation_id}' + ) + except Exception as e: + # Log error but don't fail conversation creation + _logger.warning( + f'Failed to set security analyzer for conversation {conversation_id}: {e}', + exc_info=True, + ) 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 2f04bf9a71..a8d490489c 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 @@ -13,7 +13,6 @@ from pydantic import Field, SecretStr, TypeAdapter from openhands.agent_server.models import ( ConversationInfo, - NeverConfirm, SendMessageRequest, StartConversationRequest, ) @@ -70,9 +69,8 @@ from openhands.app_server.utils.docker_utils import ( from openhands.experiments.experiment_manager import ExperimentManagerImpl from openhands.integrations.provider import ProviderType from openhands.sdk import Agent, AgentContext, LocalWorkspace -from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret from openhands.sdk.llm import LLM -from openhands.sdk.security.confirmation_policy import AlwaysConfirm +from openhands.sdk.secret import LookupSecret, StaticSecret from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.server.types import AppMode from openhands.tools.preset.default import ( @@ -272,7 +270,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): user_id = await self.user_context.get_user_id() app_conversation_info = AppConversationInfo( id=info.id, - title=f'Conversation {info.id.hex}', + title=f'Conversation {info.id.hex[:5]}', sandbox_id=sandbox.id, created_by_user_id=user_id, llm_model=start_conversation_request.agent.llm.model, @@ -308,6 +306,16 @@ class LiveStatusAppConversationService(AppConversationServiceBase): ) ) + # Set security analyzer from settings + user = await self.user_context.get_user_info() + await self._set_security_analyzer_from_settings( + agent_server_url, + sandbox.session_api_key, + info.id, + user.security_analyzer, + self.httpx_client, + ) + # Update the start task task.status = AppConversationStartTaskStatus.READY task.app_conversation_id = info.id @@ -577,6 +585,204 @@ class LiveStatusAppConversationService(AppConversationServiceBase): return secrets + def _configure_llm(self, user: UserInfo, llm_model: str | None) -> LLM: + """Configure LLM settings. + + Args: + user: User information containing LLM preferences + llm_model: Optional specific model to use, falls back to user default + + Returns: + Configured LLM instance + """ + model = llm_model or user.llm_model + base_url = user.llm_base_url + if model and model.startswith('openhands/'): + base_url = user.llm_base_url or self.openhands_provider_base_url + + return LLM( + model=model, + base_url=base_url, + api_key=user.llm_api_key, + usage_id='agent', + ) + + async def _get_tavily_api_key(self, user: UserInfo) -> str | None: + """Get Tavily search API key, prioritizing user's key over service key. + + Args: + user: User information + + Returns: + Tavily API key if available, None otherwise + """ + # Get the actual API key values, prioritizing user's key over service key + user_search_key = None + if user.search_api_key: + key_value = user.search_api_key.get_secret_value() + if key_value and key_value.strip(): + user_search_key = key_value + + service_tavily_key = None + if self.tavily_api_key: + # tavily_api_key is already a string (extracted in the factory method) + if self.tavily_api_key.strip(): + service_tavily_key = self.tavily_api_key + + return user_search_key or service_tavily_key + + async def _add_system_mcp_servers( + self, mcp_servers: dict[str, Any], user: UserInfo + ) -> None: + """Add system-generated MCP servers (default OpenHands server and Tavily). + + Args: + mcp_servers: Dictionary to add servers to + user: User information for API keys + """ + if not self.web_url: + return + + # Add default OpenHands MCP server + mcp_url = f'{self.web_url}/mcp/mcp' + mcp_servers['default'] = {'url': mcp_url} + + # Add API key if available + mcp_api_key = await self.user_context.get_mcp_api_key() + if mcp_api_key: + mcp_servers['default']['headers'] = { + 'X-Session-API-Key': mcp_api_key, + } + + # Add Tavily search if API key is available + tavily_api_key = await self._get_tavily_api_key(user) + if tavily_api_key: + _logger.info('Adding search engine to MCP config') + mcp_servers['tavily'] = { + 'url': f'https://mcp.tavily.com/mcp/?tavilyApiKey={tavily_api_key}' + } + else: + _logger.info('No search engine API key found, skipping search engine') + + def _add_custom_sse_servers( + self, mcp_servers: dict[str, Any], sse_servers: list + ) -> None: + """Add custom SSE MCP servers from user configuration. + + Args: + mcp_servers: Dictionary to add servers to + sse_servers: List of SSE server configurations + """ + for sse_server in sse_servers: + server_config = { + 'url': sse_server.url, + 'transport': 'sse', + } + if sse_server.api_key: + server_config['headers'] = { + 'Authorization': f'Bearer {sse_server.api_key}' + } + + # Generate unique server name using UUID + # TODO: Let the users specify the server name + server_name = f'sse_{uuid4().hex[:8]}' + mcp_servers[server_name] = server_config + _logger.debug( + f'Added custom SSE server: {server_name} for {sse_server.url}' + ) + + def _add_custom_shttp_servers( + self, mcp_servers: dict[str, Any], shttp_servers: list + ) -> None: + """Add custom SHTTP MCP servers from user configuration. + + Args: + mcp_servers: Dictionary to add servers to + shttp_servers: List of SHTTP server configurations + """ + for shttp_server in shttp_servers: + server_config = { + 'url': shttp_server.url, + 'transport': 'streamable-http', + } + if shttp_server.api_key: + server_config['headers'] = { + 'Authorization': f'Bearer {shttp_server.api_key}' + } + if shttp_server.timeout: + server_config['timeout'] = shttp_server.timeout + + # Generate unique server name using UUID + # TODO: Let the users specify the server name + server_name = f'shttp_{uuid4().hex[:8]}' + mcp_servers[server_name] = server_config + _logger.debug( + f'Added custom SHTTP server: {server_name} for {shttp_server.url}' + ) + + def _add_custom_stdio_servers( + self, mcp_servers: dict[str, Any], stdio_servers: list + ) -> None: + """Add custom STDIO MCP servers from user configuration. + + Args: + mcp_servers: Dictionary to add servers to + stdio_servers: List of STDIO server configurations + """ + for stdio_server in stdio_servers: + server_config = { + 'command': stdio_server.command, + 'args': stdio_server.args, + } + if stdio_server.env: + server_config['env'] = stdio_server.env + + # STDIO servers have an explicit name field + mcp_servers[stdio_server.name] = server_config + _logger.debug(f'Added custom STDIO server: {stdio_server.name}') + + def _merge_custom_mcp_config( + self, mcp_servers: dict[str, Any], user: UserInfo + ) -> None: + """Merge custom MCP configuration from user settings. + + Args: + mcp_servers: Dictionary to add servers to + user: User information containing custom MCP config + """ + if not user.mcp_config: + return + + try: + sse_count = len(user.mcp_config.sse_servers) + shttp_count = len(user.mcp_config.shttp_servers) + stdio_count = len(user.mcp_config.stdio_servers) + + _logger.info( + f'Loading custom MCP config from user settings: ' + f'{sse_count} SSE, {shttp_count} SHTTP, {stdio_count} STDIO servers' + ) + + # Add each type of custom server + self._add_custom_sse_servers(mcp_servers, user.mcp_config.sse_servers) + self._add_custom_shttp_servers(mcp_servers, user.mcp_config.shttp_servers) + self._add_custom_stdio_servers(mcp_servers, user.mcp_config.stdio_servers) + + _logger.info( + f'Successfully merged custom MCP config: added {sse_count} SSE, ' + f'{shttp_count} SHTTP, and {stdio_count} STDIO servers' + ) + + except Exception as e: + _logger.error( + f'Error loading custom MCP config from user settings: {e}', + exc_info=True, + ) + # Continue with system config only, don't fail conversation startup + _logger.warning( + 'Continuing with system-generated MCP config only due to custom config error' + ) + async def _configure_llm_and_mcp( self, user: UserInfo, llm_model: str | None ) -> tuple[LLM, dict]: @@ -590,56 +796,20 @@ class LiveStatusAppConversationService(AppConversationServiceBase): Tuple of (configured LLM instance, MCP config dictionary) """ # Configure LLM - model = llm_model or user.llm_model - base_url = user.llm_base_url - if model and model.startswith('openhands/'): - base_url = user.llm_base_url or self.openhands_provider_base_url - llm = LLM( - model=model, - base_url=base_url, - api_key=user.llm_api_key, - usage_id='agent', - ) + llm = self._configure_llm(user, llm_model) - # Configure MCP - mcp_config: dict[str, Any] = {} - if self.web_url: - mcp_url = f'{self.web_url}/mcp/mcp' - mcp_config = { - 'default': { - 'url': mcp_url, - } - } + # Configure MCP - SDK expects format: {'mcpServers': {'server_name': {...}}} + mcp_servers: dict[str, Any] = {} - # Add API key if available - mcp_api_key = await self.user_context.get_mcp_api_key() - if mcp_api_key: - mcp_config['default']['headers'] = { - 'X-Session-API-Key': mcp_api_key, - } + # Add system-generated servers (default + tavily) + await self._add_system_mcp_servers(mcp_servers, user) - # Get the actual API key values, prioritizing user's key over service key - user_search_key = None - if user.search_api_key: - key_value = user.search_api_key.get_secret_value() - if key_value and key_value.strip(): - user_search_key = key_value + # Merge custom servers from user settings + self._merge_custom_mcp_config(mcp_servers, user) - service_tavily_key = None - if self.tavily_api_key: - # tavily_api_key is already a string (extracted in the factory method) - if self.tavily_api_key.strip(): - service_tavily_key = self.tavily_api_key - - tavily_api_key = user_search_key or service_tavily_key - - if tavily_api_key: - _logger.info('Adding search engine to MCP config') - mcp_config['tavily'] = { - 'url': f'https://mcp.tavily.com/mcp/?tavilyApiKey={tavily_api_key}' - } - else: - _logger.info('No search engine API key found, skipping search engine') + # Wrap in the mcpServers structure required by the SDK + mcp_config = {'mcpServers': mcp_servers} if mcp_servers else {} + _logger.info(f'Final MCP configuration: {mcp_config}') return llm, mcp_config @@ -650,6 +820,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): system_message_suffix: str | None, mcp_config: dict, condenser_max_size: int | None, + secrets: dict | None = None, ) -> Agent: """Create an agent with appropriate tools and context based on agent type. @@ -659,6 +830,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): system_message_suffix: Optional suffix for system messages mcp_config: MCP configuration dictionary condenser_max_size: condenser_max_size setting + secrets: Optional dictionary of secrets for authentication Returns: Configured Agent instance with context @@ -687,7 +859,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase): ) # Add agent context - agent_context = AgentContext(system_message_suffix=system_message_suffix) + agent_context = AgentContext( + system_message_suffix=system_message_suffix, secrets=secrets + ) agent = agent.model_copy(update={'agent_context': agent_context}) return agent @@ -745,8 +919,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase): conversation_id=conversation_id, agent=agent, workspace=workspace, - confirmation_policy=( - AlwaysConfirm() if user.confirmation_mode else NeverConfirm() + confirmation_policy=self._select_confirmation_policy( + bool(user.confirmation_mode), user.security_analyzer ), initial_message=initial_message, secrets=secrets, @@ -784,7 +958,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase): # Create agent with context agent = self._create_agent_with_context( - llm, agent_type, system_message_suffix, mcp_config, user.condenser_max_size + llm, + agent_type, + system_message_suffix, + mcp_config, + user.condenser_max_size, + secrets=secrets, ) # Finalize and return the conversation request diff --git a/openhands/app_server/app_conversation/skill_loader.py b/openhands/app_server/app_conversation/skill_loader.py index d8fca7cfc3..d237ff0542 100644 --- a/openhands/app_server/app_conversation/skill_loader.py +++ b/openhands/app_server/app_conversation/skill_loader.py @@ -14,6 +14,9 @@ from pathlib import Path import openhands from openhands.app_server.sandbox.sandbox_models import SandboxInfo +from openhands.app_server.user.user_context import UserContext +from openhands.integrations.provider import ProviderType +from openhands.integrations.service_types import AuthenticationError from openhands.sdk.context.skills import Skill from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace @@ -119,6 +122,96 @@ def _determine_repo_root(working_dir: str, selected_repository: str | None) -> s return working_dir +async def _is_gitlab_repository(repo_name: str, user_context: UserContext) -> bool: + """Check if a repository is hosted on GitLab. + + Args: + repo_name: Repository name (e.g., "gitlab.com/org/repo" or "org/repo") + user_context: UserContext to access provider handler + + Returns: + True if the repository is hosted on GitLab, False otherwise + """ + try: + provider_handler = await user_context.get_provider_handler() # type: ignore[attr-defined] + repository = await provider_handler.verify_repo_provider(repo_name) + return repository.git_provider == ProviderType.GITLAB + except Exception: + # If we can't determine the provider, assume it's not GitLab + # This is a safe fallback since we'll just use the default .openhands + return False + + +async def _is_azure_devops_repository( + repo_name: str, user_context: UserContext +) -> bool: + """Check if a repository is hosted on Azure DevOps. + + Args: + repo_name: Repository name (e.g., "org/project/repo") + user_context: UserContext to access provider handler + + Returns: + True if the repository is hosted on Azure DevOps, False otherwise + """ + try: + provider_handler = await user_context.get_provider_handler() # type: ignore[attr-defined] + repository = await provider_handler.verify_repo_provider(repo_name) + return repository.git_provider == ProviderType.AZURE_DEVOPS + except Exception: + # If we can't determine the provider, assume it's not Azure DevOps + return False + + +async def _determine_org_repo_path( + selected_repository: str, user_context: UserContext +) -> tuple[str, str]: + """Determine the organization repository path and organization name. + + Args: + selected_repository: Repository name (e.g., 'owner/repo' or 'org/project/repo') + user_context: UserContext to access provider handler + + Returns: + Tuple of (org_repo_path, org_name) where: + - org_repo_path: Full path to org-level config repo + - org_name: Organization name extracted from repository + + Examples: + - GitHub/Bitbucket: ('owner/.openhands', 'owner') + - GitLab: ('owner/openhands-config', 'owner') + - Azure DevOps: ('org/openhands-config/openhands-config', 'org') + """ + repo_parts = selected_repository.split('/') + + # Determine repository type + is_azure_devops = await _is_azure_devops_repository( + selected_repository, user_context + ) + is_gitlab = await _is_gitlab_repository(selected_repository, user_context) + + # Extract the org/user name + # Azure DevOps format: org/project/repo (3 parts) - extract org (first part) + # GitHub/GitLab/Bitbucket format: owner/repo (2 parts) - extract owner (first part) + if is_azure_devops and len(repo_parts) >= 3: + org_name = repo_parts[0] # Get org from org/project/repo + else: + org_name = repo_parts[-2] # Get owner from owner/repo + + # For GitLab and Azure DevOps, use openhands-config (since .openhands is not a valid repo name) + # For other providers, use .openhands + if is_gitlab: + org_openhands_repo = f'{org_name}/openhands-config' + elif is_azure_devops: + # Azure DevOps format: org/project/repo + # For org-level config, use: org/openhands-config/openhands-config + org_openhands_repo = f'{org_name}/openhands-config/openhands-config' + else: + org_openhands_repo = f'{org_name}/.openhands' + + return org_openhands_repo, org_name + + async def _read_file_from_workspace( workspace: AsyncRemoteWorkspace, file_path: str, working_dir: str ) -> str | None: @@ -322,6 +415,248 @@ async def load_repo_skills( return [] +def _validate_repository_for_org_skills(selected_repository: str) -> bool: + """Validate that the repository path has sufficient parts for org skills. + + Args: + selected_repository: Repository name (e.g., 'owner/repo') + + Returns: + True if repository is valid for org skills loading, False otherwise + """ + repo_parts = selected_repository.split('/') + if len(repo_parts) < 2: + _logger.warning( + f'Repository path has insufficient parts ({len(repo_parts)} < 2), skipping org-level skills' + ) + return False + return True + + +async def _get_org_repository_url( + org_openhands_repo: str, user_context: UserContext +) -> str | None: + """Get authenticated Git URL for organization repository. + + Args: + org_openhands_repo: Organization repository path + user_context: UserContext to access authentication + + Returns: + Authenticated Git URL if successful, None otherwise + """ + try: + remote_url = await user_context.get_authenticated_git_url(org_openhands_repo) + return remote_url + except AuthenticationError as e: + _logger.debug( + f'org-level skill directory {org_openhands_repo} not found: {str(e)}' + ) + return None + except Exception as e: + _logger.debug( + f'Failed to get authenticated URL for {org_openhands_repo}: {str(e)}' + ) + return None + + +async def _clone_org_repository( + workspace: AsyncRemoteWorkspace, + remote_url: str, + org_repo_dir: str, + working_dir: str, + org_openhands_repo: str, +) -> bool: + """Clone organization repository to temporary directory. + + Args: + workspace: AsyncRemoteWorkspace to execute commands + remote_url: Authenticated Git URL + org_repo_dir: Temporary directory path for cloning + working_dir: Working directory for command execution + org_openhands_repo: Organization repository path (for logging) + + Returns: + True if clone successful, False otherwise + """ + _logger.debug(f'Creating temporary directory for org repo: {org_repo_dir}') + + # Clone the repo (shallow clone for efficiency) + clone_cmd = f'GIT_TERMINAL_PROMPT=0 git clone --depth 1 {remote_url} {org_repo_dir}' + _logger.info('Executing clone command for org-level repo') + + result = await workspace.execute_command(clone_cmd, working_dir, timeout=120.0) + + if result.exit_code != 0: + _logger.info( + f'No org-level skills found at {org_openhands_repo} (exit_code: {result.exit_code})' + ) + _logger.debug(f'Clone command output: {result.stderr}') + return False + + _logger.info(f'Successfully cloned org-level skills from {org_openhands_repo}') + return True + + +async def _load_skills_from_org_directories( + workspace: AsyncRemoteWorkspace, org_repo_dir: str, working_dir: str +) -> tuple[list[Skill], list[Skill]]: + """Load skills from both skills/ and microagents/ directories in org repo. + + Args: + workspace: AsyncRemoteWorkspace to execute commands + org_repo_dir: Path to cloned organization repository + working_dir: Working directory for command execution + + Returns: + Tuple of (skills_dir_skills, microagents_dir_skills) + """ + skills_dir = f'{org_repo_dir}/skills' + skills_dir_skills = await _find_and_load_skill_md_files( + workspace, skills_dir, working_dir + ) + + microagents_dir = f'{org_repo_dir}/microagents' + microagents_dir_skills = await _find_and_load_skill_md_files( + workspace, microagents_dir, working_dir + ) + + return skills_dir_skills, microagents_dir_skills + + +def _merge_org_skills_with_precedence( + skills_dir_skills: list[Skill], microagents_dir_skills: list[Skill] +) -> list[Skill]: + """Merge skills from skills/ and microagents/ with proper precedence. + + Precedence: skills/ > microagents/ (skills/ overrides microagents/ for same name) + + Args: + skills_dir_skills: Skills loaded from skills/ directory + microagents_dir_skills: Skills loaded from microagents/ directory + + Returns: + Merged list of skills with proper precedence applied + """ + skills_by_name = {} + for skill in microagents_dir_skills + skills_dir_skills: + # Later sources (skills/) override earlier ones (microagents/) + if skill.name not in skills_by_name: + skills_by_name[skill.name] = skill + else: + _logger.debug( + f'Overriding org skill "{skill.name}" from microagents/ with skills/' + ) + skills_by_name[skill.name] = skill + + return list(skills_by_name.values()) + + +async def _cleanup_org_repository( + workspace: AsyncRemoteWorkspace, org_repo_dir: str, working_dir: str +) -> None: + """Clean up cloned organization repository directory. + + Args: + workspace: AsyncRemoteWorkspace to execute commands + org_repo_dir: Path to cloned organization repository + working_dir: Working directory for command execution + """ + cleanup_cmd = f'rm -rf {org_repo_dir}' + await workspace.execute_command(cleanup_cmd, working_dir, timeout=10.0) + + +async def load_org_skills( + workspace: AsyncRemoteWorkspace, + selected_repository: str | None, + working_dir: str, + user_context: UserContext, +) -> list[Skill]: + """Load organization-level skills from the organization repository. + + For example, if the repository is github.com/acme-co/api, this will check if + github.com/acme-co/.openhands exists. If it does, it will clone it and load + the skills from both the ./skills/ and ./microagents/ folders. + + For GitLab repositories, it will use openhands-config instead of .openhands + since GitLab doesn't support repository names starting with non-alphanumeric + characters. + + For Azure DevOps repositories, it will use org/openhands-config/openhands-config + format to match Azure DevOps's three-part repository structure (org/project/repo). + + Args: + workspace: AsyncRemoteWorkspace to execute commands in the sandbox + selected_repository: Repository name (e.g., 'owner/repo') or None + working_dir: Working directory path + user_context: UserContext to access provider handler and authentication + + Returns: + List of Skill objects loaded from organization repository. + Returns empty list if no repository selected or on errors. + """ + if not selected_repository: + return [] + + try: + _logger.debug( + f'Starting org-level skill loading for repository: {selected_repository}' + ) + + # Validate repository path + if not _validate_repository_for_org_skills(selected_repository): + return [] + + # Determine organization repository path + org_openhands_repo, org_name = await _determine_org_repo_path( + selected_repository, user_context + ) + + _logger.info(f'Checking for org-level skills at {org_openhands_repo}') + + # Get authenticated URL for org repository + remote_url = await _get_org_repository_url(org_openhands_repo, user_context) + if not remote_url: + return [] + + # Clone the organization repository + org_repo_dir = f'{working_dir}/_org_openhands_{org_name}' + clone_success = await _clone_org_repository( + workspace, remote_url, org_repo_dir, working_dir, org_openhands_repo + ) + if not clone_success: + return [] + + # Load skills from both skills/ and microagents/ directories + ( + skills_dir_skills, + microagents_dir_skills, + ) = await _load_skills_from_org_directories( + workspace, org_repo_dir, working_dir + ) + + # Merge skills with proper precedence + loaded_skills = _merge_org_skills_with_precedence( + skills_dir_skills, microagents_dir_skills + ) + + _logger.info( + f'Loaded {len(loaded_skills)} skills from org-level repository {org_openhands_repo}: {[s.name for s in loaded_skills]}' + ) + + # Clean up the org repo directory + await _cleanup_org_repository(workspace, org_repo_dir, working_dir) + + return loaded_skills + + except AuthenticationError as e: + _logger.debug(f'org-level skill directory not found: {str(e)}') + return [] + except Exception as e: + _logger.warning(f'Failed to load org-level skills: {str(e)}') + return [] + + def merge_skills(skill_lists: list[list[Skill]]) -> list[Skill]: """Merge multiple skill lists, avoiding duplicates by name. diff --git a/openhands/app_server/sandbox/docker_sandbox_spec_service.py b/openhands/app_server/sandbox/docker_sandbox_spec_service.py index b7a9553e7e..063b4e8a96 100644 --- a/openhands/app_server/sandbox/docker_sandbox_spec_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_spec_service.py @@ -14,9 +14,9 @@ from openhands.app_server.sandbox.sandbox_spec_models import ( SandboxSpecInfo, ) from openhands.app_server.sandbox.sandbox_spec_service import ( - AGENT_SERVER_IMAGE, SandboxSpecService, SandboxSpecServiceInjector, + get_default_agent_server_image, ) from openhands.app_server.services.injector import InjectorState @@ -34,7 +34,7 @@ def get_docker_client() -> docker.DockerClient: def get_default_sandbox_specs(): return [ SandboxSpecInfo( - id=AGENT_SERVER_IMAGE, + id=get_default_agent_server_image(), command=['--port', '8000'], initial_env={ 'OPENVSCODE_SERVER_ROOT': '/openhands/.openvscode-server', diff --git a/openhands/app_server/sandbox/process_sandbox_spec_service.py b/openhands/app_server/sandbox/process_sandbox_spec_service.py index b5476669f7..4e2e88a2f9 100644 --- a/openhands/app_server/sandbox/process_sandbox_spec_service.py +++ b/openhands/app_server/sandbox/process_sandbox_spec_service.py @@ -10,9 +10,9 @@ from openhands.app_server.sandbox.sandbox_spec_models import ( SandboxSpecInfo, ) from openhands.app_server.sandbox.sandbox_spec_service import ( - AGENT_SERVER_IMAGE, SandboxSpecService, SandboxSpecServiceInjector, + get_default_agent_server_image, ) from openhands.app_server.services.injector import InjectorState @@ -20,7 +20,7 @@ from openhands.app_server.services.injector import InjectorState def get_default_sandbox_specs(): return [ SandboxSpecInfo( - id=AGENT_SERVER_IMAGE, + id=get_default_agent_server_image(), command=['python', '-m', 'openhands.agent_server'], initial_env={ # VSCode disabled for now diff --git a/openhands/app_server/sandbox/remote_sandbox_service.py b/openhands/app_server/sandbox/remote_sandbox_service.py index 5ee42218dc..035870cd45 100644 --- a/openhands/app_server/sandbox/remote_sandbox_service.py +++ b/openhands/app_server/sandbox/remote_sandbox_service.py @@ -303,6 +303,31 @@ class RemoteSandboxService(SandboxService): 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 await 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) diff --git a/openhands/app_server/sandbox/remote_sandbox_spec_service.py b/openhands/app_server/sandbox/remote_sandbox_spec_service.py index a2a7c58099..6228338d72 100644 --- a/openhands/app_server/sandbox/remote_sandbox_spec_service.py +++ b/openhands/app_server/sandbox/remote_sandbox_spec_service.py @@ -10,9 +10,9 @@ from openhands.app_server.sandbox.sandbox_spec_models import ( SandboxSpecInfo, ) from openhands.app_server.sandbox.sandbox_spec_service import ( - AGENT_SERVER_IMAGE, SandboxSpecService, SandboxSpecServiceInjector, + get_default_agent_server_image, ) from openhands.app_server.services.injector import InjectorState @@ -20,7 +20,7 @@ from openhands.app_server.services.injector import InjectorState def get_default_sandbox_specs(): return [ SandboxSpecInfo( - id=AGENT_SERVER_IMAGE, + id=get_default_agent_server_image(), command=['/usr/local/bin/openhands-agent-server', '--port', '60000'], initial_env={ 'OPENVSCODE_SERVER_ROOT': '/openhands/.openvscode-server', diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py index 3f1ff6b4d1..fe9d1653a9 100644 --- a/openhands/app_server/sandbox/sandbox_spec_service.py +++ b/openhands/app_server/sandbox/sandbox_spec_service.py @@ -1,4 +1,5 @@ import asyncio +import os from abc import ABC, abstractmethod from openhands.app_server.errors import SandboxError @@ -11,7 +12,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin # The version of the agent server to use for deployments. # Typically this will be the same as the values from the pyproject.toml -AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:37c4b35-python' +AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:97652be-python' class SandboxSpecService(ABC): @@ -57,3 +58,11 @@ class SandboxSpecServiceInjector( DiscriminatedUnionMixin, Injector[SandboxSpecService], ABC ): pass + + +def get_default_agent_server_image(): + agent_server_image_repository = os.getenv('AGENT_SERVER_IMAGE_REPOSITORY') + agent_server_image_tag = os.getenv('AGENT_SERVER_IMAGE_TAG') + if agent_server_image_repository and agent_server_image_tag: + return f'{agent_server_image_repository}:{agent_server_image_tag}' + return AGENT_SERVER_IMAGE diff --git a/openhands/app_server/user/auth_user_context.py b/openhands/app_server/user/auth_user_context.py index 8ea95036f4..4d64888427 100644 --- a/openhands/app_server/user/auth_user_context.py +++ b/openhands/app_server/user/auth_user_context.py @@ -14,7 +14,7 @@ from openhands.integrations.provider import ( ProviderHandler, ProviderType, ) -from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret +from openhands.sdk.secret import SecretSource, StaticSecret from openhands.server.user_auth.user_auth import UserAuth, get_user_auth USER_AUTH_ATTR = 'user_auth' diff --git a/openhands/app_server/user/specifiy_user_context.py b/openhands/app_server/user/specifiy_user_context.py index 87e2d74da2..51e6233972 100644 --- a/openhands/app_server/user/specifiy_user_context.py +++ b/openhands/app_server/user/specifiy_user_context.py @@ -6,7 +6,7 @@ from openhands.app_server.errors import OpenHandsError from openhands.app_server.user.user_context import UserContext from openhands.app_server.user.user_models import UserInfo from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType -from openhands.sdk.conversation.secret_source import SecretSource +from openhands.sdk.secret import SecretSource @dataclass(frozen=True) diff --git a/openhands/app_server/user/user_context.py b/openhands/app_server/user/user_context.py index 02c0ba8aaf..4102df5cf9 100644 --- a/openhands/app_server/user/user_context.py +++ b/openhands/app_server/user/user_context.py @@ -5,7 +5,7 @@ from openhands.app_server.user.user_models import ( UserInfo, ) from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType -from openhands.sdk.conversation.secret_source import SecretSource +from openhands.sdk.secret import SecretSource from openhands.sdk.utils.models import DiscriminatedUnionMixin diff --git a/openhands/memory/conversation_memory.py b/openhands/memory/conversation_memory.py index 5ff6ec7e58..5ae1a2cd71 100644 --- a/openhands/memory/conversation_memory.py +++ b/openhands/memory/conversation_memory.py @@ -76,6 +76,7 @@ class ConversationMemory: self, condensed_history: list[Event], initial_user_action: MessageAction, + forgotten_event_ids: set[int] | None = None, max_message_chars: int | None = None, vision_is_active: bool = False, ) -> list[Message]: @@ -85,16 +86,23 @@ class ConversationMemory: Args: condensed_history: The condensed history of events to convert + initial_user_action: The initial user message action, if available. Used to ensure the conversation starts correctly. + forgotten_event_ids: Set of event IDs that have been forgotten/condensed. If the initial user action's ID + is in this set, it will not be re-inserted to prevent re-execution of old instructions. max_message_chars: The maximum number of characters in the content of an event included in the prompt to the LLM. Larger observations are truncated. vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included. - initial_user_action: The initial user message action, if available. Used to ensure the conversation starts correctly. """ events = condensed_history + # Default to empty set if not provided + if forgotten_event_ids is None: + forgotten_event_ids = set() # Ensure the event list starts with SystemMessageAction, then MessageAction(source='user') self._ensure_system_message(events) - self._ensure_initial_user_message(events, initial_user_action) + self._ensure_initial_user_message( + events, initial_user_action, forgotten_event_ids + ) # log visual browsing status logger.debug(f'Visual browsing: {self.agent_config.enable_som_visual_browsing}') @@ -827,9 +835,23 @@ class ConversationMemory: ) def _ensure_initial_user_message( - self, events: list[Event], initial_user_action: MessageAction + self, + events: list[Event], + initial_user_action: MessageAction, + forgotten_event_ids: set[int], ) -> None: - """Checks if the second event is a user MessageAction and inserts the provided one if needed.""" + """Checks if the second event is a user MessageAction and inserts the provided one if needed. + + IMPORTANT: If the initial user action has been condensed (its ID is in forgotten_event_ids), + we do NOT re-insert it. This prevents old instructions from being re-executed after + conversation condensation. The condensation summary already contains the context of + what was requested and completed. + + Args: + events: The list of events to modify in-place + initial_user_action: The initial user message action from the full history + forgotten_event_ids: Set of event IDs that have been forgotten/condensed + """ if ( not events ): # Should have system message from previous step, but safety check @@ -837,6 +859,17 @@ class ConversationMemory: # Or raise? Let's log for now, _ensure_system_message should handle this. return + # Check if the initial user action has been condensed/forgotten. + # If so, we should NOT re-insert it to prevent re-execution of old instructions. + # The condensation summary already contains the context of what was requested. + initial_user_action_id = initial_user_action.id + if initial_user_action_id in forgotten_event_ids: + logger.info( + f'Initial user action (id={initial_user_action_id}) has been condensed. ' + 'Not re-inserting to prevent re-execution of old instructions.' + ) + return + # We expect events[0] to be SystemMessageAction after _ensure_system_message if len(events) == 1: # Only system message exists diff --git a/openhands/memory/view.py b/openhands/memory/view.py index 87a20b6340..81dd8bab5d 100644 --- a/openhands/memory/view.py +++ b/openhands/memory/view.py @@ -18,6 +18,8 @@ class View(BaseModel): events: list[Event] unhandled_condensation_request: bool = False + # Set of event IDs that have been forgotten/condensed + forgotten_event_ids: set[int] = set() def __len__(self) -> int: return len(self.events) @@ -90,4 +92,5 @@ class View(BaseModel): return View( events=kept_events, unhandled_condensation_request=unhandled_condensation_request, + forgotten_event_ids=forgotten_event_ids, ) diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 5eb5429f71..c7a332166b 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -76,6 +76,8 @@ from openhands.utils.async_utils import ( call_sync_from_async, ) +DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true' + def _default_env_vars(sandbox_config: SandboxConfig) -> dict[str, str]: ret = {} @@ -153,9 +155,11 @@ class Runtime(FileEditRuntimeMixin): self.plugins = ( copy.deepcopy(plugins) if plugins is not None and len(plugins) > 0 else [] ) + # add VSCode plugin if not in headless mode - if not headless_mode: + if not headless_mode and not DISABLE_VSCODE_PLUGIN: self.plugins.append(VSCodeRequirement()) + logger.info(f'Loaded plugins for runtime {self.sid}: {self.plugins}') self.status_callback = status_callback self.attach_to_existing = attach_to_existing 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/kubernetes/README.md b/openhands/runtime/impl/kubernetes/README.md index d16247389d..36b7452d1e 100644 --- a/openhands/runtime/impl/kubernetes/README.md +++ b/openhands/runtime/impl/kubernetes/README.md @@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime: 2. **Runtime Container Image**: Specify the container image to use for the runtime environment ```toml [sandbox] - runtime_container_image = "docker.openhands.dev/openhands/runtime:0.62-nikolaik" + runtime_container_image = "docker.openhands.dev/openhands/runtime:1.0-nikolaik" ``` #### Additional Kubernetes Options diff --git a/openhands/runtime/impl/local/local_runtime.py b/openhands/runtime/impl/local/local_runtime.py index ed8d26996a..cf81b222eb 100644 --- a/openhands/runtime/impl/local/local_runtime.py +++ b/openhands/runtime/impl/local/local_runtime.py @@ -45,6 +45,8 @@ from openhands.utils.async_utils import call_sync_from_async from openhands.utils.http_session import httpx_verify_option from openhands.utils.tenacity_stop import stop_if_should_exit +DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true' + @dataclass class ActionExecutionServerInfo: @@ -247,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. ' @@ -406,7 +423,7 @@ class LocalRuntime(ActionExecutionClient): plugins = _get_plugins(config) # Copy the logic from Runtime where we add a VSCodePlugin on init if missing - if not headless_mode: + if not headless_mode and not DISABLE_VSCODE_PLUGIN: plugins.append(VSCodeRequirement()) for _ in range(initial_num_warm_servers): diff --git a/openhands/storage/settings/file_settings_store.py b/openhands/storage/settings/file_settings_store.py index 3acedeb16f..5b43bf6b80 100644 --- a/openhands/storage/settings/file_settings_store.py +++ b/openhands/storage/settings/file_settings_store.py @@ -21,6 +21,11 @@ class FileSettingsStore(SettingsStore): json_str = await call_sync_from_async(self.file_store.read, self.path) kwargs = json.loads(json_str) settings = Settings(**kwargs) + + # Turn on V1 in OpenHands + # We can simplify / remove this as part of V0 removal + settings.v1_enabled = True + return settings except FileNotFoundError: return None diff --git a/openhands/utils/llm.py b/openhands/utils/llm.py index 9eeb7c5393..1686babd96 100644 --- a/openhands/utils/llm.py +++ b/openhands/utils/llm.py @@ -90,4 +90,4 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]: ] model_list = clarifai_models + model_list - return list(sorted(set(model_list))) + return sorted(set(model_list)) diff --git a/poetry.lock b/poetry.lock index 06e67f3ca2..23789d3285 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5675,14 +5675,14 @@ utils = ["numpydoc"] [[package]] name = "lmnr" -version = "0.7.20" +version = "0.7.24" description = "Python SDK for Laminar" optional = false python-versions = "<4,>=3.10" groups = ["main"] files = [ - {file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"}, - {file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"}, + {file = "lmnr-0.7.24-py3-none-any.whl", hash = "sha256:ad780d4a62ece897048811f3368639c240a9329ab31027da8c96545137a3a08a"}, + {file = "lmnr-0.7.24.tar.gz", hash = "sha256:aa6973f46fc4ba95c9061c1feceb58afc02eb43c9376c21e32545371ff6123d7"}, ] [package.dependencies] @@ -5705,14 +5705,15 @@ tqdm = ">=4.0" [package.extras] alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"] -all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"] +all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"] bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"] chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"] +claude-agent-sdk = ["lmnr-claude-code-proxy (>=0.1.0a5)"] cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"] crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"] haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"] lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"] -langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"] +langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)"] llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"] marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"] mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"] @@ -7379,14 +7380,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0 [[package]] name = "openhands-agent-server" -version = "1.4.1" +version = "1.6.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_agent_server-1.4.1-py3-none-any.whl", hash = "sha256:1e621d15215a48e2398e23c58a791347f06c215c2344053aeb26b562c34a44ee"}, - {file = "openhands_agent_server-1.4.1.tar.gz", hash = "sha256:03010a5c8d63bbd5b088458eb75308ef16559018140d75a3644ae5bbc3531bbf"}, + {file = "openhands_agent_server-1.6.0-py3-none-any.whl", hash = "sha256:e6ae865ac3e7a96b234e10a0faad23f6210e025bbf7721cb66bc7a71d160848c"}, + {file = "openhands_agent_server-1.6.0.tar.gz", hash = "sha256:44ce7694ae2d4bb0666d318ef13e6618bd4dc73022c60354839fe6130e67d02a"}, ] [package.dependencies] @@ -7403,14 +7404,14 @@ wsproto = ">=1.2.0" [[package]] name = "openhands-sdk" -version = "1.4.1" +version = "1.6.0" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_sdk-1.4.1-py3-none-any.whl", hash = "sha256:70e453eab7f9ab6b705198c2615fdd844b21e14b29d78afaf62724f4a440bcdc"}, - {file = "openhands_sdk-1.4.1.tar.gz", hash = "sha256:37365de25ed57cf8cc2a8003ab4d7a1fe2a40b49c8e8da84a3f1ea2b522eddf2"}, + {file = "openhands_sdk-1.6.0-py3-none-any.whl", hash = "sha256:94d2f87fb35406373da6728ae2d88584137f9e9b67fa0e940444c72f2e44e7d3"}, + {file = "openhands_sdk-1.6.0.tar.gz", hash = "sha256:f45742350e3874a7f5b08befc4a9d5adc7e4454f7ab5f8391c519eee3116090f"}, ] [package.dependencies] @@ -7418,7 +7419,7 @@ deprecation = ">=2.1.0" fastmcp = ">=2.11.3" httpx = ">=0.27.0" litellm = ">=1.80.7" -lmnr = ">=0.7.20" +lmnr = ">=0.7.24" pydantic = ">=2.11.7" python-frontmatter = ">=1.1.0" python-json-logger = ">=3.3.0" @@ -7430,14 +7431,14 @@ boto3 = ["boto3 (>=1.35.0)"] [[package]] name = "openhands-tools" -version = "1.4.1" +version = "1.6.0" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_tools-1.4.1-py3-none-any.whl", hash = "sha256:8f40189a08bf80eb4a33219ee9ccc528f9c6c4f2d5c9ab807b06c3f3fe21a612"}, - {file = "openhands_tools-1.4.1.tar.gz", hash = "sha256:4c0caf87f520a207d9035191c77b7b5c53eeec996350a24ffaf7f740a6566b22"}, + {file = "openhands_tools-1.6.0-py3-none-any.whl", hash = "sha256:176556d44186536751b23fe052d3505492cc2afb8d52db20fb7a2cc0169cd57a"}, + {file = "openhands_tools-1.6.0.tar.gz", hash = "sha256:d07ba31050fd4a7891a4c48388aa53ce9f703e17064ddbd59146d6c77e5980b3"}, ] [package.dependencies] @@ -16822,4 +16823,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "c208fcc692f74540f7b6e822136002dd0f079a3d8d1b93227a5bb07a7f4432cb" +content-hash = "9764f3b69ec8ed35feebd78a826bbc6bfa4ac6d5b56bc999be8bc738b644e538" diff --git a/pyproject.toml b/pyproject.toml index 764751f2bc..c70c110dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires = [ [tool.poetry] name = "openhands-ai" -version = "0.62.0" +version = "1.0.0" description = "OpenHands: Code Less, Make More" authors = [ "OpenHands" ] license = "MIT" @@ -116,9 +116,9 @@ pybase62 = "^1.0.0" #openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" } #openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" } #openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" } -openhands-sdk = "1.4.1" -openhands-agent-server = "1.4.1" -openhands-tools = "1.4.1" +openhands-sdk = "1.6.0" +openhands-agent-server = "1.6.0" +openhands-tools = "1.6.0" python-jose = { version = ">=3.3", extras = [ "cryptography" ] } sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" } pg8000 = "^1.31.5" diff --git a/tests/unit/agenthub/test_agents.py b/tests/unit/agenthub/test_agents.py index 2a90dcb668..09f28e991c 100644 --- a/tests/unit/agenthub/test_agents.py +++ b/tests/unit/agenthub/test_agents.py @@ -393,7 +393,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message( # 2. The action message # 3. The observation message mock_state.history = [initial_user_message, action, observation] - messages = agent._get_messages(mock_state.history, initial_user_message) + messages = agent._get_messages(mock_state.history, initial_user_message, set()) assert len(messages) == 4 # System + initial user + action + observation assert messages[0].role == 'system' # First message should be the system message assert ( @@ -404,7 +404,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message( # The same should hold if the events are presented out-of-order mock_state.history = [initial_user_message, observation, action] - messages = agent._get_messages(mock_state.history, initial_user_message) + messages = agent._get_messages(mock_state.history, initial_user_message, set()) assert len(messages) == 4 assert messages[0].role == 'system' # First message should be the system message assert ( @@ -414,7 +414,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message( # If only one of the two events is present, then we should just get the system message # plus any valid message from the event mock_state.history = [initial_user_message, action] - messages = agent._get_messages(mock_state.history, initial_user_message) + messages = agent._get_messages(mock_state.history, initial_user_message, set()) assert ( len(messages) == 2 ) # System + initial user message, action is waiting for its observation @@ -422,7 +422,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message( assert messages[1].role == 'user' mock_state.history = [initial_user_message, observation] - messages = agent._get_messages(mock_state.history, initial_user_message) + messages = agent._get_messages(mock_state.history, initial_user_message, set()) assert ( len(messages) == 2 ) # System + initial user message, observation has no matching action diff --git a/tests/unit/agenthub/test_prompt_caching.py b/tests/unit/agenthub/test_prompt_caching.py index 60cc0bb16f..2435b1320a 100644 --- a/tests/unit/agenthub/test_prompt_caching.py +++ b/tests/unit/agenthub/test_prompt_caching.py @@ -80,7 +80,7 @@ def test_get_messages(codeact_agent: CodeActAgent): history.append(message_action_5) codeact_agent.reset() - messages = codeact_agent._get_messages(history, message_action_1) + messages = codeact_agent._get_messages(history, message_action_1, set()) assert ( len(messages) == 6 @@ -122,7 +122,7 @@ def test_get_messages_prompt_caching(codeact_agent: CodeActAgent): history.append(message_action_agent) codeact_agent.reset() - messages = codeact_agent._get_messages(history, initial_user_message) + messages = codeact_agent._get_messages(history, initial_user_message, set()) # Check that only the last two user messages have cache_prompt=True cached_user_messages = [ diff --git a/tests/unit/app_server/test_app_conversation_service_base.py b/tests/unit/app_server/test_app_conversation_service_base.py index a179a11c24..db31d8d3d2 100644 --- a/tests/unit/app_server/test_app_conversation_service_base.py +++ b/tests/unit/app_server/test_app_conversation_service_base.py @@ -1,11 +1,13 @@ -"""Unit tests for git functionality in AppConversationServiceBase. +"""Unit tests for git and security functionality in AppConversationServiceBase. This module tests the git-related functionality, specifically the clone_or_init_git_repo method and the recent bug fixes for git checkout operations. """ import subprocess +from types import MethodType from unittest.mock import AsyncMock, MagicMock, Mock, patch +from uuid import uuid4 import pytest @@ -13,6 +15,7 @@ from openhands.app_server.app_conversation.app_conversation_models import AgentT from openhands.app_server.app_conversation.app_conversation_service_base import ( AppConversationServiceBase, ) +from openhands.app_server.sandbox.sandbox_models import SandboxInfo from openhands.app_server.user.user_context import UserContext @@ -434,13 +437,298 @@ def test_create_condenser_plan_agent_with_custom_max_size(mock_condenser_class): mock_llm.model_copy.assert_called_once() +# ============================================================================= +# Tests for security analyzer helpers +# ============================================================================= + + +@pytest.mark.parametrize('value', [None, '', 'none', 'NoNe']) +def test_create_security_analyzer_returns_none_for_empty_values(value): + """_create_security_analyzer_from_string returns None for empty/none values.""" + # Arrange + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',) + ) + + # Act + result = service._create_security_analyzer_from_string(value) + + # Assert + assert result is None + + +def test_create_security_analyzer_returns_llm_analyzer(): + """_create_security_analyzer_from_string returns LLMSecurityAnalyzer for llm string.""" + # Arrange + security_analyzer_str = 'llm' + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',) + ) + + # Act + result = service._create_security_analyzer_from_string(security_analyzer_str) + + # Assert + from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer + + assert isinstance(result, LLMSecurityAnalyzer) + + +def test_create_security_analyzer_logs_warning_for_unknown_value(): + """_create_security_analyzer_from_string logs warning and returns None for unknown.""" + # Arrange + unknown_value = 'custom' + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',) + ) + + # Act + with patch( + 'openhands.app_server.app_conversation.app_conversation_service_base._logger' + ) as mock_logger: + result = service._create_security_analyzer_from_string(unknown_value) + + # Assert + assert result is None + mock_logger.warning.assert_called_once() + + +def test_select_confirmation_policy_when_disabled_returns_never_confirm(): + """_select_confirmation_policy returns NeverConfirm when confirmation_mode is False.""" + # Arrange + confirmation_mode = False + security_analyzer = 'llm' + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_select_confirmation_policy',) + ) + + # Act + policy = service._select_confirmation_policy(confirmation_mode, security_analyzer) + + # Assert + from openhands.sdk.security.confirmation_policy import NeverConfirm + + assert isinstance(policy, NeverConfirm) + + +def test_select_confirmation_policy_llm_returns_confirm_risky(): + """_select_confirmation_policy uses ConfirmRisky when analyzer is llm.""" + # Arrange + confirmation_mode = True + security_analyzer = 'llm' + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_select_confirmation_policy',) + ) + + # Act + policy = service._select_confirmation_policy(confirmation_mode, security_analyzer) + + # Assert + from openhands.sdk.security.confirmation_policy import ConfirmRisky + + assert isinstance(policy, ConfirmRisky) + + +@pytest.mark.parametrize('security_analyzer', [None, '', 'none', 'custom']) +def test_select_confirmation_policy_non_llm_returns_always_confirm( + security_analyzer, +): + """_select_confirmation_policy falls back to AlwaysConfirm for non-llm values.""" + # Arrange + confirmation_mode = True + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), bind_methods=('_select_confirmation_policy',) + ) + + # Act + policy = service._select_confirmation_policy(confirmation_mode, security_analyzer) + + # Assert + from openhands.sdk.security.confirmation_policy import AlwaysConfirm + + assert isinstance(policy, AlwaysConfirm) + + +@pytest.mark.asyncio +async def test_set_security_analyzer_skips_when_no_session_key(): + """_set_security_analyzer_from_settings exits early without session_api_key.""" + # Arrange + agent_server_url = 'https://agent.example.com' + conversation_id = uuid4() + httpx_client = AsyncMock() + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), + bind_methods=( + '_create_security_analyzer_from_string', + '_set_security_analyzer_from_settings', + ), + ) + + with patch.object(service, '_create_security_analyzer_from_string') as mock_create: + # Act + await service._set_security_analyzer_from_settings( + agent_server_url=agent_server_url, + session_api_key=None, + conversation_id=conversation_id, + security_analyzer_str='llm', + httpx_client=httpx_client, + ) + + # Assert + mock_create.assert_not_called() + httpx_client.post.assert_not_called() + + +@pytest.mark.asyncio +async def test_set_security_analyzer_skips_when_analyzer_none(): + """_set_security_analyzer_from_settings skips API call when analyzer resolves to None.""" + # Arrange + agent_server_url = 'https://agent.example.com' + session_api_key = 'session-key' + conversation_id = uuid4() + httpx_client = AsyncMock() + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), + bind_methods=( + '_create_security_analyzer_from_string', + '_set_security_analyzer_from_settings', + ), + ) + + with patch.object( + service, '_create_security_analyzer_from_string', return_value=None + ) as mock_create: + # Act + await service._set_security_analyzer_from_settings( + agent_server_url=agent_server_url, + session_api_key=session_api_key, + conversation_id=conversation_id, + security_analyzer_str='none', + httpx_client=httpx_client, + ) + + # Assert + mock_create.assert_called_once_with('none') + httpx_client.post.assert_not_called() + + +class DummyAnalyzer: + """Simple analyzer stub for testing model_dump contract.""" + + def __init__(self, payload: dict): + self._payload = payload + + def model_dump(self) -> dict: + return self._payload + + +@pytest.mark.asyncio +async def test_set_security_analyzer_successfully_calls_agent_server(): + """_set_security_analyzer_from_settings posts analyzer payload when available.""" + # Arrange + agent_server_url = 'https://agent.example.com' + session_api_key = 'session-key' + conversation_id = uuid4() + analyzer_payload = {'type': 'llm'} + httpx_client = AsyncMock() + http_response = MagicMock() + http_response.raise_for_status = MagicMock() + httpx_client.post.return_value = http_response + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), + bind_methods=( + '_create_security_analyzer_from_string', + '_set_security_analyzer_from_settings', + ), + ) + + analyzer = DummyAnalyzer(analyzer_payload) + + with ( + patch.object( + service, + '_create_security_analyzer_from_string', + return_value=analyzer, + ) as mock_create, + patch( + 'openhands.app_server.app_conversation.app_conversation_service_base._logger' + ) as mock_logger, + ): + # Act + await service._set_security_analyzer_from_settings( + agent_server_url=agent_server_url, + session_api_key=session_api_key, + conversation_id=conversation_id, + security_analyzer_str='llm', + httpx_client=httpx_client, + ) + + # Assert + mock_create.assert_called_once_with('llm') + httpx_client.post.assert_awaited_once_with( + f'{agent_server_url}/api/conversations/{conversation_id}/security_analyzer', + json={'security_analyzer': analyzer_payload}, + headers={'X-Session-API-Key': session_api_key}, + timeout=30.0, + ) + http_response.raise_for_status.assert_called_once() + mock_logger.info.assert_called() + + +@pytest.mark.asyncio +async def test_set_security_analyzer_logs_warning_on_failure(): + """_set_security_analyzer_from_settings warns but does not raise on errors.""" + # Arrange + agent_server_url = 'https://agent.example.com' + session_api_key = 'session-key' + conversation_id = uuid4() + analyzer_payload = {'type': 'llm'} + httpx_client = AsyncMock() + httpx_client.post.side_effect = RuntimeError('network down') + service, _ = _create_service_with_mock_user_context( + MockUserInfo(), + bind_methods=( + '_create_security_analyzer_from_string', + '_set_security_analyzer_from_settings', + ), + ) + + analyzer = DummyAnalyzer(analyzer_payload) + + with ( + patch.object( + service, + '_create_security_analyzer_from_string', + return_value=analyzer, + ) as mock_create, + patch( + 'openhands.app_server.app_conversation.app_conversation_service_base._logger' + ) as mock_logger, + ): + # Act + await service._set_security_analyzer_from_settings( + agent_server_url=agent_server_url, + session_api_key=session_api_key, + conversation_id=conversation_id, + security_analyzer_str='llm', + httpx_client=httpx_client, + ) + + # Assert + mock_create.assert_called_once_with('llm') + httpx_client.post.assert_awaited_once() + mock_logger.warning.assert_called() + + # ============================================================================= # Tests for _configure_git_user_settings # ============================================================================= -def _create_service_with_mock_user_context(user_info: MockUserInfo) -> tuple: - """Create a mock service with the actual _configure_git_user_settings method. +def _create_service_with_mock_user_context( + user_info: MockUserInfo, bind_methods: tuple[str, ...] | None = None +) -> tuple: + """Create a mock service with selected real methods bound for testing. Uses MagicMock for the service but binds the real method for testing. @@ -452,13 +740,16 @@ def _create_service_with_mock_user_context(user_info: MockUserInfo) -> tuple: # Create a simple mock service and set required attribute service = MagicMock() service.user_context = mock_user_context + methods_to_bind = ['_configure_git_user_settings'] + if bind_methods: + methods_to_bind.extend(bind_methods) + # Remove potential duplicates while keeping order + methods_to_bind = list(dict.fromkeys(methods_to_bind)) - # Bind the actual method from the real class to test real implementation - service._configure_git_user_settings = ( - lambda workspace: AppConversationServiceBase._configure_git_user_settings( - service, workspace - ) - ) + # Bind actual methods from the real class to test implementations directly + for method_name in methods_to_bind: + real_method = getattr(AppConversationServiceBase, method_name) + setattr(service, method_name, MethodType(real_method, service)) return service, mock_user_context @@ -626,3 +917,350 @@ async def test_configure_git_user_settings_special_characters_in_name(mock_works mock_workspace.execute_command.assert_any_call( 'git config --global user.name "Test O\'Brien"', '/workspace/project' ) + + +# ============================================================================= +# Tests for load_and_merge_all_skills with org skills +# ============================================================================= + + +class TestLoadAndMergeAllSkillsWithOrgSkills: + """Test load_and_merge_all_skills includes organization skills.""" + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills' + ) + async def test_load_and_merge_includes_org_skills( + self, + mock_load_repo, + mock_load_org, + mock_load_user, + mock_load_global, + mock_load_sandbox, + ): + """Test that load_and_merge_all_skills loads and merges org skills.""" + # Arrange + mock_user_context = Mock(spec=UserContext) + with patch.object( + AppConversationServiceBase, + '__abstractmethods__', + set(), + ): + service = AppConversationServiceBase( + init_git_in_empty_workspace=True, + user_context=mock_user_context, + ) + + sandbox = Mock(spec=SandboxInfo) + sandbox.exposed_urls = [] + remote_workspace = AsyncMock() + + # Create distinct mock skills for each source + sandbox_skill = Mock() + sandbox_skill.name = 'sandbox_skill' + global_skill = Mock() + global_skill.name = 'global_skill' + user_skill = Mock() + user_skill.name = 'user_skill' + org_skill = Mock() + org_skill.name = 'org_skill' + repo_skill = Mock() + repo_skill.name = 'repo_skill' + + mock_load_sandbox.return_value = [sandbox_skill] + mock_load_global.return_value = [global_skill] + mock_load_user.return_value = [user_skill] + mock_load_org.return_value = [org_skill] + mock_load_repo.return_value = [repo_skill] + + # Act + result = await service.load_and_merge_all_skills( + sandbox, remote_workspace, 'owner/repo', '/workspace' + ) + + # Assert + assert len(result) == 5 + names = {s.name for s in result} + assert names == { + 'sandbox_skill', + 'global_skill', + 'user_skill', + 'org_skill', + 'repo_skill', + } + mock_load_org.assert_called_once_with( + remote_workspace, 'owner/repo', '/workspace', mock_user_context + ) + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills' + ) + async def test_load_and_merge_org_skills_precedence( + self, + mock_load_repo, + mock_load_org, + mock_load_user, + mock_load_global, + mock_load_sandbox, + ): + """Test that org skills have correct precedence (higher than user, lower than repo).""" + # Arrange + mock_user_context = Mock(spec=UserContext) + with patch.object( + AppConversationServiceBase, + '__abstractmethods__', + set(), + ): + service = AppConversationServiceBase( + init_git_in_empty_workspace=True, + user_context=mock_user_context, + ) + + sandbox = Mock(spec=SandboxInfo) + sandbox.exposed_urls = [] + remote_workspace = AsyncMock() + + # Create skills with same name but different sources + user_skill = Mock() + user_skill.name = 'common_skill' + user_skill.source = 'user' + + org_skill = Mock() + org_skill.name = 'common_skill' + org_skill.source = 'org' + + repo_skill = Mock() + repo_skill.name = 'common_skill' + repo_skill.source = 'repo' + + mock_load_sandbox.return_value = [] + mock_load_global.return_value = [] + mock_load_user.return_value = [user_skill] + mock_load_org.return_value = [org_skill] + mock_load_repo.return_value = [repo_skill] + + # Act + result = await service.load_and_merge_all_skills( + sandbox, remote_workspace, 'owner/repo', '/workspace' + ) + + # Assert + # Should have only one skill with repo source (highest precedence) + assert len(result) == 1 + assert result[0].source == 'repo' + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills' + ) + async def test_load_and_merge_org_skills_override_user_skills( + self, + mock_load_repo, + mock_load_org, + mock_load_user, + mock_load_global, + mock_load_sandbox, + ): + """Test that org skills override user skills for same name.""" + # Arrange + mock_user_context = Mock(spec=UserContext) + with patch.object( + AppConversationServiceBase, + '__abstractmethods__', + set(), + ): + service = AppConversationServiceBase( + init_git_in_empty_workspace=True, + user_context=mock_user_context, + ) + + sandbox = Mock(spec=SandboxInfo) + sandbox.exposed_urls = [] + remote_workspace = AsyncMock() + + # Create skills with same name + user_skill = Mock() + user_skill.name = 'shared_skill' + user_skill.priority = 'low' + + org_skill = Mock() + org_skill.name = 'shared_skill' + org_skill.priority = 'high' + + mock_load_sandbox.return_value = [] + mock_load_global.return_value = [] + mock_load_user.return_value = [user_skill] + mock_load_org.return_value = [org_skill] + mock_load_repo.return_value = [] + + # Act + result = await service.load_and_merge_all_skills( + sandbox, remote_workspace, 'owner/repo', '/workspace' + ) + + # Assert + assert len(result) == 1 + assert result[0].priority == 'high' # Org skill should win + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills' + ) + async def test_load_and_merge_handles_org_skills_failure( + self, + mock_load_repo, + mock_load_org, + mock_load_user, + mock_load_global, + mock_load_sandbox, + ): + """Test that failure to load org skills doesn't break the overall process.""" + # Arrange + mock_user_context = Mock(spec=UserContext) + with patch.object( + AppConversationServiceBase, + '__abstractmethods__', + set(), + ): + service = AppConversationServiceBase( + init_git_in_empty_workspace=True, + user_context=mock_user_context, + ) + + sandbox = Mock(spec=SandboxInfo) + sandbox.exposed_urls = [] + remote_workspace = AsyncMock() + + global_skill = Mock() + global_skill.name = 'global_skill' + repo_skill = Mock() + repo_skill.name = 'repo_skill' + + mock_load_sandbox.return_value = [] + mock_load_global.return_value = [global_skill] + mock_load_user.return_value = [] + mock_load_org.return_value = [] # Org skills failed/empty + mock_load_repo.return_value = [repo_skill] + + # Act + result = await service.load_and_merge_all_skills( + sandbox, remote_workspace, 'owner/repo', '/workspace' + ) + + # Assert + # Should still have skills from other sources + assert len(result) == 2 + names = {s.name for s in result} + assert names == {'global_skill', 'repo_skill'} + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills' + ) + @patch( + 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills' + ) + async def test_load_and_merge_no_selected_repository( + self, + mock_load_repo, + mock_load_org, + mock_load_user, + mock_load_global, + mock_load_sandbox, + ): + """Test skill loading when no repository is selected.""" + # Arrange + mock_user_context = Mock(spec=UserContext) + with patch.object( + AppConversationServiceBase, + '__abstractmethods__', + set(), + ): + service = AppConversationServiceBase( + init_git_in_empty_workspace=True, + user_context=mock_user_context, + ) + + sandbox = Mock(spec=SandboxInfo) + sandbox.exposed_urls = [] + remote_workspace = AsyncMock() + + global_skill = Mock() + global_skill.name = 'global_skill' + + mock_load_sandbox.return_value = [] + mock_load_global.return_value = [global_skill] + mock_load_user.return_value = [] + mock_load_org.return_value = [] + mock_load_repo.return_value = [] + + # Act + result = await service.load_and_merge_all_skills( + sandbox, remote_workspace, None, '/workspace' + ) + + # Assert + assert len(result) == 1 + # Org skills should be called even with None repository + mock_load_org.assert_called_once_with( + remote_workspace, None, '/workspace', mock_user_context + ) diff --git a/tests/unit/app_server/test_app_conversation_skills_endpoint.py b/tests/unit/app_server/test_app_conversation_skills_endpoint.py new file mode 100644 index 0000000000..e84412bcd0 --- /dev/null +++ b/tests/unit/app_server/test_app_conversation_skills_endpoint.py @@ -0,0 +1,503 @@ +"""Unit tests for the V1 skills endpoint in app_conversation_router. + +This module tests the GET /{conversation_id}/skills endpoint functionality, +following TDD best practices with AAA structure. +""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from fastapi import status + +from openhands.app_server.app_conversation.app_conversation_models import ( + AppConversation, +) +from openhands.app_server.app_conversation.app_conversation_router import ( + get_conversation_skills, +) +from openhands.app_server.app_conversation.app_conversation_service_base import ( + AppConversationServiceBase, +) +from openhands.app_server.sandbox.sandbox_models import ( + AGENT_SERVER, + ExposedUrl, + SandboxInfo, + SandboxStatus, +) +from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo +from openhands.app_server.user.user_context import UserContext +from openhands.sdk.context.skills import KeywordTrigger, Skill, TaskTrigger + + +def _make_service_mock( + *, + user_context: UserContext, + conversation_return: AppConversation | None = None, + skills_return: list[Skill] | None = None, + raise_on_load: bool = False, +): + """Create a mock service that passes the isinstance check and returns the desired values.""" + + mock_cls = type('AppConversationServiceMock', (MagicMock,), {}) + AppConversationServiceBase.register(mock_cls) + + service = mock_cls() + service.user_context = user_context + service.get_app_conversation = AsyncMock(return_value=conversation_return) + + async def _load_skills(*_args, **_kwargs): + if raise_on_load: + raise Exception('Skill loading failed') + return skills_return or [] + + service.load_and_merge_all_skills = AsyncMock(side_effect=_load_skills) + return service + + +@pytest.mark.asyncio +class TestGetConversationSkills: + """Test suite for get_conversation_skills endpoint.""" + + async def test_get_skills_returns_repo_and_knowledge_skills(self): + """Test successful retrieval of both repo and knowledge skills. + + Arrange: Setup conversation, sandbox, and skills with different types + Act: Call get_conversation_skills endpoint + Assert: Response contains both repo and knowledge skills with correct types + """ + # Arrange + conversation_id = uuid4() + sandbox_id = str(uuid4()) + working_dir = '/workspace' + + # Create mock conversation + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + selected_repository='owner/repo', + sandbox_status=SandboxStatus.RUNNING, + ) + + # Create mock sandbox with agent server URL + mock_sandbox = SandboxInfo( + id=sandbox_id, + created_by_user_id='test-user', + status=SandboxStatus.RUNNING, + sandbox_spec_id=str(uuid4()), + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000) + ], + ) + + # Create mock sandbox spec + mock_sandbox_spec = SandboxSpecInfo( + id=str(uuid4()), command=None, working_dir=working_dir + ) + + # Create mock skills - repo skill (no trigger) + repo_skill = Skill( + name='repo_skill', + content='Repository skill content', + trigger=None, + ) + + # Create mock skills - knowledge skill (with KeywordTrigger) + knowledge_skill = Skill( + name='knowledge_skill', + content='Knowledge skill content', + trigger=KeywordTrigger(keywords=['test', 'help']), + ) + + # Mock services + mock_user_context = MagicMock(spec=UserContext) + mock_app_conversation_service = _make_service_mock( + user_context=mock_user_context, + conversation_return=mock_conversation, + skills_return=[repo_skill, knowledge_skill], + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + mock_sandbox_spec_service = MagicMock() + mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( + return_value=mock_sandbox_spec + ) + + # Act + response = await get_conversation_skills( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Assert + assert response.status_code == status.HTTP_200_OK + content = response.body.decode('utf-8') + import json + + data = json.loads(content) + assert 'skills' in data + assert len(data['skills']) == 2 + + # Check repo skill + repo_skill_data = next( + (s for s in data['skills'] if s['name'] == 'repo_skill'), None + ) + assert repo_skill_data is not None + assert repo_skill_data['type'] == 'repo' + assert repo_skill_data['content'] == 'Repository skill content' + assert repo_skill_data['triggers'] == [] + + # Check knowledge skill + knowledge_skill_data = next( + (s for s in data['skills'] if s['name'] == 'knowledge_skill'), None + ) + assert knowledge_skill_data is not None + assert knowledge_skill_data['type'] == 'knowledge' + assert knowledge_skill_data['content'] == 'Knowledge skill content' + assert knowledge_skill_data['triggers'] == ['test', 'help'] + + async def test_get_skills_returns_404_when_conversation_not_found(self): + """Test endpoint returns 404 when conversation doesn't exist. + + Arrange: Setup mocks to return None for conversation + Act: Call get_conversation_skills endpoint + Assert: Response is 404 with appropriate error message + """ + # Arrange + conversation_id = uuid4() + + mock_user_context = MagicMock(spec=UserContext) + mock_app_conversation_service = _make_service_mock( + user_context=mock_user_context, + conversation_return=None, + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_spec_service = MagicMock() + + # Act + response = await get_conversation_skills( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + content = response.body.decode('utf-8') + import json + + data = json.loads(content) + assert 'error' in data + assert str(conversation_id) in data['error'] + + async def test_get_skills_returns_404_when_sandbox_not_found(self): + """Test endpoint returns 404 when sandbox doesn't exist. + + Arrange: Setup conversation but no sandbox + Act: Call get_conversation_skills endpoint + Assert: Response is 404 with sandbox error message + """ + # Arrange + conversation_id = uuid4() + sandbox_id = str(uuid4()) + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + sandbox_status=SandboxStatus.RUNNING, + ) + + mock_user_context = MagicMock(spec=UserContext) + mock_app_conversation_service = _make_service_mock( + user_context=mock_user_context, + conversation_return=mock_conversation, + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=None) + + mock_sandbox_spec_service = MagicMock() + + # Act + response = await get_conversation_skills( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + content = response.body.decode('utf-8') + import json + + data = json.loads(content) + assert 'error' in data + assert 'Sandbox not found' in data['error'] + + async def test_get_skills_returns_404_when_sandbox_not_running(self): + """Test endpoint returns 404 when sandbox is not in RUNNING state. + + Arrange: Setup conversation with stopped sandbox + Act: Call get_conversation_skills endpoint + Assert: Response is 404 with sandbox not running message + """ + # Arrange + conversation_id = uuid4() + sandbox_id = str(uuid4()) + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + sandbox_status=SandboxStatus.PAUSED, + ) + + mock_sandbox = SandboxInfo( + id=sandbox_id, + created_by_user_id='test-user', + status=SandboxStatus.PAUSED, + sandbox_spec_id=str(uuid4()), + session_api_key='test-api-key', + ) + + mock_user_context = MagicMock(spec=UserContext) + mock_app_conversation_service = _make_service_mock( + user_context=mock_user_context, + conversation_return=mock_conversation, + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + mock_sandbox_spec_service = MagicMock() + + # Act + response = await get_conversation_skills( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Assert + assert response.status_code == status.HTTP_404_NOT_FOUND + content = response.body.decode('utf-8') + import json + + data = json.loads(content) + assert 'error' in data + assert 'not running' in data['error'] + + async def test_get_skills_handles_task_trigger_skills(self): + """Test endpoint correctly handles skills with TaskTrigger. + + Arrange: Setup skill with TaskTrigger + Act: Call get_conversation_skills endpoint + Assert: Skill is categorized as knowledge type with correct triggers + """ + # Arrange + conversation_id = uuid4() + sandbox_id = str(uuid4()) + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + sandbox_status=SandboxStatus.RUNNING, + ) + + mock_sandbox = SandboxInfo( + id=sandbox_id, + created_by_user_id='test-user', + status=SandboxStatus.RUNNING, + sandbox_spec_id=str(uuid4()), + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000) + ], + ) + + mock_sandbox_spec = SandboxSpecInfo( + id=str(uuid4()), command=None, working_dir='/workspace' + ) + + # Create task skill with TaskTrigger + task_skill = Skill( + name='task_skill', + content='Task skill content', + trigger=TaskTrigger(triggers=['task', 'execute']), + ) + + mock_user_context = MagicMock(spec=UserContext) + mock_app_conversation_service = _make_service_mock( + user_context=mock_user_context, + conversation_return=mock_conversation, + skills_return=[task_skill], + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + mock_sandbox_spec_service = MagicMock() + mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( + return_value=mock_sandbox_spec + ) + + # Act + response = await get_conversation_skills( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Assert + assert response.status_code == status.HTTP_200_OK + content = response.body.decode('utf-8') + import json + + data = json.loads(content) + assert len(data['skills']) == 1 + skill_data = data['skills'][0] + assert skill_data['type'] == 'knowledge' + assert skill_data['triggers'] == ['task', 'execute'] + + async def test_get_skills_returns_500_on_skill_loading_error(self): + """Test endpoint returns 500 when skill loading fails. + + Arrange: Setup mocks to raise exception during skill loading + Act: Call get_conversation_skills endpoint + Assert: Response is 500 with error message + """ + # Arrange + conversation_id = uuid4() + sandbox_id = str(uuid4()) + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + sandbox_status=SandboxStatus.RUNNING, + ) + + mock_sandbox = SandboxInfo( + id=sandbox_id, + created_by_user_id='test-user', + status=SandboxStatus.RUNNING, + sandbox_spec_id=str(uuid4()), + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000) + ], + ) + + mock_sandbox_spec = SandboxSpecInfo( + id=str(uuid4()), command=None, working_dir='/workspace' + ) + + mock_user_context = MagicMock(spec=UserContext) + mock_app_conversation_service = _make_service_mock( + user_context=mock_user_context, + conversation_return=mock_conversation, + raise_on_load=True, + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + mock_sandbox_spec_service = MagicMock() + mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( + return_value=mock_sandbox_spec + ) + + # Act + response = await get_conversation_skills( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Assert + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + content = response.body.decode('utf-8') + import json + + data = json.loads(content) + assert 'error' in data + assert 'Error getting skills' in data['error'] + + async def test_get_skills_returns_empty_list_when_no_skills_loaded(self): + """Test endpoint returns empty skills list when no skills are found. + + Arrange: Setup all skill loaders to return empty lists + Act: Call get_conversation_skills endpoint + Assert: Response contains empty skills array + """ + # Arrange + conversation_id = uuid4() + sandbox_id = str(uuid4()) + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + sandbox_status=SandboxStatus.RUNNING, + ) + + mock_sandbox = SandboxInfo( + id=sandbox_id, + created_by_user_id='test-user', + status=SandboxStatus.RUNNING, + sandbox_spec_id=str(uuid4()), + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000) + ], + ) + + mock_sandbox_spec = SandboxSpecInfo( + id=str(uuid4()), command=None, working_dir='/workspace' + ) + + mock_user_context = MagicMock(spec=UserContext) + mock_app_conversation_service = _make_service_mock( + user_context=mock_user_context, + conversation_return=mock_conversation, + skills_return=[], + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + mock_sandbox_spec_service = MagicMock() + mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( + return_value=mock_sandbox_spec + ) + + # Act + response = await get_conversation_skills( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + ) + + # Assert + assert response.status_code == status.HTTP_200_OK + content = response.body.decode('utf-8') + import json + + data = json.loads(content) + assert 'skills' in data + assert len(data['skills']) == 0 diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index 1dabdfa88a..f662f33146 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -6,16 +6,25 @@ from uuid import UUID, uuid4 import pytest from openhands.agent_server.models import SendMessageRequest, StartConversationRequest -from openhands.app_server.app_conversation.app_conversation_models import AgentType +from openhands.app_server.app_conversation.app_conversation_models import ( + AgentType, + AppConversationStartRequest, +) from openhands.app_server.app_conversation.live_status_app_conversation_service import ( LiveStatusAppConversationService, ) -from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus +from openhands.app_server.sandbox.sandbox_models import ( + AGENT_SERVER, + ExposedUrl, + SandboxInfo, + SandboxStatus, +) +from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo from openhands.app_server.user.user_context import UserContext from openhands.integrations.provider import ProviderType from openhands.sdk import Agent -from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret from openhands.sdk.llm import LLM +from openhands.sdk.secret import LookupSecret, StaticSecret from openhands.sdk.workspace import LocalWorkspace from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.server.types import AppMode @@ -68,6 +77,7 @@ class TestLiveStatusAppConversationService: self.mock_user.search_api_key = None # Default to None self.mock_user.condenser_max_size = None # Default to None self.mock_user.llm_base_url = 'https://api.openai.com/v1' + self.mock_user.mcp_config = None # Default to None to avoid error handling path # Mock sandbox self.mock_sandbox = Mock(spec=SandboxInfo) @@ -239,9 +249,16 @@ class TestLiveStatusAppConversationService: assert llm.api_key.get_secret_value() == self.mock_user.llm_api_key assert llm.usage_id == 'agent' - assert 'default' in mcp_config - assert mcp_config['default']['url'] == 'https://test.example.com/mcp/mcp' - assert mcp_config['default']['headers']['X-Session-API-Key'] == 'mcp_api_key' + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert ( + mcp_config['mcpServers']['default']['url'] + == 'https://test.example.com/mcp/mcp' + ) + assert ( + mcp_config['mcpServers']['default']['headers']['X-Session-API-Key'] + == 'mcp_api_key' + ) @pytest.mark.asyncio async def test_configure_llm_and_mcp_openhands_model_prefers_user_base_url(self): @@ -290,7 +307,7 @@ class TestLiveStatusAppConversationService: ) # Assert - assert llm.base_url is None + assert llm.base_url == 'https://llm-proxy.app.all-hands.dev/' @pytest.mark.asyncio async def test_configure_llm_and_mcp_non_openhands_model_ignores_provider(self): @@ -320,8 +337,9 @@ class TestLiveStatusAppConversationService: # Assert assert llm.model == self.mock_user.llm_model - assert 'default' in mcp_config - assert 'headers' not in mcp_config['default'] + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'headers' not in mcp_config['mcpServers']['default'] @pytest.mark.asyncio async def test_configure_llm_and_mcp_without_web_url(self): @@ -354,10 +372,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' in mcp_config['mcpServers'] assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key' ) @@ -375,10 +394,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' in mcp_config['mcpServers'] assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key' ) @@ -399,9 +419,10 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'tavily' in mcp_config['mcpServers'] assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key' ) @@ -420,8 +441,9 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' not in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' not in mcp_config['mcpServers'] @pytest.mark.asyncio async def test_configure_llm_and_mcp_saas_mode_no_tavily_without_user_key(self): @@ -443,8 +465,9 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' not in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' not in mcp_config['mcpServers'] @pytest.mark.asyncio async def test_configure_llm_and_mcp_saas_mode_with_user_search_key(self): @@ -467,10 +490,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'default' in mcp_config - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'default' in mcp_config['mcpServers'] + assert 'tavily' in mcp_config['mcpServers'] assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key' ) @@ -491,10 +515,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'tavily' in mcp_config['mcpServers'] # Should fall back to env key since user key is empty assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key' ) @@ -515,10 +540,11 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(llm, LLM) - assert 'tavily' in mcp_config + assert 'mcpServers' in mcp_config + assert 'tavily' in mcp_config['mcpServers'] # Should fall back to env key since user key is whitespace only assert ( - mcp_config['tavily']['url'] + mcp_config['mcpServers']['tavily']['url'] == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key' ) @@ -821,5 +847,510 @@ class TestLiveStatusAppConversationService: 'Test suffix', mock_mcp_config, self.mock_user.condenser_max_size, + secrets=mock_secrets, ) self.service._finalize_conversation_request.assert_called_once() + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.live_status_app_conversation_service.AsyncRemoteWorkspace' + ) + @patch( + 'openhands.app_server.app_conversation.live_status_app_conversation_service.ConversationInfo' + ) + async def test_start_app_conversation_default_title_uses_first_five_characters( + self, mock_conversation_info_class, mock_remote_workspace_class + ): + """Test that v1 conversations use first 5 characters of conversation ID for default title.""" + # Arrange + conversation_id = uuid4() + conversation_id_hex = conversation_id.hex + expected_title = f'Conversation {conversation_id_hex[:5]}' + + # Mock user context + self.mock_user_context.get_user_id = AsyncMock(return_value='test_user_123') + self.mock_user_context.get_user_info = AsyncMock(return_value=self.mock_user) + + # Mock sandbox and sandbox spec + mock_sandbox_spec = Mock(spec=SandboxSpecInfo) + mock_sandbox_spec.working_dir = '/test/workspace' + self.mock_sandbox.sandbox_spec_id = str(uuid4()) + self.mock_sandbox.id = str(uuid4()) # Ensure sandbox.id is a string + self.mock_sandbox.session_api_key = 'test_session_key' + exposed_url = ExposedUrl( + name=AGENT_SERVER, url='http://agent-server:8000', port=60000 + ) + self.mock_sandbox.exposed_urls = [exposed_url] + + self.mock_sandbox_service.get_sandbox = AsyncMock( + return_value=self.mock_sandbox + ) + self.mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( + return_value=mock_sandbox_spec + ) + + # Mock remote workspace + mock_remote_workspace = Mock() + mock_remote_workspace_class.return_value = mock_remote_workspace + + # Mock the wait for sandbox and setup scripts + async def mock_wait_for_sandbox(task): + task.sandbox_id = self.mock_sandbox.id + yield task + + async def mock_run_setup_scripts(task, sandbox, workspace): + yield task + + self.service._wait_for_sandbox_start = mock_wait_for_sandbox + self.service.run_setup_scripts = mock_run_setup_scripts + + # Mock build start conversation request + mock_agent = Mock(spec=Agent) + mock_agent.llm = Mock(spec=LLM) + mock_agent.llm.model = 'gpt-4' + mock_start_request = Mock(spec=StartConversationRequest) + mock_start_request.agent = mock_agent + mock_start_request.model_dump.return_value = {'test': 'data'} + + self.service._build_start_conversation_request_for_user = AsyncMock( + return_value=mock_start_request + ) + + # Mock ConversationInfo returned from agent server + mock_conversation_info = Mock() + mock_conversation_info.id = conversation_id + mock_conversation_info_class.model_validate.return_value = ( + mock_conversation_info + ) + + # Mock HTTP response from agent server + mock_response = Mock() + mock_response.json.return_value = {'id': str(conversation_id)} + mock_response.raise_for_status = Mock() + self.mock_httpx_client.post = AsyncMock(return_value=mock_response) + + # Mock event callback service + self.mock_event_callback_service.save_event_callback = AsyncMock() + + # Create request + request = AppConversationStartRequest() + + # Act + async for task in self.service._start_app_conversation(request): + # Consume all tasks to reach the point where title is set + pass + + # Assert + # Verify that save_app_conversation_info was called with the correct title format + self.mock_app_conversation_info_service.save_app_conversation_info.assert_called_once() + call_args = ( + self.mock_app_conversation_info_service.save_app_conversation_info.call_args + ) + saved_info = call_args[0][0] # First positional argument + + assert saved_info.title == expected_title, ( + f'Expected title to be "{expected_title}" (first 5 chars), ' + f'but got "{saved_info.title}"' + ) + assert saved_info.id == conversation_id + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_with_custom_sse_servers(self): + """Test _configure_llm_and_mcp merges custom SSE servers with UUID-based names.""" + # Arrange + + from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[ + MCPSSEServerConfig(url='https://linear.app/sse', api_key='linear_key'), + MCPSSEServerConfig(url='https://notion.com/sse'), + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + assert isinstance(llm, LLM) + assert 'mcpServers' in mcp_config + + # Should have default server + 2 custom SSE servers + mcp_servers = mcp_config['mcpServers'] + assert 'default' in mcp_servers + + # Find SSE servers (they have sse_ prefix) + sse_servers = {k: v for k, v in mcp_servers.items() if k.startswith('sse_')} + assert len(sse_servers) == 2 + + # Verify SSE server configurations + for server_name, server_config in sse_servers.items(): + assert server_name.startswith('sse_') + assert len(server_name) > 4 # Has UUID suffix + assert 'url' in server_config + assert 'transport' in server_config + assert server_config['transport'] == 'sse' + + # Check if this is the Linear server (has headers) + if 'headers' in server_config: + assert server_config['headers']['Authorization'] == 'Bearer linear_key' + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_with_custom_shttp_servers(self): + """Test _configure_llm_and_mcp merges custom SHTTP servers with timeout.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPSHTTPServerConfig + + self.mock_user.mcp_config = MCPConfig( + shttp_servers=[ + MCPSHTTPServerConfig( + url='https://example.com/mcp', + api_key='test_key', + timeout=120, + ) + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + assert isinstance(llm, LLM) + mcp_servers = mcp_config['mcpServers'] + + # Find SHTTP servers + shttp_servers = {k: v for k, v in mcp_servers.items() if k.startswith('shttp_')} + assert len(shttp_servers) == 1 + + server_config = list(shttp_servers.values())[0] + assert server_config['url'] == 'https://example.com/mcp' + assert server_config['transport'] == 'streamable-http' + assert server_config['headers']['Authorization'] == 'Bearer test_key' + assert server_config['timeout'] == 120 + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_with_custom_stdio_servers(self): + """Test _configure_llm_and_mcp merges custom STDIO servers with explicit names.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig + + self.mock_user.mcp_config = MCPConfig( + stdio_servers=[ + MCPStdioServerConfig( + name='my-custom-server', + command='npx', + args=['-y', 'my-package'], + env={'API_KEY': 'secret'}, + ) + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + assert isinstance(llm, LLM) + mcp_servers = mcp_config['mcpServers'] + + # STDIO server should use its explicit name + assert 'my-custom-server' in mcp_servers + server_config = mcp_servers['my-custom-server'] + assert server_config['command'] == 'npx' + assert server_config['args'] == ['-y', 'my-package'] + assert server_config['env'] == {'API_KEY': 'secret'} + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_merges_system_and_custom_servers(self): + """Test _configure_llm_and_mcp merges both system and custom MCP servers.""" + # Arrange + from pydantic import SecretStr + + from openhands.core.config.mcp_config import ( + MCPConfig, + MCPSSEServerConfig, + MCPStdioServerConfig, + ) + + self.mock_user.search_api_key = SecretStr('tavily_key') + self.mock_user.mcp_config = MCPConfig( + sse_servers=[MCPSSEServerConfig(url='https://custom.com/sse')], + stdio_servers=[ + MCPStdioServerConfig( + name='custom-stdio', command='node', args=['app.js'] + ) + ], + ) + self.mock_user_context.get_mcp_api_key.return_value = 'mcp_api_key' + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + + # Should have system servers + assert 'default' in mcp_servers + assert 'tavily' in mcp_servers + + # Should have custom SSE server with UUID name + sse_servers = [k for k in mcp_servers if k.startswith('sse_')] + assert len(sse_servers) == 1 + + # Should have custom STDIO server with explicit name + assert 'custom-stdio' in mcp_servers + + # Total: default + tavily + 1 SSE + 1 STDIO = 4 servers + assert len(mcp_servers) == 4 + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_custom_config_error_handling(self): + """Test _configure_llm_and_mcp handles errors in custom MCP config gracefully.""" + # Arrange + self.mock_user.mcp_config = Mock() + # Simulate error when accessing sse_servers + self.mock_user.mcp_config.sse_servers = property( + lambda self: (_ for _ in ()).throw(Exception('Config error')) + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert - should still return valid config with system servers only + assert isinstance(llm, LLM) + mcp_servers = mcp_config['mcpServers'] + assert 'default' in mcp_servers + # Custom servers should not be added due to error + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_sdk_format_with_mcpservers_wrapper(self): + """Test _configure_llm_and_mcp returns SDK-required format with mcpServers key.""" + # Arrange + self.mock_user_context.get_mcp_api_key.return_value = 'mcp_key' + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert - SDK expects {'mcpServers': {...}} format + assert 'mcpServers' in mcp_config + assert isinstance(mcp_config['mcpServers'], dict) + + # Verify structure matches SDK expectations + for server_name, server_config in mcp_config['mcpServers'].items(): + assert isinstance(server_name, str) + assert isinstance(server_config, dict) + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_empty_custom_config(self): + """Test _configure_llm_and_mcp handles empty custom MCP config.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[], stdio_servers=[], shttp_servers=[] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + # Should only have system default server + assert 'default' in mcp_servers + assert len(mcp_servers) == 1 + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_sse_server_without_api_key(self): + """Test _configure_llm_and_mcp handles SSE servers without API keys.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[MCPSSEServerConfig(url='https://public.com/sse')] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + sse_servers = {k: v for k, v in mcp_servers.items() if k.startswith('sse_')} + + # Server should exist but without headers + assert len(sse_servers) == 1 + server_config = list(sse_servers.values())[0] + assert 'headers' not in server_config + assert server_config['url'] == 'https://public.com/sse' + assert server_config['transport'] == 'sse' + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_shttp_server_without_timeout(self): + """Test _configure_llm_and_mcp handles SHTTP servers without timeout.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPSHTTPServerConfig + + self.mock_user.mcp_config = MCPConfig( + shttp_servers=[MCPSHTTPServerConfig(url='https://example.com/mcp')] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + shttp_servers = {k: v for k, v in mcp_servers.items() if k.startswith('shttp_')} + + assert len(shttp_servers) == 1 + server_config = list(shttp_servers.values())[0] + # Timeout should be included even if None (defaults to 60) + assert 'timeout' in server_config + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_stdio_server_without_env(self): + """Test _configure_llm_and_mcp handles STDIO servers without environment variables.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig + + self.mock_user.mcp_config = MCPConfig( + stdio_servers=[ + MCPStdioServerConfig( + name='simple-server', command='node', args=['app.js'] + ) + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + assert 'simple-server' in mcp_servers + server_config = mcp_servers['simple-server'] + + # Should not have env key if not provided + assert 'env' not in server_config + assert server_config['command'] == 'node' + assert server_config['args'] == ['app.js'] + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_multiple_servers_same_type(self): + """Test _configure_llm_and_mcp handles multiple custom servers of the same type.""" + # Arrange + from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[ + MCPSSEServerConfig(url='https://server1.com/sse'), + MCPSSEServerConfig(url='https://server2.com/sse'), + MCPSSEServerConfig(url='https://server3.com/sse'), + ] + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + sse_servers = {k: v for k, v in mcp_servers.items() if k.startswith('sse_')} + + # All 3 servers should be present with unique UUID-based names + assert len(sse_servers) == 3 + + # Verify all have unique names + server_names = list(sse_servers.keys()) + assert len(set(server_names)) == 3 # All names are unique + + # Verify all URLs are preserved + urls = [v['url'] for v in sse_servers.values()] + assert 'https://server1.com/sse' in urls + assert 'https://server2.com/sse' in urls + assert 'https://server3.com/sse' in urls + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_mixed_server_types(self): + """Test _configure_llm_and_mcp handles all three server types together.""" + # Arrange + from openhands.core.config.mcp_config import ( + MCPConfig, + MCPSHTTPServerConfig, + MCPSSEServerConfig, + MCPStdioServerConfig, + ) + + self.mock_user.mcp_config = MCPConfig( + sse_servers=[ + MCPSSEServerConfig(url='https://sse.example.com/sse', api_key='sse_key') + ], + shttp_servers=[ + MCPSHTTPServerConfig(url='https://shttp.example.com/mcp', timeout=90) + ], + stdio_servers=[ + MCPStdioServerConfig( + name='stdio-server', + command='npx', + args=['mcp-server'], + env={'TOKEN': 'value'}, + ) + ], + ) + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, mcp_config = await self.service._configure_llm_and_mcp( + self.mock_user, None + ) + + # Assert + mcp_servers = mcp_config['mcpServers'] + + # Check all server types are present + sse_count = len([k for k in mcp_servers if k.startswith('sse_')]) + shttp_count = len([k for k in mcp_servers if k.startswith('shttp_')]) + stdio_count = 1 if 'stdio-server' in mcp_servers else 0 + + assert sse_count == 1 + assert shttp_count == 1 + assert stdio_count == 1 + + # Verify each type has correct configuration + sse_server = next(v for k, v in mcp_servers.items() if k.startswith('sse_')) + assert sse_server['transport'] == 'sse' + assert sse_server['headers']['Authorization'] == 'Bearer sse_key' + + shttp_server = next(v for k, v in mcp_servers.items() if k.startswith('shttp_')) + assert shttp_server['transport'] == 'streamable-http' + assert shttp_server['timeout'] == 90 + + stdio_server = mcp_servers['stdio-server'] + assert stdio_server['command'] == 'npx' + assert stdio_server['env'] == {'TOKEN': 'value'} diff --git a/tests/unit/app_server/test_skill_loader.py b/tests/unit/app_server/test_skill_loader.py index c9e54ba5a1..e4daadfa14 100644 --- a/tests/unit/app_server/test_skill_loader.py +++ b/tests/unit/app_server/test_skill_loader.py @@ -11,15 +11,27 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from openhands.app_server.app_conversation.skill_loader import ( + _cleanup_org_repository, + _clone_org_repository, + _determine_org_repo_path, _determine_repo_root, _find_and_load_global_skill_files, _find_and_load_skill_md_files, + _get_org_repository_url, + _is_azure_devops_repository, + _is_gitlab_repository, + _load_skills_from_org_directories, _load_special_files, + _merge_org_skills_with_precedence, _read_file_from_workspace, + _validate_repository_for_org_skills, load_global_skills, + load_org_skills, load_repo_skills, merge_skills, ) +from openhands.integrations.provider import ProviderType +from openhands.integrations.service_types import AuthenticationError # ===== Test Fixtures ===== @@ -667,6 +679,669 @@ class TestMergeSkills: assert len(result) == 2 +# ===== Tests for Organization Skills Functions ===== + + +class TestIsGitlabRepository: + """Test _is_gitlab_repository helper function.""" + + @pytest.mark.asyncio + async def test_is_gitlab_repository_true(self): + """Test GitLab repository detection returns True.""" + # Arrange + mock_user_context = AsyncMock() + mock_provider_handler = AsyncMock() + mock_repository = Mock() + mock_repository.git_provider = ProviderType.GITLAB + + mock_user_context.get_provider_handler.return_value = mock_provider_handler + mock_provider_handler.verify_repo_provider.return_value = mock_repository + + # Act + result = await _is_gitlab_repository('owner/repo', mock_user_context) + + # Assert + assert result is True + mock_provider_handler.verify_repo_provider.assert_called_once_with('owner/repo') + + @pytest.mark.asyncio + async def test_is_gitlab_repository_false(self): + """Test non-GitLab repository detection returns False.""" + # Arrange + mock_user_context = AsyncMock() + mock_provider_handler = AsyncMock() + mock_repository = Mock() + mock_repository.git_provider = ProviderType.GITHUB + + mock_user_context.get_provider_handler.return_value = mock_provider_handler + mock_provider_handler.verify_repo_provider.return_value = mock_repository + + # Act + result = await _is_gitlab_repository('owner/repo', mock_user_context) + + # Assert + assert result is False + + @pytest.mark.asyncio + async def test_is_gitlab_repository_exception_handling(self): + """Test exception handling returns False.""" + # Arrange + mock_user_context = AsyncMock() + mock_user_context.get_provider_handler.side_effect = Exception('API error') + + # Act + result = await _is_gitlab_repository('owner/repo', mock_user_context) + + # Assert + assert result is False + + +class TestIsAzureDevOpsRepository: + """Test _is_azure_devops_repository helper function.""" + + @pytest.mark.asyncio + async def test_is_azure_devops_repository_true(self): + """Test Azure DevOps repository detection returns True.""" + # Arrange + mock_user_context = AsyncMock() + mock_provider_handler = AsyncMock() + mock_repository = Mock() + mock_repository.git_provider = ProviderType.AZURE_DEVOPS + + mock_user_context.get_provider_handler.return_value = mock_provider_handler + mock_provider_handler.verify_repo_provider.return_value = mock_repository + + # Act + result = await _is_azure_devops_repository( + 'org/project/repo', mock_user_context + ) + + # Assert + assert result is True + mock_provider_handler.verify_repo_provider.assert_called_once_with( + 'org/project/repo' + ) + + @pytest.mark.asyncio + async def test_is_azure_devops_repository_false(self): + """Test non-Azure DevOps repository detection returns False.""" + # Arrange + mock_user_context = AsyncMock() + mock_provider_handler = AsyncMock() + mock_repository = Mock() + mock_repository.git_provider = ProviderType.GITHUB + + mock_user_context.get_provider_handler.return_value = mock_provider_handler + mock_provider_handler.verify_repo_provider.return_value = mock_repository + + # Act + result = await _is_azure_devops_repository('owner/repo', mock_user_context) + + # Assert + assert result is False + + @pytest.mark.asyncio + async def test_is_azure_devops_repository_exception_handling(self): + """Test exception handling returns False.""" + # Arrange + mock_user_context = AsyncMock() + mock_user_context.get_provider_handler.side_effect = Exception('Network error') + + # Act + result = await _is_azure_devops_repository('owner/repo', mock_user_context) + + # Assert + assert result is False + + +class TestDetermineOrgRepoPath: + """Test _determine_org_repo_path helper function.""" + + @pytest.mark.asyncio + @patch('openhands.app_server.app_conversation.skill_loader._is_gitlab_repository') + @patch( + 'openhands.app_server.app_conversation.skill_loader._is_azure_devops_repository' + ) + async def test_github_repository_path(self, mock_is_azure, mock_is_gitlab): + """Test org path for GitHub repository.""" + # Arrange + mock_user_context = AsyncMock() + mock_is_gitlab.return_value = False + mock_is_azure.return_value = False + + # Act + org_repo, org_name = await _determine_org_repo_path( + 'owner/repo', mock_user_context + ) + + # Assert + assert org_repo == 'owner/.openhands' + assert org_name == 'owner' + + @pytest.mark.asyncio + @patch('openhands.app_server.app_conversation.skill_loader._is_gitlab_repository') + @patch( + 'openhands.app_server.app_conversation.skill_loader._is_azure_devops_repository' + ) + async def test_gitlab_repository_path(self, mock_is_azure, mock_is_gitlab): + """Test org path for GitLab repository.""" + # Arrange + mock_user_context = AsyncMock() + mock_is_gitlab.return_value = True + mock_is_azure.return_value = False + + # Act + org_repo, org_name = await _determine_org_repo_path( + 'owner/repo', mock_user_context + ) + + # Assert + assert org_repo == 'owner/openhands-config' + assert org_name == 'owner' + + @pytest.mark.asyncio + @patch('openhands.app_server.app_conversation.skill_loader._is_gitlab_repository') + @patch( + 'openhands.app_server.app_conversation.skill_loader._is_azure_devops_repository' + ) + async def test_azure_devops_repository_path(self, mock_is_azure, mock_is_gitlab): + """Test org path for Azure DevOps repository.""" + # Arrange + mock_user_context = AsyncMock() + mock_is_gitlab.return_value = False + mock_is_azure.return_value = True + + # Act + org_repo, org_name = await _determine_org_repo_path( + 'org/project/repo', mock_user_context + ) + + # Assert + assert org_repo == 'org/openhands-config/openhands-config' + assert org_name == 'org' + + +class TestValidateRepositoryForOrgSkills: + """Test _validate_repository_for_org_skills helper function.""" + + def test_valid_repository_two_parts(self): + """Test validation passes for repository with two parts.""" + # Act + result = _validate_repository_for_org_skills('owner/repo') + + # Assert + assert result is True + + def test_valid_repository_three_parts(self): + """Test validation passes for repository with three parts (Azure DevOps).""" + # Act + result = _validate_repository_for_org_skills('org/project/repo') + + # Assert + assert result is True + + def test_invalid_repository_one_part(self): + """Test validation fails for repository with only one part.""" + # Act + result = _validate_repository_for_org_skills('repo') + + # Assert + assert result is False + + def test_invalid_repository_empty_string(self): + """Test validation fails for empty string.""" + # Act + result = _validate_repository_for_org_skills('') + + # Assert + assert result is False + + +class TestGetOrgRepositoryUrl: + """Test _get_org_repository_url helper function.""" + + @pytest.mark.asyncio + async def test_successful_url_retrieval(self): + """Test successfully retrieving authenticated URL.""" + # Arrange + mock_user_context = AsyncMock() + expected_url = 'https://token@github.com/owner/.openhands.git' + mock_user_context.get_authenticated_git_url.return_value = expected_url + + # Act + result = await _get_org_repository_url('owner/.openhands', mock_user_context) + + # Assert + assert result == expected_url + mock_user_context.get_authenticated_git_url.assert_called_once_with( + 'owner/.openhands' + ) + + @pytest.mark.asyncio + async def test_authentication_error(self): + """Test handling of authentication error returns None.""" + # Arrange + mock_user_context = AsyncMock() + mock_user_context.get_authenticated_git_url.side_effect = AuthenticationError( + 'Not found' + ) + + # Act + result = await _get_org_repository_url('owner/.openhands', mock_user_context) + + # Assert + assert result is None + + @pytest.mark.asyncio + async def test_general_exception(self): + """Test handling of general exception returns None.""" + # Arrange + mock_user_context = AsyncMock() + mock_user_context.get_authenticated_git_url.side_effect = Exception( + 'Network error' + ) + + # Act + result = await _get_org_repository_url('owner/.openhands', mock_user_context) + + # Assert + assert result is None + + +class TestCloneOrgRepository: + """Test _clone_org_repository helper function.""" + + @pytest.mark.asyncio + async def test_successful_clone(self, mock_async_remote_workspace): + """Test successful repository clone.""" + # Arrange + result_obj = Mock() + result_obj.exit_code = 0 + mock_async_remote_workspace.execute_command.return_value = result_obj + + # Act + success = await _clone_org_repository( + mock_async_remote_workspace, + 'https://github.com/owner/.openhands.git', + '/workspace/_org_openhands_owner', + '/workspace', + 'owner/.openhands', + ) + + # Assert + assert success is True + mock_async_remote_workspace.execute_command.assert_called_once() + call_args = mock_async_remote_workspace.execute_command.call_args + assert 'git clone' in call_args[0][0] + assert '--depth 1' in call_args[0][0] + + @pytest.mark.asyncio + async def test_failed_clone(self, mock_async_remote_workspace): + """Test failed repository clone.""" + # Arrange + result_obj = Mock() + result_obj.exit_code = 1 + result_obj.stderr = 'Repository not found' + mock_async_remote_workspace.execute_command.return_value = result_obj + + # Act + success = await _clone_org_repository( + mock_async_remote_workspace, + 'https://github.com/owner/.openhands.git', + '/workspace/_org_openhands_owner', + '/workspace', + 'owner/.openhands', + ) + + # Assert + assert success is False + + +class TestLoadSkillsFromOrgDirectories: + """Test _load_skills_from_org_directories helper function.""" + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.skill_loader._find_and_load_skill_md_files' + ) + async def test_load_from_both_directories( + self, mock_find_and_load, mock_async_remote_workspace, mock_skills_list + ): + """Test loading skills from both skills/ and microagents/ directories.""" + # Arrange + skills_dir_skills = [mock_skills_list[0]] + microagents_dir_skills = [mock_skills_list[1], mock_skills_list[2]] + mock_find_and_load.side_effect = [skills_dir_skills, microagents_dir_skills] + + # Act + result_skills, result_microagents = await _load_skills_from_org_directories( + mock_async_remote_workspace, '/workspace/_org_openhands_owner', '/workspace' + ) + + # Assert + assert result_skills == skills_dir_skills + assert result_microagents == microagents_dir_skills + assert mock_find_and_load.call_count == 2 + + # Verify correct directories were checked + first_call = mock_find_and_load.call_args_list[0] + second_call = mock_find_and_load.call_args_list[1] + assert '/skills' in first_call[0][1] + assert '/microagents' in second_call[0][1] + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.skill_loader._find_and_load_skill_md_files' + ) + async def test_load_with_empty_directories( + self, mock_find_and_load, mock_async_remote_workspace + ): + """Test loading when both directories are empty.""" + # Arrange + mock_find_and_load.side_effect = [[], []] + + # Act + result_skills, result_microagents = await _load_skills_from_org_directories( + mock_async_remote_workspace, '/workspace/_org_openhands_owner', '/workspace' + ) + + # Assert + assert result_skills == [] + assert result_microagents == [] + + +class TestMergeOrgSkillsWithPrecedence: + """Test _merge_org_skills_with_precedence helper function.""" + + def test_merge_no_duplicates(self, mock_skills_list): + """Test merging skills with no name conflicts.""" + # Arrange + skills_dir_skills = [mock_skills_list[0]] + microagents_dir_skills = [mock_skills_list[1], mock_skills_list[2]] + + # Act + result = _merge_org_skills_with_precedence( + skills_dir_skills, microagents_dir_skills + ) + + # Assert + assert len(result) == 3 + names = {s.name for s in result} + assert names == {'skill_0', 'skill_1', 'skill_2'} + + def test_merge_with_duplicate_skills_dir_wins(self): + """Test skills/ directory takes precedence over microagents/.""" + # Arrange + skill_from_microagents = Mock() + skill_from_microagents.name = 'common_skill' + skill_from_microagents.source = 'microagents' + + skill_from_skills = Mock() + skill_from_skills.name = 'common_skill' + skill_from_skills.source = 'skills' + + # Act + result = _merge_org_skills_with_precedence( + [skill_from_skills], [skill_from_microagents] + ) + + # Assert + assert len(result) == 1 + assert result[0].source == 'skills' + + def test_merge_with_empty_lists(self): + """Test merging with empty skill lists.""" + # Act + result = _merge_org_skills_with_precedence([], []) + + # Assert + assert result == [] + + def test_merge_with_only_skills_dir(self, mock_skills_list): + """Test merging with only skills/ directory populated.""" + # Act + result = _merge_org_skills_with_precedence([mock_skills_list[0]], []) + + # Assert + assert len(result) == 1 + assert result[0] == mock_skills_list[0] + + def test_merge_with_only_microagents_dir(self, mock_skills_list): + """Test merging with only microagents/ directory populated.""" + # Act + result = _merge_org_skills_with_precedence([], [mock_skills_list[0]]) + + # Assert + assert len(result) == 1 + assert result[0] == mock_skills_list[0] + + +class TestCleanupOrgRepository: + """Test _cleanup_org_repository helper function.""" + + @pytest.mark.asyncio + async def test_cleanup_successful(self, mock_async_remote_workspace): + """Test successful cleanup of org repository directory.""" + # Arrange + result_obj = Mock() + result_obj.exit_code = 0 + mock_async_remote_workspace.execute_command.return_value = result_obj + + # Act + await _cleanup_org_repository( + mock_async_remote_workspace, + '/workspace/_org_openhands_owner', + '/workspace', + ) + + # Assert + mock_async_remote_workspace.execute_command.assert_called_once() + call_args = mock_async_remote_workspace.execute_command.call_args + assert 'rm -rf' in call_args[0][0] + assert '/workspace/_org_openhands_owner' in call_args[0][0] + + +class TestLoadOrgSkills: + """Test load_org_skills main function.""" + + @pytest.mark.asyncio + async def test_load_org_skills_no_selected_repository( + self, mock_async_remote_workspace + ): + """Test load_org_skills returns empty list when no repository selected.""" + # Arrange + mock_user_context = AsyncMock() + + # Act + result = await load_org_skills( + mock_async_remote_workspace, None, '/workspace', mock_user_context + ) + + # Assert + assert result == [] + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills' + ) + async def test_load_org_skills_invalid_repository( + self, mock_validate, mock_async_remote_workspace + ): + """Test load_org_skills returns empty list for invalid repository.""" + # Arrange + mock_validate.return_value = False + mock_user_context = AsyncMock() + + # Act + result = await load_org_skills( + mock_async_remote_workspace, 'invalid', '/workspace', mock_user_context + ) + + # Assert + assert result == [] + mock_validate.assert_called_once_with('invalid') + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills' + ) + @patch( + 'openhands.app_server.app_conversation.skill_loader._determine_org_repo_path' + ) + @patch('openhands.app_server.app_conversation.skill_loader._get_org_repository_url') + async def test_load_org_skills_no_url_available( + self, + mock_get_url, + mock_determine_path, + mock_validate, + mock_async_remote_workspace, + ): + """Test load_org_skills returns empty list when URL cannot be retrieved.""" + # Arrange + mock_validate.return_value = True + mock_determine_path.return_value = ('owner/.openhands', 'owner') + mock_get_url.return_value = None + mock_user_context = AsyncMock() + + # Act + result = await load_org_skills( + mock_async_remote_workspace, + 'owner/repo', + '/workspace', + mock_user_context, + ) + + # Assert + assert result == [] + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills' + ) + @patch( + 'openhands.app_server.app_conversation.skill_loader._determine_org_repo_path' + ) + @patch('openhands.app_server.app_conversation.skill_loader._get_org_repository_url') + @patch('openhands.app_server.app_conversation.skill_loader._clone_org_repository') + async def test_load_org_skills_clone_fails( + self, + mock_clone, + mock_get_url, + mock_determine_path, + mock_validate, + mock_async_remote_workspace, + ): + """Test load_org_skills returns empty list when clone fails.""" + # Arrange + mock_validate.return_value = True + mock_determine_path.return_value = ('owner/.openhands', 'owner') + mock_get_url.return_value = 'https://github.com/owner/.openhands.git' + mock_clone.return_value = False + mock_user_context = AsyncMock() + + # Act + result = await load_org_skills( + mock_async_remote_workspace, + 'owner/repo', + '/workspace', + mock_user_context, + ) + + # Assert + assert result == [] + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills' + ) + @patch( + 'openhands.app_server.app_conversation.skill_loader._determine_org_repo_path' + ) + @patch('openhands.app_server.app_conversation.skill_loader._get_org_repository_url') + @patch('openhands.app_server.app_conversation.skill_loader._clone_org_repository') + @patch( + 'openhands.app_server.app_conversation.skill_loader._load_skills_from_org_directories' + ) + @patch('openhands.app_server.app_conversation.skill_loader._cleanup_org_repository') + async def test_load_org_skills_success( + self, + mock_cleanup, + mock_load_skills, + mock_clone, + mock_get_url, + mock_determine_path, + mock_validate, + mock_async_remote_workspace, + mock_skills_list, + ): + """Test successful org skills loading.""" + # Arrange + mock_validate.return_value = True + mock_determine_path.return_value = ('owner/.openhands', 'owner') + mock_get_url.return_value = 'https://github.com/owner/.openhands.git' + mock_clone.return_value = True + mock_load_skills.return_value = ([mock_skills_list[0]], [mock_skills_list[1]]) + mock_user_context = AsyncMock() + + # Act + result = await load_org_skills( + mock_async_remote_workspace, + 'owner/repo', + '/workspace', + mock_user_context, + ) + + # Assert + assert len(result) == 2 + mock_cleanup.assert_called_once() + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills' + ) + async def test_load_org_skills_handles_authentication_error( + self, mock_validate, mock_async_remote_workspace + ): + """Test load_org_skills handles AuthenticationError gracefully.""" + # Arrange + mock_validate.side_effect = AuthenticationError('Auth failed') + mock_user_context = AsyncMock() + + # Act + result = await load_org_skills( + mock_async_remote_workspace, + 'owner/repo', + '/workspace', + mock_user_context, + ) + + # Assert + assert result == [] + + @pytest.mark.asyncio + @patch( + 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills' + ) + async def test_load_org_skills_handles_general_exception( + self, mock_validate, mock_async_remote_workspace + ): + """Test load_org_skills handles general exceptions gracefully.""" + # Arrange + mock_validate.side_effect = Exception('Unexpected error') + mock_user_context = AsyncMock() + + # Act + result = await load_org_skills( + mock_async_remote_workspace, + 'owner/repo', + '/workspace', + mock_user_context, + ) + + # Assert + assert result == [] + + # ===== Integration Tests ===== @@ -754,3 +1429,110 @@ class TestSkillLoaderIntegration: # Should have only one skill with repo source (highest precedence) assert len(all_skills) == 1 assert all_skills[0].source == 'repo' + + @pytest.mark.asyncio + @patch('openhands.app_server.app_conversation.skill_loader.load_global_skills') + @patch('openhands.sdk.context.skills.load_user_skills') + @patch('openhands.app_server.app_conversation.skill_loader.load_org_skills') + @patch('openhands.app_server.app_conversation.skill_loader.load_repo_skills') + async def test_loading_with_org_skills_precedence( + self, + mock_load_repo, + mock_load_org, + mock_load_user, + mock_load_global, + mock_async_remote_workspace, + ): + """Test that org skills fit correctly in precedence order.""" + # Arrange + # Create skills with same name but different sources + global_skill = Mock() + global_skill.name = 'shared_skill' + global_skill.priority = 'low' + + user_skill = Mock() + user_skill.name = 'shared_skill' + user_skill.priority = 'medium' + + org_skill = Mock() + org_skill.name = 'shared_skill' + org_skill.priority = 'high' + + repo_skill = Mock() + repo_skill.name = 'shared_skill' + repo_skill.priority = 'highest' + + mock_load_global.return_value = [global_skill] + mock_load_user.return_value = [user_skill] + mock_load_org.return_value = [org_skill] + mock_load_repo.return_value = [repo_skill] + + mock_user_context = AsyncMock() + + # Act + global_skills = mock_load_global() + user_skills = mock_load_user() + org_skills = await mock_load_org( + mock_async_remote_workspace, 'owner/repo', '/workspace', mock_user_context + ) + repo_skills = await mock_load_repo( + mock_async_remote_workspace, 'owner/repo', '/workspace' + ) + + # Merge with correct precedence: global < user < org < repo + all_skills = merge_skills([global_skills, user_skills, org_skills, repo_skills]) + + # Assert + assert len(all_skills) == 1 + assert all_skills[0].priority == 'highest' # Repo has highest precedence + + @pytest.mark.asyncio + @patch('openhands.app_server.app_conversation.skill_loader.load_global_skills') + @patch('openhands.sdk.context.skills.load_user_skills') + @patch('openhands.app_server.app_conversation.skill_loader.load_org_skills') + @patch('openhands.app_server.app_conversation.skill_loader.load_repo_skills') + async def test_loading_org_skills_with_unique_names( + self, + mock_load_repo, + mock_load_org, + mock_load_user, + mock_load_global, + mock_async_remote_workspace, + ): + """Test loading org skills with unique names alongside other sources.""" + # Arrange + global_skill = Mock() + global_skill.name = 'global_skill' + + user_skill = Mock() + user_skill.name = 'user_skill' + + org_skill = Mock() + org_skill.name = 'org_skill' + + repo_skill = Mock() + repo_skill.name = 'repo_skill' + + mock_load_global.return_value = [global_skill] + mock_load_user.return_value = [user_skill] + mock_load_org.return_value = [org_skill] + mock_load_repo.return_value = [repo_skill] + + mock_user_context = AsyncMock() + + # Act + global_skills = mock_load_global() + user_skills = mock_load_user() + org_skills = await mock_load_org( + mock_async_remote_workspace, 'owner/repo', '/workspace', mock_user_context + ) + repo_skills = await mock_load_repo( + mock_async_remote_workspace, 'owner/repo', '/workspace' + ) + + all_skills = merge_skills([global_skills, user_skills, org_skills, repo_skills]) + + # Assert + assert len(all_skills) == 4 + names = {s.name for s in all_skills} + assert names == {'global_skill', 'user_skill', 'org_skill', 'repo_skill'} diff --git a/tests/unit/experiments/test_experiment_manager.py b/tests/unit/experiments/test_experiment_manager.py index 85faa078f5..c389423cf5 100644 --- a/tests/unit/experiments/test_experiment_manager.py +++ b/tests/unit/experiments/test_experiment_manager.py @@ -153,6 +153,7 @@ class TestExperimentManagerIntegration: llm_api_key=None, confirmation_mode=False, condenser_max_size=None, + security_analyzer=None, ) async def get_secrets(self): diff --git a/tests/unit/memory/test_conversation_memory.py b/tests/unit/memory/test_conversation_memory.py index abaa8d9a3d..50fd48f49a 100644 --- a/tests/unit/memory/test_conversation_memory.py +++ b/tests/unit/memory/test_conversation_memory.py @@ -158,7 +158,8 @@ def test_ensure_initial_user_message_adds_if_only_system( system_message = SystemMessageAction(content='System') system_message._source = EventSource.AGENT events = [system_message] - conversation_memory._ensure_initial_user_message(events, initial_user_action) + # Pass empty set for forgotten_event_ids (no events have been condensed) + conversation_memory._ensure_initial_user_message(events, initial_user_action, set()) assert len(events) == 2 assert events[0] == system_message assert events[1] == initial_user_action @@ -177,7 +178,8 @@ def test_ensure_initial_user_message_correct_already_present( agent_message, ] original_events = list(events) - conversation_memory._ensure_initial_user_message(events, initial_user_action) + # Pass empty set for forgotten_event_ids (no events have been condensed) + conversation_memory._ensure_initial_user_message(events, initial_user_action, set()) assert events == original_events @@ -189,7 +191,8 @@ def test_ensure_initial_user_message_incorrect_at_index_1( incorrect_second_message = MessageAction(content='Assistant') incorrect_second_message._source = EventSource.AGENT events = [system_message, incorrect_second_message] - conversation_memory._ensure_initial_user_message(events, initial_user_action) + # Pass empty set for forgotten_event_ids (no events have been condensed) + conversation_memory._ensure_initial_user_message(events, initial_user_action, set()) assert len(events) == 3 assert events[0] == system_message assert events[1] == initial_user_action # Correct one inserted @@ -206,7 +209,8 @@ def test_ensure_initial_user_message_correct_present_later( # Correct initial message is present, but later in the list events = [system_message, incorrect_second_message] conversation_memory._ensure_system_message(events) - conversation_memory._ensure_initial_user_message(events, initial_user_action) + # Pass empty set for forgotten_event_ids (no events have been condensed) + conversation_memory._ensure_initial_user_message(events, initial_user_action, set()) assert len(events) == 3 # Should still insert at index 1, not remove the later one assert events[0] == system_message assert events[1] == initial_user_action # Correct one inserted at index 1 @@ -222,7 +226,8 @@ def test_ensure_initial_user_message_different_user_msg_at_index_1( different_user_message = MessageAction(content='Different User Message') different_user_message._source = EventSource.USER events = [system_message, different_user_message] - conversation_memory._ensure_initial_user_message(events, initial_user_action) + # Pass empty set for forgotten_event_ids (no events have been condensed) + conversation_memory._ensure_initial_user_message(events, initial_user_action, set()) assert len(events) == 2 assert events[0] == system_message assert events[1] == different_user_message # Original second message remains @@ -1583,3 +1588,132 @@ def test_process_ipython_observation_with_vision_disabled( assert isinstance(message.content[1], ImageContent) # Check that NO explanatory text about filtered images was added when vision is disabled assert 'invalid or empty image(s) were filtered' not in message.content[0].text + + +def test_ensure_initial_user_message_not_reinserted_when_condensed( + conversation_memory, initial_user_action +): + """Test that initial user message is NOT re-inserted when it has been condensed. + + This is a critical test for bug #11910: Old instructions should not be re-executed + after conversation condensation. If the initial user message has been condensed + (its ID is in the forgotten_event_ids set), we should NOT re-insert it to prevent + the LLM from seeing old instructions as fresh commands. + """ + system_message = SystemMessageAction(content='System') + system_message._source = EventSource.AGENT + + # Simulate that the initial_user_action has been condensed by adding its ID + # to the forgotten_event_ids set + initial_user_action._id = 1 # Assign an ID to the initial user action + forgotten_event_ids = {1} # The initial user action's ID is in the forgotten set + + events = [system_message] # Only system message, no user message + + # Call _ensure_initial_user_message with the condensed event ID + conversation_memory._ensure_initial_user_message( + events, initial_user_action, forgotten_event_ids + ) + + # The initial user action should NOT be inserted because it was condensed + assert len(events) == 1 + assert events[0] == system_message + # Verify the initial user action was NOT added + assert initial_user_action not in events + + +def test_ensure_initial_user_message_reinserted_when_not_condensed( + conversation_memory, initial_user_action +): + """Test that initial user message IS re-inserted when it has NOT been condensed. + + This ensures backward compatibility: when no condensation has happened, + the initial user message should still be inserted as before. + """ + system_message = SystemMessageAction(content='System') + system_message._source = EventSource.AGENT + + # The initial user action has NOT been condensed + initial_user_action._id = 1 + forgotten_event_ids = {5, 10, 15} # Different IDs, not including the initial action + + events = [system_message] + + # Call _ensure_initial_user_message with non-matching forgotten IDs + conversation_memory._ensure_initial_user_message( + events, initial_user_action, forgotten_event_ids + ) + + # The initial user action SHOULD be inserted because it was NOT condensed + assert len(events) == 2 + assert events[0] == system_message + assert events[1] == initial_user_action + + +def test_process_events_does_not_reinsert_condensed_initial_message( + conversation_memory, +): + """Test that process_events does not re-insert initial user message when condensed. + + This is an integration test for the full process_events flow, verifying that + when the initial user message has been condensed, it is not re-inserted into + the conversation sent to the LLM. + """ + # Create a system message + system_message = SystemMessageAction(content='System message') + system_message._source = EventSource.AGENT + system_message._id = 0 + + # Create the initial user message (will be marked as condensed) + initial_user_message = MessageAction(content='Do task A, B, and C') + initial_user_message._source = EventSource.USER + initial_user_message._id = 1 + + # Create a condensation summary observation + from openhands.events.observation.agent import AgentCondensationObservation + + condensation_summary = AgentCondensationObservation( + content='Summary: User requested tasks A, B, C. Task A was completed successfully.' + ) + condensation_summary._id = 2 + + # Create a recent user message (not condensed) + recent_user_message = MessageAction(content='Now continue with task D') + recent_user_message._source = EventSource.USER + recent_user_message._id = 3 + + # Simulate condensed history: system + summary + recent message + # The initial user message (id=1) has been condensed/forgotten + condensed_history = [system_message, condensation_summary, recent_user_message] + + # The initial user message's ID is in the forgotten set + forgotten_event_ids = {1} + + messages = conversation_memory.process_events( + condensed_history=condensed_history, + initial_user_action=initial_user_message, + forgotten_event_ids=forgotten_event_ids, + max_message_chars=None, + vision_is_active=False, + ) + + # Verify the structure of messages + # Should have: system, condensation summary, recent user message + # Should NOT have the initial user message "Do task A, B, and C" + assert len(messages) == 3 + assert messages[0].role == 'system' + assert messages[0].content[0].text == 'System message' + + # The second message should be the condensation summary, NOT the initial user message + assert messages[1].role == 'user' + assert 'Summary: User requested tasks A, B, C' in messages[1].content[0].text + + # The third message should be the recent user message + assert messages[2].role == 'user' + assert 'Now continue with task D' in messages[2].content[0].text + + # Critically, the old instruction should NOT appear + for msg in messages: + for content in msg.content: + if hasattr(content, 'text'): + assert 'Do task A, B, and C' not in content.text