mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 495f48bf64 | |||
| 41e142bbab | |||
| b06b9eedac | |||
| a9afafa991 | |||
| 663ace4b39 | |||
| 2d085a6e0a | |||
| 8b7112abe8 | |||
| 34547ba947 | |||
| 5f958ab60d | |||
| d7656bf1c9 | |||
| 2bc107564c | |||
| 85eb1e1504 | |||
| cd235cc8c7 | |||
| 40f52dfabc | |||
| bab7bf85e8 | |||
| c856537f65 | |||
| 736f5b2255 | |||
| c1d9d11772 | |||
| 85244499fe | |||
| c55084e223 | |||
| e3bb75deb4 | |||
| 1948200762 | |||
| affe0af361 | |||
| f20c956196 | |||
| 4a089a3a0d | |||
| aa0b2d0b74 | |||
| bef9b80b9d | |||
| c4a90b1f89 | |||
| 0d13c57d9f | |||
| b3422f1275 | |||
| f139a9970b | |||
| 54d156122c | |||
| ac072bf686 | |||
| a53812c029 | |||
| 1d1c0925b5 | |||
| 872f41e3c0 | |||
| d43ff82534 | |||
| 8cd8c011b2 | |||
| 5c68b10983 | |||
| a97fad1976 | |||
| 4c3542a91c | |||
| f460057f58 | |||
| 4fa2ad0f47 | |||
| dd8be12809 | |||
| 89475095d9 | |||
| 05d5f8848a | |||
| ee2885eb0b | |||
| 545257f870 |
@@ -9,6 +9,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "saas-rel-*"
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: PR Review by OpenHands
|
||||
|
||||
on:
|
||||
# Use pull_request_target to allow fork PRs to access secrets when triggered by maintainers
|
||||
# Security: This workflow runs when:
|
||||
# 1. A new PR is opened (non-draft), OR
|
||||
# 2. A draft PR is marked as ready for review, OR
|
||||
# 3. A maintainer adds the 'review-this' label, OR
|
||||
# 4. A maintainer requests openhands-agent or all-hands-bot as a reviewer
|
||||
# Only users with write access can add labels or request reviews, ensuring security.
|
||||
# The PR code is explicitly checked out for review, but secrets are only accessible
|
||||
# because the workflow runs in the base repository context
|
||||
pull_request_target:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-review:
|
||||
# Run when one of the following conditions is met:
|
||||
# 1. A new non-draft PR is opened by a trusted contributor, OR
|
||||
# 2. A draft PR is converted to ready for review by a trusted contributor, OR
|
||||
# 3. 'review-this' label is added, OR
|
||||
# 4. openhands-agent or all-hands-bot is requested as a reviewer
|
||||
# Note: FIRST_TIME_CONTRIBUTOR PRs require manual trigger via label/reviewer request
|
||||
if: |
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
|
||||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR') ||
|
||||
github.event.label.name == 'review-this' ||
|
||||
github.event.requested_reviewer.login == 'openhands-agent' ||
|
||||
github.event.requested_reviewer.login == 'all-hands-bot'
|
||||
concurrency:
|
||||
group: pr-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
env:
|
||||
LLM_MODEL: litellm_proxy/claude-sonnet-4-5-20250929
|
||||
LLM_BASE_URL: https://llm-proxy.app.all-hands.dev
|
||||
# PR context will be automatically provided by the agent script
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
|
||||
PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Checkout software-agent-sdk repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: OpenHands/software-agent-sdk
|
||||
path: software-agent-sdk
|
||||
|
||||
- name: Checkout PR repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
# When using pull_request_target, explicitly checkout the PR branch
|
||||
# This ensures we review the actual PR code (including fork PRs)
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
# Security: Don't persist credentials to prevent untrusted PR code from using them
|
||||
persist-credentials: false
|
||||
path: pr-repo
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install GitHub CLI
|
||||
run: |
|
||||
# Install GitHub CLI for posting review comments
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gh
|
||||
|
||||
- name: Install OpenHands dependencies
|
||||
run: |
|
||||
# Install OpenHands SDK and tools from local checkout
|
||||
uv pip install --system ./software-agent-sdk/openhands-sdk ./software-agent-sdk/openhands-tools
|
||||
|
||||
- name: Check required configuration
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$LLM_API_KEY" ]; then
|
||||
echo "Error: LLM_API_KEY secret is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR Number: $PR_NUMBER"
|
||||
echo "PR Title: $PR_TITLE"
|
||||
echo "Repository: $REPO_NAME"
|
||||
echo "LLM model: $LLM_MODEL"
|
||||
if [ -n "$LLM_BASE_URL" ]; then
|
||||
echo "LLM base URL: $LLM_BASE_URL"
|
||||
fi
|
||||
|
||||
- name: Run PR review
|
||||
env:
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
LMNR_PROJECT_API_KEY: ${{ secrets.LMNR_SKILLS_API_KEY }}
|
||||
run: |
|
||||
# Change to the PR repository directory so agent can analyze the code
|
||||
cd pr-repo
|
||||
|
||||
# Run the PR review script from the software-agent-sdk checkout
|
||||
uv run python ../software-agent-sdk/examples/03_github_workflows/02_pr_review/agent_script.py
|
||||
|
||||
- name: Upload logs as artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
if: always()
|
||||
with:
|
||||
name: openhands-pr-review-logs
|
||||
path: |
|
||||
*.log
|
||||
output/
|
||||
retention-days: 7
|
||||
+17
-6
@@ -23,12 +23,23 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python packages with security fixes
|
||||
RUN /app/.venv/bin/pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy google-cloud-recaptcha-enterprise && \
|
||||
# Update packages with known CVE fixes
|
||||
/app/.venv/bin/pip install --upgrade \
|
||||
"mcp>=1.10.0" \
|
||||
"pillow>=11.3.0"
|
||||
# Install poetry and export before importing current code.
|
||||
RUN /app/.venv/bin/pip install poetry poetry-plugin-export
|
||||
|
||||
# Install Python dependencies from poetry.lock for reproducible builds
|
||||
# Copy lock files first for better Docker layer caching
|
||||
COPY --chown=openhands:openhands enterprise/pyproject.toml enterprise/poetry.lock /tmp/enterprise/
|
||||
RUN cd /tmp/enterprise && \
|
||||
# Export only main dependencies with hashes for supply chain security
|
||||
/app/.venv/bin/poetry export --only main -o requirements.txt && \
|
||||
# Remove the local path dependency (openhands-ai is already in base image)
|
||||
sed -i '/^-e /d; /openhands-ai/d' requirements.txt && \
|
||||
# Install pinned dependencies from lock file
|
||||
/app/.venv/bin/pip install -r requirements.txt && \
|
||||
# Cleanup - return to /app before removing /tmp/enterprise
|
||||
cd /app && \
|
||||
rm -rf /tmp/enterprise && \
|
||||
/app/.venv/bin/pip uninstall -y poetry poetry-plugin-export
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=openhands:openhands --chmod=770 enterprise .
|
||||
|
||||
@@ -28,9 +28,11 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
return agent
|
||||
|
||||
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
|
||||
agent = agent.model_copy(
|
||||
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
|
||||
)
|
||||
# Skip experiment for planning agents which require their specialized prompt
|
||||
if agent.system_prompt_filename != 'system_prompt_planning.j2':
|
||||
agent = agent.model_copy(
|
||||
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
@@ -145,11 +145,7 @@ class GithubManager(Manager):
|
||||
).get('body', ''):
|
||||
return False
|
||||
|
||||
if GithubFactory.is_eligible_for_conversation_starter(
|
||||
message
|
||||
) and self._user_has_write_access_to_repo(installation_id, repo_name, username):
|
||||
await GithubFactory.trigger_conversation_starter(message)
|
||||
|
||||
# Check event types before making expensive API calls (e.g., _user_has_write_access_to_repo)
|
||||
if not (
|
||||
GithubFactory.is_labeled_issue(message)
|
||||
or GithubFactory.is_issue_comment(message)
|
||||
@@ -159,8 +155,17 @@ class GithubManager(Manager):
|
||||
return False
|
||||
|
||||
logger.info(f'[GitHub] Checking permissions for {username} in {repo_name}')
|
||||
user_has_write_access = self._user_has_write_access_to_repo(
|
||||
installation_id, repo_name, username
|
||||
)
|
||||
|
||||
return self._user_has_write_access_to_repo(installation_id, repo_name, username)
|
||||
if (
|
||||
GithubFactory.is_eligible_for_conversation_starter(message)
|
||||
and user_has_write_access
|
||||
):
|
||||
await GithubFactory.trigger_conversation_starter(message)
|
||||
|
||||
return user_has_write_access
|
||||
|
||||
async def receive_message(self, message: Message):
|
||||
self._confirm_incoming_source_type(message)
|
||||
|
||||
@@ -167,17 +167,15 @@ async def install_webhook_on_resource(
|
||||
scopes=SCOPES,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Creating new webhook',
|
||||
extra={
|
||||
'webhook_id': webhook_id,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
log_extra = {
|
||||
'webhook_id': webhook_id,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
}
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
logger.warning('Rate limited while creating webhook', extra=log_extra)
|
||||
raise BreakLoopException()
|
||||
|
||||
if webhook_id:
|
||||
@@ -191,9 +189,8 @@ async def install_webhook_on_resource(
|
||||
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
|
||||
)
|
||||
logger.info('Created new webhook', extra=log_extra)
|
||||
else:
|
||||
logger.error('Failed to create webhook', extra=log_extra)
|
||||
|
||||
return webhook_id, status
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
@@ -14,6 +14,7 @@ class ResolverUserContext(UserContext):
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
self.saas_user_auth = saas_user_auth
|
||||
self._provider_handler: ProviderHandler | None = None
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return await self.saas_user_auth.get_user_id()
|
||||
@@ -29,12 +30,26 @@ class ResolverUserContext(UserContext):
|
||||
|
||||
return UserInfo(id=user_id)
|
||||
|
||||
async def _get_provider_handler(self) -> ProviderHandler:
|
||||
"""Get or create a ProviderHandler for git operations."""
|
||||
if self._provider_handler is None:
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
raise ValueError('No provider tokens available')
|
||||
user_id = await self.saas_user_auth.get_user_id()
|
||||
self._provider_handler = ProviderHandler(
|
||||
provider_tokens=provider_tokens, external_auth_id=user_id
|
||||
)
|
||||
return self._provider_handler
|
||||
|
||||
async def get_authenticated_git_url(
|
||||
self, repository: str, is_optional: bool = False
|
||||
) -> str:
|
||||
# This would need to be implemented based on the git provider tokens
|
||||
# For now, return a basic HTTPS URL
|
||||
return f'https://github.com/{repository}.git'
|
||||
provider_handler = await self._get_provider_handler()
|
||||
url = await provider_handler.get_authenticated_git_url(
|
||||
repository, is_optional=is_optional
|
||||
)
|
||||
return url
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
# Return the appropriate token string from git_provider_tokens
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import logging
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from google.cloud.sql.connector import Connector
|
||||
from sqlalchemy import create_engine
|
||||
from storage.base import Base
|
||||
# Suppress alembic.runtime.plugins INFO logs during import to prevent non-JSON logs in production
|
||||
# These plugin setup messages would otherwise appear before logging is configured
|
||||
logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
|
||||
|
||||
from alembic import context # noqa: E402
|
||||
from google.cloud.sql.connector import Connector # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from storage.base import Base # noqa: E402
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Add byor_export_enabled flag to org table.
|
||||
|
||||
Revision ID: 091
|
||||
Revises: 090
|
||||
Create Date: 2025-01-15 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '091'
|
||||
down_revision: Union[str, None] = '090'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add byor_export_enabled column to org table with default false
|
||||
op.add_column(
|
||||
'org',
|
||||
sa.Column(
|
||||
'byor_export_enabled',
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
),
|
||||
)
|
||||
|
||||
# Set byor_export_enabled to true for orgs that have completed billing sessions
|
||||
op.execute(
|
||||
sa.text("""
|
||||
UPDATE org SET byor_export_enabled = TRUE
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT org_id FROM billing_sessions
|
||||
WHERE status = 'completed' AND org_id IS NOT NULL
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('org', 'byor_export_enabled')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Rename 'user' role to 'member' in role table.
|
||||
|
||||
Revision ID: 092
|
||||
Revises: 091
|
||||
Create Date: 2025-02-12 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '092'
|
||||
down_revision: Union[str, None] = '091'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Rename 'user' role to 'member' for clarity
|
||||
# This avoids confusion between the 'user' role and the 'user' entity/account
|
||||
op.execute(sa.text("UPDATE role SET name = 'member' WHERE name = 'user'"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert 'member' role back to 'user'
|
||||
op.execute(sa.text("UPDATE role SET name = 'user' WHERE name = 'member'"))
|
||||
Generated
+105
-105
@@ -6102,14 +6102,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.11.1"
|
||||
version = "1.11.4"
|
||||
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.11.1-py3-none-any.whl", hash = "sha256:28e3ca670114c7a936a33f2d193238fbdc75f429c4e0bb99a03b14e6c01663c9"},
|
||||
{file = "openhands_agent_server-1.11.1.tar.gz", hash = "sha256:06eaf8b8eda4ca05de24751a7d269b22f611328c6cb2b4b91f2486011228b69a"},
|
||||
{file = "openhands_agent_server-1.11.4-py3-none-any.whl", hash = "sha256:739bdb774dbfcd23d6e87ee6ee32bc0999f22300037506b6dd33e9ea67fa5c2a"},
|
||||
{file = "openhands_agent_server-1.11.4.tar.gz", hash = "sha256:41247f7022a046eb50ca3b552bc6d12bfa9776e1bd27d0989da91b9f7ac77ca2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6168,9 +6168,9 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = "1.11.1"
|
||||
openhands-sdk = "1.11.1"
|
||||
openhands-tools = "1.11.1"
|
||||
openhands-agent-server = "1.11.4"
|
||||
openhands-sdk = "1.11.4"
|
||||
openhands-tools = "1.11.4"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
pathspec = ">=0.12.1"
|
||||
@@ -6225,14 +6225,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.11.1"
|
||||
version = "1.11.4"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.11.1-py3-none-any.whl", hash = "sha256:10ee0777286b149db21bdeeadb6d4c57f461da4049a4ba07576e7228b5c76c85"},
|
||||
{file = "openhands_sdk-1.11.1.tar.gz", hash = "sha256:57f5884d0596a8659b7c0cdbe86ebaa74c810c4e2645fcff45f0113894dd9376"},
|
||||
{file = "openhands_sdk-1.11.4-py3-none-any.whl", hash = "sha256:9f4607c5d94b56fbcd533207026ee892779dd50e29bce79277ff82454a4f76d5"},
|
||||
{file = "openhands_sdk-1.11.4.tar.gz", hash = "sha256:4088744f6b8856eeab22d3bc17e47d1736ea7ced945c2fa126bd7d48c14bb313"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6253,14 +6253,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.11.1"
|
||||
version = "1.11.4"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.11.1-py3-none-any.whl", hash = "sha256:0b64763def90dda5b6545a356a437437c2029ec9bc47a4e6dac5c06dea6a4e77"},
|
||||
{file = "openhands_tools-1.11.1.tar.gz", hash = "sha256:2a71d2d0619ca631b3b7f5bd741bfdf97f7ebe6f96dc2540f79b9a688a6309fc"},
|
||||
{file = "openhands_tools-1.11.4-py3-none-any.whl", hash = "sha256:efd721b73e87a0dac69171a76931363fa59fcde98107ca86081ee7bf0253673a"},
|
||||
{file = "openhands_tools-1.11.4.tar.gz", hash = "sha256:80671b1ea8c85a5247a75ea2340ae31d76363e9c723b104699a9a77e66d2043c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6851,103 +6851,103 @@ scramp = ">=1.4.5"
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.0"
|
||||
version = "12.1.1"
|
||||
description = "Python Imaging Library (fork)"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91"},
|
||||
{file = "pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75"},
|
||||
{file = "pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d"},
|
||||
{file = "pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a"},
|
||||
{file = "pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0"},
|
||||
{file = "pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94"},
|
||||
{file = "pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6"},
|
||||
{file = "pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831"},
|
||||
{file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377"},
|
||||
{file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72"},
|
||||
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c"},
|
||||
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd"},
|
||||
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc"},
|
||||
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a"},
|
||||
{file = "pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19"},
|
||||
{file = "pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"},
|
||||
{file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"},
|
||||
{file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"},
|
||||
{file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"},
|
||||
{file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"},
|
||||
{file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"},
|
||||
{file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"},
|
||||
{file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"},
|
||||
{file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"},
|
||||
{file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"},
|
||||
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"},
|
||||
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"},
|
||||
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"},
|
||||
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"},
|
||||
{file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"},
|
||||
{file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -14917,4 +14917,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "b5cbb1e25176845ac9f95650a802667e2f8be1a536e3e55a9269b5af5a42e3fc"
|
||||
content-hash = "1cad6029269393af67155e930c72eae2c03da02e4b3a3699823f6168c14a4218"
|
||||
|
||||
@@ -44,6 +44,12 @@ httpx = "*"
|
||||
scikit-learn = "^1.7.0"
|
||||
shap = "^0.48.0"
|
||||
google-cloud-recaptcha-enterprise = "^1.24.0"
|
||||
# Dependencies previously only in Dockerfile, now managed via poetry.lock
|
||||
prometheus-client = "^0.24.0"
|
||||
pandas = "^2.2.0"
|
||||
numpy = "^2.2.0"
|
||||
mcp = "^1.10.0"
|
||||
pillow = "^12.1.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.8.3"
|
||||
|
||||
@@ -78,8 +78,15 @@ base_app.include_router(shared_event_router)
|
||||
|
||||
# Add GitHub integration router only if GITHUB_APP_CLIENT_ID is set
|
||||
if GITHUB_APP_CLIENT_ID:
|
||||
# Make sure that the callback processor is loaded here so we don't get an error when deserializing
|
||||
from integrations.github.github_v1_callback_processor import ( # noqa: E402
|
||||
GithubV1CallbackProcessor,
|
||||
)
|
||||
from server.routes.integration.github import github_integration_router # noqa: E402
|
||||
|
||||
# Bludgeon mypy into not deleting my import
|
||||
logger.debug(f'Loaded {GithubV1CallbackProcessor.__name__}')
|
||||
|
||||
base_app.include_router(
|
||||
github_integration_router
|
||||
) # Add additional route for integration webhook events
|
||||
|
||||
@@ -15,6 +15,11 @@ IS_FEATURE_ENV = (
|
||||
) # Does not include the staging deployment
|
||||
IS_LOCAL_ENV = bool(HOST == 'localhost')
|
||||
|
||||
# Role name constants
|
||||
ROLE_OWNER = 'owner'
|
||||
ROLE_ADMIN = 'admin'
|
||||
ROLE_MEMBER = 'member'
|
||||
|
||||
# Deprecated - billing margins are now handled internally in litellm
|
||||
DEFAULT_BILLING_MARGIN = float(os.environ.get('DEFAULT_BILLING_MARGIN', '1.0'))
|
||||
|
||||
@@ -25,7 +30,9 @@ PERSONAL_WORKSPACE_VERSION_TO_MODEL = {
|
||||
2: 'claude-3-7-sonnet-20250219',
|
||||
3: 'claude-sonnet-4-20250514',
|
||||
4: 'claude-sonnet-4-20250514',
|
||||
5: 'claude-opus-4-5-20251101',
|
||||
# Minimax is now the default as it gives results close to claude in terms of quality
|
||||
# but at a much lower price
|
||||
5: 'minimax-m2.5',
|
||||
}
|
||||
|
||||
LITELLM_DEFAULT_MODEL = os.getenv('LITELLM_DEFAULT_MODEL')
|
||||
|
||||
@@ -6,6 +6,7 @@ from storage.api_key_store import ApiKeyStore
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org_member import OrgMember
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -52,7 +53,6 @@ async def store_byor_key_in_db(user_id: str, key: str) -> None:
|
||||
|
||||
async def generate_byor_key(user_id: str) -> str | None:
|
||||
"""Generate a new BYOR key for a user."""
|
||||
|
||||
try:
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
@@ -148,6 +148,26 @@ class LlmApiKeyResponse(BaseModel):
|
||||
key: str | None
|
||||
|
||||
|
||||
class ByorPermittedResponse(BaseModel):
|
||||
permitted: bool
|
||||
|
||||
|
||||
@api_router.get('/llm/byor/permitted', response_model=ByorPermittedResponse)
|
||||
async def check_byor_permitted(user_id: str = Depends(get_user_id)):
|
||||
"""Check if BYOR key export is permitted for the user's current org."""
|
||||
try:
|
||||
permitted = await OrgService.check_byor_export_enabled(user_id)
|
||||
return {'permitted': permitted}
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error checking BYOR export permission', extra={'error': str(e)}
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to check BYOR export permission',
|
||||
)
|
||||
|
||||
|
||||
@api_router.post('', response_model=ApiKeyCreateResponse)
|
||||
async def create_api_key(key_data: ApiKeyCreate, user_id: str = Depends(get_user_id)):
|
||||
"""Create a new API key for the authenticated user."""
|
||||
@@ -253,8 +273,17 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
|
||||
This endpoint validates that the key exists in LiteLLM before returning it.
|
||||
If validation fails, it automatically generates a new key to ensure users
|
||||
always receive a working key.
|
||||
|
||||
Returns 402 Payment Required if BYOR export is not enabled for the user's org.
|
||||
"""
|
||||
try:
|
||||
# Check if BYOR export is enabled for the user's org
|
||||
if not await OrgService.check_byor_export_enabled(user_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
|
||||
)
|
||||
|
||||
# Check if the BYOR key exists in the database
|
||||
byor_key = await get_byor_key_from_db(user_id)
|
||||
if byor_key:
|
||||
@@ -310,10 +339,20 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
|
||||
|
||||
@api_router.post('/llm/byor/refresh', response_model=LlmApiKeyResponse)
|
||||
async def refresh_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
|
||||
"""Refresh the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user."""
|
||||
"""Refresh the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user.
|
||||
|
||||
Returns 402 Payment Required if BYOR export is not enabled for the user's org.
|
||||
"""
|
||||
logger.info('Starting BYOR LLM API key refresh', extra={'user_id': user_id})
|
||||
|
||||
try:
|
||||
# Check if BYOR export is enabled for the user's org
|
||||
if not await OrgService.check_byor_export_enabled(user_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
|
||||
)
|
||||
|
||||
# Get the existing BYOR key from the database
|
||||
existing_byor_key = await get_byor_key_from_db(user_id)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from starlette.datastructures import URL
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.database import session_maker
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org import Org
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
from storage.user_store import UserStore
|
||||
|
||||
@@ -259,6 +260,12 @@ async def success_callback(session_id: str, request: Request):
|
||||
str(user.current_org_id), new_max_budget
|
||||
)
|
||||
|
||||
# Enable BYOR export for the org now that they've purchased credits
|
||||
# Update within the same session to avoid nested session issues
|
||||
org = session.query(Org).filter(Org.id == user.current_org_id).first()
|
||||
if org:
|
||||
org.byor_export_enabled = True
|
||||
|
||||
# Store transaction status
|
||||
billing_session.status = 'completed'
|
||||
billing_session.price = add_credits
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, StringConstraints
|
||||
from pydantic import BaseModel, EmailStr, Field, SecretStr, StringConstraints
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
|
||||
|
||||
class OrgCreationError(Exception):
|
||||
@@ -43,6 +45,16 @@ class OrgAuthorizationError(OrgDeletionError):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class OrphanedUserError(OrgDeletionError):
|
||||
"""Raised when deleting an org would leave users without any organization."""
|
||||
|
||||
def __init__(self, user_ids: list[str]):
|
||||
self.user_ids = user_ids
|
||||
super().__init__(
|
||||
f'Cannot delete organization: {len(user_ids)} user(s) would have no remaining organization'
|
||||
)
|
||||
|
||||
|
||||
class OrgNotFoundError(Exception):
|
||||
"""Raised when organization is not found or user doesn't have access."""
|
||||
|
||||
@@ -51,6 +63,61 @@ class OrgNotFoundError(Exception):
|
||||
super().__init__(f'Organization with id "{org_id}" not found')
|
||||
|
||||
|
||||
class OrgMemberNotFoundError(Exception):
|
||||
"""Raised when a member is not found in an organization."""
|
||||
|
||||
def __init__(self, org_id: str, user_id: str):
|
||||
self.org_id = org_id
|
||||
self.user_id = user_id
|
||||
super().__init__(f'Member "{user_id}" not found in organization "{org_id}"')
|
||||
|
||||
|
||||
class RoleNotFoundError(Exception):
|
||||
"""Raised when a role is not found."""
|
||||
|
||||
def __init__(self, role_id: int):
|
||||
self.role_id = role_id
|
||||
super().__init__(f'Role with id "{role_id}" not found')
|
||||
|
||||
|
||||
class InvalidRoleError(Exception):
|
||||
"""Raised when an invalid role name is specified."""
|
||||
|
||||
def __init__(self, role_name: str):
|
||||
self.role_name = role_name
|
||||
super().__init__(f'Invalid role: "{role_name}"')
|
||||
|
||||
|
||||
class InsufficientPermissionError(Exception):
|
||||
"""Raised when user lacks permission to perform an operation."""
|
||||
|
||||
def __init__(self, message: str = 'Insufficient permission'):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CannotModifySelfError(Exception):
|
||||
"""Raised when user attempts to modify their own membership."""
|
||||
|
||||
def __init__(self, action: str = 'modify'):
|
||||
self.action = action
|
||||
super().__init__(f'Cannot {action} your own membership')
|
||||
|
||||
|
||||
class LastOwnerError(Exception):
|
||||
"""Raised when attempting to remove or demote the last owner."""
|
||||
|
||||
def __init__(self, action: str = 'remove'):
|
||||
self.action = action
|
||||
super().__init__(f'Cannot {action} the last owner of an organization')
|
||||
|
||||
|
||||
class MemberUpdateError(Exception):
|
||||
"""Raised when member update operation fails."""
|
||||
|
||||
def __init__(self, message: str = 'Failed to update member'):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class OrgCreate(BaseModel):
|
||||
"""Request model for creating a new organization."""
|
||||
|
||||
@@ -91,14 +158,18 @@ class OrgResponse(BaseModel):
|
||||
enable_solvability_analysis: bool | None = None
|
||||
v1_enabled: bool | None = None
|
||||
credits: float | None = None
|
||||
is_personal: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_org(cls, org: Org, credits: float | None = None) -> 'OrgResponse':
|
||||
def from_org(
|
||||
cls, org: Org, credits: float | None = None, user_id: str | None = None
|
||||
) -> 'OrgResponse':
|
||||
"""Create an OrgResponse from an Org entity.
|
||||
|
||||
Args:
|
||||
org: The organization entity to convert
|
||||
credits: Optional credits value (defaults to None)
|
||||
user_id: Optional user ID to determine if org is personal (defaults to None)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The response model instance
|
||||
@@ -134,6 +205,7 @@ class OrgResponse(BaseModel):
|
||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||
v1_enabled=org.v1_enabled,
|
||||
credits=credits,
|
||||
is_personal=str(org.id) == user_id if user_id else False,
|
||||
)
|
||||
|
||||
|
||||
@@ -148,6 +220,10 @@ class OrgUpdate(BaseModel):
|
||||
"""Request model for updating an organization."""
|
||||
|
||||
# Basic organization information (any authenticated user can update)
|
||||
name: Annotated[
|
||||
str | None,
|
||||
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
|
||||
] = None
|
||||
contact_name: str | None = None
|
||||
contact_email: EmailStr | None = None
|
||||
conversation_expiration: int | None = None
|
||||
@@ -173,3 +249,79 @@ class OrgUpdate(BaseModel):
|
||||
confirmation_mode: bool | None = None
|
||||
enable_default_condenser: bool | None = None
|
||||
condenser_max_size: int | None = Field(default=None, ge=20)
|
||||
|
||||
|
||||
class OrgMemberResponse(BaseModel):
|
||||
"""Response model for a single organization member."""
|
||||
|
||||
user_id: str
|
||||
email: str | None
|
||||
role_id: int
|
||||
role_name: str
|
||||
role_rank: int
|
||||
status: str | None
|
||||
|
||||
|
||||
class OrgMemberPage(BaseModel):
|
||||
"""Paginated response for organization members."""
|
||||
|
||||
items: list[OrgMemberResponse]
|
||||
next_page_id: str | None = None
|
||||
|
||||
|
||||
class OrgMemberUpdate(BaseModel):
|
||||
"""Request model for updating an organization member."""
|
||||
|
||||
role: str | None = None # Role name: 'owner', 'admin', or 'member'
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
"""Response model for the current user's membership in an organization."""
|
||||
|
||||
org_id: str
|
||||
user_id: str
|
||||
email: str
|
||||
role: str
|
||||
llm_api_key: str
|
||||
max_iterations: int | None = None
|
||||
llm_model: str | None = None
|
||||
llm_api_key_for_byor: str | None = None
|
||||
llm_base_url: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def _mask_key(secret: SecretStr | None) -> str:
|
||||
"""Mask an API key, showing only last 4 characters."""
|
||||
if secret is None:
|
||||
return ''
|
||||
raw = secret.get_secret_value()
|
||||
if not raw:
|
||||
return ''
|
||||
if len(raw) <= 4:
|
||||
return '****'
|
||||
return '****' + raw[-4:]
|
||||
|
||||
@classmethod
|
||||
def from_org_member(cls, member: OrgMember, role: Role, email: str) -> 'MeResponse':
|
||||
"""Create a MeResponse from an OrgMember, Role, and user email.
|
||||
|
||||
Args:
|
||||
member: The OrgMember entity
|
||||
role: The Role entity (provides role name)
|
||||
email: The user's email address
|
||||
|
||||
Returns:
|
||||
MeResponse with masked API keys
|
||||
"""
|
||||
return cls(
|
||||
org_id=str(member.org_id),
|
||||
user_id=str(member.user_id),
|
||||
email=email,
|
||||
role=role.name,
|
||||
llm_api_key=cls._mask_key(member.llm_api_key),
|
||||
max_iterations=member.max_iterations,
|
||||
llm_model=member.llm_model,
|
||||
llm_api_key_for_byor=cls._mask_key(member.llm_api_key_for_byor) or None,
|
||||
llm_base_url=member.llm_base_url,
|
||||
status=member.status,
|
||||
)
|
||||
|
||||
@@ -4,16 +4,29 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from server.email_validation import get_admin_user_id
|
||||
from server.routes.org_models import (
|
||||
CannotModifySelfError,
|
||||
InsufficientPermissionError,
|
||||
InvalidRoleError,
|
||||
LastOwnerError,
|
||||
LiteLLMIntegrationError,
|
||||
MemberUpdateError,
|
||||
MeResponse,
|
||||
OrgAuthorizationError,
|
||||
OrgCreate,
|
||||
OrgDatabaseError,
|
||||
OrgMemberNotFoundError,
|
||||
OrgMemberPage,
|
||||
OrgMemberResponse,
|
||||
OrgMemberUpdate,
|
||||
OrgNameExistsError,
|
||||
OrgNotFoundError,
|
||||
OrgPage,
|
||||
OrgResponse,
|
||||
OrgUpdate,
|
||||
OrphanedUserError,
|
||||
RoleNotFoundError,
|
||||
)
|
||||
from server.services.org_member_service import OrgMemberService
|
||||
from storage.org_service import OrgService
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -69,7 +82,9 @@ async def list_user_orgs(
|
||||
)
|
||||
|
||||
# Convert Org entities to OrgResponse objects
|
||||
org_responses = [OrgResponse.from_org(org, credits=None) for org in orgs]
|
||||
org_responses = [
|
||||
OrgResponse.from_org(org, credits=None, user_id=user_id) for org in orgs
|
||||
]
|
||||
|
||||
logger.info(
|
||||
'Successfully retrieved organizations',
|
||||
@@ -136,7 +151,7 @@ async def create_org(
|
||||
# Retrieve credits from LiteLLM
|
||||
credits = await OrgService.get_org_credits(user_id, org.id)
|
||||
|
||||
return OrgResponse.from_org(org, credits=credits)
|
||||
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
|
||||
except OrgNameExistsError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
@@ -211,7 +226,7 @@ async def get_org(
|
||||
# Retrieve credits from LiteLLM
|
||||
credits = await OrgService.get_org_credits(user_id, org.id)
|
||||
|
||||
return OrgResponse.from_org(org, credits=credits)
|
||||
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -228,10 +243,69 @@ async def get_org(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}/me', response_model=MeResponse)
|
||||
async def get_me(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> MeResponse:
|
||||
"""Get the current user's membership record for an organization.
|
||||
|
||||
Returns the authenticated user's role, status, email, and LLM override
|
||||
fields (with masked API keys) within the specified organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
user_id: Authenticated user ID (injected by dependency)
|
||||
|
||||
Returns:
|
||||
MeResponse: The user's membership data
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if user is not a member or org doesn't exist
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
logger.info(
|
||||
'Retrieving current member details',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
try:
|
||||
user_uuid = UUID(user_id)
|
||||
return OrgMemberService.get_me(org_id, user_uuid)
|
||||
|
||||
except OrgMemberNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Organization with id "{org_id}" not found',
|
||||
)
|
||||
except RoleNotFoundError as e:
|
||||
logger.exception(
|
||||
'Role not found for org member',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'role_id': e.role_id,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error retrieving member details',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.delete('/{org_id}', status_code=status.HTTP_200_OK)
|
||||
async def delete_org(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> dict:
|
||||
"""Delete an organization.
|
||||
|
||||
@@ -303,6 +377,19 @@ async def delete_org(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrphanedUserError as e:
|
||||
logger.warning(
|
||||
'Cannot delete organization: users would be orphaned',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'orphaned_users': e.user_ids,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgDatabaseError as e:
|
||||
logger.error(
|
||||
'Database error during organization deletion',
|
||||
@@ -368,7 +455,7 @@ async def update_org(
|
||||
# Retrieve credits from LiteLLM (following same pattern as create endpoint)
|
||||
credits = await OrgService.get_org_credits(user_id, updated_org.id)
|
||||
|
||||
return OrgResponse.from_org(updated_org, credits=credits)
|
||||
return OrgResponse.from_org(updated_org, credits=credits, user_id=user_id)
|
||||
|
||||
except ValueError as e:
|
||||
# Organization not found
|
||||
@@ -376,6 +463,11 @@ async def update_org(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgNameExistsError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(e),
|
||||
)
|
||||
except PermissionError as e:
|
||||
# User lacks permission for LLM settings
|
||||
raise HTTPException(
|
||||
@@ -400,3 +492,294 @@ async def update_org(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}/members')
|
||||
async def get_org_members(
|
||||
org_id: str,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Optional next_page_id from the previously returned page'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(
|
||||
title='The max number of results in the page',
|
||||
gt=0,
|
||||
lte=100,
|
||||
),
|
||||
] = 100,
|
||||
current_user_id: str = Depends(get_user_id),
|
||||
) -> OrgMemberPage:
|
||||
"""Get all members of an organization with cursor-based pagination."""
|
||||
try:
|
||||
success, error_code, data = await OrgMemberService.get_org_members(
|
||||
org_id=UUID(org_id),
|
||||
current_user_id=UUID(current_user_id),
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if not success:
|
||||
error_map = {
|
||||
'not_a_member': (
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
'You are not a member of this organization',
|
||||
),
|
||||
'invalid_page_id': (
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'Invalid page_id format',
|
||||
),
|
||||
}
|
||||
status_code, detail = error_map.get(
|
||||
error_code, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=detail)
|
||||
|
||||
if data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve members',
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError:
|
||||
logger.exception('Invalid UUID format')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid organization ID format',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Error retrieving organization members')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve members',
|
||||
)
|
||||
|
||||
|
||||
@org_router.delete('/{org_id}/members/{user_id}')
|
||||
async def remove_org_member(
|
||||
org_id: str,
|
||||
user_id: str,
|
||||
current_user_id: str = Depends(get_user_id),
|
||||
):
|
||||
"""Remove a member from an organization.
|
||||
|
||||
Only owners and admins can remove members:
|
||||
- Owners can remove admins and regular users
|
||||
- Admins can only remove regular users
|
||||
|
||||
Users cannot remove themselves. The last owner cannot be removed.
|
||||
"""
|
||||
try:
|
||||
success, error = await OrgMemberService.remove_org_member(
|
||||
org_id=UUID(org_id),
|
||||
target_user_id=UUID(user_id),
|
||||
current_user_id=UUID(current_user_id),
|
||||
)
|
||||
|
||||
if not success:
|
||||
error_map = {
|
||||
'not_a_member': (
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
'You are not a member of this organization',
|
||||
),
|
||||
'cannot_remove_self': (
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
'Cannot remove yourself from an organization',
|
||||
),
|
||||
'member_not_found': (
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
'Member not found in this organization',
|
||||
),
|
||||
'insufficient_permission': (
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
'You do not have permission to remove this member',
|
||||
),
|
||||
'cannot_remove_last_owner': (
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'Cannot remove the last owner of an organization',
|
||||
),
|
||||
'removal_failed': (
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'Failed to remove member',
|
||||
),
|
||||
}
|
||||
status_code, detail = error_map.get(
|
||||
error, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=detail)
|
||||
|
||||
return {'message': 'Member removed successfully'}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError:
|
||||
logger.exception('Invalid UUID format')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid organization or user ID format',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Error removing organization member')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to remove member',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/{org_id}/switch', response_model=OrgResponse, status_code=status.HTTP_200_OK
|
||||
)
|
||||
async def switch_org(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> OrgResponse:
|
||||
"""Switch to a different organization.
|
||||
|
||||
This endpoint allows authenticated users to switch their current active
|
||||
organization. The user must be a member of the target organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID to switch to (UUID)
|
||||
user_id: Authenticated user ID (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The organization details that was switched to
|
||||
|
||||
Raises:
|
||||
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
|
||||
HTTPException: 403 if user is not a member of the organization
|
||||
HTTPException: 404 if organization not found
|
||||
HTTPException: 500 if switch fails
|
||||
"""
|
||||
logger.info(
|
||||
'Switching organization',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Use service layer to switch organization with membership validation
|
||||
org = await OrgService.switch_org(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Retrieve credits from LiteLLM for the new current org
|
||||
credits = await OrgService.get_org_credits(user_id, org.id)
|
||||
|
||||
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
|
||||
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgAuthorizationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgDatabaseError as e:
|
||||
logger.error(
|
||||
'Database operation failed during organization switch',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to switch organization',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error switching organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.patch('/{org_id}/members/{user_id}', response_model=OrgMemberResponse)
|
||||
async def update_org_member(
|
||||
org_id: str,
|
||||
user_id: str,
|
||||
update_data: OrgMemberUpdate,
|
||||
current_user_id: str = Depends(get_user_id),
|
||||
) -> OrgMemberResponse:
|
||||
"""Update a member's role in an organization.
|
||||
|
||||
Permission rules:
|
||||
- Admins can change roles of regular members to Admin or Member
|
||||
- Admins cannot modify other Admins or Owners
|
||||
- Owners can change roles of Admins and Members to any role (Owner, Admin, Member)
|
||||
- Owners cannot modify other Owners
|
||||
|
||||
Members cannot modify their own role. The last owner cannot be demoted.
|
||||
"""
|
||||
try:
|
||||
return await OrgMemberService.update_org_member(
|
||||
org_id=UUID(org_id),
|
||||
target_user_id=UUID(user_id),
|
||||
current_user_id=UUID(current_user_id),
|
||||
update_data=update_data,
|
||||
)
|
||||
except OrgMemberNotFoundError as e:
|
||||
# Distinguish between requester not being a member vs target not found
|
||||
if str(current_user_id) in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='You are not a member of this organization',
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Member not found in this organization',
|
||||
)
|
||||
except CannotModifySelfError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot modify your own role',
|
||||
)
|
||||
except RoleNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Role configuration error',
|
||||
)
|
||||
except InvalidRoleError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid role specified',
|
||||
)
|
||||
except InsufficientPermissionError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='You do not have permission to modify this member',
|
||||
)
|
||||
except LastOwnerError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot demote the last owner of an organization',
|
||||
)
|
||||
except MemberUpdateError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update member',
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception('Invalid UUID format')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid organization or user ID format',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Error updating organization member')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update member',
|
||||
)
|
||||
|
||||
@@ -1139,6 +1139,71 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
}
|
||||
update_conversation_metadata(conversation_id, metadata_content)
|
||||
|
||||
async def list_files(self, sid: str, path: str | None = None) -> list[str]:
|
||||
"""List files in the workspace for a conversation.
|
||||
|
||||
Delegates to the nested container's list-files endpoint.
|
||||
|
||||
Args:
|
||||
sid: The session/conversation ID.
|
||||
path: Optional path to list files from. If None, lists from workspace root.
|
||||
|
||||
Returns:
|
||||
A list of file paths.
|
||||
|
||||
Raises:
|
||||
ValueError: If the conversation is not running.
|
||||
httpx.HTTPError: If there's an error communicating with the nested runtime.
|
||||
"""
|
||||
runtime = await self._get_runtime(sid)
|
||||
if runtime is None or runtime.get('status') != 'running':
|
||||
raise ValueError(f'Conversation {sid} is not running')
|
||||
|
||||
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
|
||||
session_api_key = runtime.get('session_api_key')
|
||||
|
||||
return await self._fetch_list_files_from_nested(
|
||||
sid, nested_url, session_api_key, path
|
||||
)
|
||||
|
||||
async def select_file(self, sid: str, file: str) -> tuple[str | None, str | None]:
|
||||
"""Read a file from the workspace via nested container.
|
||||
|
||||
Raises:
|
||||
ValueError: If the conversation is not running.
|
||||
httpx.HTTPError: If there's an error communicating with the nested runtime.
|
||||
"""
|
||||
runtime = await self._get_runtime(sid)
|
||||
if runtime is None or runtime.get('status') != 'running':
|
||||
raise ValueError(f'Conversation {sid} is not running')
|
||||
|
||||
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
|
||||
session_api_key = runtime.get('session_api_key')
|
||||
|
||||
return await self._fetch_select_file_from_nested(
|
||||
sid, nested_url, session_api_key, file
|
||||
)
|
||||
|
||||
async def upload_files(
|
||||
self, sid: str, files: list[tuple[str, bytes]]
|
||||
) -> tuple[list[str], list[dict[str, str]]]:
|
||||
"""Upload files to the workspace via nested container.
|
||||
|
||||
Raises:
|
||||
ValueError: If the conversation is not running.
|
||||
httpx.HTTPError: If there's an error communicating with the nested runtime.
|
||||
"""
|
||||
runtime = await self._get_runtime(sid)
|
||||
if runtime is None or runtime.get('status') != 'running':
|
||||
raise ValueError(f'Conversation {sid} is not running')
|
||||
|
||||
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
|
||||
session_api_key = runtime.get('session_api_key')
|
||||
|
||||
return await self._fetch_upload_files_to_nested(
|
||||
sid, nested_url, session_api_key, files
|
||||
)
|
||||
|
||||
|
||||
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
|
||||
last_updated_at = conversation.last_updated_at
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Service for managing organization members."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from server.constants import ROLE_ADMIN, ROLE_MEMBER, ROLE_OWNER
|
||||
from server.routes.org_models import (
|
||||
CannotModifySelfError,
|
||||
InsufficientPermissionError,
|
||||
InvalidRoleError,
|
||||
LastOwnerError,
|
||||
MemberUpdateError,
|
||||
MeResponse,
|
||||
OrgMemberNotFoundError,
|
||||
OrgMemberPage,
|
||||
OrgMemberResponse,
|
||||
OrgMemberUpdate,
|
||||
RoleNotFoundError,
|
||||
)
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.role_store import RoleStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
|
||||
class OrgMemberService:
|
||||
"""Service for organization member operations."""
|
||||
|
||||
@staticmethod
|
||||
def get_me(org_id: UUID, user_id: UUID) -> MeResponse:
|
||||
"""Get the current user's membership record for an organization.
|
||||
|
||||
Retrieves the authenticated user's role, status, email, and LLM override
|
||||
fields (with masked API keys) within the specified organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
user_id: User ID (UUID)
|
||||
|
||||
Returns:
|
||||
MeResponse: The user's membership data with masked API keys
|
||||
|
||||
Raises:
|
||||
OrgMemberNotFoundError: If user is not a member of the organization
|
||||
RoleNotFoundError: If the role associated with the member is not found
|
||||
"""
|
||||
# Look up the user's membership in this org
|
||||
org_member = OrgMemberStore.get_org_member(org_id, user_id)
|
||||
if org_member is None:
|
||||
raise OrgMemberNotFoundError(str(org_id), str(user_id))
|
||||
|
||||
# Resolve role name from role_id
|
||||
role = RoleStore.get_role_by_id(org_member.role_id)
|
||||
if role is None:
|
||||
raise RoleNotFoundError(org_member.role_id)
|
||||
|
||||
# Get user email
|
||||
user = UserStore.get_user_by_id(str(user_id))
|
||||
email = user.email if user and user.email else ''
|
||||
|
||||
return MeResponse.from_org_member(org_member, role, email)
|
||||
|
||||
@staticmethod
|
||||
async def get_org_members(
|
||||
org_id: UUID,
|
||||
current_user_id: UUID,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> tuple[bool, str | None, OrgMemberPage | None]:
|
||||
"""Get organization members with authorization check.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, error_code, data). If success is True, error_code is None.
|
||||
"""
|
||||
# Verify current user is a member of the organization
|
||||
requester_membership = OrgMemberStore.get_org_member(org_id, current_user_id)
|
||||
if not requester_membership:
|
||||
return False, 'not_a_member', None
|
||||
|
||||
# Parse page_id to get offset (page_id is offset encoded as string)
|
||||
offset = 0
|
||||
if page_id is not None:
|
||||
try:
|
||||
offset = int(page_id)
|
||||
if offset < 0:
|
||||
return False, 'invalid_page_id', None
|
||||
except ValueError:
|
||||
return False, 'invalid_page_id', None
|
||||
|
||||
# Call store to get paginated members
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=offset, limit=limit
|
||||
)
|
||||
|
||||
# Transform data to response format
|
||||
items = []
|
||||
for member in members:
|
||||
# Access user and role relationships (eagerly loaded)
|
||||
user = member.user
|
||||
role = member.role
|
||||
|
||||
items.append(
|
||||
OrgMemberResponse(
|
||||
user_id=str(member.user_id),
|
||||
email=user.email if user else None,
|
||||
role_id=member.role_id,
|
||||
role_name=role.name if role else '',
|
||||
role_rank=role.rank if role else 0,
|
||||
status=member.status,
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate next_page_id
|
||||
next_page_id = None
|
||||
if has_more:
|
||||
next_page_id = str(offset + limit)
|
||||
|
||||
return True, None, OrgMemberPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
@staticmethod
|
||||
async def remove_org_member(
|
||||
org_id: UUID,
|
||||
target_user_id: UUID,
|
||||
current_user_id: UUID,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Remove a member from an organization.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, error_message). If success is True, error_message is None.
|
||||
"""
|
||||
|
||||
def _remove_member():
|
||||
# Get current user's membership in the org
|
||||
requester_membership = OrgMemberStore.get_org_member(
|
||||
org_id, current_user_id
|
||||
)
|
||||
if not requester_membership:
|
||||
return False, 'not_a_member'
|
||||
|
||||
# Check if trying to remove self
|
||||
if str(current_user_id) == str(target_user_id):
|
||||
return False, 'cannot_remove_self'
|
||||
|
||||
# Get target user's membership
|
||||
target_membership = OrgMemberStore.get_org_member(org_id, target_user_id)
|
||||
if not target_membership:
|
||||
return False, 'member_not_found'
|
||||
|
||||
requester_role = RoleStore.get_role_by_id(requester_membership.role_id)
|
||||
target_role = RoleStore.get_role_by_id(target_membership.role_id)
|
||||
|
||||
if not requester_role or not target_role:
|
||||
return False, 'role_not_found'
|
||||
|
||||
# Check permission based on roles
|
||||
if not OrgMemberService._can_remove_member(
|
||||
requester_role.name, target_role.name
|
||||
):
|
||||
return False, 'insufficient_permission'
|
||||
|
||||
# Check if removing the last owner
|
||||
if target_role.name == ROLE_OWNER:
|
||||
if OrgMemberService._is_last_owner(org_id, target_user_id):
|
||||
return False, 'cannot_remove_last_owner'
|
||||
|
||||
# Perform the removal
|
||||
success = OrgMemberStore.remove_user_from_org(org_id, target_user_id)
|
||||
if not success:
|
||||
return False, 'removal_failed'
|
||||
|
||||
return True, None
|
||||
|
||||
return await call_sync_from_async(_remove_member)
|
||||
|
||||
@staticmethod
|
||||
async def update_org_member(
|
||||
org_id: UUID,
|
||||
target_user_id: UUID,
|
||||
current_user_id: UUID,
|
||||
update_data: OrgMemberUpdate,
|
||||
) -> OrgMemberResponse:
|
||||
"""Update a member's role in an organization.
|
||||
|
||||
Permission rules:
|
||||
- Admins can change roles of users (rank > ADMIN_RANK) to Admin or User
|
||||
- Admins cannot modify other Admins or Owners
|
||||
- Owners can change roles of non-owners (rank > OWNER_RANK) to any role
|
||||
- Owners cannot modify other Owners
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
target_user_id: User ID of the member to update
|
||||
current_user_id: User ID of the requester
|
||||
update_data: Update data containing fields to modify
|
||||
|
||||
Returns:
|
||||
OrgMemberResponse: The updated member data
|
||||
|
||||
Raises:
|
||||
OrgMemberNotFoundError: If requester or target is not a member
|
||||
CannotModifySelfError: If trying to modify self
|
||||
RoleNotFoundError: If role configuration is invalid
|
||||
InvalidRoleError: If new_role_name is not a valid role
|
||||
InsufficientPermissionError: If requester lacks permission
|
||||
LastOwnerError: If trying to demote the last owner
|
||||
MemberUpdateError: If update operation fails
|
||||
"""
|
||||
new_role_name = update_data.role
|
||||
|
||||
def _update_member():
|
||||
# Get current user's membership in the org
|
||||
requester_membership = OrgMemberStore.get_org_member(
|
||||
org_id, current_user_id
|
||||
)
|
||||
if not requester_membership:
|
||||
raise OrgMemberNotFoundError(str(org_id), str(current_user_id))
|
||||
|
||||
# Check if trying to modify self
|
||||
if str(current_user_id) == str(target_user_id):
|
||||
raise CannotModifySelfError('modify')
|
||||
|
||||
# Get target user's membership
|
||||
target_membership = OrgMemberStore.get_org_member(org_id, target_user_id)
|
||||
if not target_membership:
|
||||
raise OrgMemberNotFoundError(str(org_id), str(target_user_id))
|
||||
|
||||
# Get roles
|
||||
requester_role = RoleStore.get_role_by_id(requester_membership.role_id)
|
||||
target_role = RoleStore.get_role_by_id(target_membership.role_id)
|
||||
|
||||
if not requester_role:
|
||||
raise RoleNotFoundError(requester_membership.role_id)
|
||||
if not target_role:
|
||||
raise RoleNotFoundError(target_membership.role_id)
|
||||
|
||||
# If no role change requested, return current state
|
||||
if new_role_name is None:
|
||||
user = UserStore.get_user_by_id(str(target_user_id))
|
||||
return OrgMemberResponse(
|
||||
user_id=str(target_membership.user_id),
|
||||
email=user.email if user else None,
|
||||
role_id=target_membership.role_id,
|
||||
role_name=target_role.name,
|
||||
role_rank=target_role.rank,
|
||||
status=target_membership.status,
|
||||
)
|
||||
|
||||
# Validate new role exists
|
||||
new_role = RoleStore.get_role_by_name(new_role_name.lower())
|
||||
if not new_role:
|
||||
raise InvalidRoleError(new_role_name)
|
||||
|
||||
# Check permission to modify target
|
||||
if not OrgMemberService._can_update_member_role(
|
||||
requester_role.name, target_role.name, new_role.name
|
||||
):
|
||||
raise InsufficientPermissionError(
|
||||
'You do not have permission to modify this member'
|
||||
)
|
||||
|
||||
# Check if demoting the last owner
|
||||
if (
|
||||
target_role.name == ROLE_OWNER
|
||||
and new_role.name != ROLE_OWNER
|
||||
and OrgMemberService._is_last_owner(org_id, target_user_id)
|
||||
):
|
||||
raise LastOwnerError('demote')
|
||||
|
||||
# Perform the update
|
||||
updated_member = OrgMemberStore.update_user_role_in_org(
|
||||
org_id, target_user_id, new_role.id
|
||||
)
|
||||
if not updated_member:
|
||||
raise MemberUpdateError('Failed to update member')
|
||||
|
||||
# Get user email for response
|
||||
user = UserStore.get_user_by_id(str(target_user_id))
|
||||
|
||||
return OrgMemberResponse(
|
||||
user_id=str(updated_member.user_id),
|
||||
email=user.email if user else None,
|
||||
role_id=updated_member.role_id,
|
||||
role_name=new_role.name,
|
||||
role_rank=new_role.rank,
|
||||
status=updated_member.status,
|
||||
)
|
||||
|
||||
return await call_sync_from_async(_update_member)
|
||||
|
||||
@staticmethod
|
||||
def _can_update_member_role(
|
||||
requester_role_name: str, target_role_name: str, new_role_name: str
|
||||
) -> bool:
|
||||
"""Check if requester can change target's role to new_role.
|
||||
|
||||
Permission rules:
|
||||
- Owners can modify admins and users, can set any role
|
||||
- Owners cannot modify other owners
|
||||
- Admins can only modify users
|
||||
- Admins can only set admin or user roles (not owner)
|
||||
"""
|
||||
is_requester_owner = requester_role_name == ROLE_OWNER
|
||||
is_requester_admin = requester_role_name == ROLE_ADMIN
|
||||
is_target_owner = target_role_name == ROLE_OWNER
|
||||
is_target_admin = target_role_name == ROLE_ADMIN
|
||||
is_new_role_owner = new_role_name == ROLE_OWNER
|
||||
|
||||
if is_requester_owner:
|
||||
# Owners cannot modify other owners
|
||||
if is_target_owner:
|
||||
return False
|
||||
# Owners can set any role (owner, admin, user)
|
||||
return True
|
||||
elif is_requester_admin:
|
||||
# Admins cannot modify owners or other admins
|
||||
if is_target_owner or is_target_admin:
|
||||
return False
|
||||
# Admins can only set admin or user roles (not owner)
|
||||
return not is_new_role_owner
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _can_remove_member(requester_role_name: str, target_role_name: str) -> bool:
|
||||
"""Check if requester can remove target based on roles."""
|
||||
if requester_role_name == ROLE_OWNER:
|
||||
return True
|
||||
elif requester_role_name == ROLE_ADMIN:
|
||||
# Admins can only remove members (not owners or other admins)
|
||||
return target_role_name == ROLE_MEMBER
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_last_owner(org_id: UUID, user_id: UUID) -> bool:
|
||||
"""Check if user is the last owner of the organization."""
|
||||
members = OrgMemberStore.get_org_members(org_id)
|
||||
owners = []
|
||||
for m in members:
|
||||
# Use role_id (column) instead of role (relationship) to avoid DetachedInstanceError
|
||||
role = RoleStore.get_role_by_id(m.role_id)
|
||||
if role and role.name == ROLE_OWNER:
|
||||
owners.append(m)
|
||||
return len(owners) == 1 and str(owners[0].user_id) == str(user_id)
|
||||
@@ -233,7 +233,13 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
result = result_set.first()
|
||||
if result:
|
||||
stored_metadata, saas_metadata = result
|
||||
return self._to_info_with_user_id(stored_metadata, saas_metadata)
|
||||
# Fetch sub-conversation IDs
|
||||
sub_conversation_ids = await self.get_sub_conversation_ids(conversation_id)
|
||||
return self._to_info_with_user_id(
|
||||
stored_metadata,
|
||||
saas_metadata,
|
||||
sub_conversation_ids=sub_conversation_ids,
|
||||
)
|
||||
return None
|
||||
|
||||
async def batch_get_app_conversation_info(
|
||||
@@ -262,8 +268,16 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
for conversation_id in conversation_id_strs:
|
||||
if conversation_id in info_by_id:
|
||||
stored_metadata, saas_metadata = info_by_id[conversation_id]
|
||||
# Fetch sub-conversation IDs for each conversation
|
||||
sub_conversation_ids = await self.get_sub_conversation_ids(
|
||||
UUID(conversation_id)
|
||||
)
|
||||
results.append(
|
||||
self._to_info_with_user_id(stored_metadata, saas_metadata)
|
||||
self._to_info_with_user_id(
|
||||
stored_metadata,
|
||||
saas_metadata,
|
||||
sub_conversation_ids=sub_conversation_ids,
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(None)
|
||||
@@ -316,10 +330,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
self,
|
||||
stored: StoredConversationMetadata,
|
||||
saas_metadata: StoredConversationMetadataSaas,
|
||||
sub_conversation_ids: list[UUID] | None = None,
|
||||
) -> AppConversationInfo:
|
||||
"""Convert stored metadata to AppConversationInfo with user_id from SAAS metadata."""
|
||||
# Use the base _to_info method to get the basic info
|
||||
info = self._to_info(stored)
|
||||
info = self._to_info(stored, sub_conversation_ids=sub_conversation_ids)
|
||||
|
||||
# Override the created_by_user_id with the user_id from SAAS metadata
|
||||
info.created_by_user_id = (
|
||||
|
||||
@@ -46,6 +46,7 @@ class Org(Base): # type: ignore
|
||||
v1_enabled = Column(Boolean, nullable=True)
|
||||
conversation_expiration = Column(Integer, nullable=True)
|
||||
condenser_max_size = Column(Integer, nullable=True)
|
||||
byor_export_enabled = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Relationships
|
||||
org_members = relationship('OrgMember', back_populates='org')
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.org_member import OrgMember
|
||||
from storage.user_settings import UserSettings
|
||||
@@ -135,3 +136,36 @@ class OrgMemberStore:
|
||||
if (normalized := c.name.lstrip('_')) and hasattr(user_settings, normalized)
|
||||
}
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
async def get_org_members_paginated(
|
||||
org_id: UUID,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[OrgMember], bool]:
|
||||
"""Get paginated list of organization members with user and role info.
|
||||
|
||||
Returns:
|
||||
Tuple of (members_list, has_more) where has_more indicates if there are more results.
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
# Query for limit + 1 items to determine if there are more results
|
||||
# Order by user_id for consistent pagination
|
||||
query = (
|
||||
select(OrgMember)
|
||||
.options(joinedload(OrgMember.user), joinedload(OrgMember.role))
|
||||
.filter(OrgMember.org_id == org_id)
|
||||
.order_by(OrgMember.user_id)
|
||||
.offset(offset)
|
||||
.limit(limit + 1)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
members = list(result.scalars().all())
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(members) > limit
|
||||
if has_more:
|
||||
# Remove the extra item
|
||||
members = members[:limit]
|
||||
|
||||
return members, has_more
|
||||
|
||||
@@ -521,6 +521,7 @@ class OrgService:
|
||||
Raises:
|
||||
ValueError: If organization not found
|
||||
PermissionError: If user is not a member, or lacks admin/owner role for LLM settings
|
||||
OrgNameExistsError: If new name already exists for another organization
|
||||
OrgDatabaseError: If database update fails
|
||||
"""
|
||||
logger.info(
|
||||
@@ -550,6 +551,24 @@ class OrgService:
|
||||
'User must be a member of the organization to update it'
|
||||
)
|
||||
|
||||
# Check if name is being updated and validate uniqueness
|
||||
if update_data.name is not None:
|
||||
# Check if new name conflicts with another org
|
||||
existing_org_with_name = OrgStore.get_org_by_name(update_data.name)
|
||||
if (
|
||||
existing_org_with_name is not None
|
||||
and existing_org_with_name.id != org_id
|
||||
):
|
||||
logger.warning(
|
||||
'Attempted to update organization with duplicate name',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'attempted_name': update_data.name,
|
||||
},
|
||||
)
|
||||
raise OrgNameExistsError(update_data.name)
|
||||
|
||||
# Check if update contains any LLM settings
|
||||
llm_fields_being_updated = OrgService._has_llm_settings_updates(update_data)
|
||||
if llm_fields_being_updated:
|
||||
@@ -842,3 +861,94 @@ class OrgService:
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise OrgDatabaseError(f'Failed to delete organization: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
async def check_byor_export_enabled(user_id: str) -> bool:
|
||||
"""Check if BYOR export is enabled for the user's current org.
|
||||
|
||||
Returns True if the user's current org has byor_export_enabled set to True.
|
||||
Returns False if the user is not found, has no current org, or the flag is False.
|
||||
|
||||
Args:
|
||||
user_id: User ID to check
|
||||
|
||||
Returns:
|
||||
bool: True if BYOR export is enabled, False otherwise
|
||||
"""
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user or not user.current_org_id:
|
||||
return False
|
||||
|
||||
org = OrgStore.get_org_by_id(user.current_org_id)
|
||||
if not org:
|
||||
return False
|
||||
|
||||
return org.byor_export_enabled
|
||||
|
||||
@staticmethod
|
||||
async def switch_org(user_id: str, org_id: UUID) -> Org:
|
||||
"""
|
||||
Switch user's current organization to the specified organization.
|
||||
|
||||
This method:
|
||||
1. Validates that the organization exists
|
||||
2. Validates that the user is a member of the organization
|
||||
3. Updates the user's current_org_id
|
||||
|
||||
Args:
|
||||
user_id: User ID (string that will be converted to UUID)
|
||||
org_id: Organization ID to switch to
|
||||
|
||||
Returns:
|
||||
Org: The organization that was switched to
|
||||
|
||||
Raises:
|
||||
OrgNotFoundError: If organization doesn't exist
|
||||
OrgAuthorizationError: If user is not a member of the organization
|
||||
OrgDatabaseError: If database update fails
|
||||
"""
|
||||
logger.info(
|
||||
'Switching user organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# Step 1: Check if organization exists
|
||||
org = OrgStore.get_org_by_id(org_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError(str(org_id))
|
||||
|
||||
# Step 2: Validate user is a member of the organization
|
||||
if not OrgService.is_org_member(user_id, org_id):
|
||||
logger.warning(
|
||||
'User attempted to switch to organization they are not a member of',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
raise OrgAuthorizationError(
|
||||
'User must be a member of the organization to switch to it'
|
||||
)
|
||||
|
||||
# Step 3: Update user's current_org_id
|
||||
try:
|
||||
updated_user = UserStore.update_current_org(user_id, org_id)
|
||||
if not updated_user:
|
||||
raise OrgDatabaseError('User not found')
|
||||
|
||||
logger.info(
|
||||
'Successfully switched user organization',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'org_name': org.name,
|
||||
},
|
||||
)
|
||||
|
||||
return org
|
||||
|
||||
except OrgDatabaseError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Failed to switch user organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise OrgDatabaseError(f'Failed to switch organization: {str(e)}')
|
||||
|
||||
@@ -10,6 +10,7 @@ from server.constants import (
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.routes.org_models import OrphanedUserError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import session_maker
|
||||
@@ -320,17 +321,41 @@ class OrgStore:
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# 3. Delete organization memberships
|
||||
# 3. Handle users with this as current_org_id BEFORE deleting memberships
|
||||
# Single query to find orphaned users (those with no alternative org)
|
||||
orphaned_users = session.execute(
|
||||
text("""
|
||||
SELECT u.id
|
||||
FROM "user" u
|
||||
WHERE u.current_org_id = :org_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM org_member om
|
||||
WHERE om.user_id = u.id AND om.org_id != :org_id
|
||||
)
|
||||
"""),
|
||||
{'org_id': str(org_id)},
|
||||
).fetchall()
|
||||
|
||||
if orphaned_users:
|
||||
raise OrphanedUserError([str(row[0]) for row in orphaned_users])
|
||||
|
||||
# Batch update: reassign current_org_id to an alternative org for all affected users
|
||||
session.execute(
|
||||
text('DELETE FROM org_member WHERE org_id = :org_id'),
|
||||
text("""
|
||||
UPDATE "user" u
|
||||
SET current_org_id = (
|
||||
SELECT om.org_id FROM org_member om
|
||||
WHERE om.user_id = u.id AND om.org_id != :org_id
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE u.current_org_id = :org_id
|
||||
"""),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# 4. Handle users with this as current_org_id
|
||||
# 4. Delete organization memberships (now safe)
|
||||
session.execute(
|
||||
text(
|
||||
'UPDATE "user" SET current_org_id = NULL WHERE current_org_id = :org_id'
|
||||
),
|
||||
text('DELETE FROM org_member WHERE org_id = :org_id'),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.llm import is_openhands_model
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -163,7 +164,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
# Check if we need to generate an LLM key.
|
||||
if item.llm_base_url == LITE_LLM_API_URL:
|
||||
await self._ensure_api_key(
|
||||
item, str(org_id), openhands_type=self._is_openhands_provider(item)
|
||||
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
|
||||
)
|
||||
|
||||
kwargs = item.model_dump(context={'expose_secrets': True})
|
||||
@@ -229,10 +230,6 @@ class SaasSettingsStore(SettingsStore):
|
||||
fernet_key = b64encode(hashlib.sha256(jwt_secret.encode()).digest())
|
||||
return Fernet(fernet_key)
|
||||
|
||||
def _is_openhands_provider(self, item: Settings) -> bool:
|
||||
"""Check if the settings use the OpenHands provider."""
|
||||
return bool(item.llm_model and item.llm_model.startswith('openhands/'))
|
||||
|
||||
async def _ensure_api_key(
|
||||
self, item: Settings, org_id: str, openhands_type: bool = False
|
||||
) -> None:
|
||||
|
||||
@@ -5,6 +5,7 @@ Store class for managing users.
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import (
|
||||
@@ -172,6 +173,19 @@ class UserStore:
|
||||
)
|
||||
decrypted_user_settings = UserSettings(**kwargs)
|
||||
with session_maker() as session:
|
||||
# Check if user has completed billing sessions to enable BYOR export
|
||||
from storage.billing_session import BillingSession
|
||||
|
||||
has_completed_billing = (
|
||||
session.query(BillingSession)
|
||||
.filter(
|
||||
BillingSession.user_id == user_id,
|
||||
BillingSession.status == 'completed',
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
# create personal org
|
||||
org = Org(
|
||||
id=uuid.UUID(user_id),
|
||||
@@ -180,6 +194,7 @@ class UserStore:
|
||||
contact_name=resolve_display_name(user_info)
|
||||
or user_info.get('username', ''),
|
||||
contact_email=user_info['email'],
|
||||
byor_export_enabled=has_completed_billing,
|
||||
)
|
||||
session.add(org)
|
||||
|
||||
@@ -759,6 +774,32 @@ class UserStore:
|
||||
with session_maker() as session:
|
||||
return session.query(User).all()
|
||||
|
||||
@staticmethod
|
||||
def update_current_org(user_id: str, org_id: UUID) -> Optional[User]:
|
||||
"""Update the user's current organization.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
org_id: The organization ID to set as current
|
||||
|
||||
Returns:
|
||||
User: The updated user object, or None if user not found
|
||||
"""
|
||||
with session_maker() as session:
|
||||
user = (
|
||||
session.query(User)
|
||||
.filter(User.id == uuid.UUID(user_id))
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
user.current_org_id = org_id
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def backfill_contact_name(user_id: str, user_info: dict) -> None:
|
||||
"""Update contact_name on the personal org if it still has a username-style value.
|
||||
|
||||
@@ -26,6 +26,7 @@ Optional environment variables:
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -90,6 +91,31 @@ class ResendAPIError(ResendSyncError):
|
||||
pass
|
||||
|
||||
|
||||
# Email validation regex pattern - matches standard email format
|
||||
# This pattern is intentionally strict to avoid Resend API validation errors
|
||||
# It rejects special characters like ! that some email providers technically allow
|
||||
# but Resend's API does not accept
|
||||
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||
|
||||
|
||||
def is_valid_email(email: str) -> bool:
|
||||
"""Validate an email address format.
|
||||
|
||||
This uses a regex pattern that matches most valid email addresses
|
||||
while rejecting addresses with special characters that Resend's API
|
||||
does not accept (e.g., exclamation marks).
|
||||
|
||||
Args:
|
||||
email: The email address to validate.
|
||||
|
||||
Returns:
|
||||
True if the email is valid, False otherwise.
|
||||
"""
|
||||
if not email:
|
||||
return False
|
||||
return bool(EMAIL_REGEX.match(email))
|
||||
|
||||
|
||||
def get_keycloak_users(offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""Get users from Keycloak using the admin client.
|
||||
|
||||
@@ -336,6 +362,7 @@ def sync_users_to_resend():
|
||||
'total_users': total_users,
|
||||
'existing_contacts': len(resend_contacts),
|
||||
'added_contacts': 0,
|
||||
'skipped_invalid_emails': 0,
|
||||
'errors': 0,
|
||||
}
|
||||
|
||||
@@ -355,6 +382,12 @@ def sync_users_to_resend():
|
||||
logger.debug(f'User {email} already exists in Resend, skipping')
|
||||
continue
|
||||
|
||||
# Validate email format before attempting to add to Resend
|
||||
if not is_valid_email(email):
|
||||
logger.warning(f'Skipping user with invalid email format: {email}')
|
||||
stats['skipped_invalid_emails'] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
first_name = user.get('first_name')
|
||||
last_name = user.get('last_name')
|
||||
|
||||
@@ -126,3 +126,24 @@ def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeyp
|
||||
# Should be a different instance than the original (copied after handler runs)
|
||||
assert result is not agent
|
||||
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
|
||||
|
||||
|
||||
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
|
||||
@patch('experiments.experiment_manager.EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT', True)
|
||||
def test_run_agent_variant_tests_v1_preserves_planning_agent_system_prompt():
|
||||
"""Planning agents should retain their specialized system prompt and not be overwritten by the experiment."""
|
||||
# Arrange
|
||||
planning_agent = make_agent().model_copy(
|
||||
update={'system_prompt_filename': 'system_prompt_planning.j2'}
|
||||
)
|
||||
conv_id = uuid4()
|
||||
|
||||
# Act
|
||||
result: Agent = SaaSExperimentManager.run_agent_variant_tests__v1(
|
||||
user_id='user-planning',
|
||||
conversation_id=conv_id,
|
||||
agent=planning_agent,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.system_prompt_filename == 'system_prompt_planning.j2'
|
||||
|
||||
@@ -141,12 +141,14 @@ def test_custom_to_static_conversion():
|
||||
|
||||
def create_provider_tokens(
|
||||
tokens_dict: dict[ProviderType, str],
|
||||
) -> dict[ProviderType, ProviderToken]:
|
||||
"""Helper to create provider tokens dictionary."""
|
||||
return {
|
||||
provider_type: ProviderToken(token=SecretStr(token_value))
|
||||
for provider_type, token_value in tokens_dict.items()
|
||||
}
|
||||
) -> MappingProxyType:
|
||||
"""Helper to create provider tokens as MappingProxyType."""
|
||||
return MappingProxyType(
|
||||
{
|
||||
provider_type: ProviderToken(token=SecretStr(token_value))
|
||||
for provider_type, token_value in tokens_dict.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -264,3 +266,63 @@ async def test_get_latest_token_can_be_used_with_static_secret(
|
||||
# Assert - this should NOT raise a ValidationError
|
||||
static_secret = StaticSecret(value=token, description='GITHUB authentication token')
|
||||
assert static_secret.get_value() == token_value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for get_authenticated_git_url - ensuring proper authenticated URLs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_authenticated_git_url_raises_when_no_tokens(
|
||||
resolver_context, mock_saas_user_auth
|
||||
):
|
||||
"""Test that get_authenticated_git_url raises error when no provider tokens available."""
|
||||
# Arrange
|
||||
mock_saas_user_auth.get_provider_tokens = AsyncMock(return_value=None)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match='No provider tokens available'):
|
||||
await resolver_context.get_authenticated_git_url('owner/repo')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_provider_handler_caches_instance(
|
||||
resolver_context, mock_saas_user_auth
|
||||
):
|
||||
"""Test that _get_provider_handler caches the handler instance."""
|
||||
# Arrange
|
||||
token_value = 'ghp_test_token'
|
||||
provider_tokens = create_provider_tokens({ProviderType.GITHUB: token_value})
|
||||
mock_saas_user_auth.get_provider_tokens = AsyncMock(return_value=provider_tokens)
|
||||
mock_saas_user_auth.get_user_id = AsyncMock(return_value='test-user-id')
|
||||
|
||||
# Act - call _get_provider_handler twice
|
||||
handler1 = await resolver_context._get_provider_handler()
|
||||
handler2 = await resolver_context._get_provider_handler()
|
||||
|
||||
# Assert - should be the same instance (cached)
|
||||
assert handler1 is handler2
|
||||
# get_provider_tokens should only be called once
|
||||
assert mock_saas_user_auth.get_provider_tokens.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_provider_handler_creates_handler_with_correct_params(
|
||||
resolver_context, mock_saas_user_auth
|
||||
):
|
||||
"""Test that _get_provider_handler creates ProviderHandler with correct parameters."""
|
||||
# Arrange
|
||||
token_value = 'ghp_test_token'
|
||||
provider_tokens = create_provider_tokens({ProviderType.GITHUB: token_value})
|
||||
mock_saas_user_auth.get_provider_tokens = AsyncMock(return_value=provider_tokens)
|
||||
mock_saas_user_auth.get_user_id = AsyncMock(return_value='test-user-id')
|
||||
|
||||
# Act
|
||||
handler = await resolver_context._get_provider_handler()
|
||||
|
||||
# Assert
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
|
||||
assert isinstance(handler, ProviderHandler)
|
||||
assert handler.provider_tokens == provider_tokens
|
||||
|
||||
@@ -6,6 +6,7 @@ import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from server.routes.api_keys import (
|
||||
check_byor_permitted,
|
||||
delete_byor_key_from_litellm,
|
||||
get_llm_api_key_for_byor,
|
||||
)
|
||||
@@ -182,16 +183,18 @@ class TestGetLlmApiKeyForByor:
|
||||
"""Test the get_llm_api_key_for_byor endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
@patch('server.routes.api_keys.store_byor_key_in_db')
|
||||
@patch('server.routes.api_keys.generate_byor_key')
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_no_key_in_database_generates_new(
|
||||
self, mock_get_key, mock_generate_key, mock_store_key
|
||||
self, mock_get_key, mock_generate_key, mock_store_key, mock_check_enabled
|
||||
):
|
||||
"""Test that when no key exists in database, a new one is generated."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
new_key = 'sk-new-generated-key'
|
||||
mock_check_enabled.return_value = True
|
||||
mock_get_key.return_value = None
|
||||
mock_generate_key.return_value = new_key
|
||||
mock_store_key.return_value = None
|
||||
@@ -201,20 +204,23 @@ class TestGetLlmApiKeyForByor:
|
||||
|
||||
# Assert
|
||||
assert result == {'key': new_key}
|
||||
mock_check_enabled.assert_called_once_with(user_id)
|
||||
mock_get_key.assert_called_once_with(user_id)
|
||||
mock_generate_key.assert_called_once_with(user_id)
|
||||
mock_store_key.assert_called_once_with(user_id, new_key)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
@patch('storage.lite_llm_manager.LiteLlmManager.verify_key')
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_valid_key_in_database_returns_key(
|
||||
self, mock_get_key, mock_verify_key
|
||||
self, mock_get_key, mock_verify_key, mock_check_enabled
|
||||
):
|
||||
"""Test that when a valid key exists in database, it is returned."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
existing_key = 'sk-existing-valid-key'
|
||||
mock_check_enabled.return_value = True
|
||||
mock_get_key.return_value = existing_key
|
||||
mock_verify_key.return_value = True
|
||||
|
||||
@@ -223,10 +229,12 @@ class TestGetLlmApiKeyForByor:
|
||||
|
||||
# Assert
|
||||
assert result == {'key': existing_key}
|
||||
mock_check_enabled.assert_called_once_with(user_id)
|
||||
mock_get_key.assert_called_once_with(user_id)
|
||||
mock_verify_key.assert_called_once_with(existing_key, user_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
@patch('server.routes.api_keys.store_byor_key_in_db')
|
||||
@patch('server.routes.api_keys.generate_byor_key')
|
||||
@patch('server.routes.api_keys.delete_byor_key_from_litellm')
|
||||
@@ -239,12 +247,14 @@ class TestGetLlmApiKeyForByor:
|
||||
mock_delete_key,
|
||||
mock_generate_key,
|
||||
mock_store_key,
|
||||
mock_check_enabled,
|
||||
):
|
||||
"""Test that when an invalid key exists in database, it is regenerated."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
invalid_key = 'sk-invalid-key'
|
||||
new_key = 'sk-new-generated-key'
|
||||
mock_check_enabled.return_value = True
|
||||
mock_get_key.return_value = invalid_key
|
||||
mock_verify_key.return_value = False
|
||||
mock_delete_key.return_value = True
|
||||
@@ -256,6 +266,7 @@ class TestGetLlmApiKeyForByor:
|
||||
|
||||
# Assert
|
||||
assert result == {'key': new_key}
|
||||
mock_check_enabled.assert_called_once_with(user_id)
|
||||
mock_get_key.assert_called_once_with(user_id)
|
||||
mock_verify_key.assert_called_once_with(invalid_key, user_id)
|
||||
mock_delete_key.assert_called_once_with(user_id, invalid_key)
|
||||
@@ -263,6 +274,7 @@ class TestGetLlmApiKeyForByor:
|
||||
mock_store_key.assert_called_once_with(user_id, new_key)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
@patch('server.routes.api_keys.store_byor_key_in_db')
|
||||
@patch('server.routes.api_keys.generate_byor_key')
|
||||
@patch('server.routes.api_keys.delete_byor_key_from_litellm')
|
||||
@@ -275,12 +287,14 @@ class TestGetLlmApiKeyForByor:
|
||||
mock_delete_key,
|
||||
mock_generate_key,
|
||||
mock_store_key,
|
||||
mock_check_enabled,
|
||||
):
|
||||
"""Test that even if deletion fails, regeneration still proceeds."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
invalid_key = 'sk-invalid-key'
|
||||
new_key = 'sk-new-generated-key'
|
||||
mock_check_enabled.return_value = True
|
||||
mock_get_key.return_value = invalid_key
|
||||
mock_verify_key.return_value = False
|
||||
mock_delete_key.return_value = False # Deletion fails
|
||||
@@ -292,19 +306,22 @@ class TestGetLlmApiKeyForByor:
|
||||
|
||||
# Assert
|
||||
assert result == {'key': new_key}
|
||||
mock_check_enabled.assert_called_once_with(user_id)
|
||||
mock_delete_key.assert_called_once_with(user_id, invalid_key)
|
||||
mock_generate_key.assert_called_once_with(user_id)
|
||||
mock_store_key.assert_called_once_with(user_id, new_key)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
@patch('server.routes.api_keys.generate_byor_key')
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_key_generation_failure_raises_exception(
|
||||
self, mock_get_key, mock_generate_key
|
||||
self, mock_get_key, mock_generate_key, mock_check_enabled
|
||||
):
|
||||
"""Test that when key generation fails, an HTTPException is raised."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_check_enabled.return_value = True
|
||||
mock_get_key.return_value = None
|
||||
mock_generate_key.return_value = None
|
||||
|
||||
@@ -316,11 +333,15 @@ class TestGetLlmApiKeyForByor:
|
||||
assert 'Failed to generate new BYOR LLM API key' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
@patch('server.routes.api_keys.get_byor_key_from_db')
|
||||
async def test_database_error_raises_exception(self, mock_get_key):
|
||||
async def test_database_error_raises_exception(
|
||||
self, mock_get_key, mock_check_enabled
|
||||
):
|
||||
"""Test that database errors are properly handled."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_check_enabled.return_value = True
|
||||
mock_get_key.side_effect = Exception('Database connection error')
|
||||
|
||||
# Act & Assert
|
||||
@@ -330,6 +351,21 @@ class TestGetLlmApiKeyForByor:
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to retrieve BYOR LLM API key' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
async def test_byor_export_disabled_returns_402(self, mock_check_enabled):
|
||||
"""Test that when BYOR export is disabled, 402 is returned."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_check_enabled.return_value = False
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_llm_api_key_for_byor(user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 402
|
||||
assert 'BYOR key export is not enabled' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestDeleteByorKeyFromLitellm:
|
||||
"""Test the delete_byor_key_from_litellm function with alias cleanup."""
|
||||
@@ -425,3 +461,52 @@ class TestDeleteByorKeyFromLitellm:
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestCheckByorPermitted:
|
||||
"""Test the check_byor_permitted endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
async def test_permitted_when_enabled(self, mock_check_enabled):
|
||||
"""Test that permitted=True is returned when BYOR export is enabled."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_check_enabled.return_value = True
|
||||
|
||||
# Act
|
||||
result = await check_byor_permitted(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result == {'permitted': True}
|
||||
mock_check_enabled.assert_called_once_with(user_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
async def test_not_permitted_when_disabled(self, mock_check_enabled):
|
||||
"""Test that permitted=False is returned when BYOR export is disabled."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_check_enabled.return_value = False
|
||||
|
||||
# Act
|
||||
result = await check_byor_permitted(user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert result == {'permitted': False}
|
||||
mock_check_enabled.assert_called_once_with(user_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('storage.org_service.OrgService.check_byor_export_enabled')
|
||||
async def test_error_raises_500(self, mock_check_enabled):
|
||||
"""Test that an exception raises 500 error."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_check_enabled.side_effect = Exception('Database error')
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await check_byor_permitted(user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to check BYOR export permission' in exc_info.value.detail
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
"""Tests for resend_keycloak email validation."""
|
||||
|
||||
from sync.resend_keycloak import is_valid_email
|
||||
|
||||
|
||||
class TestIsValidEmail:
|
||||
"""Test cases for is_valid_email function."""
|
||||
|
||||
def test_valid_simple_email(self):
|
||||
"""Test that a simple valid email passes validation."""
|
||||
assert is_valid_email('user@example.com') is True
|
||||
|
||||
def test_valid_email_with_plus(self):
|
||||
"""Test that email with + modifier passes validation."""
|
||||
assert is_valid_email('user+tag@example.com') is True
|
||||
|
||||
def test_valid_email_with_dots(self):
|
||||
"""Test that email with dots in local part passes validation."""
|
||||
assert is_valid_email('first.last@example.com') is True
|
||||
|
||||
def test_valid_email_with_numbers(self):
|
||||
"""Test that email with numbers passes validation."""
|
||||
assert is_valid_email('user123@example.com') is True
|
||||
|
||||
def test_valid_email_with_subdomain(self):
|
||||
"""Test that email with subdomain passes validation."""
|
||||
assert is_valid_email('user@mail.example.com') is True
|
||||
|
||||
def test_valid_email_with_hyphen_domain(self):
|
||||
"""Test that email with hyphen in domain passes validation."""
|
||||
assert is_valid_email('user@example-site.com') is True
|
||||
|
||||
def test_valid_email_with_underscore(self):
|
||||
"""Test that email with underscore passes validation."""
|
||||
assert is_valid_email('user_name@example.com') is True
|
||||
|
||||
def test_valid_email_with_percent(self):
|
||||
"""Test that email with percent sign passes validation."""
|
||||
assert is_valid_email('user%name@example.com') is True
|
||||
|
||||
def test_invalid_email_with_exclamation(self):
|
||||
"""Test that email with exclamation mark fails validation.
|
||||
|
||||
This is the specific case from the bug report:
|
||||
ethanjames3713+!@gmail.com
|
||||
"""
|
||||
assert is_valid_email('ethanjames3713+!@gmail.com') is False
|
||||
|
||||
def test_invalid_email_with_special_chars(self):
|
||||
"""Test that email with other special characters fails validation."""
|
||||
assert is_valid_email('user!name@example.com') is False
|
||||
assert is_valid_email('user#name@example.com') is False
|
||||
assert is_valid_email('user$name@example.com') is False
|
||||
assert is_valid_email('user&name@example.com') is False
|
||||
assert is_valid_email("user'name@example.com") is False
|
||||
assert is_valid_email('user*name@example.com') is False
|
||||
assert is_valid_email('user=name@example.com') is False
|
||||
assert is_valid_email('user^name@example.com') is False
|
||||
assert is_valid_email('user`name@example.com') is False
|
||||
assert is_valid_email('user{name@example.com') is False
|
||||
assert is_valid_email('user|name@example.com') is False
|
||||
assert is_valid_email('user}name@example.com') is False
|
||||
assert is_valid_email('user~name@example.com') is False
|
||||
|
||||
def test_invalid_email_no_at_symbol(self):
|
||||
"""Test that email without @ symbol fails validation."""
|
||||
assert is_valid_email('userexample.com') is False
|
||||
|
||||
def test_invalid_email_no_domain(self):
|
||||
"""Test that email without domain fails validation."""
|
||||
assert is_valid_email('user@') is False
|
||||
|
||||
def test_invalid_email_no_local_part(self):
|
||||
"""Test that email without local part fails validation."""
|
||||
assert is_valid_email('@example.com') is False
|
||||
|
||||
def test_invalid_email_no_tld(self):
|
||||
"""Test that email without TLD fails validation."""
|
||||
assert is_valid_email('user@example') is False
|
||||
|
||||
def test_invalid_email_single_char_tld(self):
|
||||
"""Test that email with single character TLD fails validation."""
|
||||
assert is_valid_email('user@example.c') is False
|
||||
|
||||
def test_invalid_email_empty_string(self):
|
||||
"""Test that empty string fails validation."""
|
||||
assert is_valid_email('') is False
|
||||
|
||||
def test_invalid_email_none(self):
|
||||
"""Test that None fails validation."""
|
||||
assert is_valid_email(None) is False
|
||||
|
||||
def test_invalid_email_whitespace(self):
|
||||
"""Test that email with whitespace fails validation."""
|
||||
assert is_valid_email('user @example.com') is False
|
||||
assert is_valid_email('user@ example.com') is False
|
||||
assert is_valid_email(' user@example.com') is False
|
||||
assert is_valid_email('user@example.com ') is False
|
||||
|
||||
def test_invalid_email_double_at(self):
|
||||
"""Test that email with double @ fails validation."""
|
||||
assert is_valid_email('user@@example.com') is False
|
||||
|
||||
def test_email_double_dot_domain(self):
|
||||
"""Test email with double dot in domain.
|
||||
|
||||
Note: The regex allows this as it's technically valid in some edge cases,
|
||||
and Resend's API may accept it. The main goal is to reject special
|
||||
characters like ! that Resend definitely rejects.
|
||||
"""
|
||||
# This is allowed by our regex - Resend may or may not accept it
|
||||
assert is_valid_email('user@example..com') is True
|
||||
|
||||
def test_case_insensitive_validation(self):
|
||||
"""Test that validation works for uppercase emails."""
|
||||
assert is_valid_email('USER@EXAMPLE.COM') is True
|
||||
assert is_valid_email('User@Example.Com') is True
|
||||
@@ -299,6 +299,8 @@ async def test_success_callback_success():
|
||||
mock_billing_session.status = 'in_progress'
|
||||
mock_billing_session.user_id = 'mock_user'
|
||||
|
||||
mock_org = MagicMock()
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
patch('stripe.checkout.Session.retrieve') as mock_stripe_retrieve,
|
||||
@@ -319,7 +321,17 @@ async def test_success_callback_success():
|
||||
) as mock_update_budget,
|
||||
):
|
||||
mock_db_session = MagicMock()
|
||||
# First query: BillingSession (query().filter().filter().first())
|
||||
mock_db_session.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_billing_session
|
||||
# Second query: Org (query().filter().first()) - use side_effect for different return chains
|
||||
mock_query_chain_billing = MagicMock()
|
||||
mock_query_chain_billing.filter.return_value.filter.return_value.first.return_value = mock_billing_session
|
||||
mock_query_chain_org = MagicMock()
|
||||
mock_query_chain_org.filter.return_value.first.return_value = mock_org
|
||||
mock_db_session.query.side_effect = [
|
||||
mock_query_chain_billing,
|
||||
mock_query_chain_org,
|
||||
]
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_db_session
|
||||
|
||||
mock_stripe_retrieve.return_value = MagicMock(
|
||||
@@ -340,6 +352,9 @@ async def test_success_callback_success():
|
||||
125.0, # 100 + (25.00 from Stripe)
|
||||
)
|
||||
|
||||
# Verify BYOR export is enabled for the org (updated in same session)
|
||||
assert mock_org.byor_export_enabled is True
|
||||
|
||||
# Verify database updates
|
||||
assert mock_billing_session.status == 'completed'
|
||||
assert mock_billing_session.price == 25.0
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Mock the database module before importing OrgMemberStore
|
||||
with patch('storage.database.engine'), patch('storage.database.a_engine'):
|
||||
with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
@@ -10,6 +17,31 @@ with patch('storage.database.engine'), patch('storage.database.a_engine'):
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
def test_get_org_members(session_maker):
|
||||
# Test getting org_members by org ID
|
||||
with session_maker() as session:
|
||||
@@ -251,3 +283,324 @@ def test_remove_user_from_org_not_found(session_maker):
|
||||
with patch('storage.org_member_store.session_maker', session_maker):
|
||||
result = OrgMemberStore.remove_user_from_org(uuid4(), 99999)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_paginated_basic(async_session_maker):
|
||||
"""Test basic pagination returns correct number of items."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='admin', rank=1)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
# Create 5 users
|
||||
users = [
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
|
||||
for i in range(5)
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
# Create org members
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key=f'test-key-{i}',
|
||||
status='active',
|
||||
)
|
||||
for i, user in enumerate(users)
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=0, limit=3
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(members) == 3
|
||||
assert has_more is True
|
||||
# Verify user and role relationships are loaded
|
||||
assert all(member.user is not None for member in members)
|
||||
assert all(member.role is not None for member in members)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_paginated_no_more(async_session_maker):
|
||||
"""Test pagination when there are no more results."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='admin', rank=1)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
# Create 3 users
|
||||
users = [
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
|
||||
for i in range(3)
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
# Create org members
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key=f'test-key-{i}',
|
||||
status='active',
|
||||
)
|
||||
for i, user in enumerate(users)
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=0, limit=5
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(members) == 3
|
||||
assert has_more is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_paginated_exact_limit(async_session_maker):
|
||||
"""Test pagination when results exactly match limit."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='admin', rank=1)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
# Create exactly 5 users
|
||||
users = [
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
|
||||
for i in range(5)
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
# Create org members
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key=f'test-key-{i}',
|
||||
status='active',
|
||||
)
|
||||
for i, user in enumerate(users)
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=0, limit=5
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(members) == 5
|
||||
assert has_more is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_paginated_with_offset(async_session_maker):
|
||||
"""Test pagination with offset skips correct number of items."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='admin', rank=1)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
# Create 10 users
|
||||
users = [
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
|
||||
for i in range(10)
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
# Create org members
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key=f'test-key-{i}',
|
||||
status='active',
|
||||
)
|
||||
for i, user in enumerate(users)
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act - Get first page
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
first_page, has_more_first = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=0, limit=3
|
||||
)
|
||||
|
||||
# Get second page
|
||||
second_page, has_more_second = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=3, limit=3
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(first_page) == 3
|
||||
assert has_more_first is True
|
||||
assert len(second_page) == 3
|
||||
assert has_more_second is True
|
||||
|
||||
# Verify no overlap between pages
|
||||
first_user_ids = {member.user_id for member in first_page}
|
||||
second_user_ids = {member.user_id for member in second_page}
|
||||
assert first_user_ids.isdisjoint(second_user_ids)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_paginated_empty_org(async_session_maker):
|
||||
"""Test pagination with empty organization returns empty list."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=0, limit=10
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(members) == 0
|
||||
assert has_more is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_paginated_ordering(async_session_maker):
|
||||
"""Test that pagination orders results by user_id."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='admin', rank=1)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
# Create users with specific IDs to test ordering
|
||||
user_ids = [uuid.uuid4() for _ in range(5)]
|
||||
user_ids.sort() # Sort to verify ordering
|
||||
|
||||
users = [
|
||||
User(id=user_id, current_org_id=org.id, email=f'user{i}@example.com')
|
||||
for i, user_id in enumerate(user_ids)
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
# Create org members in reverse order to test that ordering works
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user_id,
|
||||
role_id=role.id,
|
||||
llm_api_key=f'test-key-{i}',
|
||||
status='active',
|
||||
)
|
||||
for i, user_id in enumerate(reversed(user_ids))
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=0, limit=10
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(members) == 5
|
||||
# Verify members are ordered by user_id
|
||||
member_user_ids = [member.user_id for member in members]
|
||||
assert member_user_ids == sorted(member_user_ids)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members_paginated_eager_loading(async_session_maker):
|
||||
"""Test that user and role relationships are eagerly loaded."""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='owner', rank=10)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
user = User(id=uuid.uuid4(), current_org_id=org.id, email='test@example.com')
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
org_member = OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key='test-key',
|
||||
status='active',
|
||||
)
|
||||
session.add(org_member)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
with patch('storage.org_member_store.a_session_maker', async_session_maker):
|
||||
members, has_more = await OrgMemberStore.get_org_members_paginated(
|
||||
org_id=org_id, offset=0, limit=10
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(members) == 1
|
||||
member = members[0]
|
||||
# Verify relationships are loaded (not lazy)
|
||||
assert member.user is not None
|
||||
assert member.user.email == 'test@example.com'
|
||||
assert member.role is not None
|
||||
assert member.role.name == 'owner'
|
||||
assert member.role.rank == 10
|
||||
|
||||
@@ -1535,6 +1535,118 @@ async def test_update_org_with_permissions_database_error(session_maker):
|
||||
assert 'Failed to update organization' in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_with_permissions_duplicate_name_raises_org_name_exists_error(
|
||||
session_maker,
|
||||
):
|
||||
"""
|
||||
GIVEN: User updates org name to a name already used by another organization
|
||||
WHEN: update_org_with_permissions is called
|
||||
THEN: OrgNameExistsError is raised with the conflicting name
|
||||
"""
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
other_org_id = uuid.uuid4()
|
||||
user_id = str(uuid.uuid4())
|
||||
duplicate_name = 'Existing Org Name'
|
||||
|
||||
mock_current_org = Org(
|
||||
id=org_id,
|
||||
name='My Org',
|
||||
contact_name='John Doe',
|
||||
contact_email='john@example.com',
|
||||
org_version=5,
|
||||
)
|
||||
mock_org_with_name = Org(
|
||||
id=other_org_id,
|
||||
name=duplicate_name,
|
||||
contact_name='Jane Doe',
|
||||
contact_email='jane@example.com',
|
||||
)
|
||||
|
||||
from server.routes.org_models import OrgUpdate
|
||||
|
||||
update_data = OrgUpdate(name=duplicate_name)
|
||||
|
||||
with (
|
||||
patch('storage.org_store.session_maker', session_maker),
|
||||
patch('storage.org_member_store.session_maker', session_maker),
|
||||
patch('storage.role_store.session_maker', session_maker),
|
||||
patch(
|
||||
'storage.org_service.OrgStore.get_org_by_id',
|
||||
return_value=mock_current_org,
|
||||
),
|
||||
patch('storage.org_service.OrgService.is_org_member', return_value=True),
|
||||
patch(
|
||||
'storage.org_service.OrgStore.get_org_by_name',
|
||||
return_value=mock_org_with_name,
|
||||
),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNameExistsError) as exc_info:
|
||||
await OrgService.update_org_with_permissions(
|
||||
org_id=org_id,
|
||||
update_data=update_data,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
assert duplicate_name in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_with_permissions_same_name_allowed(session_maker):
|
||||
"""
|
||||
GIVEN: User updates org with name unchanged (same as current org name)
|
||||
WHEN: update_org_with_permissions is called
|
||||
THEN: No OrgNameExistsError; update proceeds (name uniqueness allows same org)
|
||||
"""
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
user_id = str(uuid.uuid4())
|
||||
current_name = 'My Org'
|
||||
|
||||
mock_org = Org(
|
||||
id=org_id,
|
||||
name=current_name,
|
||||
contact_name='John Doe',
|
||||
contact_email='john@example.com',
|
||||
org_version=5,
|
||||
)
|
||||
|
||||
from server.routes.org_models import OrgUpdate
|
||||
|
||||
update_data = OrgUpdate(name=current_name)
|
||||
|
||||
with (
|
||||
patch('storage.org_store.session_maker', session_maker),
|
||||
patch('storage.org_member_store.session_maker', session_maker),
|
||||
patch('storage.role_store.session_maker', session_maker),
|
||||
patch(
|
||||
'storage.org_service.OrgStore.get_org_by_id',
|
||||
return_value=mock_org,
|
||||
),
|
||||
patch('storage.org_service.OrgService.is_org_member', return_value=True),
|
||||
patch(
|
||||
'storage.org_service.OrgStore.get_org_by_name',
|
||||
return_value=mock_org,
|
||||
),
|
||||
patch(
|
||||
'storage.org_service.OrgStore.update_org',
|
||||
return_value=mock_org,
|
||||
),
|
||||
):
|
||||
# Act
|
||||
result = await OrgService.update_org_with_permissions(
|
||||
org_id=org_id,
|
||||
update_data=update_data,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.name == current_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_with_permissions_only_llm_fields(session_maker):
|
||||
"""
|
||||
@@ -1657,3 +1769,258 @@ async def test_update_org_with_permissions_only_non_llm_fields(session_maker):
|
||||
assert result.contact_name == 'Jane Doe'
|
||||
assert result.conversation_expiration == 60
|
||||
assert result.enable_proactive_conversation_starters is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_byor_export_enabled_returns_true_when_enabled():
|
||||
"""
|
||||
GIVEN: User has current_org with byor_export_enabled=True
|
||||
WHEN: check_byor_export_enabled is called
|
||||
THEN: Returns True
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.current_org_id = org_id
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.byor_export_enabled = True
|
||||
|
||||
with (
|
||||
patch(
|
||||
'storage.org_service.UserStore.get_user_by_id_async',
|
||||
AsyncMock(return_value=mock_user),
|
||||
),
|
||||
patch(
|
||||
'storage.org_service.OrgStore.get_org_by_id',
|
||||
return_value=mock_org,
|
||||
),
|
||||
):
|
||||
# Act
|
||||
result = await OrgService.check_byor_export_enabled(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_byor_export_enabled_returns_false_when_disabled():
|
||||
"""
|
||||
GIVEN: User has current_org with byor_export_enabled=False
|
||||
WHEN: check_byor_export_enabled is called
|
||||
THEN: Returns False
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.current_org_id = org_id
|
||||
|
||||
mock_org = MagicMock()
|
||||
mock_org.byor_export_enabled = False
|
||||
|
||||
with (
|
||||
patch(
|
||||
'storage.org_service.UserStore.get_user_by_id_async',
|
||||
AsyncMock(return_value=mock_user),
|
||||
),
|
||||
patch(
|
||||
'storage.org_service.OrgStore.get_org_by_id',
|
||||
return_value=mock_org,
|
||||
),
|
||||
):
|
||||
# Act
|
||||
result = await OrgService.check_byor_export_enabled(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_byor_export_enabled_returns_false_when_user_not_found():
|
||||
"""
|
||||
GIVEN: User does not exist
|
||||
WHEN: check_byor_export_enabled is called
|
||||
THEN: Returns False
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'nonexistent-user'
|
||||
|
||||
with patch(
|
||||
'storage.org_service.UserStore.get_user_by_id_async',
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
# Act
|
||||
result = await OrgService.check_byor_export_enabled(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_byor_export_enabled_returns_false_when_no_current_org():
|
||||
"""
|
||||
GIVEN: User exists but has no current_org_id
|
||||
WHEN: check_byor_export_enabled is called
|
||||
THEN: Returns False
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.current_org_id = None
|
||||
|
||||
with patch(
|
||||
'storage.org_service.UserStore.get_user_by_id_async',
|
||||
AsyncMock(return_value=mock_user),
|
||||
):
|
||||
# Act
|
||||
result = await OrgService.check_byor_export_enabled(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_byor_export_enabled_returns_false_when_org_not_found():
|
||||
"""
|
||||
GIVEN: User has current_org_id but org does not exist
|
||||
WHEN: check_byor_export_enabled is called
|
||||
THEN: Returns False
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.current_org_id = org_id
|
||||
|
||||
with (
|
||||
patch(
|
||||
'storage.org_service.UserStore.get_user_by_id_async',
|
||||
AsyncMock(return_value=mock_user),
|
||||
),
|
||||
patch(
|
||||
'storage.org_service.OrgStore.get_org_by_id',
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
# Act
|
||||
result = await OrgService.check_byor_export_enabled(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_org_success():
|
||||
"""
|
||||
GIVEN: Valid org_id and user_id where user is a member
|
||||
WHEN: switch_org is called
|
||||
THEN: User's current_org_id is updated and org is returned
|
||||
"""
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_org = Org(
|
||||
id=org_id,
|
||||
name='Target Organization',
|
||||
contact_name='John Doe',
|
||||
contact_email='john@example.com',
|
||||
)
|
||||
mock_updated_user = User(id=uuid.UUID(user_id), current_org_id=org_id)
|
||||
|
||||
with (
|
||||
patch('storage.org_service.OrgStore.get_org_by_id', return_value=mock_org),
|
||||
patch('storage.org_service.OrgService.is_org_member', return_value=True),
|
||||
patch(
|
||||
'storage.org_service.UserStore.update_current_org',
|
||||
return_value=mock_updated_user,
|
||||
),
|
||||
):
|
||||
# Act
|
||||
result = await OrgService.switch_org(user_id, org_id)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.id == org_id
|
||||
assert result.name == 'Target Organization'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_org_org_not_found():
|
||||
"""
|
||||
GIVEN: Organization does not exist
|
||||
WHEN: switch_org is called
|
||||
THEN: OrgNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
with patch('storage.org_service.OrgStore.get_org_by_id', return_value=None):
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNotFoundError) as exc_info:
|
||||
await OrgService.switch_org(user_id, org_id)
|
||||
|
||||
assert str(org_id) in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_org_user_not_member():
|
||||
"""
|
||||
GIVEN: User is not a member of the organization
|
||||
WHEN: switch_org is called
|
||||
THEN: OrgAuthorizationError is raised
|
||||
"""
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_org = Org(
|
||||
id=org_id,
|
||||
name='Target Organization',
|
||||
contact_name='John Doe',
|
||||
contact_email='john@example.com',
|
||||
)
|
||||
|
||||
with (
|
||||
patch('storage.org_service.OrgStore.get_org_by_id', return_value=mock_org),
|
||||
patch('storage.org_service.OrgService.is_org_member', return_value=False),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgAuthorizationError) as exc_info:
|
||||
await OrgService.switch_org(user_id, org_id)
|
||||
|
||||
assert 'member' in str(exc_info.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_org_user_not_found():
|
||||
"""
|
||||
GIVEN: User does not exist in database
|
||||
WHEN: switch_org is called
|
||||
THEN: OrgDatabaseError is raised
|
||||
"""
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_org = Org(
|
||||
id=org_id,
|
||||
name='Target Organization',
|
||||
contact_name='John Doe',
|
||||
contact_email='john@example.com',
|
||||
)
|
||||
|
||||
with (
|
||||
patch('storage.org_service.OrgStore.get_org_by_id', return_value=mock_org),
|
||||
patch('storage.org_service.OrgService.is_org_member', return_value=True),
|
||||
patch('storage.org_service.UserStore.update_current_org', return_value=None),
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgDatabaseError) as exc_info:
|
||||
await OrgService.switch_org(user_id, org_id)
|
||||
|
||||
assert 'User not found' in str(exc_info.value)
|
||||
|
||||
@@ -786,3 +786,23 @@ def test_get_user_orgs_paginated_ordering(session_maker, mock_litellm_api):
|
||||
assert orgs[0].name == 'Apple Org'
|
||||
assert orgs[1].name == 'Banana Org'
|
||||
assert orgs[2].name == 'Zebra Org'
|
||||
|
||||
|
||||
def test_orphaned_user_error_contains_user_ids():
|
||||
"""
|
||||
GIVEN: OrphanedUserError is created with a list of user IDs
|
||||
WHEN: The error message is accessed
|
||||
THEN: Message includes the count and stores user IDs
|
||||
"""
|
||||
# Arrange
|
||||
from server.routes.org_models import OrphanedUserError
|
||||
|
||||
user_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
|
||||
# Act
|
||||
error = OrphanedUserError(user_ids)
|
||||
|
||||
# Assert
|
||||
assert error.user_ids == user_ids
|
||||
assert '2 user(s)' in str(error)
|
||||
assert 'no remaining organization' in str(error)
|
||||
|
||||
@@ -3,7 +3,9 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
# Mock the database module before importing
|
||||
with patch('storage.database.engine'), patch('storage.database.a_engine'):
|
||||
with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from integrations.github.github_view import get_user_proactive_conversation_setting
|
||||
from storage.org import Org
|
||||
|
||||
|
||||
@@ -522,3 +522,46 @@ async def test_backfill_contact_name_preserves_custom_value(session_maker):
|
||||
with session_maker() as session:
|
||||
org = session.query(Org).filter(Org.id == uuid.UUID(user_id)).first()
|
||||
assert org.contact_name == 'Custom Corp Name'
|
||||
|
||||
|
||||
def test_update_current_org_success(session_maker):
|
||||
"""
|
||||
GIVEN: User exists in database
|
||||
WHEN: update_current_org is called with new org_id
|
||||
THEN: User's current_org_id is updated and user is returned
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
initial_org_id = uuid.uuid4()
|
||||
new_org_id = uuid.uuid4()
|
||||
|
||||
with session_maker() as session:
|
||||
user = User(id=uuid.UUID(user_id), current_org_id=initial_org_id)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
# Act
|
||||
with patch('storage.user_store.session_maker', session_maker):
|
||||
result = UserStore.update_current_org(user_id, new_org_id)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.current_org_id == new_org_id
|
||||
|
||||
|
||||
def test_update_current_org_user_not_found(session_maker):
|
||||
"""
|
||||
GIVEN: User does not exist in database
|
||||
WHEN: update_current_org is called
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
# Act
|
||||
with patch('storage.user_store.session_maker', session_maker):
|
||||
result = UserStore.update_current_org(user_id, org_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
vi.mock("#/api/open-hands-axios", () => ({
|
||||
openHands: { get: mockGet },
|
||||
}));
|
||||
|
||||
describe("V1ConversationService", () => {
|
||||
describe("readConversationFile", () => {
|
||||
it("uses default plan path when filePath is not provided", async () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-123";
|
||||
mockGet.mockResolvedValue({ data: "# PLAN content" });
|
||||
|
||||
// Act
|
||||
await V1ConversationService.readConversationFile(conversationId);
|
||||
|
||||
// Assert
|
||||
expect(mockGet).toHaveBeenCalledTimes(1);
|
||||
const callUrl = mockGet.mock.calls[0][0] as string;
|
||||
expect(callUrl).toContain(
|
||||
"file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -125,7 +125,7 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
});
|
||||
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { APP_MODE: "local" },
|
||||
data: { app_mode: "local" },
|
||||
});
|
||||
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
@@ -258,7 +258,7 @@ describe("ChatInterface - Empty state", () => {
|
||||
errorMessage: null,
|
||||
});
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { APP_MODE: "local" },
|
||||
data: { app_mode: "local" },
|
||||
});
|
||||
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
|
||||
@@ -113,15 +113,15 @@ describe("ExpandableMessage", () => {
|
||||
|
||||
it("should render the out of credits message when the user is out of credits", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields
|
||||
// @ts-expect-error - We only care about the app_mode and feature_flags fields
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
const RouterStub = createRoutesStub([
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { render } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useParamsMock, createUserMessageEvent } from "test-utils";
|
||||
import { ChatInterface } from "#/components/features/chat/chat-interface";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
|
||||
// Module-level mocks
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-get-trajectory");
|
||||
vi.mock("#/hooks/mutation/use-unified-upload-files");
|
||||
vi.mock("#/hooks/use-conversation-id");
|
||||
vi.mock("#/hooks/query/use-active-conversation");
|
||||
vi.mock("#/contexts/conversation-websocket-context");
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
useConversationNameContextMenu: () => ({
|
||||
isOpen: false,
|
||||
contextMenuRef: { current: null },
|
||||
handleContextMenu: vi.fn(),
|
||||
handleClose: vi.fn(),
|
||||
handleRename: vi.fn(),
|
||||
handleDelete: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(() => ({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Helper to render with QueryClient and route params
|
||||
const renderWithQueryClient = (
|
||||
ui: React.ReactElement,
|
||||
queryClient: QueryClient,
|
||||
route = "/test-conversation-id",
|
||||
) =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<Routes>
|
||||
<Route path="/:conversationId" element={ui} />
|
||||
<Route path="/" element={ui} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// V0 user event (numeric id, action property)
|
||||
const createV0UserEvent = (): OpenHandsAction => ({
|
||||
id: 1,
|
||||
source: "user",
|
||||
action: "message",
|
||||
args: {
|
||||
content: "Hello from V0",
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
},
|
||||
message: "Hello from V0",
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
});
|
||||
|
||||
describe("ChatInterface – message display continuity (spec 3.1)", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" });
|
||||
vi.mocked(useConversationId).mockReturnValue({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
|
||||
// Default: V0, no loading, no events
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { app_mode: "local" },
|
||||
});
|
||||
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(
|
||||
useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Default: no conversation (V0 behavior)
|
||||
vi.mocked(useActiveConversation).mockReturnValue({
|
||||
data: undefined,
|
||||
} as ReturnType<typeof useActiveConversation>);
|
||||
|
||||
// Default: no websocket context
|
||||
vi.mocked(useConversationWebSocket).mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("V1 conversations", () => {
|
||||
beforeEach(() => {
|
||||
// Set up V1 conversation
|
||||
vi.mocked(useActiveConversation).mockReturnValue({
|
||||
data: { conversation_version: "V1" },
|
||||
} as ReturnType<typeof useActiveConversation>);
|
||||
});
|
||||
|
||||
it("shows messages immediately when V1 events exist in store, even while loading", () => {
|
||||
// Simulate: history is loading but events already exist in store (e.g., remount)
|
||||
vi.mocked(useConversationWebSocket).mockReturnValue({
|
||||
isLoadingHistory: true,
|
||||
connectionState: "OPEN",
|
||||
sendMessage: vi.fn(),
|
||||
});
|
||||
|
||||
// Put V1 user events in the store
|
||||
const v1UserEvent = createUserMessageEvent("evt-1");
|
||||
useEventStore.setState({
|
||||
events: [v1UserEvent],
|
||||
uiEvents: [v1UserEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// AC1: Messages should display immediately without skeleton
|
||||
expect(
|
||||
screen.queryByTestId("chat-messages-skeleton"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows skeleton when store is empty and loading", () => {
|
||||
// Simulate: first load, no events yet
|
||||
vi.mocked(useConversationWebSocket).mockReturnValue({
|
||||
isLoadingHistory: true,
|
||||
connectionState: "OPEN",
|
||||
sendMessage: vi.fn(),
|
||||
});
|
||||
|
||||
// Store is empty
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
uiEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// AC5: Genuine first-load shows skeleton
|
||||
expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows messages when loading is already false on mount (edge case)", () => {
|
||||
// Simulate: component re-mounts when WebSocket has already finished loading
|
||||
vi.mocked(useConversationWebSocket).mockReturnValue({
|
||||
isLoadingHistory: false,
|
||||
connectionState: "OPEN",
|
||||
sendMessage: vi.fn(),
|
||||
});
|
||||
|
||||
// V1 events in store
|
||||
const v1UserEvent = createUserMessageEvent("evt-2");
|
||||
useEventStore.setState({
|
||||
events: [v1UserEvent],
|
||||
uiEvents: [v1UserEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// Messages should display, no skeleton
|
||||
expect(
|
||||
screen.queryByTestId("chat-messages-skeleton"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("V0 conversations", () => {
|
||||
it("shows messages when V0 events exist in store even if isLoadingMessages is true", () => {
|
||||
// Simulate: loading flag is still true but events already exist in store (e.g., remount)
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: true,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
// Put V0 user events in the store
|
||||
useEventStore.setState({
|
||||
events: [createV0UserEvent()],
|
||||
uiEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// AC1/AC4: Messages display immediately, no skeleton
|
||||
expect(
|
||||
screen.queryByTestId("chat-messages-skeleton"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows skeleton when store is empty and isLoadingMessages is true", () => {
|
||||
// Simulate: genuine first load, no events yet
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: true,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
// Store is empty
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
uiEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// AC5: Genuine first-load shows skeleton
|
||||
expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { EventMessage } from "#/components/features/chat/event-message";
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AlertBanner } from "#/components/features/alerts/alert-banner";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { time?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
MAINTENANCE$SCHEDULED_MESSAGE: `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
|
||||
ALERT$FAULTY_MODELS_MESSAGE:
|
||||
"The following models are currently reporting errors:",
|
||||
"ERROR$TRANSLATED_KEY": "This is a translated error message",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("AlertBanner", () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("Maintenance alerts", () => {
|
||||
it("renders maintenance banner with formatted time", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00";
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner maintenanceStartTime={startTime} updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
|
||||
const svgIcon = container.querySelector("svg");
|
||||
expect(svgIcon).toBeInTheDocument();
|
||||
|
||||
const button = within(banner!).queryByTestId("dismiss-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("click on dismiss button removes banner", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00";
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner maintenanceStartTime={startTime} updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
const button = within(banner!).queryByTestId("dismiss-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(button!);
|
||||
});
|
||||
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("banner reappears when updatedAt changes", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00";
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
const newUpdatedAt = "2024-01-15T10:00:00Z";
|
||||
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner maintenanceStartTime={startTime} updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
const button = within(banner!).queryByTestId("dismiss-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(button!);
|
||||
});
|
||||
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<AlertBanner
|
||||
maintenanceStartTime={startTime}
|
||||
updatedAt={newUpdatedAt}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("alert-banner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Faulty models alerts", () => {
|
||||
it("renders banner with faulty models list", () => {
|
||||
const faultyModels = ["gpt-4", "claude-3"];
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner faultyModels={faultyModels} updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(/The following models are currently reporting errors:/),
|
||||
).toBeInTheDocument();
|
||||
// Models are displayed in the order they are provided
|
||||
expect(screen.getByText(/gpt-4/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/claude-3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render banner when faulty models array is empty", () => {
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner faultyModels={[]} updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("banner reappears when updatedAt changes", () => {
|
||||
const faultyModels = ["gpt-4"];
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
const newUpdatedAt = "2024-01-15T10:00:00Z";
|
||||
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner faultyModels={faultyModels} updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
const button = within(banner!).queryByTestId("dismiss-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(button!);
|
||||
});
|
||||
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<AlertBanner faultyModels={faultyModels} updatedAt={newUpdatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("alert-banner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error message alerts", () => {
|
||||
it("renders banner with translated error message", () => {
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner errorMessage="ERROR$TRANSLATED_KEY" updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("This is a translated error message"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders banner with raw error message when no translation exists", () => {
|
||||
const rawErrorMessage = "This is a raw error without translation";
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner errorMessage={rawErrorMessage} updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(screen.getByText(rawErrorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render banner when error message is empty", () => {
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner errorMessage="" updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render banner when error message is null", () => {
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner errorMessage={null} updatedAt={updatedAt} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple alerts", () => {
|
||||
it("renders all alerts when multiple conditions are present", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00";
|
||||
const faultyModels = ["gpt-4"];
|
||||
const errorMessage = "ERROR$TRANSLATED_KEY";
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner
|
||||
maintenanceStartTime={startTime}
|
||||
faultyModels={faultyModels}
|
||||
errorMessage={errorMessage}
|
||||
updatedAt={updatedAt}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(/Scheduled maintenance will begin at/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/The following models are currently reporting errors:/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("This is a translated error message"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dismissing hides all alerts", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00";
|
||||
const faultyModels = ["gpt-4"];
|
||||
const updatedAt = "2024-01-14T10:00:00Z";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertBanner
|
||||
maintenanceStartTime={startTime}
|
||||
faultyModels={faultyModels}
|
||||
updatedAt={updatedAt}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const banner = screen.queryByTestId("alert-banner");
|
||||
const button = within(banner!).queryByTestId("dismiss-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(button!);
|
||||
});
|
||||
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -92,19 +92,21 @@ describe("PlanPreview", () => {
|
||||
});
|
||||
|
||||
it("should render nothing when planContent is null", () => {
|
||||
renderPlanPreview(<PlanPreview planContent={null} />);
|
||||
// Arrange & Act
|
||||
const { container } = renderPlanPreview(<PlanPreview planContent={null} />);
|
||||
|
||||
const contentDiv = screen.getByTestId("plan-preview-content");
|
||||
expect(contentDiv).toBeInTheDocument();
|
||||
expect(contentDiv.textContent?.trim() || "").toBe("");
|
||||
// Assert
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should render nothing when planContent is undefined", () => {
|
||||
renderPlanPreview(<PlanPreview planContent={undefined} />);
|
||||
// Arrange & Act
|
||||
const { container } = renderPlanPreview(
|
||||
<PlanPreview planContent={undefined} />,
|
||||
);
|
||||
|
||||
const contentDiv = screen.getByTestId("plan-preview-content");
|
||||
expect(contentDiv).toBeInTheDocument();
|
||||
expect(contentDiv.textContent?.trim() || "").toBe("");
|
||||
// Assert
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should render markdown content when planContent is provided", () => {
|
||||
@@ -170,7 +172,7 @@ describe("PlanPreview", () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const expectedPrompt =
|
||||
"Execute the plan based on the workspace/project/PLAN.md file.";
|
||||
"Execute the plan based on the .agents_tmp/PLAN.md file.";
|
||||
renderPlanPreview(<PlanPreview planContent="Plan content" />);
|
||||
const buildButton = screen.getByTestId("plan-preview-build-button");
|
||||
|
||||
@@ -201,7 +203,7 @@ describe("PlanPreview", () => {
|
||||
useOptimisticUserMessageStore.setState({ optimisticUserMessage: null });
|
||||
const user = userEvent.setup();
|
||||
const expectedPrompt =
|
||||
"Execute the plan based on the workspace/project/PLAN.md file.";
|
||||
"Execute the plan based on the .agents_tmp/PLAN.md file.";
|
||||
renderPlanPreview(<PlanPreview planContent="Plan content" />);
|
||||
const buildButton = screen.getByTestId("plan-preview-build-button");
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mutable mock state for controlling breakpoint
|
||||
let mockIsMobile = false;
|
||||
|
||||
// Track ChatInterface unmount via vi.fn()
|
||||
const chatInterfaceUnmount = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-breakpoint", () => ({
|
||||
useBreakpoint: () => mockIsMobile,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-resizable-panels", () => ({
|
||||
useResizablePanels: () => ({
|
||||
leftWidth: 50,
|
||||
rightWidth: 50,
|
||||
isDragging: false,
|
||||
containerRef: { current: null },
|
||||
handleMouseDown: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/stores/conversation-store", () => ({
|
||||
useConversationStore: () => ({
|
||||
isRightPanelShown: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock ChatInterface with useEffect to track mount/unmount lifecycle
|
||||
vi.mock("#/components/features/chat/chat-interface", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const React = require("react");
|
||||
return {
|
||||
ChatInterface: () => {
|
||||
React.useEffect(() => {
|
||||
return () => chatInterfaceUnmount();
|
||||
}, []);
|
||||
return <div data-testid="chat-interface">Chat Interface</div>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
"#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content",
|
||||
() => ({
|
||||
ConversationTabContent: () => <div data-testid="tab-content" />,
|
||||
}),
|
||||
);
|
||||
|
||||
import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
|
||||
|
||||
describe("ConversationMain - Layout Transition Stability", () => {
|
||||
beforeEach(() => {
|
||||
mockIsMobile = false;
|
||||
chatInterfaceUnmount.mockClear();
|
||||
});
|
||||
|
||||
it("renders ChatInterface at desktop width", () => {
|
||||
mockIsMobile = false;
|
||||
render(<ConversationMain />);
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ChatInterface at mobile width", () => {
|
||||
mockIsMobile = true;
|
||||
render(<ConversationMain />);
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not unmount ChatInterface when crossing from desktop to mobile", () => {
|
||||
mockIsMobile = false;
|
||||
const { rerender } = render(<ConversationMain />);
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
|
||||
// Cross the breakpoint to mobile
|
||||
mockIsMobile = true;
|
||||
rerender(<ConversationMain />);
|
||||
|
||||
// ChatInterface must NOT have been unmounted and remounted
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not unmount ChatInterface when crossing from mobile to desktop", () => {
|
||||
mockIsMobile = true;
|
||||
const { rerender } = render(<ConversationMain />);
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
|
||||
// Cross the breakpoint to desktop
|
||||
mockIsMobile = false;
|
||||
rerender(<ConversationMain />);
|
||||
|
||||
// ChatInterface must NOT have been unmounted and remounted
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("survives rapid back-and-forth resize without unmounting ChatInterface", () => {
|
||||
mockIsMobile = false;
|
||||
const { rerender } = render(<ConversationMain />);
|
||||
|
||||
// Simulate rapid resize back and forth across the breakpoint
|
||||
for (const mobile of [true, false, true, false, true]) {
|
||||
mockIsMobile = mobile;
|
||||
rerender(<ConversationMain />);
|
||||
}
|
||||
|
||||
expect(chatInterfaceUnmount).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("chat-interface")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ const {
|
||||
})),
|
||||
useConfigMock: vi.fn(() => ({
|
||||
data: {
|
||||
APP_MODE: "oss",
|
||||
app_mode: "oss",
|
||||
},
|
||||
})),
|
||||
}));
|
||||
@@ -659,7 +659,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -685,7 +685,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -718,7 +718,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -751,7 +751,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -781,7 +781,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -810,7 +810,7 @@ describe("ConversationNameContextMenu - Share Link Functionality", () => {
|
||||
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,10 +177,10 @@ describe("RepoConnector", () => {
|
||||
|
||||
it("should render the 'add github repos' link in dropdown if saas mode and github provider is set", async () => {
|
||||
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE and APP_SLUG
|
||||
// @ts-expect-error - only return the app_mode and github_app_slug
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
APP_SLUG: "openhands",
|
||||
app_mode: "saas",
|
||||
github_app_slug: "openhands",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
@@ -224,10 +224,10 @@ describe("RepoConnector", () => {
|
||||
|
||||
it("should not render the 'add github repos' link if github provider is not set", async () => {
|
||||
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE and APP_SLUG
|
||||
// @ts-expect-error - only return the app_mode and github_app_slug for this test
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
APP_SLUG: "openhands",
|
||||
app_mode: "saas",
|
||||
github_app_slug: "openhands",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
@@ -269,9 +269,9 @@ describe("RepoConnector", () => {
|
||||
|
||||
it("should not render the 'add github repos' link in dropdown if oss mode", async () => {
|
||||
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE
|
||||
// @ts-expect-error - only return the app_mode
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
app_mode: "oss",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
@@ -30,7 +30,7 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { time?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
MAINTENANCE$SCHEDULED_MESSAGE: `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("MaintenanceBanner", () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders maintenance banner with formatted time", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={startTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Check if the banner is rendered
|
||||
const banner = screen.queryByTestId("maintenance-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
|
||||
// Check if the warning icon (SVG) is present
|
||||
const svgIcon = container.querySelector("svg");
|
||||
expect(svgIcon).toBeInTheDocument();
|
||||
|
||||
// Check if the button to close is present
|
||||
const button = within(banner!).queryByTestId("dismiss-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles invalid date gracefully", () => {
|
||||
// Suppress expected console.warn for invalid date parsing
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const invalidTime = "invalid-date";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={invalidTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Check if the banner is rendered
|
||||
const banner = screen.queryByTestId("maintenance-banner");
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
|
||||
// Restore console.warn
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("click on dismiss button removes banner", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={startTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Check if the banner is rendered
|
||||
const banner = screen.queryByTestId("maintenance-banner");
|
||||
|
||||
const button = within(banner!).queryByTestId("dismiss-button");
|
||||
act(() => {
|
||||
fireEvent.click(button!);
|
||||
});
|
||||
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
});
|
||||
it("banner reappears after dismissing on next maintenance event(future time)", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
|
||||
const nextStartTime = "2025-01-15T10:00:00-05:00"; // EST timestamp
|
||||
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={startTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Check if the banner is rendered
|
||||
const banner = screen.queryByTestId("maintenance-banner");
|
||||
const button = within(banner!).queryByTestId("dismiss-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(button!);
|
||||
});
|
||||
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={nextStartTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("maintenance-banner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -305,7 +305,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "oss",
|
||||
app_mode: "oss",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -27,17 +27,17 @@ describe("PaymentForm", () => {
|
||||
const renderPaymentForm = () => renderWithProviders(<PaymentForm />);
|
||||
|
||||
beforeEach(() => {
|
||||
// useBalance hook will return the balance only if the APP_MODE is "saas" and the billing feature is enabled
|
||||
// useBalance hook will return the balance only if the app_mode is "saas" and the billing feature is enabled
|
||||
// @ts-expect-error - partial mock for testing
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,27 +9,34 @@ import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
|
||||
// Helper to create mock config with sensible defaults
|
||||
const createMockConfig = (
|
||||
overrides: Omit<Partial<GetConfigResponse>, "FEATURE_FLAGS"> & {
|
||||
FEATURE_FLAGS?: Partial<GetConfigResponse["FEATURE_FLAGS"]>;
|
||||
overrides: Omit<Partial<WebClientConfig>, "feature_flags"> & {
|
||||
feature_flags?: Partial<WebClientConfig["feature_flags"]>;
|
||||
} = {},
|
||||
): GetConfigResponse => {
|
||||
const { FEATURE_FLAGS: featureFlagOverrides, ...restOverrides } = overrides;
|
||||
): WebClientConfig => {
|
||||
const { feature_flags: featureFlagOverrides, ...restOverrides } = overrides;
|
||||
return {
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
...featureFlagOverrides,
|
||||
},
|
||||
providers_configured: [],
|
||||
maintenance_start_time: null,
|
||||
auth_url: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
...restOverrides,
|
||||
};
|
||||
};
|
||||
@@ -76,9 +83,9 @@ describe("Sidebar", () => {
|
||||
});
|
||||
|
||||
describe("Settings modal auto-open behavior", () => {
|
||||
it("should NOT open settings modal when HIDE_LLM_SETTINGS is true even with 404 error", async () => {
|
||||
it("should NOT open settings modal when hide_llm_settings is true even with 404 error", async () => {
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: true } }),
|
||||
createMockConfig({ feature_flags: { hide_llm_settings: true } }),
|
||||
);
|
||||
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
|
||||
|
||||
@@ -89,21 +96,21 @@ describe("Sidebar", () => {
|
||||
expect(getSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Settings modal should NOT appear when HIDE_LLM_SETTINGS is true
|
||||
// Settings modal should NOT appear when hide_llm_settings is true
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should open settings modal when HIDE_LLM_SETTINGS is false and 404 error in OSS mode", async () => {
|
||||
it("should open settings modal when hide_llm_settings is false and 404 error in OSS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }),
|
||||
createMockConfig({ feature_flags: { hide_llm_settings: false } }),
|
||||
);
|
||||
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
|
||||
|
||||
renderSidebar();
|
||||
|
||||
// Settings modal should appear when HIDE_LLM_SETTINGS is false
|
||||
// Settings modal should appear when hide_llm_settings is false
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("ai-config-modal")).toBeInTheDocument();
|
||||
});
|
||||
@@ -112,8 +119,8 @@ describe("Sidebar", () => {
|
||||
it("should NOT open settings modal in SaaS mode even with 404 error", async () => {
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockConfig({
|
||||
APP_MODE: "saas",
|
||||
FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false },
|
||||
app_mode: "saas",
|
||||
feature_flags: { hide_llm_settings: false },
|
||||
}),
|
||||
);
|
||||
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
|
||||
@@ -133,7 +140,7 @@ describe("Sidebar", () => {
|
||||
|
||||
it("should NOT open settings modal when settings exist (no 404 error)", async () => {
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }),
|
||||
createMockConfig({ feature_flags: { hide_llm_settings: false } }),
|
||||
);
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
@@ -152,7 +159,7 @@ describe("Sidebar", () => {
|
||||
|
||||
it("should NOT open settings modal when on /settings path", async () => {
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockConfig({ FEATURE_FLAGS: { HIDE_LLM_SETTINGS: false } }),
|
||||
createMockConfig({ feature_flags: { hide_llm_settings: false } }),
|
||||
);
|
||||
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("PostHogWrapper", () => {
|
||||
// Mock the config fetch
|
||||
// @ts-expect-error - partial mock
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const useIsAuthedMock = vi
|
||||
|
||||
const useConfigMock = vi
|
||||
.fn()
|
||||
.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
.mockReturnValue({ data: { app_mode: "saas" }, isLoading: false });
|
||||
|
||||
const useUserProvidersMock = vi
|
||||
.fn()
|
||||
@@ -46,7 +46,7 @@ describe("UserActions", () => {
|
||||
// Reset all mocks to default values before each test
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
@@ -89,7 +89,7 @@ describe("UserActions", () => {
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
@@ -126,7 +126,7 @@ describe("UserActions", () => {
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
@@ -154,7 +154,7 @@ describe("UserActions", () => {
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
@@ -179,7 +179,7 @@ describe("UserActions", () => {
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
// Ensure config and providers are set correctly
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
@@ -211,7 +211,7 @@ describe("UserActions", () => {
|
||||
// Start with authentication and providers
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
@@ -236,7 +236,7 @@ describe("UserActions", () => {
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
@@ -265,7 +265,7 @@ describe("UserActions", () => {
|
||||
// Ensure authentication and providers are set correctly
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
useUserProvidersMock.mockReturnValue({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
|
||||
/**
|
||||
* Creates a mock WebClientConfig with all required fields.
|
||||
* Use this helper to create test config objects with sensible defaults.
|
||||
*/
|
||||
export const createMockWebClientConfig = (
|
||||
overrides: Partial<WebClientConfig> = {},
|
||||
): WebClientConfig => ({
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
...overrides.feature_flags,
|
||||
},
|
||||
providers_configured: [],
|
||||
maintenance_start_time: null,
|
||||
auth_url: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
github_app_slug: null,
|
||||
...overrides,
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/hooks/query/use-settings",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useSettings: vi.fn().mockReturnValue({
|
||||
data: {
|
||||
v1_enabled: true,
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackConversationCreated: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useCreateConversation", () => {
|
||||
it("passes suggested tasks to the V1 create conversation API", async () => {
|
||||
const createConversationSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: null,
|
||||
sandbox_id: null,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
sandbox_id: null,
|
||||
initial_message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Please address the comments" }],
|
||||
},
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCreateConversation(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const suggestedTask: SuggestedTask = {
|
||||
git_provider: "github",
|
||||
issue_number: 42,
|
||||
repo: "owner/repo",
|
||||
title: "Resolve comments",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
};
|
||||
|
||||
await result.current.mutateAsync({
|
||||
query: "Please address the comments",
|
||||
repository: {
|
||||
name: "owner/repo",
|
||||
gitProvider: "github",
|
||||
branch: "main",
|
||||
},
|
||||
conversationInstructions: "Focus on review comments",
|
||||
suggestedTask,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
"owner/repo",
|
||||
"github",
|
||||
"Please address the comments",
|
||||
"main",
|
||||
"Focus on review comments",
|
||||
suggestedTask,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -112,3 +112,192 @@ describe("useConversationHistory", () => {
|
||||
expect(EventService.searchEventsV1).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useConversationHistory cache key stability", () => {
|
||||
let localQueryClient: QueryClient;
|
||||
let localWrapper: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => React.ReactElement;
|
||||
|
||||
beforeEach(() => {
|
||||
localQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
localWrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: localQueryClient },
|
||||
children,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localQueryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not refetch when conversation object changes but version stays the same", async () => {
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
|
||||
const conv1 = makeConversation("V1");
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: conv1,
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
() => useConversationHistory("conv-stable"),
|
||||
{ wrapper: localWrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
expect(v1Spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate background polling: new object reference with different mutable fields
|
||||
// but the SAME conversation_version
|
||||
const conv2: Conversation = {
|
||||
...conv1,
|
||||
last_updated_at: "2099-01-01T00:00:00Z",
|
||||
status: "STOPPED",
|
||||
runtime_status: "STATUS$STOPPED",
|
||||
};
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: conv2,
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
rerender();
|
||||
|
||||
// Allow any potential async refetch to trigger
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, 50);
|
||||
});
|
||||
|
||||
// Must NOT refetch — version hasn't changed, only mutable fields did
|
||||
expect(v1Spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Edge case: version change MUST trigger a refetch with the correct endpoint
|
||||
it("refetches when conversation_version changes from V0 to V1", async () => {
|
||||
const v0Spy = vi.spyOn(EventService, "searchEventsV0");
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v0Spy.mockResolvedValue([makeEvent()]);
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
|
||||
// Start with V0
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V0"),
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
() => useConversationHistory("conv-version-change"),
|
||||
{ wrapper: localWrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
expect(v0Spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Switch to V1 — new version means new cache key, must refetch
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V1"),
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(v1Spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats cached history as never stale (staleTime is Infinity)", async () => {
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V1"),
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useConversationHistory("conv-stale-check"),
|
||||
{ wrapper: localWrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
// Check the query's staleTime option in the cache
|
||||
const queries = localQueryClient.getQueryCache().findAll({
|
||||
queryKey: ["conversation-history", "conv-stale-check"],
|
||||
});
|
||||
expect(queries).toHaveLength(1);
|
||||
expect((queries[0].options as Record<string, unknown>).staleTime).toBe(
|
||||
Infinity,
|
||||
);
|
||||
});
|
||||
|
||||
it("has gcTime of at least 30 minutes for navigation resilience", async () => {
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V1"),
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useConversationHistory("conv-gc-check"),
|
||||
{ wrapper: localWrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
const queries = localQueryClient.getQueryCache().findAll({
|
||||
queryKey: ["conversation-history", "conv-gc-check"],
|
||||
});
|
||||
expect(queries).toHaveLength(1);
|
||||
expect(queries[0].options.gcTime).toBeGreaterThanOrEqual(30 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useBreakpoint } from "#/hooks/use-breakpoint";
|
||||
|
||||
// Helper to set window.innerWidth and dispatch resize event
|
||||
function setWindowWidth(width: number) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: width,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}
|
||||
|
||||
describe("useBreakpoint", () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
|
||||
beforeEach(() => {
|
||||
// Start at a known desktop width
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1200,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: originalInnerWidth,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false (not mobile) when window width is above the breakpoint", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1200 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true (mobile) when window width is at the breakpoint (1024)", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1024 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true (mobile) when window width is below the breakpoint", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 800 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("updates from false to true when window resizes below the breakpoint", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1200 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(800);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("updates from true to false when window resizes above the breakpoint", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 800 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(1200);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT trigger re-render when width changes within the desktop range", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1200 });
|
||||
const renderCount = vi.fn();
|
||||
const { result } = renderHook(() => {
|
||||
renderCount();
|
||||
return useBreakpoint();
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
const initialRenderCount = renderCount.mock.calls.length;
|
||||
|
||||
// Resize within desktop range (still above 1024) — should NOT re-render
|
||||
act(() => {
|
||||
setWindowWidth(1300);
|
||||
});
|
||||
act(() => {
|
||||
setWindowWidth(1100);
|
||||
});
|
||||
act(() => {
|
||||
setWindowWidth(1025);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
// No additional renders beyond the initial render
|
||||
expect(renderCount.mock.calls.length).toBe(initialRenderCount);
|
||||
});
|
||||
|
||||
it("does NOT trigger re-render when width changes within the mobile range", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 800 });
|
||||
const renderCount = vi.fn();
|
||||
const { result } = renderHook(() => {
|
||||
renderCount();
|
||||
return useBreakpoint();
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
const initialRenderCount = renderCount.mock.calls.length;
|
||||
|
||||
// Resize within mobile range (still at or below 1024) — should NOT re-render
|
||||
act(() => {
|
||||
setWindowWidth(600);
|
||||
});
|
||||
act(() => {
|
||||
setWindowWidth(1024);
|
||||
});
|
||||
act(() => {
|
||||
setWindowWidth(900);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(renderCount.mock.calls.length).toBe(initialRenderCount);
|
||||
});
|
||||
|
||||
it("handles rapid resize across the breakpoint without issues", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1200 });
|
||||
const { result } = renderHook(() => useBreakpoint());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Rapid toggles across the breakpoint
|
||||
act(() => {
|
||||
setWindowWidth(800);
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(1200);
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(1024);
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(1025);
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up the resize event listener on unmount", () => {
|
||||
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
||||
const { unmount } = renderHook(() => useBreakpoint());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"resize",
|
||||
expect.any(Function),
|
||||
);
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("accepts a custom breakpoint value", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 768 });
|
||||
const { result } = renderHook(() => useBreakpoint(768));
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
setWindowWidth(769);
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useFilteredEvents } from "#/hooks/use-filtered-events";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import type { OpenHandsAction } from "#/types/core/actions";
|
||||
import type { ActionEvent, MessageEvent } from "#/types/v1/core";
|
||||
import { SecurityRisk } from "#/types/v1/core";
|
||||
|
||||
// --- V0 event factories ---
|
||||
|
||||
function createV0UserMessage(id: number): OpenHandsAction {
|
||||
return {
|
||||
id,
|
||||
source: "user",
|
||||
action: "message",
|
||||
args: { content: `User message ${id}`, image_urls: [], file_urls: [] },
|
||||
message: `User message ${id}`,
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
};
|
||||
}
|
||||
|
||||
function createV0AgentMessage(id: number): OpenHandsAction {
|
||||
return {
|
||||
id,
|
||||
source: "agent",
|
||||
action: "message",
|
||||
args: {
|
||||
thought: `Agent thought ${id}`,
|
||||
image_urls: null,
|
||||
file_urls: [],
|
||||
wait_for_response: true,
|
||||
},
|
||||
message: `Agent response ${id}`,
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
};
|
||||
}
|
||||
|
||||
function createV0SystemEvent(id: number): OpenHandsAction {
|
||||
return {
|
||||
id,
|
||||
source: "environment",
|
||||
action: "system",
|
||||
args: {
|
||||
content: "source .openhands/setup.sh",
|
||||
tools: null,
|
||||
openhands_version: null,
|
||||
agent_class: null,
|
||||
},
|
||||
message: "Running setup script",
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
};
|
||||
}
|
||||
|
||||
// --- V1 event factories ---
|
||||
|
||||
function createV1UserMessage(id: string): MessageEvent {
|
||||
return {
|
||||
id,
|
||||
timestamp: "2025-07-01T00:00:01Z",
|
||||
source: "user",
|
||||
llm_message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `User message ${id}` }],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createV1AgentAction(id: string): ActionEvent {
|
||||
return {
|
||||
id,
|
||||
timestamp: "2025-07-01T00:00:02Z",
|
||||
source: "agent",
|
||||
thought: [{ type: "text", text: "Agent thought" }],
|
||||
thinking_blocks: [],
|
||||
action: {
|
||||
kind: "ExecuteBashAction",
|
||||
command: "echo test",
|
||||
is_input: false,
|
||||
timeout: null,
|
||||
reset: false,
|
||||
},
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call-1",
|
||||
tool_call: {
|
||||
id: "call-1",
|
||||
type: "function",
|
||||
function: { name: "execute_bash", arguments: '{"command": "echo test"}' },
|
||||
},
|
||||
llm_response_id: "response-1",
|
||||
security_risk: SecurityRisk.UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the event store before each test
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
eventIds: new Set(),
|
||||
uiEvents: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFilteredEvents", () => {
|
||||
describe("referential stability", () => {
|
||||
it("returns the same v0Events reference when storeEvents has not changed", () => {
|
||||
const v0Event = createV0UserMessage(1);
|
||||
useEventStore.setState({
|
||||
events: [v0Event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [v0Event],
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useFilteredEvents());
|
||||
const firstV0Events = result.current.v0Events;
|
||||
|
||||
// Rerender without changing the store
|
||||
rerender();
|
||||
|
||||
expect(result.current.v0Events).toBe(firstV0Events);
|
||||
});
|
||||
|
||||
it("returns the same v1UiEvents reference when uiEvents has not changed", () => {
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
useEventStore.setState({
|
||||
events: [v1Event],
|
||||
eventIds: new Set(["msg-1"]),
|
||||
uiEvents: [v1Event],
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useFilteredEvents());
|
||||
const firstV1UiEvents = result.current.v1UiEvents;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.v1UiEvents).toBe(firstV1UiEvents);
|
||||
});
|
||||
|
||||
it("returns the same v1FullEvents reference when storeEvents has not changed", () => {
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
useEventStore.setState({
|
||||
events: [v1Event],
|
||||
eventIds: new Set(["msg-1"]),
|
||||
uiEvents: [v1Event],
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useFilteredEvents());
|
||||
const firstV1FullEvents = result.current.v1FullEvents;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.v1FullEvents).toBe(firstV1FullEvents);
|
||||
});
|
||||
|
||||
it("returns a new v0Events reference when storeEvents changes", () => {
|
||||
const v0Event1 = createV0UserMessage(1);
|
||||
useEventStore.setState({
|
||||
events: [v0Event1],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [v0Event1],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
const firstV0Events = result.current.v0Events;
|
||||
|
||||
// Add a new event to the store (new array reference)
|
||||
const v0Event2 = createV0AgentMessage(2);
|
||||
act(() => {
|
||||
useEventStore.setState({
|
||||
events: [v0Event1, v0Event2],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [v0Event1, v0Event2],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.v0Events).not.toBe(firstV0Events);
|
||||
expect(result.current.v0Events).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V0 event filtering", () => {
|
||||
it("filters V0 events through isV0Event, isActionOrObservation, and shouldRenderEvent", () => {
|
||||
const userMsg = createV0UserMessage(1);
|
||||
const agentMsg = createV0AgentMessage(2);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg, agentMsg],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [userMsg, agentMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v0Events).toHaveLength(2);
|
||||
expect(result.current.v0Events).toContainEqual(userMsg);
|
||||
expect(result.current.v0Events).toContainEqual(agentMsg);
|
||||
});
|
||||
|
||||
it("excludes V0 system events from v0Events", () => {
|
||||
const userMsg = createV0UserMessage(1);
|
||||
const systemEvent = createV0SystemEvent(2);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg, systemEvent],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [userMsg, systemEvent],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
// System events are filtered out by shouldRenderEvent
|
||||
expect(result.current.v0Events).toHaveLength(1);
|
||||
expect(result.current.v0Events[0]).toEqual(userMsg);
|
||||
});
|
||||
|
||||
it("does not include V1 events in v0Events", () => {
|
||||
const v0Event = createV0UserMessage(1);
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [v0Event, v1Event],
|
||||
eventIds: new Set([1, "msg-1"]),
|
||||
uiEvents: [v0Event, v1Event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v0Events).toHaveLength(1);
|
||||
expect(result.current.v0Events[0]).toEqual(v0Event);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V1 event filtering", () => {
|
||||
it("filters V1 events into v1FullEvents", () => {
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [v1Event],
|
||||
eventIds: new Set(["msg-1"]),
|
||||
uiEvents: [v1Event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v1FullEvents).toHaveLength(1);
|
||||
expect(result.current.v1FullEvents[0]).toEqual(v1Event);
|
||||
});
|
||||
|
||||
it("does not include V0 events in v1FullEvents", () => {
|
||||
const v0Event = createV0UserMessage(1);
|
||||
const v1Event = createV1UserMessage("msg-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [v0Event, v1Event],
|
||||
eventIds: new Set([1, "msg-1"]),
|
||||
uiEvents: [v0Event, v1Event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v1FullEvents).toHaveLength(1);
|
||||
expect(result.current.v1FullEvents[0]).toEqual(v1Event);
|
||||
});
|
||||
});
|
||||
|
||||
describe("totalEvents", () => {
|
||||
it("returns V0 event count when V0 events exist", () => {
|
||||
const v0Event1 = createV0UserMessage(1);
|
||||
const v0Event2 = createV0AgentMessage(2);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [v0Event1, v0Event2],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [v0Event1, v0Event2],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.totalEvents).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 0 when no events exist", () => {
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.totalEvents).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSubstantiveAgentActions", () => {
|
||||
it("returns false when no events exist", () => {
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when only user events exist (V0)", () => {
|
||||
const userMsg = createV0UserMessage(1);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [userMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when V0 agent message actions exist", () => {
|
||||
const agentMsg = createV0AgentMessage(1);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [agentMsg],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [agentMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when V1 agent action events exist", () => {
|
||||
const agentAction = createV1AgentAction("action-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [agentAction],
|
||||
eventIds: new Set(["action-1"]),
|
||||
uiEvents: [agentAction],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userEventsExist", () => {
|
||||
it("returns false when no events exist", () => {
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.userEventsExist).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when V0 user events exist", () => {
|
||||
const userMsg = createV0UserMessage(1);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [userMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.v0UserEventsExist).toBe(true);
|
||||
expect(result.current.userEventsExist).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when V1 user events exist", () => {
|
||||
const userMsg = createV1UserMessage("msg-1");
|
||||
|
||||
useEventStore.setState({
|
||||
events: [userMsg],
|
||||
eventIds: new Set(["msg-1"]),
|
||||
uiEvents: [userMsg],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
expect(result.current.v1UserEventsExist).toBe(true);
|
||||
expect(result.current.userEventsExist).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty store", () => {
|
||||
it("returns empty arrays and false flags for empty store", () => {
|
||||
const { result } = renderHook(() => useFilteredEvents());
|
||||
|
||||
expect(result.current.v0Events).toEqual([]);
|
||||
expect(result.current.v1UiEvents).toEqual([]);
|
||||
expect(result.current.v1FullEvents).toEqual([]);
|
||||
expect(result.current.totalEvents).toBe(0);
|
||||
expect(result.current.hasSubstantiveAgentActions).toBe(false);
|
||||
expect(result.current.v0UserEventsExist).toBe(false);
|
||||
expect(result.current.v1UserEventsExist).toBe(false);
|
||||
expect(result.current.userEventsExist).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,8 +41,7 @@ describe("useHandleBuildPlanClick", () => {
|
||||
(createChatMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
action: "message",
|
||||
args: {
|
||||
content:
|
||||
"Execute the plan based on the workspace/project/PLAN.md file.",
|
||||
content: "Execute the plan based on the .agents_tmp/PLAN.md file.",
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
timestamp: expect.any(String),
|
||||
@@ -78,7 +77,7 @@ describe("useHandleBuildPlanClick", () => {
|
||||
// Arrange
|
||||
const { result } = renderHook(() => useHandleBuildPlanClick());
|
||||
const expectedPrompt =
|
||||
"Execute the plan based on the workspace/project/PLAN.md file.";
|
||||
"Execute the plan based on the .agents_tmp/PLAN.md file.";
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
@@ -109,7 +108,7 @@ describe("useHandleBuildPlanClick", () => {
|
||||
useOptimisticUserMessageStore.setState({ optimisticUserMessage: null });
|
||||
const { result } = renderHook(() => useHandleBuildPlanClick());
|
||||
const expectedPrompt =
|
||||
"Execute the plan based on the workspace/project/PLAN.md file.";
|
||||
"Execute the plan based on the .agents_tmp/PLAN.md file.";
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
@@ -155,7 +154,7 @@ describe("useHandleBuildPlanClick", () => {
|
||||
expect(useConversationStore.getState().conversationMode).toBe("code");
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
expect(useOptimisticUserMessageStore.getState().optimisticUserMessage).toBe(
|
||||
"Execute the plan based on the workspace/project/PLAN.md file.",
|
||||
"Execute the plan based on the .agents_tmp/PLAN.md file.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
/**
|
||||
* Creates a mock scroll element with a trackable scrollTop setter.
|
||||
*
|
||||
* state.scrollTop can be set directly (bypassing the spy) to position
|
||||
* the element for onChatBodyScroll calls without polluting the spy.
|
||||
*/
|
||||
function createMockScrollElement(initialScrollHeight = 1000) {
|
||||
const state = {
|
||||
scrollTop: 0,
|
||||
scrollHeight: initialScrollHeight,
|
||||
clientHeight: 500,
|
||||
};
|
||||
|
||||
const scrollTopSetter = vi.fn((value: number) => {
|
||||
state.scrollTop = value;
|
||||
});
|
||||
|
||||
const element = {
|
||||
get scrollTop() {
|
||||
return state.scrollTop;
|
||||
},
|
||||
set scrollTop(value: number) {
|
||||
scrollTopSetter(value);
|
||||
},
|
||||
get scrollHeight() {
|
||||
return state.scrollHeight;
|
||||
},
|
||||
get clientHeight() {
|
||||
return state.clientHeight;
|
||||
},
|
||||
} as unknown as HTMLDivElement;
|
||||
|
||||
return { element, scrollTopSetter, state };
|
||||
}
|
||||
|
||||
describe("useScrollToBottom", () => {
|
||||
let mock: ReturnType<typeof createMockScrollElement>;
|
||||
let ref: RefObject<HTMLDivElement>;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = createMockScrollElement(1000);
|
||||
ref = { current: mock.element } as RefObject<HTMLDivElement>;
|
||||
});
|
||||
|
||||
describe("no automatic scrolling on render", () => {
|
||||
it("does NOT scroll on initial render", () => {
|
||||
renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
// No useLayoutEffect means no automatic scroll-to-bottom
|
||||
expect(mock.scrollTopSetter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT scroll when re-rendered (e.g., during resize)", () => {
|
||||
const { rerender } = renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
mock.state.scrollHeight = 1500;
|
||||
rerender();
|
||||
|
||||
expect(mock.scrollTopSetter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scroll position tracking", () => {
|
||||
it("tracks hitBottom correctly via onChatBodyScroll", () => {
|
||||
const { result } = renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
// Position at bottom: scrollTop(480) + clientHeight(500) = 980 >= 1000 - 20
|
||||
mock.state.scrollTop = 480;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.hitBottom).toBe(true);
|
||||
|
||||
// Position not at bottom: scrollTop(200) + clientHeight(500) = 700 < 980
|
||||
mock.state.scrollTop = 200;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.hitBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("disables autoScroll when user scrolls up", () => {
|
||||
const { result } = renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
// First scroll to establish prevScrollTopRef
|
||||
mock.state.scrollTop = 400;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
|
||||
// Scroll up (lower scrollTop than previous)
|
||||
mock.state.scrollTop = 200;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.autoScroll).toBe(false);
|
||||
});
|
||||
|
||||
it("re-enables autoScroll when user reaches bottom", () => {
|
||||
const { result } = renderHook(() => useScrollToBottom(ref));
|
||||
|
||||
// Scroll up to disable autoScroll
|
||||
mock.state.scrollTop = 400;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
mock.state.scrollTop = 200;
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.autoScroll).toBe(false);
|
||||
|
||||
// Scroll to bottom
|
||||
mock.state.scrollTop = 500; // 500 + 500 = 1000 >= 980
|
||||
act(() => {
|
||||
result.current.onChatBodyScroll(mock.element);
|
||||
});
|
||||
expect(result.current.autoScroll).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,8 @@ const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
|
||||
const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
APP_MODE: appMode,
|
||||
FEATURE_FLAGS: { HIDE_LLM_SETTINGS: hideLlmSettings },
|
||||
app_mode: appMode,
|
||||
feature_flags: { hide_llm_settings: hideLlmSettings },
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("useSettingsNavItems", () => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("should return SAAS_NAV_ITEMS when APP_MODE is 'saas'", async () => {
|
||||
it("should return SAAS_NAV_ITEMS when app_mode is 'saas'", async () => {
|
||||
mockConfig("saas");
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("useSettingsNavItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return OSS_NAV_ITEMS when APP_MODE is 'oss'", async () => {
|
||||
it("should return OSS_NAV_ITEMS when app_mode is 'oss'", async () => {
|
||||
mockConfig("oss");
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("useSettingsNavItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter out '/settings' item when HIDE_LLM_SETTINGS feature flag is enabled", async () => {
|
||||
it("should filter out '/settings' item when hide_llm_settings feature flag is enabled", async () => {
|
||||
mockConfig("saas", true);
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ describe("frontend/routes/_oh", () => {
|
||||
const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted(
|
||||
() => {
|
||||
const defaultFeatureFlags = {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -33,7 +33,7 @@ describe("frontend/routes/_oh", () => {
|
||||
isError: false,
|
||||
}),
|
||||
useConfigMock: vi.fn().mockReturnValue({
|
||||
data: { APP_MODE: "oss", FEATURE_FLAGS: defaultFeatureFlags },
|
||||
data: { app_mode: "oss", feature_flags: defaultFeatureFlags },
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
@@ -84,7 +84,7 @@ describe("frontend/routes/_oh", () => {
|
||||
isError: false,
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
|
||||
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("frontend/routes/_oh", () => {
|
||||
isError: false,
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
|
||||
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
@@ -129,16 +129,16 @@ describe("frontend/routes/_oh", () => {
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
|
||||
// @ts-expect-error - partial mock for testing
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "test-id",
|
||||
POSTHOG_CLIENT_KEY: "test-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "test-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -167,20 +167,20 @@ describe("frontend/routes/_oh", () => {
|
||||
|
||||
it("should not render the user consent form if saas mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - partial mock for testing
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-id",
|
||||
POSTHOG_CLIENT_KEY: "test-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
|
||||
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
@@ -255,20 +255,20 @@ describe("frontend/routes/_oh", () => {
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
// @ts-expect-error - partial mock for testing
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-id",
|
||||
POSTHOG_CLIENT_KEY: "test-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
|
||||
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,35 +10,49 @@ import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { integrationService } from "#/api/integration-service/integration-service.api";
|
||||
|
||||
const VALID_OSS_CONFIG: GetConfigResponse = {
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
const VALID_OSS_CONFIG: WebClientConfig = {
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
providers_configured: [],
|
||||
maintenance_start_time: null,
|
||||
auth_url: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
};
|
||||
|
||||
const VALID_SAAS_CONFIG: GetConfigResponse = {
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
const VALID_SAAS_CONFIG: WebClientConfig = {
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
providers_configured: [],
|
||||
maintenance_start_time: null,
|
||||
auth_url: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -247,7 +261,7 @@ describe("Content", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
|
||||
it("should render the 'Configure GitHub Repositories' button if SaaS mode and github_app_slug exists", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
@@ -272,7 +286,7 @@ describe("Content", () => {
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
APP_SLUG: "test-slug",
|
||||
github_app_slug: "test-slug",
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
@@ -615,7 +629,6 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
APP_SLUG: "test-slug",
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
@@ -633,40 +646,4 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render GitLab webhook manager when token is set", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getResourcesSpy = vi.spyOn(
|
||||
integrationService,
|
||||
"getGitLabResources",
|
||||
);
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
APP_SLUG: "test-slug",
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
getResourcesSpy.mockResolvedValue({
|
||||
resources: [],
|
||||
});
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
expect(getResourcesSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,11 +16,11 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted(
|
||||
() => {
|
||||
const defaultFeatureFlags = {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -33,8 +33,8 @@ const { DEFAULT_FEATURE_FLAGS, useIsAuthedMock, useConfigMock } = vi.hoisted(
|
||||
}),
|
||||
useConfigMock: vi.fn().mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "oss",
|
||||
FEATURE_FLAGS: defaultFeatureFlags,
|
||||
app_mode: "oss",
|
||||
feature_flags: defaultFeatureFlags,
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -141,19 +141,24 @@ describe("HomeScreen", () => {
|
||||
isError: false,
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
|
||||
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Mock config to avoid SaaS redirect logic
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
PROVIDERS_CONFIGURED: ["github"],
|
||||
AUTH_URL: "https://auth.example.com",
|
||||
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: DEFAULT_FEATURE_FLAGS,
|
||||
maintenance_start_time: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
});
|
||||
|
||||
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
|
||||
|
||||
@@ -444,18 +449,23 @@ describe("Settings 404", () => {
|
||||
isError: false,
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "oss", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
|
||||
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
PROVIDERS_CONFIGURED: ["github"],
|
||||
AUTH_URL: "https://auth.example.com",
|
||||
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: DEFAULT_FEATURE_FLAGS,
|
||||
maintenance_start_time: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
});
|
||||
|
||||
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
|
||||
|
||||
@@ -510,14 +520,14 @@ describe("Settings 404", () => {
|
||||
|
||||
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { APP_MODE: "saas", FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS },
|
||||
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// @ts-expect-error - we only need APP_MODE for this test
|
||||
// @ts-expect-error - we only need app_mode for this test
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
|
||||
app_mode: "saas",
|
||||
feature_flags: DEFAULT_FEATURE_FLAGS,
|
||||
});
|
||||
const error = createAxiosNotFoundErrorObject();
|
||||
getSettingsSpy.mockRejectedValue(error);
|
||||
@@ -543,20 +553,25 @@ describe("Setup Payment modal", () => {
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
FEATURE_FLAGS: { ...DEFAULT_FEATURE_FLAGS, ENABLE_BILLING: true },
|
||||
app_mode: "saas",
|
||||
feature_flags: { ...DEFAULT_FEATURE_FLAGS, enable_billing: true },
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
PROVIDERS_CONFIGURED: ["github"],
|
||||
AUTH_URL: "https://auth.example.com",
|
||||
FEATURE_FLAGS: { ...DEFAULT_FEATURE_FLAGS, ENABLE_BILLING: true },
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: { ...DEFAULT_FEATURE_FLAGS, enable_billing: true },
|
||||
maintenance_start_time: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
});
|
||||
|
||||
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
|
||||
|
||||
|
||||
@@ -337,9 +337,9 @@ describe("Content", () => {
|
||||
describe("API key visibility in Basic Settings", () => {
|
||||
it("should hide API key input when SaaS mode is enabled and OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -364,9 +364,9 @@ describe("Content", () => {
|
||||
|
||||
it("should show API key input when SaaS mode is enabled and non-OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -395,9 +395,9 @@ describe("Content", () => {
|
||||
|
||||
it("should show API key input when OSS mode is enabled and OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
app_mode: "oss",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -422,9 +422,9 @@ describe("Content", () => {
|
||||
|
||||
it("should show API key input when OSS mode is enabled and non-OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
app_mode: "oss",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -453,9 +453,9 @@ describe("Content", () => {
|
||||
|
||||
it("should hide API key input when switching from non-OpenHands to OpenHands provider in SaaS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -498,9 +498,9 @@ describe("Content", () => {
|
||||
|
||||
it("should show API key input when switching from OpenHands to non-OpenHands provider in SaaS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return APP_MODE for these tests
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -1010,16 +1010,16 @@ describe("View persistence after saving advanced settings", () => {
|
||||
it("should remain on Advanced view after saving when search API key is set", async () => {
|
||||
// Arrange: Start with default settings (non-SaaS mode to show search API key field)
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - partial mock for testing
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "fake-posthog-client-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -87,18 +87,18 @@ describe("LoginPage", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
|
||||
// @ts-expect-error - partial mock for testing
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
PROVIDERS_CONFIGURED: ["github", "gitlab", "bitbucket"],
|
||||
AUTH_URL: "https://auth.example.com",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github", "gitlab", "bitbucket"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,18 +151,18 @@ describe("LoginPage", () => {
|
||||
});
|
||||
|
||||
it("should only display configured providers", async () => {
|
||||
// @ts-expect-error - partial mock for testing
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
PROVIDERS_CONFIGURED: ["github"],
|
||||
AUTH_URL: "https://auth.example.com",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -187,18 +187,18 @@ describe("LoginPage", () => {
|
||||
});
|
||||
|
||||
it("should display message when no providers are configured", async () => {
|
||||
// @ts-expect-error - partial mock for testing
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
PROVIDERS_CONFIGURED: [],
|
||||
AUTH_URL: "https://auth.example.com",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: [],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,16 +320,16 @@ describe("LoginPage", () => {
|
||||
});
|
||||
|
||||
it("should redirect OSS mode users to home", async () => {
|
||||
// @ts-expect-error - partial mock for testing
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@ vi.mock("#/hooks/query/use-config", () => ({
|
||||
}));
|
||||
|
||||
const DEFAULT_FEATURE_FLAGS = {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
};
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -61,9 +61,9 @@ describe("MainApp - Auth refetch behavior", () => {
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
FEATURE_FLAGS: DEFAULT_FEATURE_FLAGS,
|
||||
app_mode: "saas",
|
||||
github_client_id: "test-client-id",
|
||||
feature_flags: DEFAULT_FEATURE_FLAGS,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
@@ -160,18 +160,18 @@ describe("MainApp", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// @ts-expect-error - partial mock for testing
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-client-id",
|
||||
POSTHOG_CLIENT_KEY: "test-posthog-key",
|
||||
PROVIDERS_CONFIGURED: ["github"],
|
||||
AUTH_URL: "https://auth.example.com",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ beforeEach(() => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
app_mode: "oss",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("Content", () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
app_mode: "oss",
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
@@ -97,7 +97,7 @@ describe("Content", () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
app_mode: "saas",
|
||||
});
|
||||
|
||||
renderSecretsSettings();
|
||||
|
||||
@@ -64,15 +64,15 @@ describe("Settings Billing", () => {
|
||||
// Set default config to OSS mode
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "oss",
|
||||
github_client_id: "123",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
@@ -119,15 +119,15 @@ describe("Settings Billing", () => {
|
||||
it("should render the billing tab if SaaS mode and billing is enabled", async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
github_client_id: "123",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
@@ -143,15 +143,15 @@ describe("Settings Billing", () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
app_mode: "saas",
|
||||
github_client_id: "123",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("Settings Screen", () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
app_mode: "oss",
|
||||
});
|
||||
|
||||
// Clear any existing query data
|
||||
@@ -122,11 +122,11 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
|
||||
it("should render the saas navbar", async () => {
|
||||
const saasConfig = { APP_MODE: "saas" };
|
||||
const saasConfig = { app_mode: "saas" };
|
||||
|
||||
// Clear any existing query data and set the config
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["config"], saasConfig);
|
||||
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
|
||||
|
||||
const sectionsToInclude = [
|
||||
"llm", // LLM settings are now always shown in SaaS mode
|
||||
@@ -160,7 +160,7 @@ describe("Settings Screen", () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
app_mode: "oss",
|
||||
});
|
||||
|
||||
// Clear any existing query data
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.8.7",
|
||||
"@microlink/react-json-view": "^1.27.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { AuthenticateResponse, GitHubAccessTokenResponse } from "./auth.types";
|
||||
import { GetConfigResponse } from "../option-service/option.types";
|
||||
import { WebClientConfig } from "../option-service/option.types";
|
||||
|
||||
/**
|
||||
* Authentication service for handling all authentication-related API calls
|
||||
@@ -12,7 +12,7 @@ class AuthService {
|
||||
* @returns Response with authentication status and user info if successful
|
||||
*/
|
||||
static async authenticate(
|
||||
appMode: GetConfigResponse["APP_MODE"],
|
||||
appMode: WebClientConfig["app_mode"],
|
||||
): Promise<boolean> {
|
||||
if (appMode === "oss") return true;
|
||||
|
||||
@@ -42,7 +42,7 @@ class AuthService {
|
||||
* Logout user from the application
|
||||
* @param appMode The application mode (saas or oss)
|
||||
*/
|
||||
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
|
||||
static async logout(appMode: WebClientConfig["app_mode"]): Promise<void> {
|
||||
const endpoint =
|
||||
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
|
||||
await openHands.post(endpoint);
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from "axios";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import type {
|
||||
@@ -61,6 +62,7 @@ class V1ConversationService {
|
||||
initialUserMsg?: string,
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
suggestedTask?: SuggestedTask,
|
||||
trigger?: ConversationTrigger,
|
||||
parent_conversation_id?: string,
|
||||
agent_type?: "default" | "plan",
|
||||
@@ -69,14 +71,15 @@ class V1ConversationService {
|
||||
selected_repository: selectedRepository,
|
||||
git_provider,
|
||||
selected_branch,
|
||||
suggested_task: suggestedTask,
|
||||
title: conversationInstructions,
|
||||
trigger,
|
||||
parent_conversation_id: parent_conversation_id || null,
|
||||
agent_type,
|
||||
};
|
||||
|
||||
// Add initial message if provided
|
||||
if (initialUserMsg) {
|
||||
// suggested_task implies the backend will construct the initial_message
|
||||
if (!suggestedTask && initialUserMsg) {
|
||||
body.initial_message = {
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -319,12 +322,12 @@ class V1ConversationService {
|
||||
/**
|
||||
* Read a file from a specific conversation's sandbox workspace
|
||||
* @param conversationId The conversation ID
|
||||
* @param filePath Path to the file to read within the sandbox workspace (defaults to /workspace/project/PLAN.md)
|
||||
* @param filePath Path to the file to read within the sandbox workspace (defaults to /workspace/project/.agents_tmp/PLAN.md)
|
||||
* @returns The content of the file or an empty string if the file doesn't exist
|
||||
*/
|
||||
static async readConversationFile(
|
||||
conversationId: string,
|
||||
filePath: string = "/workspace/project/PLAN.md",
|
||||
filePath: string = "/workspace/project/.agents_tmp/PLAN.md",
|
||||
): Promise<string> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("file_path", filePath);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConversationTrigger } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { V1SandboxStatus } from "../sandbox-service/sandbox-service.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
|
||||
// V1 Metrics Types
|
||||
export interface V1TokenUsage {
|
||||
@@ -47,6 +48,7 @@ export interface V1AppConversationStartRequest {
|
||||
selected_repository?: string | null;
|
||||
selected_branch?: string | null;
|
||||
git_provider?: Provider | null;
|
||||
suggested_task?: SuggestedTask | null;
|
||||
title?: string | null;
|
||||
trigger?: ConversationTrigger | null;
|
||||
pr_number?: number[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { GetConfigResponse } from "./option.types";
|
||||
import { WebClientConfig } from "./option.types";
|
||||
|
||||
/**
|
||||
* Service for handling API options endpoints
|
||||
@@ -35,12 +35,12 @@ class OptionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configuration from the server
|
||||
* @returns Configuration response
|
||||
* Get the web client configuration from the server
|
||||
* @returns Web client configuration response
|
||||
*/
|
||||
static async getConfig(): Promise<GetConfigResponse> {
|
||||
const { data } = await openHands.get<GetConfigResponse>(
|
||||
"/api/options/config",
|
||||
static async getConfig(): Promise<WebClientConfig> {
|
||||
const { data } = await openHands.get<WebClientConfig>(
|
||||
"/api/v1/web-client/config",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export interface GetConfigResponse {
|
||||
APP_MODE: "saas" | "oss";
|
||||
APP_SLUG?: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
RECAPTCHA_SITE_KEY?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
ENABLE_JIRA: boolean;
|
||||
ENABLE_JIRA_DC: boolean;
|
||||
ENABLE_LINEAR: boolean;
|
||||
};
|
||||
MAINTENANCE?: {
|
||||
startTime: string;
|
||||
};
|
||||
export interface WebClientFeatureFlags {
|
||||
enable_billing: boolean;
|
||||
hide_llm_settings: boolean;
|
||||
enable_jira: boolean;
|
||||
enable_jira_dc: boolean;
|
||||
enable_linear: boolean;
|
||||
}
|
||||
|
||||
export interface WebClientConfig {
|
||||
app_mode: "saas" | "oss";
|
||||
posthog_client_key: string | null;
|
||||
feature_flags: WebClientFeatureFlags;
|
||||
providers_configured: Provider[];
|
||||
maintenance_start_time: string | null;
|
||||
auth_url: string | null;
|
||||
recaptcha_site_key: string | null;
|
||||
faulty_models: string[];
|
||||
error_message: string | null;
|
||||
updated_at: string;
|
||||
github_app_slug: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import { FaTriangleExclamation } from "react-icons/fa6";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface AlertBannerProps {
|
||||
maintenanceStartTime?: string | null;
|
||||
faultyModels?: string[];
|
||||
errorMessage?: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function AlertBanner({
|
||||
maintenanceStartTime,
|
||||
faultyModels,
|
||||
errorMessage,
|
||||
updatedAt,
|
||||
}: AlertBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dismissedAt, setDismissedAt] = useLocalStorage<string | null>(
|
||||
"alert_banner_dismissed_at",
|
||||
null,
|
||||
);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Format ISO timestamp to user's local timezone
|
||||
const formatMaintenanceTime = (isoTimeString: string): string => {
|
||||
const date = new Date(isoTimeString);
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
};
|
||||
|
||||
const localTime = maintenanceStartTime
|
||||
? formatMaintenanceTime(maintenanceStartTime)
|
||||
: null;
|
||||
|
||||
const hasMaintenanceAlert = !!maintenanceStartTime;
|
||||
const hasFaultyModels = faultyModels && faultyModels.length > 0;
|
||||
const hasErrorMessage = errorMessage && errorMessage.trim().length > 0;
|
||||
|
||||
const hasAnyAlert = hasMaintenanceAlert || hasFaultyModels || hasErrorMessage;
|
||||
|
||||
const isBannerVisible = useMemo(() => {
|
||||
if (!hasAnyAlert) {
|
||||
return false;
|
||||
}
|
||||
return dismissedAt !== updatedAt;
|
||||
}, [dismissedAt, updatedAt, hasAnyAlert]);
|
||||
|
||||
// Try to translate error message, fallback to raw message
|
||||
const translatedErrorMessage = useMemo(() => {
|
||||
if (!errorMessage) return null;
|
||||
|
||||
// Check if the error message is a translation key (e.g., "ERROR$SOME_KEY")
|
||||
const translated = t(errorMessage as I18nKey);
|
||||
// If translation returns the same key, it means no translation exists
|
||||
if (translated === errorMessage) {
|
||||
return errorMessage;
|
||||
}
|
||||
return translated;
|
||||
}, [errorMessage, t]);
|
||||
|
||||
if (!isBannerVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderMessages = () => {
|
||||
const messages: React.ReactNode[] = [];
|
||||
|
||||
if (hasMaintenanceAlert && localTime) {
|
||||
messages.push(
|
||||
<Typography.Paragraph key="maintenance" className="text-sm font-medium">
|
||||
{t(I18nKey.MAINTENANCE$SCHEDULED_MESSAGE, { time: localTime })}
|
||||
</Typography.Paragraph>,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasFaultyModels) {
|
||||
messages.push(
|
||||
<Typography.Paragraph
|
||||
key="faulty-models"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t(I18nKey.ALERT$FAULTY_MODELS_MESSAGE)} {faultyModels!.join(", ")}
|
||||
</Typography.Paragraph>,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasErrorMessage && translatedErrorMessage) {
|
||||
messages.push(
|
||||
<Typography.Paragraph
|
||||
key="error-message"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{translatedErrorMessage}
|
||||
</Typography.Paragraph>,
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="alert-banner"
|
||||
className={cn(
|
||||
"bg-[#0D0F11] border border-primary text-white p-4 rounded",
|
||||
"flex flex-row items-center justify-between m-1",
|
||||
pathname === "/" && "mt-3 mr-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<FaTriangleExclamation className="text-primary align-middle" />
|
||||
</div>
|
||||
<div className="ml-3 flex flex-col gap-1">{renderMessages()}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dismiss-button"
|
||||
onClick={() => setDismissedAt(updatedAt)}
|
||||
className={cn(
|
||||
"bg-[#0D0F11] rounded-full w-5 h-5 flex items-center justify-center cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
|
||||
import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
|
||||
import { useAuthUrl } from "#/hooks/use-auth-url";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice";
|
||||
@@ -15,8 +15,8 @@ import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
export interface LoginContentProps {
|
||||
githubAuthUrl: string | null;
|
||||
appMode?: GetConfigResponse["APP_MODE"] | null;
|
||||
authUrl?: GetConfigResponse["AUTH_URL"];
|
||||
appMode?: WebClientConfig["app_mode"] | null;
|
||||
authUrl?: WebClientConfig["auth_url"];
|
||||
providersConfigured?: Provider[];
|
||||
emailVerified?: boolean;
|
||||
hasDuplicatedEmail?: boolean;
|
||||
@@ -38,7 +38,7 @@ export function LoginContent({
|
||||
|
||||
// reCAPTCHA - only need token generation, verification happens at backend callback
|
||||
const { isReady: recaptchaReady, executeRecaptcha } = useRecaptcha({
|
||||
siteKey: config?.RECAPTCHA_SITE_KEY,
|
||||
siteKey: config?.recaptcha_site_key ?? undefined,
|
||||
});
|
||||
|
||||
const gitlabAuthUrl = useAuthUrl({
|
||||
@@ -59,7 +59,7 @@ export function LoginContent({
|
||||
) => {
|
||||
trackLoginButtonClick({ provider });
|
||||
|
||||
if (!config?.RECAPTCHA_SITE_KEY || !recaptchaReady) {
|
||||
if (!config?.recaptcha_site_key || !recaptchaReady) {
|
||||
// No reCAPTCHA or token generation failed - redirect normally
|
||||
window.location.href = redirectUrl;
|
||||
return;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TrajectoryActions } from "../trajectory/trajectory-actions";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { useFilteredEvents } from "#/hooks/use-filtered-events";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { TypingIndicator } from "./typing-indicator";
|
||||
@@ -27,28 +27,13 @@ import { ChatMessagesSkeleton } from "./chat-messages-skeleton";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { ErrorMessageBanner } from "./error-message-banner";
|
||||
import {
|
||||
hasUserEvent,
|
||||
shouldRenderEvent,
|
||||
} from "./event-content-helpers/should-render-event";
|
||||
import {
|
||||
Messages as V1Messages,
|
||||
hasUserEvent as hasV1UserEvent,
|
||||
shouldRenderEvent as shouldRenderV1Event,
|
||||
} from "#/components/v1/chat";
|
||||
import { Messages as V1Messages } from "#/components/v1/chat";
|
||||
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
|
||||
import {
|
||||
isV0Event,
|
||||
isV1Event,
|
||||
isSystemPromptEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
@@ -73,8 +58,16 @@ export function ChatInterface() {
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
const conversationWebSocket = useConversationWebSocket();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const uiEvents = useEventStore((state) => state.uiEvents);
|
||||
const {
|
||||
v0Events,
|
||||
v1UiEvents,
|
||||
v1FullEvents,
|
||||
totalEvents,
|
||||
hasSubstantiveAgentActions,
|
||||
v0UserEventsExist,
|
||||
v1UserEventsExist,
|
||||
userEventsExist,
|
||||
} = useFilteredEvents();
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -140,74 +133,21 @@ export function ChatInterface() {
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Track when we should show V1 messages (after DOM has rendered)
|
||||
const [showV1Messages, setShowV1Messages] = React.useState(false);
|
||||
const prevV1LoadingRef = React.useRef(
|
||||
conversationWebSocket?.isLoadingHistory,
|
||||
);
|
||||
|
||||
// Wait for DOM to render before showing V1 messages
|
||||
React.useEffect(() => {
|
||||
const wasLoading = prevV1LoadingRef.current;
|
||||
const isLoading = conversationWebSocket?.isLoadingHistory;
|
||||
|
||||
if (wasLoading && !isLoading) {
|
||||
// Loading just finished - wait for next frame to ensure DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
setShowV1Messages(true);
|
||||
});
|
||||
} else if (isLoading) {
|
||||
// Reset when loading starts
|
||||
setShowV1Messages(false);
|
||||
}
|
||||
|
||||
prevV1LoadingRef.current = isLoading;
|
||||
}, [conversationWebSocket?.isLoadingHistory]);
|
||||
// Show V1 messages immediately if events exist in store (e.g., remount),
|
||||
// or once loading completes. This replaces the old transition-observation
|
||||
// pattern (useState + useEffect watching loading→loaded) which always showed
|
||||
// skeleton on remount because local state initialized to false.
|
||||
const showV1Messages =
|
||||
v1FullEvents.length > 0 || !conversationWebSocket?.isLoadingHistory;
|
||||
|
||||
const isReturningToConversation = !!params.conversationId;
|
||||
// Only show loading skeleton when genuinely loading AND no events in store yet.
|
||||
// If events exist (e.g., remount after data was already fetched), skip skeleton.
|
||||
const isHistoryLoading =
|
||||
(isLoadingMessages && !isV1Conversation) ||
|
||||
(isV1Conversation &&
|
||||
(conversationWebSocket?.isLoadingHistory || !showV1Messages));
|
||||
(isLoadingMessages && !isV1Conversation && v0Events.length === 0) ||
|
||||
(isV1Conversation && !showV1Messages);
|
||||
const isChatLoading = isHistoryLoading && !isTask;
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events - use uiEvents for rendering (actions replaced by observations)
|
||||
const v1UiEvents = uiEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
// Keep full v1 events for lookups (includes both actions and observations)
|
||||
const v1FullEvents = storeEvents.filter(isV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1UiEvents.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
() =>
|
||||
storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.some(
|
||||
(event) =>
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
) ||
|
||||
storeEvents
|
||||
.filter(isV1Event)
|
||||
.some(
|
||||
(event) =>
|
||||
event.source === "agent" &&
|
||||
!isSystemPromptEvent(event) &&
|
||||
!isConversationStateUpdateEvent(event),
|
||||
),
|
||||
[storeEvents],
|
||||
);
|
||||
|
||||
const handleSendMessage = async (
|
||||
content: string,
|
||||
originalImages: File[],
|
||||
@@ -269,6 +209,21 @@ export function ChatInterface() {
|
||||
setFeedbackPolarity(polarity);
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
React.useEffect(() => {
|
||||
if (autoScroll) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
// Note: We intentionally exclude autoScroll from deps because we only want
|
||||
// to scroll when message content changes, not when autoScroll state changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
v1UiEvents.length,
|
||||
v0Events.length,
|
||||
optimisticUserMessage,
|
||||
scrollDomToBottom,
|
||||
]);
|
||||
|
||||
// Create a ScrollProvider with the scroll hook values
|
||||
const scrollProviderValue = {
|
||||
scrollRef,
|
||||
@@ -280,10 +235,6 @@ export function ChatInterface() {
|
||||
onChatBodyScroll,
|
||||
};
|
||||
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
// Get server status indicator props
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
@@ -337,7 +288,7 @@ export function ChatInterface() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && v0UserEventsExist && (
|
||||
{(!isLoadingMessages || v0Events.length > 0) && v0UserEventsExist && (
|
||||
<V0Messages
|
||||
messages={v0Events}
|
||||
isAwaitingUserConfirmation={
|
||||
@@ -369,7 +320,7 @@ export function ChatInterface() {
|
||||
onNegativeFeedback={() =>
|
||||
onClickShareFeedbackActionButton("negative")
|
||||
}
|
||||
isSaasMode={config?.APP_MODE === "saas"}
|
||||
isSaasMode={config?.app_mode === "saas"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -391,7 +342,7 @@ export function ChatInterface() {
|
||||
<InteractiveChatBox onSubmit={handleSendMessage} />
|
||||
</div>
|
||||
|
||||
{config?.APP_MODE !== "saas" && !isV1Conversation && (
|
||||
{config?.app_mode !== "saas" && !isV1Conversation && (
|
||||
<FeedbackModal
|
||||
isOpen={feedbackModalIsOpen}
|
||||
onClose={() => setFeedbackModalIsOpen(false)}
|
||||
|
||||
@@ -55,7 +55,9 @@ export function ChatMessage({
|
||||
"flex flex-col gap-2",
|
||||
type === "user" && "p-4 bg-tertiary self-end",
|
||||
type === "agent" && "mt-6 w-full max-w-full bg-transparent",
|
||||
isFromPlanningAgent && "border border-[#597ff4] bg-tertiary p-4 mt-2",
|
||||
isFromPlanningAgent &&
|
||||
type === "agent" &&
|
||||
"border border-[#597ff4] bg-tertiary p-4 mt-2",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ interface ErrorEventMessageProps {
|
||||
}>;
|
||||
isLastMessage: boolean;
|
||||
isInLast10Actions: boolean;
|
||||
config?: { APP_MODE?: string } | null;
|
||||
config?: { app_mode?: string } | null;
|
||||
isCheckingFeedback: boolean;
|
||||
feedbackData: {
|
||||
exists: boolean;
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ interface FinishEventMessageProps {
|
||||
}>;
|
||||
isLastMessage: boolean;
|
||||
isInLast10Actions: boolean;
|
||||
config?: { APP_MODE?: string } | null;
|
||||
config?: { app_mode?: string } | null;
|
||||
isCheckingFeedback: boolean;
|
||||
feedbackData: {
|
||||
exists: boolean;
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ interface LikertScaleWrapperProps {
|
||||
event: OpenHandsAction | OpenHandsObservation;
|
||||
isLastMessage: boolean;
|
||||
isInLast10Actions: boolean;
|
||||
config?: { APP_MODE?: string } | null;
|
||||
config?: { app_mode?: string } | null;
|
||||
isCheckingFeedback: boolean;
|
||||
feedbackData: {
|
||||
exists: boolean;
|
||||
@@ -25,7 +25,7 @@ export function LikertScaleWrapper({
|
||||
isCheckingFeedback,
|
||||
feedbackData,
|
||||
}: LikertScaleWrapperProps) {
|
||||
if (config?.APP_MODE !== "saas" || isCheckingFeedback) {
|
||||
if (config?.app_mode !== "saas" || isCheckingFeedback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ interface UserAssistantEventMessageProps {
|
||||
}>;
|
||||
isLastMessage: boolean;
|
||||
isInLast10Actions: boolean;
|
||||
config?: { APP_MODE?: string } | null;
|
||||
config?: { app_mode?: string } | null;
|
||||
isCheckingFeedback: boolean;
|
||||
feedbackData: {
|
||||
exists: boolean;
|
||||
|
||||
@@ -94,8 +94,8 @@ export function ExpandableMessage({
|
||||
const statusIconClasses = "h-4 w-4 ml-2 inline";
|
||||
|
||||
if (
|
||||
config?.FEATURE_FLAGS?.ENABLE_BILLING &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
config?.feature_flags?.enable_billing &&
|
||||
config?.app_mode === "saas" &&
|
||||
id === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS
|
||||
) {
|
||||
return (
|
||||
|
||||
@@ -39,14 +39,14 @@ export function PlanPreview({
|
||||
isBuildDisabled,
|
||||
}: PlanPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectTab } = useSelectConversationTab();
|
||||
const { navigateToTab } = useSelectConversationTab();
|
||||
const { handleBuildPlanClick } = useHandleBuildPlanClick();
|
||||
const { scrollDomToBottom } = useScrollContext();
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
const handleViewClick = () => {
|
||||
selectTab("planner");
|
||||
navigateToTab("planner");
|
||||
};
|
||||
|
||||
// Handle Build action with scroll to bottom
|
||||
@@ -65,7 +65,7 @@ export function PlanPreview({
|
||||
return `${planContent.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}, [planContent]);
|
||||
|
||||
if (!shouldUsePlanningAgent) {
|
||||
if (!shouldUsePlanningAgent || !planContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export function ConversationLoading() {
|
||||
type ConversationLoadingProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ConversationLoading({ className }: ConversationLoadingProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="bg-[#25272D] flex flex-col items-center justify-center h-full w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#25272D] flex flex-col items-center justify-center h-full w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LoaderCircle className="animate-spin w-16 h-16" color="white" />
|
||||
<span className="text-2xl font-normal leading-5 text-white p-4">
|
||||
{t(I18nKey.HOME$LOADING)}
|
||||
|
||||
+106
-8
@@ -1,15 +1,113 @@
|
||||
import { useWindowSize } from "@uidotdev/usehooks";
|
||||
import { MobileLayout } from "./mobile-layout";
|
||||
import { DesktopLayout } from "./desktop-layout";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ChatInterfaceWrapper } from "./chat-interface-wrapper";
|
||||
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
|
||||
import { ResizeHandle } from "../../../ui/resize-handle";
|
||||
import { useResizablePanels } from "#/hooks/use-resizable-panels";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useBreakpoint } from "#/hooks/use-breakpoint";
|
||||
|
||||
function getMobileChatPanelClass(isRightPanelShown: boolean) {
|
||||
return isRightPanelShown ? "h-160" : "flex-1";
|
||||
}
|
||||
|
||||
function getDesktopTabPanelClass(isRightPanelShown: boolean) {
|
||||
return isRightPanelShown
|
||||
? "translate-x-0 opacity-100"
|
||||
: "w-0 translate-x-full opacity-0";
|
||||
}
|
||||
|
||||
export function ConversationMain() {
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = useBreakpoint();
|
||||
const { isRightPanelShown } = useConversationStore();
|
||||
|
||||
if (width && width <= 1024) {
|
||||
return <MobileLayout isRightPanelShown={isRightPanelShown} />;
|
||||
}
|
||||
const { leftWidth, rightWidth, isDragging, containerRef, handleMouseDown } =
|
||||
useResizablePanels({
|
||||
defaultLeftWidth: 50,
|
||||
minLeftWidth: 30,
|
||||
maxLeftWidth: 80,
|
||||
storageKey: "desktop-layout-panel-width",
|
||||
});
|
||||
|
||||
return <DesktopLayout isRightPanelShown={isRightPanelShown} />;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isMobile
|
||||
? "relative flex-1 flex flex-col"
|
||||
: "h-full flex flex-col overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"flex flex-1 overflow-hidden",
|
||||
isMobile ? "flex-col" : "transition-all duration-300 ease-in-out",
|
||||
)}
|
||||
style={
|
||||
!isMobile
|
||||
? { transitionProperty: isDragging ? "none" : "all" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Chat Panel - always mounted, styled differently for mobile/desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-base overflow-hidden",
|
||||
isMobile
|
||||
? getMobileChatPanelClass(isRightPanelShown)
|
||||
: "transition-all duration-300 ease-in-out",
|
||||
)}
|
||||
style={
|
||||
!isMobile
|
||||
? {
|
||||
width: isRightPanelShown ? `${leftWidth}%` : "100%",
|
||||
transitionProperty: isDragging ? "none" : "all",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ChatInterfaceWrapper
|
||||
isRightPanelShown={!isMobile && isRightPanelShown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize Handle - only shown on desktop when right panel is visible */}
|
||||
{!isMobile && isRightPanelShown && (
|
||||
<ResizeHandle onMouseDown={handleMouseDown} />
|
||||
)}
|
||||
|
||||
{/* Tab Content Panel - always mounted, styled as bottom sheet (mobile) or side panel (desktop) */}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isMobile
|
||||
? cn(
|
||||
"absolute bottom-4 left-0 right-0 top-160",
|
||||
isRightPanelShown
|
||||
? "h-160 translate-y-0 opacity-100"
|
||||
: "h-0 translate-y-full opacity-0",
|
||||
)
|
||||
: getDesktopTabPanelClass(isRightPanelShown),
|
||||
)}
|
||||
style={
|
||||
!isMobile
|
||||
? {
|
||||
width: isRightPanelShown ? `${rightWidth}%` : "0%",
|
||||
transitionProperty: isDragging ? "opacity, transform" : "all",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
isMobile
|
||||
? "h-full flex flex-col gap-3 pb-2 md:pb-0 pt-2"
|
||||
: "flex flex-col flex-1 gap-3 min-w-max h-full",
|
||||
)}
|
||||
>
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ChatInterfaceWrapper } from "./chat-interface-wrapper";
|
||||
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
|
||||
import { ResizeHandle } from "../../../ui/resize-handle";
|
||||
import { useResizablePanels } from "#/hooks/use-resizable-panels";
|
||||
|
||||
interface DesktopLayoutProps {
|
||||
isRightPanelShown: boolean;
|
||||
}
|
||||
|
||||
export function DesktopLayout({ isRightPanelShown }: DesktopLayoutProps) {
|
||||
const { leftWidth, rightWidth, isDragging, containerRef, handleMouseDown } =
|
||||
useResizablePanels({
|
||||
defaultLeftWidth: 50,
|
||||
minLeftWidth: 30,
|
||||
maxLeftWidth: 80,
|
||||
storageKey: "desktop-layout-panel-width",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-1 transition-all duration-300 ease-in-out overflow-hidden"
|
||||
style={{
|
||||
// Only apply smooth transitions when not dragging
|
||||
transitionProperty: isDragging ? "none" : "all",
|
||||
}}
|
||||
>
|
||||
{/* Left Panel (Chat) */}
|
||||
<div
|
||||
className="flex flex-col bg-base overflow-hidden transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
width: isRightPanelShown ? `${leftWidth}%` : "100%",
|
||||
transitionProperty: isDragging ? "none" : "all",
|
||||
}}
|
||||
>
|
||||
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
{isRightPanelShown && <ResizeHandle onMouseDown={handleMouseDown} />}
|
||||
|
||||
{/* Right Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isRightPanelShown
|
||||
? "translate-x-0 opacity-100"
|
||||
: "w-0 translate-x-full opacity-0",
|
||||
)}
|
||||
style={{
|
||||
width: isRightPanelShown ? `${rightWidth}%` : "0%",
|
||||
transitionProperty: isDragging ? "opacity, transform" : "all",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 gap-3 min-w-max h-full">
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ChatInterface } from "../../chat/chat-interface";
|
||||
import { ConversationTabContent } from "../conversation-tabs/conversation-tab-content/conversation-tab-content";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface MobileLayoutProps {
|
||||
isRightPanelShown: boolean;
|
||||
}
|
||||
|
||||
export function MobileLayout({ isRightPanelShown }: MobileLayoutProps) {
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col">
|
||||
{/* Chat area - shrinks when panel slides up */}
|
||||
<div
|
||||
className={cn(
|
||||
"bg-base overflow-hidden",
|
||||
isRightPanelShown ? "h-160" : "flex-1",
|
||||
)}
|
||||
>
|
||||
<ChatInterface />
|
||||
</div>
|
||||
|
||||
{/* Bottom panel - slides up from bottom */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-4 left-0 right-0 top-160 transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isRightPanelShown
|
||||
? "h-160 translate-y-0 opacity-100"
|
||||
: "h-0 translate-y-full opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="h-full flex flex-col gap-3 pb-2 md:pb-0 pt-2">
|
||||
<ConversationTabContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWindowSize } from "@uidotdev/usehooks";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { useBreakpoint } from "#/hooks/use-breakpoint";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
@@ -60,7 +60,7 @@ export function ConversationNameContextMenu({
|
||||
shareUrl,
|
||||
position = "bottom",
|
||||
}: ConversationNameContextMenuProps) {
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = useBreakpoint();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
@@ -73,7 +73,7 @@ export function ConversationNameContextMenu({
|
||||
// Check if we should show the public sharing option
|
||||
// Only show for V1 conversations in SAAS mode
|
||||
const shouldShowPublicSharing =
|
||||
isV1Conversation && config?.APP_MODE === "saas" && onTogglePublic;
|
||||
isV1Conversation && config?.app_mode === "saas" && onTogglePublic;
|
||||
|
||||
const hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation);
|
||||
const hasExport = Boolean(onExportConversation);
|
||||
@@ -81,8 +81,6 @@ export function ConversationNameContextMenu({
|
||||
const hasInfo = Boolean(onDisplayCost);
|
||||
const hasControl = Boolean(onStop || onDelete);
|
||||
|
||||
const isMobile = width && width <= 1024;
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export function ConversationTabContent() {
|
||||
const conversationTabTitle = t(activeTab.titleKey);
|
||||
|
||||
if (shouldShownAgentLoading) {
|
||||
return <ConversationLoading />;
|
||||
return <ConversationLoading className="rounded-xl" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -247,34 +247,6 @@ export function GitRepoDropdown({
|
||||
const isLoadingState =
|
||||
isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading;
|
||||
|
||||
// Create sticky footer item for GitHub provider
|
||||
const stickyFooterItem = useMemo(() => {
|
||||
if (
|
||||
!config?.APP_SLUG ||
|
||||
provider !== ProviderOptions.github ||
|
||||
config.APP_MODE !== "saas"
|
||||
)
|
||||
return null;
|
||||
|
||||
const githubHref = `https://github.com/apps/${config.APP_SLUG}/installations/new`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={githubHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center w-full px-2 py-2 text-sm text-white hover:bg-[#5C5D62] rounded-md transition-colors duration-150 font-normal"
|
||||
onMouseDown={(e) => {
|
||||
// Prevent downshift from closing the menu when clicking the sticky footer
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
|
||||
</a>
|
||||
);
|
||||
}, [provider, config, t]);
|
||||
|
||||
const renderItem = (
|
||||
item: GitRepository,
|
||||
index: number,
|
||||
@@ -317,6 +289,34 @@ export function GitRepoDropdown({
|
||||
);
|
||||
}, [recentRepositories, localSelectedItem, getItemProps, t]);
|
||||
|
||||
// Create sticky footer item for GitHub provider
|
||||
const stickyFooterItem = useMemo(() => {
|
||||
if (
|
||||
!config?.github_app_slug ||
|
||||
provider !== ProviderOptions.github ||
|
||||
config.app_mode !== "saas"
|
||||
)
|
||||
return null;
|
||||
|
||||
const githubHref = `https://github.com/apps/${config.github_app_slug}/installations/new`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={githubHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center w-full px-2 py-2 text-sm text-white hover:bg-[#5C5D62] rounded-md transition-colors duration-150 font-normal"
|
||||
onMouseDown={(e) => {
|
||||
// Prevent downshift from closing the menu when clicking the sticky footer
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
|
||||
</a>
|
||||
);
|
||||
}, [provider, config, t]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import { FaTriangleExclamation } from "react-icons/fa6";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface MaintenanceBannerProps {
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dismissedAt, setDismissedAt] = useLocalStorage<string | null>(
|
||||
"maintenance_banner_dismissed_at",
|
||||
null,
|
||||
);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Convert EST timestamp to user's local timezone
|
||||
const formatMaintenanceTime = (estTimeString: string): string => {
|
||||
try {
|
||||
// Parse the EST timestamp
|
||||
// If the string doesn't include timezone info, assume it's EST
|
||||
let dateToFormat: Date;
|
||||
|
||||
if (
|
||||
estTimeString.includes("T") &&
|
||||
(estTimeString.includes("-05:00") ||
|
||||
estTimeString.includes("-04:00") ||
|
||||
estTimeString.includes("EST") ||
|
||||
estTimeString.includes("EDT"))
|
||||
) {
|
||||
// Already has timezone info
|
||||
dateToFormat = new Date(estTimeString);
|
||||
} else {
|
||||
// Assume EST and convert to UTC for proper parsing
|
||||
// EST is UTC-5, EDT is UTC-4, but we'll assume EST for simplicity
|
||||
const estDate = new Date(estTimeString);
|
||||
if (Number.isNaN(estDate.getTime())) {
|
||||
throw new Error("Invalid date");
|
||||
}
|
||||
dateToFormat = estDate;
|
||||
}
|
||||
|
||||
// Format to user's local timezone
|
||||
return dateToFormat.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
} catch (error) {
|
||||
// Fallback to original string if parsing fails
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to parse maintenance time:", error);
|
||||
return estTimeString;
|
||||
}
|
||||
};
|
||||
|
||||
const localTime = formatMaintenanceTime(startTime);
|
||||
|
||||
const isBannerVisible = useMemo(() => {
|
||||
const isValid = !Number.isNaN(new Date(startTime).getTime());
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
return dismissedAt !== localTime;
|
||||
}, [dismissedAt, startTime]);
|
||||
|
||||
if (!isBannerVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="maintenance-banner"
|
||||
className={cn(
|
||||
"bg-primary text-[#0D0F11] p-4 rounded",
|
||||
"flex flex-row items-center justify-between m-1",
|
||||
pathname === "/" && "mt-3 mr-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<FaTriangleExclamation className="text-white align-middle" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("MAINTENANCE$SCHEDULED_MESSAGE", { time: localTime })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dismiss-button"
|
||||
onClick={() => setDismissedAt(localTime)}
|
||||
className={cn(
|
||||
"bg-[#0D0F11] rounded-full w-5 h-5 flex items-center justify-center cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user