From b0f7466ebaf398be007980954522998c6eb316f6 Mon Sep 17 00:00:00 2001 From: Justin Tahara <105671973+justin-tahara@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:32:20 -0800 Subject: [PATCH 001/267] chore(llm): Fixing test image selection (#8902) --- .github/actions/build-integration-image/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/build-integration-image/action.yml b/.github/actions/build-integration-image/action.yml index 254f1c67c85..c0e5d8146b9 100644 --- a/.github/actions/build-integration-image/action.yml +++ b/.github/actions/build-integration-image/action.yml @@ -54,6 +54,7 @@ runs: shell: bash env: RUNS_ON_ECR_CACHE: ${{ inputs.runs-on-ecr-cache }} + INTEGRATION_REPOSITORY: ${{ inputs.runs-on-ecr-cache }} TAG: nightly-llm-it-${{ inputs.run-id }} CACHE_SUFFIX: ${{ steps.format-branch.outputs.cache-suffix }} HEAD_SHA: ${{ inputs.github-sha }} From 22a335fffaf40baba3eed07ad94468c963393a47 Mon Sep 17 00:00:00 2001 From: Evan Lohn Date: Sun, 1 Mar 2026 18:35:31 -0800 Subject: [PATCH 002/267] feat: bg tasks via fastapi (#8861) --- ...4b1c89d2_add_indexing_to_userfilestatus.py | 51 ++++++ .../tasks/user_file_processing/tasks.py | 53 +++--- backend/onyx/background/task_utils.py | 169 ++++++++++++++++++ backend/onyx/chat/llm_loop.py | 21 ++- backend/onyx/db/enums.py | 1 + backend/onyx/db/projects.py | 36 ++-- backend/onyx/server/features/projects/api.py | 71 +++++--- .../tools/test_python_tool.py | 2 + .../tests/indexing/test_checkpointing.py | 25 ++- .../indexing/test_repeated_error_state.py | 4 +- .../test_user_file_impl_redis_locking.py | 34 ++-- .../test_user_file_processing_no_vectordb.py | 34 ++-- web/src/hooks/useCCPairs.ts | 6 +- web/src/lib/hooks.ts | 7 +- web/src/providers/SettingsProvider.tsx | 3 +- .../popovers/ActionsPopover/index.tsx | 5 +- web/src/refresh-pages/AppPage.tsx | 14 +- web/src/sections/input/AppInputBar.tsx | 4 +- .../sections/knowledge/AgentKnowledgePane.tsx | 2 +- .../sections/sidebar/UserAvatarPopover.tsx | 7 +- 20 files changed, 425 insertions(+), 124 deletions(-) create mode 100644 backend/alembic/versions/4a1e4b1c89d2_add_indexing_to_userfilestatus.py diff --git a/backend/alembic/versions/4a1e4b1c89d2_add_indexing_to_userfilestatus.py b/backend/alembic/versions/4a1e4b1c89d2_add_indexing_to_userfilestatus.py new file mode 100644 index 00000000000..7ceb195b2e6 --- /dev/null +++ b/backend/alembic/versions/4a1e4b1c89d2_add_indexing_to_userfilestatus.py @@ -0,0 +1,51 @@ +"""Add INDEXING to UserFileStatus + +Revision ID: 4a1e4b1c89d2 +Revises: 6b3b4083c5aa +Create Date: 2026-02-28 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "4a1e4b1c89d2" +down_revision = "6b3b4083c5aa" +branch_labels = None +depends_on = None + +TABLE = "user_file" +COLUMN = "status" +CONSTRAINT_NAME = "ck_user_file_status" + +OLD_VALUES = ("PROCESSING", "COMPLETED", "FAILED", "CANCELED", "DELETING") +NEW_VALUES = ("PROCESSING", "INDEXING", "COMPLETED", "FAILED", "CANCELED", "DELETING") + + +def _drop_status_check_constraint() -> None: + """Drop the existing CHECK constraint on user_file.status. + + The constraint name is auto-generated by SQLAlchemy and unknown, + so we look it up via the inspector. + """ + inspector = sa.inspect(op.get_bind()) + for constraint in inspector.get_check_constraints(TABLE): + if COLUMN in constraint.get("sqltext", ""): + constraint_name = constraint["name"] + if constraint_name is not None: + op.drop_constraint(constraint_name, TABLE, type_="check") + + +def upgrade() -> None: + _drop_status_check_constraint() + in_clause = ", ".join(f"'{v}'" for v in NEW_VALUES) + op.create_check_constraint(CONSTRAINT_NAME, TABLE, f"{COLUMN} IN ({in_clause})") + + +def downgrade() -> None: + op.execute( + f"UPDATE {TABLE} SET {COLUMN} = 'PROCESSING' WHERE {COLUMN} = 'INDEXING'" + ) + op.drop_constraint(CONSTRAINT_NAME, TABLE, type_="check") + in_clause = ", ".join(f"'{v}'" for v in OLD_VALUES) + op.create_check_constraint(CONSTRAINT_NAME, TABLE, f"{COLUMN} IN ({in_clause})") diff --git a/backend/onyx/background/celery/tasks/user_file_processing/tasks.py b/backend/onyx/background/celery/tasks/user_file_processing/tasks.py index 6b1b3290eef..8c21071908c 100644 --- a/backend/onyx/background/celery/tasks/user_file_processing/tasks.py +++ b/backend/onyx/background/celery/tasks/user_file_processing/tasks.py @@ -414,7 +414,7 @@ def _process_user_file_with_indexing( raise RuntimeError(f"Indexing pipeline failed for user file {user_file_id}") -def _process_user_file_impl( +def process_user_file_impl( *, user_file_id: str, tenant_id: str, redis_locking: bool ) -> None: """Core implementation for processing a single user file. @@ -423,7 +423,7 @@ def _process_user_file_impl( queued-key guard (Celery path). When redis_locking=False, skips all Redis operations (BackgroundTask path). """ - task_logger.info(f"_process_user_file_impl - Starting id={user_file_id}") + task_logger.info(f"process_user_file_impl - Starting id={user_file_id}") start = time.monotonic() file_lock: RedisLock | None = None @@ -436,7 +436,7 @@ def _process_user_file_impl( ) if file_lock is not None and not file_lock.acquire(blocking=False): task_logger.info( - f"_process_user_file_impl - Lock held, skipping user_file_id={user_file_id}" + f"process_user_file_impl - Lock held, skipping user_file_id={user_file_id}" ) return @@ -446,13 +446,16 @@ def _process_user_file_impl( uf = db_session.get(UserFile, _as_uuid(user_file_id)) if not uf: task_logger.warning( - f"_process_user_file_impl - UserFile not found id={user_file_id}" + f"process_user_file_impl - UserFile not found id={user_file_id}" ) return - if uf.status != UserFileStatus.PROCESSING: + if uf.status not in ( + UserFileStatus.PROCESSING, + UserFileStatus.INDEXING, + ): task_logger.info( - f"_process_user_file_impl - Skipping id={user_file_id} status={uf.status}" + f"process_user_file_impl - Skipping id={user_file_id} status={uf.status}" ) return @@ -489,7 +492,7 @@ def _process_user_file_impl( except Exception as e: task_logger.exception( - f"_process_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}" + f"process_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}" ) current_user_file = db_session.get(UserFile, _as_uuid(user_file_id)) if ( @@ -503,7 +506,7 @@ def _process_user_file_impl( elapsed = time.monotonic() - start task_logger.info( - f"_process_user_file_impl - Finished id={user_file_id} docs={len(documents)} elapsed={elapsed:.2f}s" + f"process_user_file_impl - Finished id={user_file_id} docs={len(documents)} elapsed={elapsed:.2f}s" ) except Exception as e: with get_session_with_current_tenant() as db_session: @@ -515,7 +518,7 @@ def _process_user_file_impl( db_session.commit() task_logger.exception( - f"_process_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}" + f"process_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}" ) finally: if file_lock is not None and file_lock.owned(): @@ -530,7 +533,7 @@ def _process_user_file_impl( def process_single_user_file( self: Task, *, user_file_id: str, tenant_id: str # noqa: ARG001 ) -> None: - _process_user_file_impl( + process_user_file_impl( user_file_id=user_file_id, tenant_id=tenant_id, redis_locking=True ) @@ -585,7 +588,7 @@ def check_for_user_file_delete(self: Task, *, tenant_id: str) -> None: return None -def _delete_user_file_impl( +def delete_user_file_impl( *, user_file_id: str, tenant_id: str, redis_locking: bool ) -> None: """Core implementation for deleting a single user file. @@ -593,7 +596,7 @@ def _delete_user_file_impl( When redis_locking=True, acquires a per-file Redis lock (Celery path). When redis_locking=False, skips Redis operations (BackgroundTask path). """ - task_logger.info(f"_delete_user_file_impl - Starting id={user_file_id}") + task_logger.info(f"delete_user_file_impl - Starting id={user_file_id}") file_lock: RedisLock | None = None if redis_locking: @@ -604,7 +607,7 @@ def _delete_user_file_impl( ) if file_lock is not None and not file_lock.acquire(blocking=False): task_logger.info( - f"_delete_user_file_impl - Lock held, skipping user_file_id={user_file_id}" + f"delete_user_file_impl - Lock held, skipping user_file_id={user_file_id}" ) return @@ -613,7 +616,7 @@ def _delete_user_file_impl( user_file = db_session.get(UserFile, _as_uuid(user_file_id)) if not user_file: task_logger.info( - f"_delete_user_file_impl - User file not found id={user_file_id}" + f"delete_user_file_impl - User file not found id={user_file_id}" ) return @@ -662,15 +665,15 @@ def _delete_user_file_impl( ) except Exception as e: task_logger.exception( - f"_delete_user_file_impl - Error deleting file id={user_file.id} - {e.__class__.__name__}" + f"delete_user_file_impl - Error deleting file id={user_file.id} - {e.__class__.__name__}" ) db_session.delete(user_file) db_session.commit() - task_logger.info(f"_delete_user_file_impl - Completed id={user_file_id}") + task_logger.info(f"delete_user_file_impl - Completed id={user_file_id}") except Exception as e: task_logger.exception( - f"_delete_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}" + f"delete_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}" ) finally: if file_lock is not None and file_lock.owned(): @@ -685,7 +688,7 @@ def _delete_user_file_impl( def process_single_user_file_delete( self: Task, *, user_file_id: str, tenant_id: str # noqa: ARG001 ) -> None: - _delete_user_file_impl( + delete_user_file_impl( user_file_id=user_file_id, tenant_id=tenant_id, redis_locking=True ) @@ -759,7 +762,7 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None: return None -def _project_sync_user_file_impl( +def project_sync_user_file_impl( *, user_file_id: str, tenant_id: str, redis_locking: bool ) -> None: """Core implementation for syncing a user file's project/persona metadata. @@ -768,7 +771,7 @@ def _project_sync_user_file_impl( queued-key guard (Celery path). When redis_locking=False, skips Redis operations (BackgroundTask path). """ - task_logger.info(f"_project_sync_user_file_impl - Starting id={user_file_id}") + task_logger.info(f"project_sync_user_file_impl - Starting id={user_file_id}") file_lock: RedisLock | None = None if redis_locking: @@ -780,7 +783,7 @@ def _project_sync_user_file_impl( ) if file_lock is not None and not file_lock.acquire(blocking=False): task_logger.info( - f"_project_sync_user_file_impl - Lock held, skipping user_file_id={user_file_id}" + f"project_sync_user_file_impl - Lock held, skipping user_file_id={user_file_id}" ) return @@ -793,7 +796,7 @@ def _project_sync_user_file_impl( ).scalar_one_or_none() if not user_file: task_logger.info( - f"_project_sync_user_file_impl - User file not found id={user_file_id}" + f"project_sync_user_file_impl - User file not found id={user_file_id}" ) return @@ -831,7 +834,7 @@ def _project_sync_user_file_impl( ) task_logger.info( - f"_project_sync_user_file_impl - User file id={user_file_id}" + f"project_sync_user_file_impl - User file id={user_file_id}" ) user_file.needs_project_sync = False @@ -844,7 +847,7 @@ def _project_sync_user_file_impl( except Exception as e: task_logger.exception( - f"_project_sync_user_file_impl - Error syncing project for file id={user_file_id} - {e.__class__.__name__}" + f"project_sync_user_file_impl - Error syncing project for file id={user_file_id} - {e.__class__.__name__}" ) finally: if file_lock is not None and file_lock.owned(): @@ -859,6 +862,6 @@ def _project_sync_user_file_impl( def process_single_user_file_project_sync( self: Task, *, user_file_id: str, tenant_id: str # noqa: ARG001 ) -> None: - _project_sync_user_file_impl( + project_sync_user_file_impl( user_file_id=user_file_id, tenant_id=tenant_id, redis_locking=True ) diff --git a/backend/onyx/background/task_utils.py b/backend/onyx/background/task_utils.py index a49a56745e6..070b26b6701 100644 --- a/backend/onyx/background/task_utils.py +++ b/backend/onyx/background/task_utils.py @@ -1,3 +1,33 @@ +"""Background task utilities. + +Contains query-history report helpers (used by all deployment modes) and +in-process background task execution helpers for NO_VECTOR_DB mode: + +- Atomic claim-and-mark helpers that prevent duplicate processing +- Drain loops that process all pending user file work + +Each claim function runs a short-lived transaction: SELECT ... FOR UPDATE +SKIP LOCKED, UPDATE the row to remove it from future queries, COMMIT. +After the commit the row lock is released, but the row is no longer +eligible for re-claiming. No long-lived sessions or advisory locks. +""" + +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy import select +from sqlalchemy.orm import Session + +from onyx.db.enums import UserFileStatus +from onyx.db.models import UserFile +from onyx.utils.logger import setup_logger + +logger = setup_logger() + +# ------------------------------------------------------------------ +# Query-history report helpers (pre-existing, used by all modes) +# ------------------------------------------------------------------ + QUERY_REPORT_NAME_PREFIX = "query-history" @@ -9,3 +39,142 @@ def construct_query_history_report_name( def extract_task_id_from_query_history_report_name(name: str) -> str: return name.removeprefix(f"{QUERY_REPORT_NAME_PREFIX}-").removesuffix(".csv") + + +# ------------------------------------------------------------------ +# Atomic claim-and-mark helpers +# ------------------------------------------------------------------ +# Each function runs inside a single short-lived session/transaction: +# 1. SELECT ... FOR UPDATE SKIP LOCKED (locks one eligible row) +# 2. UPDATE the row so it is no longer eligible +# 3. COMMIT (releases the row lock) +# After the commit, no other drain loop can claim the same row. + + +def _claim_next_processing_file(db_session: Session) -> UUID | None: + """Claim the next PROCESSING file by transitioning it to INDEXING. + + Returns the file id, or None when no eligible files remain. + """ + file_id = db_session.execute( + select(UserFile.id) + .where(UserFile.status == UserFileStatus.PROCESSING) + .order_by(UserFile.created_at) + .limit(1) + .with_for_update(skip_locked=True) + ).scalar_one_or_none() + if file_id is None: + return None + + db_session.execute( + sa.update(UserFile) + .where(UserFile.id == file_id) + .values(status=UserFileStatus.INDEXING) + ) + db_session.commit() + return file_id + + +def _claim_next_deleting_file(db_session: Session) -> UUID | None: + """Claim the next DELETING file. + + No status transition needed — the impl deletes the row on success. + The short-lived FOR UPDATE lock prevents concurrent claims. + """ + file_id = db_session.execute( + select(UserFile.id) + .where(UserFile.status == UserFileStatus.DELETING) + .order_by(UserFile.created_at) + .limit(1) + .with_for_update(skip_locked=True) + ).scalar_one_or_none() + # Commit to release the row lock promptly. + db_session.commit() + return file_id + + +def _claim_next_sync_file(db_session: Session) -> UUID | None: + """Claim the next file needing project/persona sync. + + No status transition needed — the impl clears the sync flags on + success. The short-lived FOR UPDATE lock prevents concurrent claims. + """ + file_id = db_session.execute( + select(UserFile.id) + .where( + sa.and_( + sa.or_( + UserFile.needs_project_sync.is_(True), + UserFile.needs_persona_sync.is_(True), + ), + UserFile.status == UserFileStatus.COMPLETED, + ) + ) + .order_by(UserFile.created_at) + .limit(1) + .with_for_update(skip_locked=True) + ).scalar_one_or_none() + db_session.commit() + return file_id + + +# ------------------------------------------------------------------ +# Drain loops — process *all* pending work of each type +# ------------------------------------------------------------------ + + +def drain_processing_loop(tenant_id: str) -> None: + """Process all pending PROCESSING user files.""" + from onyx.background.celery.tasks.user_file_processing.tasks import ( + process_user_file_impl, + ) + from onyx.db.engine.sql_engine import get_session_with_current_tenant + + while True: + with get_session_with_current_tenant() as session: + file_id = _claim_next_processing_file(session) + if file_id is None: + break + process_user_file_impl( + user_file_id=str(file_id), + tenant_id=tenant_id, + redis_locking=False, + ) + + +def drain_delete_loop(tenant_id: str) -> None: + """Delete all pending DELETING user files.""" + from onyx.background.celery.tasks.user_file_processing.tasks import ( + delete_user_file_impl, + ) + from onyx.db.engine.sql_engine import get_session_with_current_tenant + + while True: + with get_session_with_current_tenant() as session: + file_id = _claim_next_deleting_file(session) + if file_id is None: + break + delete_user_file_impl( + user_file_id=str(file_id), + tenant_id=tenant_id, + redis_locking=False, + ) + + +def drain_project_sync_loop(tenant_id: str) -> None: + """Sync all pending project/persona metadata for user files.""" + from onyx.background.celery.tasks.user_file_processing.tasks import ( + project_sync_user_file_impl, + ) + from onyx.db.engine.sql_engine import get_session_with_current_tenant + + while True: + with get_session_with_current_tenant() as session: + file_id = _claim_next_sync_file(session) + if file_id is None: + break + project_sync_user_file_impl( + user_file_id=str(file_id), + tenant_id=tenant_id, + redis_locking=False, + ) diff --git a/backend/onyx/chat/llm_loop.py b/backend/onyx/chat/llm_loop.py index bc802ce95bd..bbe05bb68a6 100644 --- a/backend/onyx/chat/llm_loop.py +++ b/backend/onyx/chat/llm_loop.py @@ -1,6 +1,7 @@ import json import time from collections.abc import Callable +from typing import Any from typing import Literal from sqlalchemy.orm import Session @@ -530,11 +531,13 @@ def _create_file_tool_metadata_message( """ lines = [ "You have access to the following files. Use the read_file tool to " - "read sections of any file:" + "read sections of any file. You MUST pass the file_id UUID (not the " + "filename) to read_file:" ] for meta in file_metadata: lines.append( - f'- {meta.file_id}: "{meta.filename}" (~{meta.approx_char_count:,} chars)' + f'- file_id="{meta.file_id}" filename="{meta.filename}" ' + f"(~{meta.approx_char_count:,} chars)" ) message_content = "\n".join(lines) @@ -558,12 +561,16 @@ def _create_context_files_message( # Format as documents JSON as described in README documents_list = [] for idx, file_text in enumerate(context_files.file_texts, start=1): - documents_list.append( - { - "document": idx, - "contents": file_text, - } + title = ( + context_files.file_metadata[idx - 1].filename + if idx - 1 < len(context_files.file_metadata) + else None ) + entry: dict[str, Any] = {"document": idx} + if title: + entry["title"] = title + entry["contents"] = file_text + documents_list.append(entry) documents_json = json.dumps({"documents": documents_list}, indent=2) message_content = f"Here are some documents provided for context, they may not all be relevant:\n{documents_json}" diff --git a/backend/onyx/db/enums.py b/backend/onyx/db/enums.py index e6191db1baa..de7b666d4b7 100644 --- a/backend/onyx/db/enums.py +++ b/backend/onyx/db/enums.py @@ -186,6 +186,7 @@ class EmbeddingPrecision(str, PyEnum): class UserFileStatus(str, PyEnum): PROCESSING = "PROCESSING" + INDEXING = "INDEXING" COMPLETED = "COMPLETED" FAILED = "FAILED" CANCELED = "CANCELED" diff --git a/backend/onyx/db/projects.py b/backend/onyx/db/projects.py index 428e9cb56cf..740ff77ac41 100644 --- a/backend/onyx/db/projects.py +++ b/backend/onyx/db/projects.py @@ -9,8 +9,9 @@ from pydantic import ConfigDict from sqlalchemy import func from sqlalchemy.orm import Session +from starlette.background import BackgroundTasks -from onyx.background.celery.versioned_apps.client import app as client_app +from onyx.configs.app_configs import DISABLE_VECTOR_DB from onyx.configs.constants import FileOrigin from onyx.configs.constants import OnyxCeleryPriority from onyx.configs.constants import OnyxCeleryQueues @@ -105,8 +106,8 @@ def upload_files_to_user_files_with_indexing( user: User, temp_id_map: dict[str, str] | None, db_session: Session, + background_tasks: BackgroundTasks | None = None, ) -> CategorizedFilesResult: - # Validate project ownership if a project_id is provided if project_id is not None and user is not None: if not check_project_ownership(project_id, user.id, db_session): raise HTTPException(status_code=404, detail="Project not found") @@ -127,16 +128,27 @@ def upload_files_to_user_files_with_indexing( logger.warning( f"File {rejected_file.filename} rejected for {rejected_file.reason}" ) - for user_file in user_files: - task = client_app.send_task( - OnyxCeleryTask.PROCESS_SINGLE_USER_FILE, - kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id}, - queue=OnyxCeleryQueues.USER_FILE_PROCESSING, - priority=OnyxCeleryPriority.HIGH, - ) - logger.info( - f"Triggered indexing for user_file_id={user_file.id} with task_id={task.id}" - ) + + if DISABLE_VECTOR_DB and background_tasks is not None: + from onyx.background.task_utils import drain_processing_loop + + background_tasks.add_task(drain_processing_loop, tenant_id) + for user_file in user_files: + logger.info(f"Queued in-process processing for user_file_id={user_file.id}") + else: + from onyx.background.celery.versioned_apps.client import app as client_app + + for user_file in user_files: + task = client_app.send_task( + OnyxCeleryTask.PROCESS_SINGLE_USER_FILE, + kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id}, + queue=OnyxCeleryQueues.USER_FILE_PROCESSING, + priority=OnyxCeleryPriority.HIGH, + ) + logger.info( + f"Triggered indexing for user_file_id={user_file.id} " + f"with task_id={task.id}" + ) return CategorizedFilesResult( user_files=user_files, diff --git a/backend/onyx/server/features/projects/api.py b/backend/onyx/server/features/projects/api.py index 5ad7a7a6089..bcd55003433 100644 --- a/backend/onyx/server/features/projects/api.py +++ b/backend/onyx/server/features/projects/api.py @@ -2,6 +2,7 @@ from uuid import UUID from fastapi import APIRouter +from fastapi import BackgroundTasks from fastapi import Depends from fastapi import File from fastapi import Form @@ -12,13 +13,7 @@ from sqlalchemy.orm import Session from onyx.auth.users import current_user -from onyx.background.celery.tasks.user_file_processing.tasks import ( - enqueue_user_file_project_sync_task, -) -from onyx.background.celery.tasks.user_file_processing.tasks import ( - get_user_file_project_sync_queue_depth, -) -from onyx.background.celery.versioned_apps.client import app as client_app +from onyx.configs.app_configs import DISABLE_VECTOR_DB from onyx.configs.constants import OnyxCeleryPriority from onyx.configs.constants import OnyxCeleryQueues from onyx.configs.constants import OnyxCeleryTask @@ -34,7 +29,6 @@ from onyx.db.persona import get_personas_by_ids from onyx.db.projects import get_project_token_count from onyx.db.projects import upload_files_to_user_files_with_indexing -from onyx.redis.redis_pool import get_redis_client from onyx.server.features.projects.models import CategorizedFilesSnapshot from onyx.server.features.projects.models import ChatSessionRequest from onyx.server.features.projects.models import TokenCountResponse @@ -55,7 +49,27 @@ class UserFileDeleteResult(BaseModel): assistant_names: list[str] = [] -def _trigger_user_file_project_sync(user_file_id: UUID, tenant_id: str) -> None: +def _trigger_user_file_project_sync( + user_file_id: UUID, + tenant_id: str, + background_tasks: BackgroundTasks | None = None, +) -> None: + if DISABLE_VECTOR_DB and background_tasks is not None: + from onyx.background.task_utils import drain_project_sync_loop + + background_tasks.add_task(drain_project_sync_loop, tenant_id) + logger.info(f"Queued in-process project sync for user_file_id={user_file_id}") + return + + from onyx.background.celery.tasks.user_file_processing.tasks import ( + enqueue_user_file_project_sync_task, + ) + from onyx.background.celery.tasks.user_file_processing.tasks import ( + get_user_file_project_sync_queue_depth, + ) + from onyx.background.celery.versioned_apps.client import app as client_app + from onyx.redis.redis_pool import get_redis_client + queue_depth = get_user_file_project_sync_queue_depth(client_app) if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH: logger.warning( @@ -111,6 +125,7 @@ def create_project( @router.post("/file/upload", tags=PUBLIC_API_TAGS) def upload_user_files( + bg_tasks: BackgroundTasks, files: list[UploadFile] = File(...), project_id: int | None = Form(None), temp_id_map: str | None = Form(None), # JSON string mapping hashed key -> temp_id @@ -137,12 +152,12 @@ def upload_user_files( user=user, temp_id_map=parsed_temp_id_map, db_session=db_session, + background_tasks=bg_tasks if DISABLE_VECTOR_DB else None, ) return CategorizedFilesSnapshot.from_result(categorized_files_result) except Exception as e: - # Log error with type, message, and stack for easier debugging logger.exception(f"Error uploading files - {type(e).__name__}: {str(e)}") raise HTTPException( status_code=500, @@ -192,6 +207,7 @@ def get_files_in_project( def unlink_user_file_from_project( project_id: int, file_id: UUID, + bg_tasks: BackgroundTasks, user: User = Depends(current_user), db_session: Session = Depends(get_session), ) -> Response: @@ -208,7 +224,6 @@ def unlink_user_file_from_project( if project is None: raise HTTPException(status_code=404, detail="Project not found") - user_id = user.id user_file = ( db_session.query(UserFile) .filter(UserFile.id == file_id, UserFile.user_id == user_id) @@ -224,7 +239,7 @@ def unlink_user_file_from_project( db_session.commit() tenant_id = get_current_tenant_id() - _trigger_user_file_project_sync(user_file.id, tenant_id) + _trigger_user_file_project_sync(user_file.id, tenant_id, bg_tasks) return Response(status_code=204) @@ -237,6 +252,7 @@ def unlink_user_file_from_project( def link_user_file_to_project( project_id: int, file_id: UUID, + bg_tasks: BackgroundTasks, user: User = Depends(current_user), db_session: Session = Depends(get_session), ) -> UserFileSnapshot: @@ -268,7 +284,7 @@ def link_user_file_to_project( db_session.commit() tenant_id = get_current_tenant_id() - _trigger_user_file_project_sync(user_file.id, tenant_id) + _trigger_user_file_project_sync(user_file.id, tenant_id, bg_tasks) return UserFileSnapshot.from_model(user_file) @@ -424,6 +440,7 @@ def delete_project( @router.delete("/file/{file_id}", tags=PUBLIC_API_TAGS) def delete_user_file( file_id: UUID, + bg_tasks: BackgroundTasks, user: User = Depends(current_user), db_session: Session = Depends(get_session), ) -> UserFileDeleteResult: @@ -456,15 +473,25 @@ def delete_user_file( db_session.commit() tenant_id = get_current_tenant_id() - task = client_app.send_task( - OnyxCeleryTask.DELETE_SINGLE_USER_FILE, - kwargs={"user_file_id": str(user_file.id), "tenant_id": tenant_id}, - queue=OnyxCeleryQueues.USER_FILE_DELETE, - priority=OnyxCeleryPriority.HIGH, - ) - logger.info( - f"Triggered delete for user_file_id={user_file.id} with task_id={task.id}" - ) + if DISABLE_VECTOR_DB: + from onyx.background.task_utils import drain_delete_loop + + bg_tasks.add_task(drain_delete_loop, tenant_id) + logger.info(f"Queued in-process delete for user_file_id={user_file.id}") + else: + from onyx.background.celery.versioned_apps.client import app as client_app + + task = client_app.send_task( + OnyxCeleryTask.DELETE_SINGLE_USER_FILE, + kwargs={"user_file_id": str(user_file.id), "tenant_id": tenant_id}, + queue=OnyxCeleryQueues.USER_FILE_DELETE, + priority=OnyxCeleryPriority.HIGH, + ) + logger.info( + f"Triggered delete for user_file_id={user_file.id} " + f"with task_id={task.id}" + ) + return UserFileDeleteResult( has_associations=False, project_names=[], assistant_names=[] ) diff --git a/backend/tests/external_dependency_unit/tools/test_python_tool.py b/backend/tests/external_dependency_unit/tools/test_python_tool.py index 5e0915b9560..d96b4cdbfea 100644 --- a/backend/tests/external_dependency_unit/tools/test_python_tool.py +++ b/backend/tests/external_dependency_unit/tools/test_python_tool.py @@ -933,6 +933,7 @@ import pytest from fastapi import UploadFile +from fastapi.background import BackgroundTasks from sqlalchemy.orm import Session from starlette.datastructures import Headers @@ -1139,6 +1140,7 @@ def test_code_interpreter_receives_chat_files( # Upload a test CSV csv_content = b"name,age,city\nAlice,30,NYC\nBob,25,SF\n" result = upload_user_files( + bg_tasks=BackgroundTasks(), files=[ UploadFile( file=io.BytesIO(csv_content), diff --git a/backend/tests/integration/tests/indexing/test_checkpointing.py b/backend/tests/integration/tests/indexing/test_checkpointing.py index 7760234d24e..869272a1848 100644 --- a/backend/tests/integration/tests/indexing/test_checkpointing.py +++ b/backend/tests/integration/tests/indexing/test_checkpointing.py @@ -414,6 +414,24 @@ def test_mock_connector_checkpoint_recovery( ) assert finished_index_attempt.status == IndexingStatus.FAILED + # Pause the connector immediately to prevent check_for_indexing from + # creating automatic retry attempts while we reset the mock server. + # Without this, the INITIAL_INDEXING status causes immediate retries + # that would consume (or fail against) the mock server before we can + # set up the recovery behavior. + CCPairManager.pause_cc_pair(cc_pair, user_performing_action=admin_user) + + # Collect all index attempt IDs created so far (the initial one plus + # any automatic retries that may have started before the pause took effect). + all_prior_attempt_ids: list[int] = [] + index_attempts_page = IndexAttemptManager.get_index_attempt_page( + cc_pair_id=cc_pair.id, + page=0, + page_size=100, + user_performing_action=admin_user, + ) + all_prior_attempt_ids = [ia.id for ia in index_attempts_page.items] + # Verify initial state: both docs should be indexed with get_session_with_current_tenant() as db_session: documents = DocumentManager.fetch_documents_for_cc_pair( @@ -465,17 +483,14 @@ def test_mock_connector_checkpoint_recovery( ) assert response.status_code == 200 - # After the failure, the connector is in repeated error state and paused. - # Set the manual indexing trigger first (while paused), then unpause. - # This ensures the trigger is set before CHECK_FOR_INDEXING runs, which will - # prevent the connector from being re-paused when repeated error state is detected. + # Set the manual indexing trigger, then unpause to allow the recovery run. CCPairManager.run_once( cc_pair, from_beginning=False, user_performing_action=admin_user ) CCPairManager.unpause_cc_pair(cc_pair, user_performing_action=admin_user) recovery_index_attempt = IndexAttemptManager.wait_for_index_attempt_start( cc_pair_id=cc_pair.id, - index_attempts_to_ignore=[initial_index_attempt.id], + index_attempts_to_ignore=all_prior_attempt_ids, user_performing_action=admin_user, ) IndexAttemptManager.wait_for_index_attempt_completion( diff --git a/backend/tests/integration/tests/indexing/test_repeated_error_state.py b/backend/tests/integration/tests/indexing/test_repeated_error_state.py index 039da0e6148..cea30095fca 100644 --- a/backend/tests/integration/tests/indexing/test_repeated_error_state.py +++ b/backend/tests/integration/tests/indexing/test_repeated_error_state.py @@ -130,8 +130,8 @@ def test_repeated_error_state_detection_and_recovery( # ) break - if time.monotonic() - start_time > 30: - assert False, "CC pair did not enter repeated error state within 30 seconds" + if time.monotonic() - start_time > 90: + assert False, "CC pair did not enter repeated error state within 90 seconds" time.sleep(2) diff --git a/backend/tests/unit/onyx/background/celery/tasks/test_user_file_impl_redis_locking.py b/backend/tests/unit/onyx/background/celery/tasks/test_user_file_impl_redis_locking.py index 5fc77b5c8d5..4ae9f297b1f 100644 --- a/backend/tests/unit/onyx/background/celery/tasks/test_user_file_impl_redis_locking.py +++ b/backend/tests/unit/onyx/background/celery/tasks/test_user_file_impl_redis_locking.py @@ -11,13 +11,13 @@ from uuid import uuid4 from onyx.background.celery.tasks.user_file_processing.tasks import ( - _delete_user_file_impl, + delete_user_file_impl, ) from onyx.background.celery.tasks.user_file_processing.tasks import ( - _process_user_file_impl, + process_user_file_impl, ) from onyx.background.celery.tasks.user_file_processing.tasks import ( - _project_sync_user_file_impl, + project_sync_user_file_impl, ) TASKS_MODULE = "onyx.background.celery.tasks.user_file_processing.tasks" @@ -32,7 +32,7 @@ def _mock_session_returning_none() -> MagicMock: # ------------------------------------------------------------------ -# _process_user_file_impl +# process_user_file_impl # ------------------------------------------------------------------ @@ -55,7 +55,7 @@ def test_redis_locking_true_acquires_and_releases_lock( mock_get_session.return_value.__enter__.return_value = session user_file_id = str(uuid4()) - _process_user_file_impl( + process_user_file_impl( user_file_id=user_file_id, tenant_id="test-tenant", redis_locking=True, @@ -79,7 +79,7 @@ def test_redis_locking_true_skips_when_lock_held( redis_client.lock.return_value = lock mock_get_redis.return_value = redis_client - _process_user_file_impl( + process_user_file_impl( user_file_id=str(uuid4()), tenant_id="test-tenant", redis_locking=True, @@ -98,7 +98,7 @@ def test_redis_locking_false_skips_redis_entirely( session = _mock_session_returning_none() mock_get_session.return_value.__enter__.return_value = session - _process_user_file_impl( + process_user_file_impl( user_file_id=str(uuid4()), tenant_id="test-tenant", redis_locking=False, @@ -127,21 +127,21 @@ def test_both_paths_call_db_get( uid = str(uuid4()) - _process_user_file_impl(user_file_id=uid, tenant_id="t", redis_locking=True) + process_user_file_impl(user_file_id=uid, tenant_id="t", redis_locking=True) call_count_true = session.get.call_count session.reset_mock() mock_get_session.reset_mock() mock_get_session.return_value.__enter__.return_value = session - _process_user_file_impl(user_file_id=uid, tenant_id="t", redis_locking=False) + process_user_file_impl(user_file_id=uid, tenant_id="t", redis_locking=False) call_count_false = session.get.call_count assert call_count_true == call_count_false == 1 # ------------------------------------------------------------------ -# _delete_user_file_impl +# delete_user_file_impl # ------------------------------------------------------------------ @@ -163,7 +163,7 @@ def test_redis_locking_true_acquires_and_releases_lock( session = _mock_session_returning_none() mock_get_session.return_value.__enter__.return_value = session - _delete_user_file_impl( + delete_user_file_impl( user_file_id=str(uuid4()), tenant_id="test-tenant", redis_locking=True, @@ -186,7 +186,7 @@ def test_redis_locking_true_skips_when_lock_held( redis_client.lock.return_value = lock mock_get_redis.return_value = redis_client - _delete_user_file_impl( + delete_user_file_impl( user_file_id=str(uuid4()), tenant_id="test-tenant", redis_locking=True, @@ -205,7 +205,7 @@ def test_redis_locking_false_skips_redis_entirely( session = _mock_session_returning_none() mock_get_session.return_value.__enter__.return_value = session - _delete_user_file_impl( + delete_user_file_impl( user_file_id=str(uuid4()), tenant_id="test-tenant", redis_locking=False, @@ -216,7 +216,7 @@ def test_redis_locking_false_skips_redis_entirely( # ------------------------------------------------------------------ -# _project_sync_user_file_impl +# project_sync_user_file_impl # ------------------------------------------------------------------ @@ -238,7 +238,7 @@ def test_redis_locking_true_acquires_and_releases_lock( session = _mock_session_returning_none() mock_get_session.return_value.__enter__.return_value = session - _project_sync_user_file_impl( + project_sync_user_file_impl( user_file_id=str(uuid4()), tenant_id="test-tenant", redis_locking=True, @@ -262,7 +262,7 @@ def test_redis_locking_true_skips_when_lock_held( redis_client.lock.return_value = lock mock_get_redis.return_value = redis_client - _project_sync_user_file_impl( + project_sync_user_file_impl( user_file_id=str(uuid4()), tenant_id="test-tenant", redis_locking=True, @@ -281,7 +281,7 @@ def test_redis_locking_false_skips_redis_entirely( session = _mock_session_returning_none() mock_get_session.return_value.__enter__.return_value = session - _project_sync_user_file_impl( + project_sync_user_file_impl( user_file_id=str(uuid4()), tenant_id="test-tenant", redis_locking=False, diff --git a/backend/tests/unit/onyx/background/celery/tasks/test_user_file_processing_no_vectordb.py b/backend/tests/unit/onyx/background/celery/tasks/test_user_file_processing_no_vectordb.py index dcaf15f9a45..6171c4e4a5d 100644 --- a/backend/tests/unit/onyx/background/celery/tasks/test_user_file_processing_no_vectordb.py +++ b/backend/tests/unit/onyx/background/celery/tasks/test_user_file_processing_no_vectordb.py @@ -1,11 +1,11 @@ """Tests for no-vector-DB user file processing paths. Verifies that when DISABLE_VECTOR_DB is True: -- _process_user_file_impl calls _process_user_file_without_vector_db (not indexing) +- process_user_file_impl calls _process_user_file_without_vector_db (not indexing) - _process_user_file_without_vector_db extracts text, counts tokens, stores plaintext, sets status=COMPLETED and chunk_count=0 -- _delete_user_file_impl skips vector DB chunk deletion -- _project_sync_user_file_impl skips vector DB metadata update +- delete_user_file_impl skips vector DB chunk deletion +- project_sync_user_file_impl skips vector DB metadata update """ from unittest.mock import MagicMock @@ -13,16 +13,16 @@ from uuid import uuid4 from onyx.background.celery.tasks.user_file_processing.tasks import ( - _delete_user_file_impl, + _process_user_file_without_vector_db, ) from onyx.background.celery.tasks.user_file_processing.tasks import ( - _process_user_file_impl, + delete_user_file_impl, ) from onyx.background.celery.tasks.user_file_processing.tasks import ( - _process_user_file_without_vector_db, + process_user_file_impl, ) from onyx.background.celery.tasks.user_file_processing.tasks import ( - _project_sync_user_file_impl, + project_sync_user_file_impl, ) from onyx.configs.constants import DocumentSource from onyx.connectors.models import Document @@ -203,7 +203,7 @@ def test_preserves_deleting_status( # ------------------------------------------------------------------ -# _process_user_file_impl — branching on DISABLE_VECTOR_DB +# process_user_file_impl — branching on DISABLE_VECTOR_DB # ------------------------------------------------------------------ @@ -227,7 +227,7 @@ def test_calls_without_vector_db_when_disabled( connector_mock.load_from_state.return_value = [_make_documents(["hello"])] with patch(f"{TASKS_MODULE}.LocalFileConnector", return_value=connector_mock): - _process_user_file_impl( + process_user_file_impl( user_file_id=str(uf.id), tenant_id="test-tenant", redis_locking=False, @@ -255,7 +255,7 @@ def test_calls_with_indexing_when_vector_db_enabled( connector_mock.load_from_state.return_value = [_make_documents(["hello"])] with patch(f"{TASKS_MODULE}.LocalFileConnector", return_value=connector_mock): - _process_user_file_impl( + process_user_file_impl( user_file_id=str(uf.id), tenant_id="test-tenant", redis_locking=False, @@ -291,7 +291,7 @@ def test_indexing_pipeline_not_called_when_disabled( return_value=MagicMock(return_value=[1, 2, 3]), ), ): - _process_user_file_impl( + process_user_file_impl( user_file_id=str(uf.id), tenant_id="test-tenant", redis_locking=False, @@ -301,7 +301,7 @@ def test_indexing_pipeline_not_called_when_disabled( # ------------------------------------------------------------------ -# _delete_user_file_impl — vector DB skip +# delete_user_file_impl — vector DB skip # ------------------------------------------------------------------ @@ -325,7 +325,7 @@ def test_skips_vector_db_deletion( patch(f"{TASKS_MODULE}.get_active_search_settings") as mock_get_ss, patch(f"{TASKS_MODULE}.httpx_init_vespa_pool") as mock_vespa_pool, ): - _delete_user_file_impl( + delete_user_file_impl( user_file_id=str(uf.id), tenant_id="test-tenant", redis_locking=False, @@ -354,7 +354,7 @@ def test_still_deletes_file_store_and_db_record( file_store = MagicMock() mock_get_file_store.return_value = file_store - _delete_user_file_impl( + delete_user_file_impl( user_file_id=str(uf.id), tenant_id="test-tenant", redis_locking=False, @@ -366,7 +366,7 @@ def test_still_deletes_file_store_and_db_record( # ------------------------------------------------------------------ -# _project_sync_user_file_impl — vector DB skip +# project_sync_user_file_impl — vector DB skip # ------------------------------------------------------------------ @@ -387,7 +387,7 @@ def test_skips_vector_db_update( patch(f"{TASKS_MODULE}.get_active_search_settings") as mock_get_ss, patch(f"{TASKS_MODULE}.httpx_init_vespa_pool") as mock_vespa_pool, ): - _project_sync_user_file_impl( + project_sync_user_file_impl( user_file_id=str(uf.id), tenant_id="test-tenant", redis_locking=False, @@ -408,7 +408,7 @@ def test_still_clears_sync_flags( session.execute.return_value.scalar_one_or_none.return_value = uf mock_get_session.return_value.__enter__.return_value = session - _project_sync_user_file_impl( + project_sync_user_file_impl( user_file_id=str(uf.id), tenant_id="test-tenant", redis_locking=False, diff --git a/web/src/hooks/useCCPairs.ts b/web/src/hooks/useCCPairs.ts index 4b52b6ea371..fe6bb0d2fbb 100644 --- a/web/src/hooks/useCCPairs.ts +++ b/web/src/hooks/useCCPairs.ts @@ -66,15 +66,15 @@ import { errorHandlingFetcher } from "@/lib/fetcher"; * }; * ``` */ -export default function useCCPairs() { +export default function useCCPairs(enabled: boolean = true) { const { data, error, isLoading, mutate } = useSWR( - "/api/manage/connector-status", + enabled ? "/api/manage/connector-status" : null, errorHandlingFetcher ); return { ccPairs: data ?? [], - isLoading, + isLoading: enabled && isLoading, error, refetch: mutate, }; diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts index 14cf980bf5a..9a7fdf5fccd 100644 --- a/web/src/lib/hooks.ts +++ b/web/src/lib/hooks.ts @@ -222,9 +222,12 @@ export const useConnectorStatus = (refreshInterval = 30000) => { }; }; -export const useBasicConnectorStatus = () => { +export const useBasicConnectorStatus = (enabled: boolean = true) => { const url = "/api/manage/connector-status"; - const swrResponse = useSWR(url, errorHandlingFetcher); + const swrResponse = useSWR( + enabled ? url : null, + errorHandlingFetcher + ); return { ...swrResponse, refreshIndexingStatus: () => mutate(url), diff --git a/web/src/providers/SettingsProvider.tsx b/web/src/providers/SettingsProvider.tsx index aad0564aeac..02f1c095c72 100644 --- a/web/src/providers/SettingsProvider.tsx +++ b/web/src/providers/SettingsProvider.tsx @@ -19,7 +19,8 @@ export function SettingsProvider({ settings: CombinedSettings; }) { const [isMobile, setIsMobile] = useState(); - const { ccPairs } = useCCPairs(); + const vectorDbEnabled = settings.settings.vector_db_enabled !== false; + const { ccPairs } = useCCPairs(vectorDbEnabled); useEffect(() => { const checkMobile = () => { diff --git a/web/src/refresh-components/popovers/ActionsPopover/index.tsx b/web/src/refresh-components/popovers/ActionsPopover/index.tsx index c4749932a79..7f058c5b615 100644 --- a/web/src/refresh-components/popovers/ActionsPopover/index.tsx +++ b/web/src/refresh-components/popovers/ActionsPopover/index.tsx @@ -29,6 +29,7 @@ import { SourceMetadata } from "@/lib/search/interfaces"; import { SourceIcon } from "@/components/SourceIcon"; import { useAvailableTools } from "@/hooks/useAvailableTools"; import useCCPairs from "@/hooks/useCCPairs"; +import { useSettingsContext } from "@/providers/SettingsProvider"; import InputTypeIn from "@/refresh-components/inputs/InputTypeIn"; import { useToolOAuthStatus } from "@/lib/hooks/useToolOAuthStatus"; import LineItem from "@/refresh-components/buttons/LineItem"; @@ -274,9 +275,11 @@ export default function ActionsPopover({ }, [selectedAssistant.id, setForcedToolIds]); const { isAdmin, isCurator } = useUser(); + const settings = useSettingsContext(); + const vectorDbEnabled = settings?.settings.vector_db_enabled !== false; const { tools: availableTools } = useAvailableTools(); - const { ccPairs } = useCCPairs(); + const { ccPairs } = useCCPairs(vectorDbEnabled); const { currentProjectId, allCurrentProjectFiles } = useProjectsContext(); const availableToolIdSet = new Set(availableTools.map((tool) => tool.id)); diff --git a/web/src/refresh-pages/AppPage.tsx b/web/src/refresh-pages/AppPage.tsx index 36e5947d51e..fe6d6a33d47 100644 --- a/web/src/refresh-pages/AppPage.tsx +++ b/web/src/refresh-pages/AppPage.tsx @@ -138,7 +138,13 @@ export default function AppPage({ firstMessage }: ChatPageProps) { currentChatSessionId, isLoading: isLoadingChatSessions, } = useChatSessions(); - const { ccPairs } = useCCPairs(); + // handle redirect if chat page is disabled + // NOTE: this must be done here, in a client component since + // settings are passed in via Context and therefore aren't + // available in server-side components + const settings = useSettingsContext(); + const vectorDbEnabled = settings?.settings.vector_db_enabled !== false; + const { ccPairs } = useCCPairs(vectorDbEnabled); const { tags } = useTags(); const { documentSets } = useDocumentSets(); const { @@ -156,12 +162,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) { setForcedToolIds([]); }, [currentProjectId, setForcedToolIds]); - // handle redirect if chat page is disabled - // NOTE: this must be done here, in a client component since - // settings are passed in via Context and therefore aren't - // available in server-side components - const settings = useSettingsContext(); - const isInitialLoad = useRef(true); const { agents, isLoading: isLoadingAgents } = useAgents(); diff --git a/web/src/sections/input/AppInputBar.tsx b/web/src/sections/input/AppInputBar.tsx index 23d5f343dd9..de99d815eef 100644 --- a/web/src/sections/input/AppInputBar.tsx +++ b/web/src/sections/input/AppInputBar.tsx @@ -294,7 +294,9 @@ const AppInputBar = React.memo( ); const { activePromptShortcuts } = usePromptShortcuts(); - const { ccPairs, isLoading: ccPairsLoading } = useCCPairs(); + const vectorDbEnabled = + combinedSettings?.settings.vector_db_enabled !== false; + const { ccPairs, isLoading: ccPairsLoading } = useCCPairs(vectorDbEnabled); const { data: federatedConnectorsData, isLoading: federatedLoading } = useFederatedConnectors(); diff --git a/web/src/sections/knowledge/AgentKnowledgePane.tsx b/web/src/sections/knowledge/AgentKnowledgePane.tsx index 6ca132568ee..6bfbef134ac 100644 --- a/web/src/sections/knowledge/AgentKnowledgePane.tsx +++ b/web/src/sections/knowledge/AgentKnowledgePane.tsx @@ -892,7 +892,7 @@ export default function AgentKnowledgePane({ }, [enableKnowledge]); // Get connected sources from CC pairs - const { ccPairs } = useCCPairs(); + const { ccPairs } = useCCPairs(vectorDbEnabled); const connectedSources: ConnectedSource[] = useMemo(() => { if (!ccPairs || ccPairs.length === 0) return []; const sourceSet = new Set(); diff --git a/web/src/sections/sidebar/UserAvatarPopover.tsx b/web/src/sections/sidebar/UserAvatarPopover.tsx index 343c0e5f11d..c7413edf920 100644 --- a/web/src/sections/sidebar/UserAvatarPopover.tsx +++ b/web/src/sections/sidebar/UserAvatarPopover.tsx @@ -25,6 +25,7 @@ import { import { Section } from "@/layouts/general-layouts"; import { toast } from "@/hooks/useToast"; import useAppFocus from "@/hooks/useAppFocus"; +import { useSettingsContext } from "@/providers/SettingsProvider"; function getDisplayName(email?: string, personalName?: string): string { // Prioritize custom personal name if set @@ -165,6 +166,8 @@ export default function UserAvatarPopover({ const { user } = useUser(); const router = useRouter(); const appFocus = useAppFocus(); + const settings = useSettingsContext(); + const vectorDbEnabled = settings?.settings.vector_db_enabled !== false; // Fetch notifications for display // The GET endpoint also triggers a refresh if release notes are stale @@ -183,7 +186,9 @@ export default function UserAvatarPopover({ // Prefetch user settings data when popover opens for instant modal display preload("/api/user/pats", errorHandlingFetcher); preload("/api/federated/oauth-status", errorHandlingFetcher); - preload("/api/manage/connector-status", errorHandlingFetcher); + if (vectorDbEnabled) { + preload("/api/manage/connector-status", errorHandlingFetcher); + } preload("/api/llm/provider", errorHandlingFetcher); setPopupState("Settings"); } else { From 14fab7fcdfafe4107346cafa2c06c795c0fb54d6 Mon Sep 17 00:00:00 2001 From: Evan Lohn Date: Sun, 1 Mar 2026 19:51:18 -0800 Subject: [PATCH 003/267] feat: no vector db beat tasks (#8865) --- backend/onyx/background/periodic_poller.py | 284 ++++++++++++++++++ backend/onyx/main.py | 12 + .../background/test_periodic_task_claim.py | 257 ++++++++++++++++ .../background/test_startup_recovery.py | 219 ++++++++++++++ 4 files changed, 772 insertions(+) create mode 100644 backend/onyx/background/periodic_poller.py create mode 100644 backend/tests/external_dependency_unit/background/test_periodic_task_claim.py create mode 100644 backend/tests/external_dependency_unit/background/test_startup_recovery.py diff --git a/backend/onyx/background/periodic_poller.py b/backend/onyx/background/periodic_poller.py new file mode 100644 index 00000000000..9580fb7d652 --- /dev/null +++ b/backend/onyx/background/periodic_poller.py @@ -0,0 +1,284 @@ +"""Periodic poller for NO_VECTOR_DB deployments. + +Replaces Celery Beat and background workers with a lightweight daemon thread +that runs from the API server process. Two responsibilities: + +1. Recovery polling (every 30 s): re-processes user files stuck in + PROCESSING / DELETING / needs_sync states via the drain loops defined + in ``task_utils.py``. + +2. Periodic task execution (configurable intervals): runs LLM model updates + and scheduled evals at their configured cadences, with Postgres advisory + lock deduplication across multiple API server instances. +""" + +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass +from dataclasses import field + +from onyx.utils.logger import setup_logger + +logger = setup_logger() + +RECOVERY_INTERVAL_SECONDS = 30 +PERIODIC_TASK_LOCK_BASE = 20_000 +PERIODIC_TASK_KV_PREFIX = "periodic_poller:last_claimed:" + + +# ------------------------------------------------------------------ +# Periodic task definitions +# ------------------------------------------------------------------ + + +@dataclass +class _PeriodicTaskDef: + name: str + interval_seconds: float + lock_id: int + run_fn: Callable[[], None] + last_run_at: float = field(default=0.0) + + +def _run_auto_llm_update() -> None: + from onyx.configs.app_configs import AUTO_LLM_CONFIG_URL + + if not AUTO_LLM_CONFIG_URL: + return + + from onyx.db.engine.sql_engine import get_session_with_current_tenant + from onyx.llm.well_known_providers.auto_update_service import ( + sync_llm_models_from_github, + ) + + with get_session_with_current_tenant() as db_session: + sync_llm_models_from_github(db_session) + + +def _run_scheduled_eval() -> None: + from onyx.configs.app_configs import BRAINTRUST_API_KEY + from onyx.configs.app_configs import SCHEDULED_EVAL_DATASET_NAMES + from onyx.configs.app_configs import SCHEDULED_EVAL_PERMISSIONS_EMAIL + from onyx.configs.app_configs import SCHEDULED_EVAL_PROJECT + + if not all( + [ + BRAINTRUST_API_KEY, + SCHEDULED_EVAL_PROJECT, + SCHEDULED_EVAL_DATASET_NAMES, + SCHEDULED_EVAL_PERMISSIONS_EMAIL, + ] + ): + return + + from datetime import datetime + from datetime import timezone + + from onyx.evals.eval import run_eval + from onyx.evals.models import EvalConfigurationOptions + + run_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d") + for dataset_name in SCHEDULED_EVAL_DATASET_NAMES: + try: + run_eval( + configuration=EvalConfigurationOptions( + search_permissions_email=SCHEDULED_EVAL_PERMISSIONS_EMAIL, + dataset_name=dataset_name, + no_send_logs=False, + braintrust_project=SCHEDULED_EVAL_PROJECT, + experiment_name=f"{dataset_name} - {run_timestamp}", + ), + remote_dataset_name=dataset_name, + ) + except Exception: + logger.exception( + f"Periodic poller - Failed scheduled eval for dataset {dataset_name}" + ) + + +def _build_periodic_tasks() -> list[_PeriodicTaskDef]: + from onyx.configs.app_configs import AUTO_LLM_CONFIG_URL + from onyx.configs.app_configs import AUTO_LLM_UPDATE_INTERVAL_SECONDS + from onyx.configs.app_configs import SCHEDULED_EVAL_DATASET_NAMES + + tasks: list[_PeriodicTaskDef] = [] + if AUTO_LLM_CONFIG_URL: + tasks.append( + _PeriodicTaskDef( + name="auto-llm-update", + interval_seconds=AUTO_LLM_UPDATE_INTERVAL_SECONDS, + lock_id=PERIODIC_TASK_LOCK_BASE, + run_fn=_run_auto_llm_update, + ) + ) + if SCHEDULED_EVAL_DATASET_NAMES: + tasks.append( + _PeriodicTaskDef( + name="scheduled-eval", + interval_seconds=7 * 24 * 3600, + lock_id=PERIODIC_TASK_LOCK_BASE + 1, + run_fn=_run_scheduled_eval, + ) + ) + return tasks + + +# ------------------------------------------------------------------ +# Periodic task runner with advisory-lock-guarded claim +# ------------------------------------------------------------------ + + +def _try_claim_task(task_def: _PeriodicTaskDef) -> bool: + """Atomically check whether *task_def* should run and record a claim. + + Uses a transaction-scoped advisory lock for atomicity combined with a + ``KVStore`` timestamp for cross-instance dedup. The DB session is held + only for this brief claim transaction, not during task execution. + """ + from datetime import datetime + from datetime import timezone + + from sqlalchemy import text + + from onyx.db.engine.sql_engine import get_session_with_current_tenant + from onyx.db.models import KVStore + + kv_key = PERIODIC_TASK_KV_PREFIX + task_def.name + + with get_session_with_current_tenant() as db_session: + acquired = db_session.execute( + text("SELECT pg_try_advisory_xact_lock(:id)"), + {"id": task_def.lock_id}, + ).scalar() + if not acquired: + return False + + row = db_session.query(KVStore).filter_by(key=kv_key).first() + if row and row.value is not None: + last_claimed = datetime.fromisoformat(str(row.value)) + elapsed = (datetime.now(timezone.utc) - last_claimed).total_seconds() + if elapsed < task_def.interval_seconds: + return False + + now_ts = datetime.now(timezone.utc).isoformat() + if row: + row.value = now_ts + else: + db_session.add(KVStore(key=kv_key, value=now_ts)) + db_session.commit() + + return True + + +def _try_run_periodic_task(task_def: _PeriodicTaskDef) -> None: + """Run *task_def* if its interval has elapsed and no peer holds the lock.""" + now = time.monotonic() + if now - task_def.last_run_at < task_def.interval_seconds: + return + + if not _try_claim_task(task_def): + return + + try: + task_def.run_fn() + task_def.last_run_at = now + except Exception: + logger.exception( + f"Periodic poller - Error running periodic task {task_def.name}" + ) + + +# ------------------------------------------------------------------ +# Recovery / drain loop runner +# ------------------------------------------------------------------ + + +def _run_drain_loops(tenant_id: str) -> None: + from onyx.background.task_utils import drain_delete_loop + from onyx.background.task_utils import drain_processing_loop + from onyx.background.task_utils import drain_project_sync_loop + + drain_processing_loop(tenant_id) + drain_delete_loop(tenant_id) + drain_project_sync_loop(tenant_id) + + +# ------------------------------------------------------------------ +# Startup recovery (10g) +# ------------------------------------------------------------------ + + +def recover_stuck_user_files(tenant_id: str) -> None: + """Run all drain loops once to re-process files left in intermediate states. + + Called from ``lifespan()`` on startup when ``DISABLE_VECTOR_DB`` is set. + """ + logger.info("recover_stuck_user_files - Checking for stuck user files") + try: + _run_drain_loops(tenant_id) + except Exception: + logger.exception("recover_stuck_user_files - Error during recovery") + + +# ------------------------------------------------------------------ +# Daemon thread (10f) +# ------------------------------------------------------------------ + +_shutdown_event = threading.Event() +_poller_thread: threading.Thread | None = None + + +def _poller_loop(tenant_id: str) -> None: + from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR + + CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id) + + periodic_tasks = _build_periodic_tasks() + logger.info( + f"Periodic poller started with {len(periodic_tasks)} periodic task(s): " + f"{[t.name for t in periodic_tasks]}" + ) + + while not _shutdown_event.is_set(): + try: + _run_drain_loops(tenant_id) + except Exception: + logger.exception("Periodic poller - Error in recovery polling") + + for task_def in periodic_tasks: + try: + _try_run_periodic_task(task_def) + except Exception: + logger.exception( + f"Periodic poller - Unhandled error checking task {task_def.name}" + ) + + _shutdown_event.wait(RECOVERY_INTERVAL_SECONDS) + + +def start_periodic_poller(tenant_id: str) -> None: + """Start the periodic poller daemon thread.""" + global _poller_thread # noqa: PLW0603 + _shutdown_event.clear() + _poller_thread = threading.Thread( + target=_poller_loop, + args=(tenant_id,), + daemon=True, + name="no-vectordb-periodic-poller", + ) + _poller_thread.start() + logger.info("Periodic poller thread started") + + +def stop_periodic_poller() -> None: + """Signal the periodic poller to stop and wait for it to exit.""" + global _poller_thread # noqa: PLW0603 + if _poller_thread is None: + return + _shutdown_event.set() + _poller_thread.join(timeout=10) + if _poller_thread.is_alive(): + logger.warning("Periodic poller thread did not stop within timeout") + _poller_thread = None + logger.info("Periodic poller thread stopped") diff --git a/backend/onyx/main.py b/backend/onyx/main.py index 405581aa7b5..2b692abb0d9 100644 --- a/backend/onyx/main.py +++ b/backend/onyx/main.py @@ -355,8 +355,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001 if AUTH_RATE_LIMITING_ENABLED: await setup_auth_limiter() + if DISABLE_VECTOR_DB: + from onyx.background.periodic_poller import recover_stuck_user_files + from onyx.background.periodic_poller import start_periodic_poller + + recover_stuck_user_files(POSTGRES_DEFAULT_SCHEMA) + start_periodic_poller(POSTGRES_DEFAULT_SCHEMA) + yield + if DISABLE_VECTOR_DB: + from onyx.background.periodic_poller import stop_periodic_poller + + stop_periodic_poller() + SqlEngine.reset_engine() if AUTH_RATE_LIMITING_ENABLED: diff --git a/backend/tests/external_dependency_unit/background/test_periodic_task_claim.py b/backend/tests/external_dependency_unit/background/test_periodic_task_claim.py new file mode 100644 index 00000000000..92132e1db0d --- /dev/null +++ b/backend/tests/external_dependency_unit/background/test_periodic_task_claim.py @@ -0,0 +1,257 @@ +"""External dependency unit tests for periodic task claiming. + +Tests ``_try_claim_task`` and ``_try_run_periodic_task`` against real +PostgreSQL, verifying happy-path behavior and concurrent-access safety. + +The claim mechanism uses a transaction-scoped advisory lock + a KVStore +timestamp for cross-instance dedup. The DB session is released before +the task runs, so long-running tasks don't hold connections. +""" + +import time +from collections.abc import Generator +from concurrent.futures import as_completed +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +from onyx.background.periodic_poller import _PeriodicTaskDef +from onyx.background.periodic_poller import _try_claim_task +from onyx.background.periodic_poller import _try_run_periodic_task +from onyx.background.periodic_poller import PERIODIC_TASK_KV_PREFIX +from onyx.db.engine.sql_engine import get_session_with_current_tenant +from onyx.db.engine.sql_engine import SqlEngine +from onyx.db.models import KVStore +from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR +from tests.external_dependency_unit.constants import TEST_TENANT_ID + +_TEST_LOCK_BASE = 90_000 + + +@pytest.fixture(scope="module", autouse=True) +def _init_engine() -> None: + SqlEngine.init_engine(pool_size=10, max_overflow=5) + + +def _make_task( + *, + name: str | None = None, + interval: float = 3600, + lock_id: int | None = None, + run_fn: MagicMock | None = None, +) -> _PeriodicTaskDef: + return _PeriodicTaskDef( + name=name or f"test-{uuid4().hex[:8]}", + interval_seconds=interval, + lock_id=lock_id or _TEST_LOCK_BASE, + run_fn=run_fn or MagicMock(), + ) + + +@pytest.fixture(autouse=True) +def _cleanup_kv( + tenant_context: None, # noqa: ARG001 +) -> Generator[None, None, None]: + yield + with get_session_with_current_tenant() as db_session: + db_session.query(KVStore).filter( + KVStore.key.like(f"{PERIODIC_TASK_KV_PREFIX}test-%") + ).delete(synchronize_session=False) + db_session.commit() + + +# ------------------------------------------------------------------ +# Happy-path: _try_claim_task +# ------------------------------------------------------------------ + + +class TestClaimHappyPath: + def test_first_claim_succeeds(self) -> None: + assert _try_claim_task(_make_task()) is True + + def test_first_claim_creates_kv_row(self) -> None: + task = _make_task() + _try_claim_task(task) + + with get_session_with_current_tenant() as db_session: + row = ( + db_session.query(KVStore) + .filter_by(key=PERIODIC_TASK_KV_PREFIX + task.name) + .first() + ) + assert row is not None + assert row.value is not None + + def test_second_claim_within_interval_fails(self) -> None: + task = _make_task(interval=3600) + assert _try_claim_task(task) is True + assert _try_claim_task(task) is False + + def test_claim_after_interval_succeeds(self) -> None: + task = _make_task(interval=1) + assert _try_claim_task(task) is True + + kv_key = PERIODIC_TASK_KV_PREFIX + task.name + with get_session_with_current_tenant() as db_session: + row = db_session.query(KVStore).filter_by(key=kv_key).first() + assert row is not None + row.value = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat() + db_session.commit() + + assert _try_claim_task(task) is True + + +# ------------------------------------------------------------------ +# Happy-path: _try_run_periodic_task +# ------------------------------------------------------------------ + + +class TestRunHappyPath: + def test_runs_task_and_updates_last_run_at(self) -> None: + mock_fn = MagicMock() + task = _make_task(run_fn=mock_fn) + + _try_run_periodic_task(task) + + mock_fn.assert_called_once() + assert task.last_run_at > 0 + + def test_skips_when_in_memory_interval_not_elapsed(self) -> None: + mock_fn = MagicMock() + task = _make_task(run_fn=mock_fn, interval=3600) + task.last_run_at = time.monotonic() + + _try_run_periodic_task(task) + + mock_fn.assert_not_called() + + def test_skips_when_db_claim_blocked(self) -> None: + name = f"test-{uuid4().hex[:8]}" + lock_id = _TEST_LOCK_BASE + 10 + + _try_claim_task(_make_task(name=name, lock_id=lock_id, interval=3600)) + + mock_fn = MagicMock() + task = _make_task(name=name, lock_id=lock_id, interval=3600, run_fn=mock_fn) + _try_run_periodic_task(task) + + mock_fn.assert_not_called() + + def test_task_exception_does_not_propagate(self) -> None: + task = _make_task(run_fn=MagicMock(side_effect=RuntimeError("boom"))) + _try_run_periodic_task(task) + + def test_claim_committed_before_task_runs(self) -> None: + """The KV claim must be visible in the DB when run_fn executes.""" + task_name = f"test-order-{uuid4().hex[:8]}" + kv_key = PERIODIC_TASK_KV_PREFIX + task_name + claim_visible: list[bool] = [] + + def check_claim() -> None: + with get_session_with_current_tenant() as db_session: + row = db_session.query(KVStore).filter_by(key=kv_key).first() + claim_visible.append(row is not None and row.value is not None) + + task = _PeriodicTaskDef( + name=task_name, + interval_seconds=3600, + lock_id=_TEST_LOCK_BASE + 11, + run_fn=check_claim, + ) + + _try_run_periodic_task(task) + + assert claim_visible == [True] + + +# ------------------------------------------------------------------ +# Concurrency: only one claimer should win +# ------------------------------------------------------------------ + + +class TestClaimConcurrency: + def test_concurrent_claims_single_winner(self) -> None: + """Many threads claim the same task — exactly one should succeed.""" + num_threads = 20 + task_name = f"test-race-{uuid4().hex[:8]}" + lock_id = _TEST_LOCK_BASE + 20 + + def claim() -> bool: + CURRENT_TENANT_ID_CONTEXTVAR.set(TEST_TENANT_ID) + return _try_claim_task( + _PeriodicTaskDef( + name=task_name, + interval_seconds=3600, + lock_id=lock_id, + run_fn=lambda: None, + ) + ) + + results: list[bool] = [] + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(claim) for _ in range(num_threads)] + for future in as_completed(futures): + results.append(future.result()) + + winners = sum(1 for r in results if r) + assert winners == 1, f"Expected 1 winner, got {winners}" + + def test_concurrent_run_single_execution(self) -> None: + """Many threads run the same task — run_fn fires exactly once.""" + num_threads = 20 + task_name = f"test-run-race-{uuid4().hex[:8]}" + lock_id = _TEST_LOCK_BASE + 21 + counter = MagicMock() + + def run() -> None: + CURRENT_TENANT_ID_CONTEXTVAR.set(TEST_TENANT_ID) + _try_run_periodic_task( + _PeriodicTaskDef( + name=task_name, + interval_seconds=3600, + lock_id=lock_id, + run_fn=counter, + ) + ) + + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(run) for _ in range(num_threads)] + for future in as_completed(futures): + future.result() + + assert ( + counter.call_count == 1 + ), f"Expected run_fn called once, got {counter.call_count}" + + def test_no_errors_under_contention(self) -> None: + """All threads complete without exceptions under high contention.""" + num_threads = 30 + task_name = f"test-err-{uuid4().hex[:8]}" + lock_id = _TEST_LOCK_BASE + 22 + errors: list[Exception] = [] + + def claim() -> bool: + CURRENT_TENANT_ID_CONTEXTVAR.set(TEST_TENANT_ID) + return _try_claim_task( + _PeriodicTaskDef( + name=task_name, + interval_seconds=3600, + lock_id=lock_id, + run_fn=lambda: None, + ) + ) + + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(claim) for _ in range(num_threads)] + for future in as_completed(futures): + try: + future.result() + except Exception as e: + errors.append(e) + + assert errors == [], f"Got {len(errors)} errors: {errors}" diff --git a/backend/tests/external_dependency_unit/background/test_startup_recovery.py b/backend/tests/external_dependency_unit/background/test_startup_recovery.py new file mode 100644 index 00000000000..01248efe7a2 --- /dev/null +++ b/backend/tests/external_dependency_unit/background/test_startup_recovery.py @@ -0,0 +1,219 @@ +"""External dependency unit tests for startup recovery (Step 10g). + +Seeds ``UserFile`` records in stuck states (PROCESSING, DELETING, +needs_project_sync) then calls ``recover_stuck_user_files`` and verifies +the drain loops pick them up via ``FOR UPDATE SKIP LOCKED``. + +Uses real PostgreSQL (via ``db_session`` / ``tenant_context`` fixtures). +The per-file ``*_impl`` functions are mocked so no real file store or +connector is needed — we only verify that recovery finds and dispatches +the correct files. +""" + +from collections.abc import Generator +from unittest.mock import MagicMock +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from sqlalchemy.orm import Session + +from onyx.background.periodic_poller import recover_stuck_user_files +from onyx.db.enums import UserFileStatus +from onyx.db.models import UserFile +from tests.external_dependency_unit.conftest import create_test_user +from tests.external_dependency_unit.constants import TEST_TENANT_ID + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_IMPL_MODULE = "onyx.background.celery.tasks.user_file_processing.tasks" + + +def _create_user_file( + db_session: Session, + user_id: object, + *, + status: UserFileStatus = UserFileStatus.PROCESSING, + needs_project_sync: bool = False, + needs_persona_sync: bool = False, +) -> UserFile: + uf = UserFile( + id=uuid4(), + user_id=user_id, + file_id=f"test_file_{uuid4().hex[:8]}", + name=f"test_{uuid4().hex[:8]}.txt", + file_type="text/plain", + status=status, + needs_project_sync=needs_project_sync, + needs_persona_sync=needs_persona_sync, + ) + db_session.add(uf) + db_session.commit() + db_session.refresh(uf) + return uf + + +@pytest.fixture() +def _cleanup_user_files(db_session: Session) -> Generator[list[UserFile], None, None]: + """Track created UserFile rows and delete them after each test.""" + created: list[UserFile] = [] + yield created + for uf in created: + existing = db_session.get(UserFile, uf.id) + if existing: + db_session.delete(existing) + db_session.commit() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestRecoverProcessingFiles: + """Files in PROCESSING status are re-processed via the processing drain loop.""" + + def test_processing_files_recovered( + self, + db_session: Session, + tenant_context: None, # noqa: ARG002 + _cleanup_user_files: list[UserFile], + ) -> None: + user = create_test_user(db_session, "recovery_proc") + uf = _create_user_file(db_session, user.id, status=UserFileStatus.PROCESSING) + _cleanup_user_files.append(uf) + + mock_impl = MagicMock() + with patch(f"{_IMPL_MODULE}.process_user_file_impl", mock_impl): + recover_stuck_user_files(TEST_TENANT_ID) + + called_ids = [call.kwargs["user_file_id"] for call in mock_impl.call_args_list] + assert ( + str(uf.id) in called_ids + ), f"Expected file {uf.id} to be recovered but got: {called_ids}" + + def test_completed_files_not_recovered( + self, + db_session: Session, + tenant_context: None, # noqa: ARG002 + _cleanup_user_files: list[UserFile], + ) -> None: + user = create_test_user(db_session, "recovery_comp") + uf = _create_user_file(db_session, user.id, status=UserFileStatus.COMPLETED) + _cleanup_user_files.append(uf) + + mock_impl = MagicMock() + with patch(f"{_IMPL_MODULE}.process_user_file_impl", mock_impl): + recover_stuck_user_files(TEST_TENANT_ID) + + called_ids = [call.kwargs["user_file_id"] for call in mock_impl.call_args_list] + assert ( + str(uf.id) not in called_ids + ), f"COMPLETED file {uf.id} should not have been recovered" + + +class TestRecoverDeletingFiles: + """Files in DELETING status are recovered via the delete drain loop.""" + + def test_deleting_files_recovered( + self, + db_session: Session, + tenant_context: None, # noqa: ARG002 + _cleanup_user_files: list[UserFile], + ) -> None: + user = create_test_user(db_session, "recovery_del") + uf = _create_user_file(db_session, user.id, status=UserFileStatus.DELETING) + _cleanup_user_files.append(uf) + + mock_impl = MagicMock() + with patch(f"{_IMPL_MODULE}.delete_user_file_impl", mock_impl): + recover_stuck_user_files(TEST_TENANT_ID) + + called_ids = [call.kwargs["user_file_id"] for call in mock_impl.call_args_list] + assert ( + str(uf.id) in called_ids + ), f"Expected file {uf.id} to be recovered for deletion but got: {called_ids}" + + +class TestRecoverSyncFiles: + """Files needing project/persona sync are recovered via the sync drain loop.""" + + def test_needs_project_sync_recovered( + self, + db_session: Session, + tenant_context: None, # noqa: ARG002 + _cleanup_user_files: list[UserFile], + ) -> None: + user = create_test_user(db_session, "recovery_sync") + uf = _create_user_file( + db_session, + user.id, + status=UserFileStatus.COMPLETED, + needs_project_sync=True, + ) + _cleanup_user_files.append(uf) + + mock_impl = MagicMock() + with patch(f"{_IMPL_MODULE}.project_sync_user_file_impl", mock_impl): + recover_stuck_user_files(TEST_TENANT_ID) + + called_ids = [call.kwargs["user_file_id"] for call in mock_impl.call_args_list] + assert ( + str(uf.id) in called_ids + ), f"Expected file {uf.id} to be recovered for sync but got: {called_ids}" + + def test_needs_persona_sync_recovered( + self, + db_session: Session, + tenant_context: None, # noqa: ARG002 + _cleanup_user_files: list[UserFile], + ) -> None: + user = create_test_user(db_session, "recovery_psync") + uf = _create_user_file( + db_session, + user.id, + status=UserFileStatus.COMPLETED, + needs_persona_sync=True, + ) + _cleanup_user_files.append(uf) + + mock_impl = MagicMock() + with patch(f"{_IMPL_MODULE}.project_sync_user_file_impl", mock_impl): + recover_stuck_user_files(TEST_TENANT_ID) + + called_ids = [call.kwargs["user_file_id"] for call in mock_impl.call_args_list] + assert ( + str(uf.id) in called_ids + ), f"Expected file {uf.id} to be recovered for persona sync but got: {called_ids}" + + +class TestRecoveryMultipleFiles: + """Recovery processes all stuck files in one pass, not just the first.""" + + def test_multiple_processing_files( + self, + db_session: Session, + tenant_context: None, # noqa: ARG002 + _cleanup_user_files: list[UserFile], + ) -> None: + user = create_test_user(db_session, "recovery_multi") + files = [] + for _ in range(3): + uf = _create_user_file( + db_session, user.id, status=UserFileStatus.PROCESSING + ) + _cleanup_user_files.append(uf) + files.append(uf) + + mock_impl = MagicMock() + with patch(f"{_IMPL_MODULE}.process_user_file_impl", mock_impl): + recover_stuck_user_files(TEST_TENANT_ID) + + called_ids = {call.kwargs["user_file_id"] for call in mock_impl.call_args_list} + expected_ids = {str(uf.id) for uf in files} + assert expected_ids.issubset(called_ids), ( + f"Expected all {len(files)} files to be recovered. " + f"Missing: {expected_ids - called_ids}" + ) From 3fb4f5d6e6975348968a72ecd1baea70105be9f1 Mon Sep 17 00:00:00 2001 From: Raunak Bhagat Date: Mon, 2 Mar 2026 07:11:32 -0800 Subject: [PATCH 004/267] refactor(opal): split ContentLg into ContentXl + ContentLg (#8904) --- web/lib/opal/src/components/Tag/README.md | 2 +- .../opal/src/layouts/Content/ContentLg.tsx | 198 +++++++++++++ .../opal/src/layouts/Content/ContentMd.tsx | 279 ++++++++++++++++++ .../opal/src/layouts/Content/ContentSm.tsx | 129 ++++++++ .../opal/src/layouts/Content/ContentXl.tsx | 256 ++++++++++++++++ web/lib/opal/src/layouts/Content/README.md | 57 ++-- .../opal/src/layouts/Content/components.tsx | 100 ++++--- web/lib/opal/src/layouts/Content/styles.css | 194 +++++++++--- web/lib/opal/src/layouts/README.md | 9 +- web/lib/opal/src/shared.ts | 2 +- 10 files changed, 1122 insertions(+), 104 deletions(-) create mode 100644 web/lib/opal/src/layouts/Content/ContentLg.tsx create mode 100644 web/lib/opal/src/layouts/Content/ContentMd.tsx create mode 100644 web/lib/opal/src/layouts/Content/ContentSm.tsx create mode 100644 web/lib/opal/src/layouts/Content/ContentXl.tsx diff --git a/web/lib/opal/src/components/Tag/README.md b/web/lib/opal/src/components/Tag/README.md index 9a4ad7ef05e..6bfd5feb828 100644 --- a/web/lib/opal/src/components/Tag/README.md +++ b/web/lib/opal/src/components/Tag/README.md @@ -42,7 +42,7 @@ import SvgStar from "@opal/icons/star"; ## Usage inside Content -Tag can be rendered as an accessory inside `Content`'s LabelLayout via the `tag` prop: +Tag can be rendered as an accessory inside `Content`'s ContentMd via the `tag` prop: ```tsx import { Content } from "@opal/layouts"; diff --git a/web/lib/opal/src/layouts/Content/ContentLg.tsx b/web/lib/opal/src/layouts/Content/ContentLg.tsx new file mode 100644 index 00000000000..8d26674e2b8 --- /dev/null +++ b/web/lib/opal/src/layouts/Content/ContentLg.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { Button } from "@opal/components/buttons/Button/components"; +import type { SizeVariant } from "@opal/shared"; +import SvgEdit from "@opal/icons/edit"; +import type { IconFunctionComponent } from "@opal/types"; +import { cn } from "@opal/utils"; +import { useState } from "react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ContentLgSizePreset = "headline" | "section"; + +interface ContentLgPresetConfig { + /** Icon width/height (CSS value). */ + iconSize: string; + /** Tailwind padding class for the icon container. */ + iconContainerPadding: string; + /** Gap between icon container and content (CSS value). */ + gap: string; + /** Tailwind font class for the title. */ + titleFont: string; + /** Title line-height — also used as icon container min-height (CSS value). */ + lineHeight: string; + /** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */ + editButtonSize: SizeVariant; + /** Tailwind padding class for the edit button container. */ + editButtonPadding: string; +} + +interface ContentLgProps { + /** Optional icon component. */ + icon?: IconFunctionComponent; + + /** Main title text. */ + title: string; + + /** Optional description below the title. */ + description?: string; + + /** Enable inline editing of the title. */ + editable?: boolean; + + /** Called when the user commits an edit. */ + onTitleChange?: (newTitle: string) => void; + + /** Size preset. Default: `"headline"`. */ + sizePreset?: ContentLgSizePreset; +} + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +const CONTENT_LG_PRESETS: Record = { + headline: { + iconSize: "2rem", + iconContainerPadding: "p-0.5", + gap: "0.25rem", + titleFont: "font-heading-h2", + lineHeight: "2.25rem", + editButtonSize: "md", + editButtonPadding: "p-1", + }, + section: { + iconSize: "1.25rem", + iconContainerPadding: "p-1", + gap: "0rem", + titleFont: "font-heading-h3-muted", + lineHeight: "1.75rem", + editButtonSize: "sm", + editButtonPadding: "p-0.5", + }, +}; + +// --------------------------------------------------------------------------- +// ContentLg +// --------------------------------------------------------------------------- + +function ContentLg({ + sizePreset = "headline", + icon: Icon, + title, + description, + editable, + onTitleChange, +}: ContentLgProps) { + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(title); + + const config = CONTENT_LG_PRESETS[sizePreset]; + + function startEditing() { + setEditValue(title); + setEditing(true); + } + + function commit() { + const value = editValue.trim(); + if (value && value !== title) onTitleChange?.(value); + setEditing(false); + } + + return ( +
+ {Icon && ( +
+ +
+ )} + +
+
+ {editing ? ( +
+ + {editValue || "\u00A0"} + + setEditValue(e.target.value)} + size={1} + autoFocus + onFocus={(e) => e.currentTarget.select()} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { + setEditValue(title); + setEditing(false); + } + }} + style={{ height: config.lineHeight }} + /> +
+ ) : ( + + {title} + + )} + + {editable && !editing && ( +
+
+ )} +
+ + {description && ( +
+ {description} +
+ )} +
+
+ ); +} + +export { ContentLg, type ContentLgProps, type ContentLgSizePreset }; diff --git a/web/lib/opal/src/layouts/Content/ContentMd.tsx b/web/lib/opal/src/layouts/Content/ContentMd.tsx new file mode 100644 index 00000000000..44b55b3e44a --- /dev/null +++ b/web/lib/opal/src/layouts/Content/ContentMd.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { Button } from "@opal/components/buttons/Button/components"; +import { Tag, type TagProps } from "@opal/components/Tag/components"; +import type { SizeVariant } from "@opal/shared"; +import SvgAlertCircle from "@opal/icons/alert-circle"; +import SvgAlertTriangle from "@opal/icons/alert-triangle"; +import SvgEdit from "@opal/icons/edit"; +import SvgXOctagon from "@opal/icons/x-octagon"; +import type { IconFunctionComponent } from "@opal/types"; +import { cn } from "@opal/utils"; +import { useRef, useState } from "react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ContentMdSizePreset = "main-content" | "main-ui" | "secondary"; + +type ContentMdAuxIcon = "info-gray" | "info-blue" | "warning" | "error"; + +interface ContentMdPresetConfig { + iconSize: string; + iconContainerPadding: string; + iconColorClass: string; + titleFont: string; + lineHeight: string; + gap: string; + /** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */ + editButtonSize: SizeVariant; + editButtonPadding: string; + optionalFont: string; + /** Aux icon size = lineHeight − 2 × p-0.5. */ + auxIconSize: string; +} + +interface ContentMdProps { + /** Optional icon component. */ + icon?: IconFunctionComponent; + + /** Main title text. */ + title: string; + + /** Optional description text below the title. */ + description?: string; + + /** Enable inline editing of the title. */ + editable?: boolean; + + /** Called when the user commits an edit. */ + onTitleChange?: (newTitle: string) => void; + + /** When `true`, renders "(Optional)" beside the title. */ + optional?: boolean; + + /** Auxiliary status icon rendered beside the title. */ + auxIcon?: ContentMdAuxIcon; + + /** Tag rendered beside the title. */ + tag?: TagProps; + + /** Size preset. Default: `"main-ui"`. */ + sizePreset?: ContentMdSizePreset; +} + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +const CONTENT_MD_PRESETS: Record = { + "main-content": { + iconSize: "1rem", + iconContainerPadding: "p-1", + iconColorClass: "text-text-04", + titleFont: "font-main-content-emphasis", + lineHeight: "1.5rem", + gap: "0.125rem", + editButtonSize: "sm", + editButtonPadding: "p-0", + optionalFont: "font-main-content-muted", + auxIconSize: "1.25rem", + }, + "main-ui": { + iconSize: "1rem", + iconContainerPadding: "p-0.5", + iconColorClass: "text-text-03", + titleFont: "font-main-ui-action", + lineHeight: "1.25rem", + gap: "0.25rem", + editButtonSize: "xs", + editButtonPadding: "p-0", + optionalFont: "font-main-ui-muted", + auxIconSize: "1rem", + }, + secondary: { + iconSize: "0.75rem", + iconContainerPadding: "p-0.5", + iconColorClass: "text-text-04", + titleFont: "font-secondary-action", + lineHeight: "1rem", + gap: "0.125rem", + editButtonSize: "2xs", + editButtonPadding: "p-0", + optionalFont: "font-secondary-action", + auxIconSize: "0.75rem", + }, +}; + +// --------------------------------------------------------------------------- +// ContentMd +// --------------------------------------------------------------------------- + +const AUX_ICON_CONFIG: Record< + ContentMdAuxIcon, + { icon: IconFunctionComponent; colorClass: string } +> = { + "info-gray": { icon: SvgAlertCircle, colorClass: "text-text-02" }, + "info-blue": { icon: SvgAlertCircle, colorClass: "text-status-info-05" }, + warning: { icon: SvgAlertTriangle, colorClass: "text-status-warning-05" }, + error: { icon: SvgXOctagon, colorClass: "text-status-error-05" }, +}; + +function ContentMd({ + icon: Icon, + title, + description, + editable, + onTitleChange, + optional, + auxIcon, + tag, + sizePreset = "main-ui", +}: ContentMdProps) { + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(title); + const inputRef = useRef(null); + + const config = CONTENT_MD_PRESETS[sizePreset]; + + function startEditing() { + setEditValue(title); + setEditing(true); + } + + function commit() { + const value = editValue.trim(); + if (value && value !== title) onTitleChange?.(value); + setEditing(false); + } + + return ( +
+ {Icon && ( +
+ +
+ )} + +
+
+ {editing ? ( +
+ + {editValue || "\u00A0"} + + setEditValue(e.target.value)} + size={1} + autoFocus + onFocus={(e) => e.currentTarget.select()} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { + setEditValue(title); + setEditing(false); + } + }} + style={{ height: config.lineHeight }} + /> +
+ ) : ( + + {title} + + )} + + {optional && ( + + (Optional) + + )} + + {auxIcon && + (() => { + const { icon: AuxIcon, colorClass } = AUX_ICON_CONFIG[auxIcon]; + return ( +
+ +
+ ); + })()} + + {tag && } + + {editable && !editing && ( +
+
+ )} +
+ + {description && ( +
+ {description} +
+ )} +
+
+ ); +} + +export { + ContentMd, + type ContentMdProps, + type ContentMdSizePreset, + type ContentMdAuxIcon, +}; diff --git a/web/lib/opal/src/layouts/Content/ContentSm.tsx b/web/lib/opal/src/layouts/Content/ContentSm.tsx new file mode 100644 index 00000000000..ec5b1e8aa42 --- /dev/null +++ b/web/lib/opal/src/layouts/Content/ContentSm.tsx @@ -0,0 +1,129 @@ +"use client"; + +import type { IconFunctionComponent } from "@opal/types"; +import { cn } from "@opal/utils"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ContentSmSizePreset = "main-content" | "main-ui" | "secondary"; +type ContentSmOrientation = "vertical" | "inline" | "reverse"; +type ContentSmProminence = "default" | "muted"; + +interface ContentSmPresetConfig { + /** Icon width/height (CSS value). */ + iconSize: string; + /** Tailwind padding class for the icon container. */ + iconContainerPadding: string; + /** Tailwind font class for the title. */ + titleFont: string; + /** Title line-height — also used as icon container min-height (CSS value). */ + lineHeight: string; + /** Gap between icon container and title (CSS value). */ + gap: string; +} + +/** Props for {@link ContentSm}. Does not support editing or descriptions. */ +interface ContentSmProps { + /** Optional icon component. */ + icon?: IconFunctionComponent; + + /** Main title text (read-only — editing is not supported). */ + title: string; + + /** Size preset. Default: `"main-ui"`. */ + sizePreset?: ContentSmSizePreset; + + /** Layout orientation. Default: `"inline"`. */ + orientation?: ContentSmOrientation; + + /** Title prominence. Default: `"default"`. */ + prominence?: ContentSmProminence; +} + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +const CONTENT_SM_PRESETS: Record = { + "main-content": { + iconSize: "1rem", + iconContainerPadding: "p-1", + titleFont: "font-main-content-body", + lineHeight: "1.5rem", + gap: "0.125rem", + }, + "main-ui": { + iconSize: "1rem", + iconContainerPadding: "p-0.5", + titleFont: "font-main-ui-action", + lineHeight: "1.25rem", + gap: "0.25rem", + }, + secondary: { + iconSize: "0.75rem", + iconContainerPadding: "p-0.5", + titleFont: "font-secondary-action", + lineHeight: "1rem", + gap: "0.125rem", + }, +}; + +// --------------------------------------------------------------------------- +// ContentSm +// --------------------------------------------------------------------------- + +function ContentSm({ + icon: Icon, + title, + sizePreset = "main-ui", + orientation = "inline", + prominence = "default", +}: ContentSmProps) { + const config = CONTENT_SM_PRESETS[sizePreset]; + const titleColorClass = + prominence === "muted" ? "text-text-03" : "text-text-04"; + + return ( +
+ {Icon && ( +
+ +
+ )} + + + {title} + +
+ ); +} + +export { + ContentSm, + type ContentSmProps, + type ContentSmSizePreset, + type ContentSmOrientation, + type ContentSmProminence, +}; diff --git a/web/lib/opal/src/layouts/Content/ContentXl.tsx b/web/lib/opal/src/layouts/Content/ContentXl.tsx new file mode 100644 index 00000000000..08b36fd56d3 --- /dev/null +++ b/web/lib/opal/src/layouts/Content/ContentXl.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { Button } from "@opal/components/buttons/Button/components"; +import type { SizeVariant } from "@opal/shared"; +import SvgEdit from "@opal/icons/edit"; +import type { IconFunctionComponent } from "@opal/types"; +import { cn } from "@opal/utils"; +import { useState } from "react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ContentXlSizePreset = "headline" | "section"; + +interface ContentXlPresetConfig { + /** Icon width/height (CSS value). */ + iconSize: string; + /** Tailwind padding class for the icon container. */ + iconContainerPadding: string; + /** More-icon-1 width/height (CSS value). */ + moreIcon1Size: string; + /** Tailwind padding class for the more-icon-1 container. */ + moreIcon1ContainerPadding: string; + /** More-icon-2 width/height (CSS value). */ + moreIcon2Size: string; + /** Tailwind padding class for the more-icon-2 container. */ + moreIcon2ContainerPadding: string; + /** Tailwind font class for the title. */ + titleFont: string; + /** Title line-height — also used as icon container min-height (CSS value). */ + lineHeight: string; + /** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */ + editButtonSize: SizeVariant; + /** Tailwind padding class for the edit button container. */ + editButtonPadding: string; +} + +interface ContentXlProps { + /** Optional icon component. */ + icon?: IconFunctionComponent; + + /** Main title text. */ + title: string; + + /** Optional description below the title. */ + description?: string; + + /** Enable inline editing of the title. */ + editable?: boolean; + + /** Called when the user commits an edit. */ + onTitleChange?: (newTitle: string) => void; + + /** Size preset. Default: `"headline"`. */ + sizePreset?: ContentXlSizePreset; + + /** Optional secondary icon rendered in the icon row. */ + moreIcon1?: IconFunctionComponent; + + /** Optional tertiary icon rendered in the icon row. */ + moreIcon2?: IconFunctionComponent; +} + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +const CONTENT_XL_PRESETS: Record = { + headline: { + iconSize: "2rem", + iconContainerPadding: "p-0.5", + moreIcon1Size: "1rem", + moreIcon1ContainerPadding: "p-0.5", + moreIcon2Size: "2rem", + moreIcon2ContainerPadding: "p-0.5", + titleFont: "font-heading-h2", + lineHeight: "2.25rem", + editButtonSize: "md", + editButtonPadding: "p-1", + }, + section: { + iconSize: "1.5rem", + iconContainerPadding: "p-0.5", + moreIcon1Size: "0.75rem", + moreIcon1ContainerPadding: "p-0.5", + moreIcon2Size: "1.5rem", + moreIcon2ContainerPadding: "p-0.5", + titleFont: "font-heading-h3", + lineHeight: "1.75rem", + editButtonSize: "sm", + editButtonPadding: "p-0.5", + }, +}; + +// --------------------------------------------------------------------------- +// ContentXl +// --------------------------------------------------------------------------- + +function ContentXl({ + sizePreset = "headline", + icon: Icon, + title, + description, + editable, + onTitleChange, + moreIcon1: MoreIcon1, + moreIcon2: MoreIcon2, +}: ContentXlProps) { + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(title); + + const config = CONTENT_XL_PRESETS[sizePreset]; + + function startEditing() { + setEditValue(title); + setEditing(true); + } + + function commit() { + const value = editValue.trim(); + if (value && value !== title) onTitleChange?.(value); + setEditing(false); + } + + return ( +
+ {(Icon || MoreIcon1 || MoreIcon2) && ( +
+ {Icon && ( +
+ +
+ )} + + {MoreIcon1 && ( +
+ +
+ )} + + {MoreIcon2 && ( +
+ +
+ )} +
+ )} + +
+
+ {editing ? ( +
+ + {editValue || "\u00A0"} + + setEditValue(e.target.value)} + size={1} + autoFocus + onFocus={(e) => e.currentTarget.select()} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") { + setEditValue(title); + setEditing(false); + } + }} + style={{ height: config.lineHeight }} + /> +
+ ) : ( + + {title} + + )} + + {editable && !editing && ( +
+
+ )} +
+ + {description && ( +
+ {description} +
+ )} +
+
+ ); +} + +export { ContentXl, type ContentXlProps, type ContentXlSizePreset }; diff --git a/web/lib/opal/src/layouts/Content/README.md b/web/lib/opal/src/layouts/Content/README.md index bc711ed166e..b0c7e0b0f4c 100644 --- a/web/lib/opal/src/layouts/Content/README.md +++ b/web/lib/opal/src/layouts/Content/README.md @@ -8,14 +8,21 @@ A two-axis layout component for displaying icon + title + description rows. Rout ### `sizePreset` — controls sizing (icon, padding, gap, font) -#### HeadingLayout presets +#### ContentXl presets (variant="heading") + +| Preset | Icon | Icon padding | moreIcon1 | mI1 padding | moreIcon2 | mI2 padding | Title font | Line-height | +|---|---|---|---|---|---|---|---|---| +| `headline` | 2rem (32px) | `p-0.5` (2px) | 1rem (16px) | `p-0.5` (2px) | 2rem (32px) | `p-0.5` (2px) | `font-heading-h2` | 2.25rem (36px) | +| `section` | 1.5rem (24px) | `p-0.5` (2px) | 0.75rem (12px) | `p-0.5` (2px) | 1.5rem (24px) | `p-0.5` (2px) | `font-heading-h3` | 1.75rem (28px) | + +#### ContentLg presets (variant="section") | Preset | Icon | Icon padding | Gap | Title font | Line-height | |---|---|---|---|---|---| | `headline` | 2rem (32px) | `p-0.5` (2px) | 0.25rem (4px) | `font-heading-h2` | 2.25rem (36px) | -| `section` | 1.25rem (20px) | `p-1` (4px) | 0rem | `font-heading-h3` | 1.75rem (28px) | +| `section` | 1.25rem (20px) | `p-1` (4px) | 0rem | `font-heading-h3-muted` | 1.75rem (28px) | -#### LabelLayout presets +#### ContentMd presets | Preset | Icon | Icon padding | Icon color | Gap | Title font | Line-height | |---|---|---|---|---|---|---| @@ -29,18 +36,18 @@ A two-axis layout component for displaying icon + title + description rows. Rout | variant | Description | |---|---| -| `heading` | Icon on **top** (flex-col) — HeadingLayout | -| `section` | Icon **inline** (flex-row) — HeadingLayout or LabelLayout | -| `body` | Body text layout — BodyLayout (future) | +| `heading` | Icon on **top** (flex-col) — ContentXl | +| `section` | Icon **inline** (flex-row) — ContentLg or ContentMd | +| `body` | Body text layout — ContentSm | ### Valid Combinations -> Internal Routing | sizePreset | variant | Routes to | |---|---|---| -| `headline` / `section` | `heading` | **HeadingLayout** (icon on top) | -| `headline` / `section` | `section` | **HeadingLayout** (icon inline) | -| `main-content` / `main-ui` / `secondary` | `section` | **LabelLayout** | -| `main-content` / `main-ui` / `secondary` | `body` | BodyLayout (future) | +| `headline` / `section` | `heading` | **ContentXl** (icon on top) | +| `headline` / `section` | `section` | **ContentLg** (icon inline) | +| `main-content` / `main-ui` / `secondary` | `section` | **ContentMd** | +| `main-content` / `main-ui` / `secondary` | `body` | **ContentSm** | Invalid combinations (e.g. `sizePreset="headline" + variant="body"`) are excluded at the type level. @@ -55,14 +62,20 @@ Invalid combinations (e.g. `sizePreset="headline" + variant="body"`) are exclude | `description` | `string` | — | Optional description below the title | | `editable` | `boolean` | `false` | Enable inline editing of the title | | `onTitleChange` | `(newTitle: string) => void` | — | Called when user commits an edit | +| `moreIcon1` | `IconFunctionComponent` | — | Secondary icon in icon row (ContentXl only) | +| `moreIcon2` | `IconFunctionComponent` | — | Tertiary icon in icon row (ContentXl only) | ## Internal Layouts -### HeadingLayout +### ContentXl + +For `headline` / `section` presets with `variant="heading"`. Icon row on top (flex-col), supports `moreIcon1` and `moreIcon2` in the icon row. Description is always `font-secondary-body text-text-03`. -For `headline` / `section` presets. Supports `variant="heading"` (icon on top) and `variant="section"` (icon inline). Description is always `font-secondary-body text-text-03`. +### ContentLg -### LabelLayout +For `headline` / `section` presets with `variant="section"`. Always inline (flex-row). Description is always `font-secondary-body text-text-03`. + +### ContentMd For `main-content` / `main-ui` / `secondary` presets. Always inline. Both `icon` and `description` are optional. Description is always `font-secondary-body text-text-03`. @@ -72,7 +85,7 @@ For `main-content` / `main-ui` / `secondary` presets. Always inline. Both `icon` import { Content } from "@opal/layouts"; import SvgSearch from "@opal/icons/search"; -// HeadingLayout — headline, icon on top +// ContentXl — headline, icon on top -// HeadingLayout — section, icon inline +// ContentXl — with more icons + + +// ContentLg — section, icon inline -// LabelLayout — with icon and description +// ContentMd — with icon and description -// LabelLayout — title only (no icon, no description) +// ContentMd — title only (no icon, no description) & { sizePreset: "main-content" | "main-ui" | "secondary"; variant: "body"; /** Layout orientation. Default: `"inline"`. */ - orientation?: BodyOrientation; + orientation?: ContentSmOrientation; /** Title prominence. Default: `"default"`. */ - prominence?: BodyProminence; + prominence?: ContentSmProminence; }; -type ContentProps = HeadingContentProps | LabelContentProps | BodyContentProps; +type ContentProps = + | XlContentProps + | LgContentProps + | MdContentProps + | SmContentProps; // --------------------------------------------------------------------------- // Content — routes to the appropriate internal layout @@ -111,34 +129,43 @@ function Content(props: ContentProps) { let layout: React.ReactNode = null; - // Heading layout: headline/section presets with heading/section variant + // ContentXl / ContentLg: headline/section presets if (sizePreset === "headline" || sizePreset === "section") { - layout = ( - - ); + if (variant === "heading") { + layout = ( + )} + /> + ); + } else { + layout = ( + )} + /> + ); + } } - // Label layout: main-content/main-ui/secondary with section variant + // ContentMd: main-content/main-ui/secondary with section/heading variant + // (variant defaults to "heading" when omitted on MdContentProps, so both arms are needed) else if (variant === "section" || variant === "heading") { layout = ( - )} + {...(rest as Omit)} /> ); } - // Body layout: main-content/main-ui/secondary with body variant + // ContentSm: main-content/main-ui/secondary with body variant else if (variant === "body") { layout = ( - , + React.ComponentProps, "sizePreset" >)} /> @@ -167,7 +194,8 @@ export { type ContentProps, type SizePreset, type ContentVariant, - type HeadingContentProps, - type LabelContentProps, - type BodyContentProps, + type XlContentProps, + type LgContentProps, + type MdContentProps, + type SmContentProps, }; diff --git a/web/lib/opal/src/layouts/Content/styles.css b/web/lib/opal/src/layouts/Content/styles.css index e1621a66421..7aceb2305af 100644 --- a/web/lib/opal/src/layouts/Content/styles.css +++ b/web/lib/opal/src/layouts/Content/styles.css @@ -1,41 +1,145 @@ -/* --------------------------------------------------------------------------- - Content — HeadingLayout +/* =========================================================================== + Content — ContentXl - Two icon placement modes (driven by variant): - left (variant="section") : flex-row — icon beside content - top (variant="heading") : flex-col — icon above content + Icon row on top (flex-col). Icon row contains main icon + optional + moreIcon1 / moreIcon2 in a flex-row. Sizing (icon size, gap, padding, font, line-height) is driven by the sizePreset prop via inline styles + Tailwind classes in the component. + =========================================================================== */ + +/* --------------------------------------------------------------------------- + Layout — flex-col (icon row above body) --------------------------------------------------------------------------- */ +.opal-content-xl { + @apply flex flex-col items-start; +} + /* --------------------------------------------------------------------------- - Layout — icon placement + Icon row — flex-row containing main icon + more icons --------------------------------------------------------------------------- */ -.opal-content-heading { - @apply flex items-start; +.opal-content-xl-icon-row { + @apply flex flex-row items-center; } -.opal-content-heading[data-icon-placement="left"] { - @apply flex-row; +/* --------------------------------------------------------------------------- + Icons + --------------------------------------------------------------------------- */ + +.opal-content-xl-icon-container { + display: flex; + align-items: center; + justify-content: center; } -.opal-content-heading[data-icon-placement="top"] { - @apply flex-col; +.opal-content-xl-more-icon-container { + display: flex; + align-items: center; + justify-content: center; +} + +.opal-content-xl-icon { + color: var(--text-04); +} + +/* --------------------------------------------------------------------------- + Body column + --------------------------------------------------------------------------- */ + +.opal-content-xl-body { + @apply flex flex-1 flex-col items-start; + min-width: 0.0625rem; +} + +/* --------------------------------------------------------------------------- + Title row — title (or input) + edit button + --------------------------------------------------------------------------- */ + +.opal-content-xl-title-row { + @apply flex items-center w-full; + gap: 0.25rem; +} + +.opal-content-xl-title { + @apply text-left overflow-hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + padding: 0 0.125rem; + min-width: 0.0625rem; +} + +.opal-content-xl-input-sizer { + display: inline-grid; + align-items: stretch; +} + +.opal-content-xl-input-sizer > * { + grid-area: 1 / 1; + padding: 0 0.125rem; + min-width: 0.0625rem; +} + +.opal-content-xl-input-mirror { + visibility: hidden; + white-space: pre; +} + +.opal-content-xl-input { + @apply bg-transparent outline-none border-none; +} + +/* --------------------------------------------------------------------------- + Edit button — visible only on hover of the outer container + --------------------------------------------------------------------------- */ + +.opal-content-xl-edit-button { + @apply opacity-0 transition-opacity shrink-0; +} + +.opal-content-xl:hover .opal-content-xl-edit-button { + @apply opacity-100; +} + +/* --------------------------------------------------------------------------- + Description + --------------------------------------------------------------------------- */ + +.opal-content-xl-description { + @apply text-left w-full; + padding: 0 0.125rem; +} + +/* =========================================================================== + Content — ContentLg + + Always inline (flex-row) — icon beside content. + + Sizing (icon size, gap, padding, font, line-height) is driven by the + sizePreset prop via inline styles + Tailwind classes in the component. + =========================================================================== */ + +/* --------------------------------------------------------------------------- + Layout + --------------------------------------------------------------------------- */ + +.opal-content-lg { + @apply flex flex-row items-start; } /* --------------------------------------------------------------------------- Icon --------------------------------------------------------------------------- */ -.opal-content-heading-icon-container { +.opal-content-lg-icon-container { display: flex; align-items: center; justify-content: center; } -.opal-content-heading-icon { +.opal-content-lg-icon { color: var(--text-04); } @@ -43,7 +147,7 @@ Body column --------------------------------------------------------------------------- */ -.opal-content-heading-body { +.opal-content-lg-body { @apply flex flex-1 flex-col items-start; min-width: 0.0625rem; } @@ -52,12 +156,12 @@ Title row — title (or input) + edit button --------------------------------------------------------------------------- */ -.opal-content-heading-title-row { +.opal-content-lg-title-row { @apply flex items-center w-full; gap: 0.25rem; } -.opal-content-heading-title { +.opal-content-lg-title { @apply text-left overflow-hidden; display: -webkit-box; -webkit-box-orient: vertical; @@ -66,23 +170,23 @@ min-width: 0.0625rem; } -.opal-content-heading-input-sizer { +.opal-content-lg-input-sizer { display: inline-grid; align-items: stretch; } -.opal-content-heading-input-sizer > * { +.opal-content-lg-input-sizer > * { grid-area: 1 / 1; padding: 0 0.125rem; min-width: 0.0625rem; } -.opal-content-heading-input-mirror { +.opal-content-lg-input-mirror { visibility: hidden; white-space: pre; } -.opal-content-heading-input { +.opal-content-lg-input { @apply bg-transparent outline-none border-none; } @@ -90,11 +194,11 @@ Edit button — visible only on hover of the outer container --------------------------------------------------------------------------- */ -.opal-content-heading-edit-button { +.opal-content-lg-edit-button { @apply opacity-0 transition-opacity shrink-0; } -.opal-content-heading:hover .opal-content-heading-edit-button { +.opal-content-lg:hover .opal-content-lg-edit-button { @apply opacity-100; } @@ -102,13 +206,13 @@ Description --------------------------------------------------------------------------- */ -.opal-content-heading-description { +.opal-content-lg-description { @apply text-left w-full; padding: 0 0.125rem; } /* =========================================================================== - Content — LabelLayout + Content — ContentMd Always inline (flex-row). Icon color varies per sizePreset and is applied via Tailwind class from the component. @@ -118,7 +222,7 @@ Layout --------------------------------------------------------------------------- */ -.opal-content-label { +.opal-content-md { @apply flex flex-row items-start; } @@ -126,7 +230,7 @@ Icon --------------------------------------------------------------------------- */ -.opal-content-label-icon-container { +.opal-content-md-icon-container { display: flex; align-items: center; justify-content: center; @@ -136,7 +240,7 @@ Body column --------------------------------------------------------------------------- */ -.opal-content-label-body { +.opal-content-md-body { @apply flex flex-1 flex-col items-start; min-width: 0.0625rem; } @@ -145,12 +249,12 @@ Title row — title (or input) + edit button --------------------------------------------------------------------------- */ -.opal-content-label-title-row { +.opal-content-md-title-row { @apply flex items-center w-full; gap: 0.25rem; } -.opal-content-label-title { +.opal-content-md-title { @apply text-left overflow-hidden; display: -webkit-box; -webkit-box-orient: vertical; @@ -159,23 +263,23 @@ min-width: 0.0625rem; } -.opal-content-label-input-sizer { +.opal-content-md-input-sizer { display: inline-grid; align-items: stretch; } -.opal-content-label-input-sizer > * { +.opal-content-md-input-sizer > * { grid-area: 1 / 1; padding: 0 0.125rem; min-width: 0.0625rem; } -.opal-content-label-input-mirror { +.opal-content-md-input-mirror { visibility: hidden; white-space: pre; } -.opal-content-label-input { +.opal-content-md-input { @apply bg-transparent outline-none border-none; } @@ -183,7 +287,7 @@ Aux icon --------------------------------------------------------------------------- */ -.opal-content-label-aux-icon { +.opal-content-md-aux-icon { display: flex; align-items: center; justify-content: center; @@ -193,11 +297,11 @@ Edit button — visible only on hover of the outer container --------------------------------------------------------------------------- */ -.opal-content-label-edit-button { +.opal-content-md-edit-button { @apply opacity-0 transition-opacity shrink-0; } -.opal-content-label:hover .opal-content-label-edit-button { +.opal-content-md:hover .opal-content-md-edit-button { @apply opacity-100; } @@ -205,13 +309,13 @@ Description --------------------------------------------------------------------------- */ -.opal-content-label-description { +.opal-content-md-description { @apply text-left w-full; padding: 0 0.125rem; } /* =========================================================================== - Content — BodyLayout + Content — ContentSm Three orientation modes (driven by orientation prop): inline : flex-row — icon left, title right @@ -226,19 +330,19 @@ Layout — orientation --------------------------------------------------------------------------- */ -.opal-content-body { +.opal-content-sm { @apply flex items-start; } -.opal-content-body[data-orientation="inline"] { +.opal-content-sm[data-orientation="inline"] { @apply flex-row; } -.opal-content-body[data-orientation="vertical"] { +.opal-content-sm[data-orientation="vertical"] { @apply flex-col; } -.opal-content-body[data-orientation="reverse"] { +.opal-content-sm[data-orientation="reverse"] { @apply flex-row-reverse; } @@ -246,7 +350,7 @@ Icon --------------------------------------------------------------------------- */ -.opal-content-body-icon-container { +.opal-content-sm-icon-container { display: flex; align-items: center; justify-content: center; @@ -256,7 +360,7 @@ Title --------------------------------------------------------------------------- */ -.opal-content-body-title { +.opal-content-sm-title { @apply text-left overflow-hidden; display: -webkit-box; -webkit-box-orient: vertical; diff --git a/web/lib/opal/src/layouts/README.md b/web/lib/opal/src/layouts/README.md index c357b740989..b63c9cec179 100644 --- a/web/lib/opal/src/layouts/README.md +++ b/web/lib/opal/src/layouts/README.md @@ -8,7 +8,7 @@ Layout primitives for composing icon + title + description rows. These component | Component | Description | Docs | |---|---|---| -| [`Content`](./Content/README.md) | Icon + title + description row. Routes to an internal layout (`HeadingLayout`, `LabelLayout`, or `BodyLayout`) based on `sizePreset` and `variant`. | [Content README](./Content/README.md) | +| [`Content`](./Content/README.md) | Icon + title + description row. Routes to an internal layout (`ContentXl`, `ContentLg`, `ContentMd`, or `ContentSm`) based on `sizePreset` and `variant`. | [Content README](./Content/README.md) | | [`ContentAction`](./ContentAction/README.md) | Wraps `Content` in a flex-row with an optional `rightChildren` slot for action buttons. Adds padding alignment via the shared `SizeVariant` scale. | [ContentAction README](./ContentAction/README.md) | ## Quick Start @@ -88,6 +88,7 @@ These are not exported — `Content` routes to them automatically: | Layout | Used when | File | |---|---|---| -| `HeadingLayout` | `sizePreset` is `headline` or `section` | `Content/HeadingLayout.tsx` | -| `LabelLayout` | `sizePreset` is `main-content`, `main-ui`, or `secondary` with `variant="section"` | `Content/LabelLayout.tsx` | -| `BodyLayout` | `variant="body"` | `Content/BodyLayout.tsx` | +| `ContentXl` | `sizePreset` is `headline` or `section` with `variant="heading"` | `Content/ContentXl.tsx` | +| `ContentLg` | `sizePreset` is `headline` or `section` with `variant="section"` | `Content/ContentLg.tsx` | +| `ContentMd` | `sizePreset` is `main-content`, `main-ui`, or `secondary` with `variant="section"` | `Content/ContentMd.tsx` | +| `ContentSm` | `variant="body"` | `Content/ContentSm.tsx` | diff --git a/web/lib/opal/src/shared.ts b/web/lib/opal/src/shared.ts index 63a9b51440d..0559d17c0e2 100644 --- a/web/lib/opal/src/shared.ts +++ b/web/lib/opal/src/shared.ts @@ -16,7 +16,7 @@ // - Interactive.Container (height + min-width + padding) // - Button (icon sizing) // - ContentAction (padding only) -// - Content (HeadingLayout / LabelLayout) (edit-button size) +// - Content (ContentXl / ContentLg / ContentMd) (edit-button size) // --------------------------------------------------------------------------- /** From 16c07c8756e3cbc396109fa42202c6ef6d99ab65 Mon Sep 17 00:00:00 2001 From: Jamison Lahman Date: Mon, 2 Mar 2026 09:03:24 -0800 Subject: [PATCH 005/267] feat(desktop): option to hide alt-menu (#8882) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- desktop/src-tauri/src/main.rs | 305 ++++++++++++++++++++++++++++++++-- 1 file changed, 295 insertions(+), 10 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 379b8a8dbf5..392d36bb7dd 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -40,6 +40,8 @@ const TRAY_MENU_OPEN_APP_ID: &str = "tray_open_app"; const TRAY_MENU_OPEN_CHAT_ID: &str = "tray_open_chat"; const TRAY_MENU_SHOW_IN_BAR_ID: &str = "tray_show_in_menu_bar"; const TRAY_MENU_QUIT_ID: &str = "tray_quit"; +const MENU_SHOW_MENU_BAR_ID: &str = "show_menu_bar"; +const MENU_HIDE_DECORATIONS_ID: &str = "hide_window_decorations"; const CHAT_LINK_INTERCEPT_SCRIPT: &str = r##" (() => { if (window.__ONYX_CHAT_LINK_INTERCEPT_INSTALLED__) { @@ -171,25 +173,92 @@ const CHAT_LINK_INTERCEPT_SCRIPT: &str = r##" })(); "##; +#[cfg(not(target_os = "macos"))] +const MENU_KEY_HANDLER_SCRIPT: &str = r#" +(() => { + if (window.__ONYX_MENU_KEY_HANDLER__) return; + window.__ONYX_MENU_KEY_HANDLER__ = true; + + let altHeld = false; + + function invoke(cmd) { + const fn_ = + window.__TAURI__?.core?.invoke || window.__TAURI_INTERNALS__?.invoke; + if (typeof fn_ === 'function') fn_(cmd); + } + + function releaseAltAndHideMenu() { + if (!altHeld) { + return; + } + altHeld = false; + invoke('hide_menu_bar_temporary'); + } + + document.addEventListener('keydown', (e) => { + if (e.key === 'Alt') { + if (!altHeld) { + altHeld = true; + invoke('show_menu_bar_temporarily'); + } + return; + } + if (e.altKey && e.key === 'F1') { + e.preventDefault(); + e.stopPropagation(); + altHeld = false; + invoke('toggle_menu_bar'); + return; + } + }, true); + + document.addEventListener('keyup', (e) => { + if (e.key === 'Alt' && altHeld) { + releaseAltAndHideMenu(); + } + }, true); + + window.addEventListener('blur', () => { + releaseAltAndHideMenu(); + }); + + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + releaseAltAndHideMenu(); + } + }); +})(); +"#; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { - /// The Onyx server URL (default: https://cloud.onyx.app) pub server_url: String, - /// Optional: Custom window title #[serde(default = "default_window_title")] pub window_title: String, + + #[serde(default = "default_show_menu_bar")] + pub show_menu_bar: bool, + + #[serde(default)] + pub hide_window_decorations: bool, } fn default_window_title() -> String { "Onyx".to_string() } +fn default_show_menu_bar() -> bool { + true +} + impl Default for AppConfig { fn default() -> Self { Self { server_url: DEFAULT_SERVER_URL.to_string(), window_title: default_window_title(), + show_menu_bar: true, + hide_window_decorations: false, } } } @@ -247,6 +316,7 @@ struct ConfigState { config: RwLock, config_initialized: RwLock, app_base_url: RwLock>, + menu_temporarily_visible: RwLock, } fn focus_main_window(app: &AppHandle) { @@ -301,6 +371,7 @@ fn trigger_new_window(app: &AppHandle) { inject_titlebar(window.clone()); } + apply_settings_to_window(&handle, &window); let _ = window.set_focus(); } }); @@ -577,18 +648,15 @@ async fn new_window(app: AppHandle, state: tauri::State<'_, ConfigState>) -> Res #[cfg(target_os = "linux")] let builder = builder.background_color(tauri::window::Color(0x1a, 0x1a, 0x2e, 0xff)); + let window = builder.build().map_err(|e| e.to_string())?; + #[cfg(target_os = "macos")] { - let window = builder.build().map_err(|e| e.to_string())?; - // Apply vibrancy effect and inject titlebar let _ = apply_vibrancy(&window, NSVisualEffectMaterial::Sidebar, None, None); inject_titlebar(window.clone()); } - #[cfg(not(target_os = "macos"))] - { - let _window = builder.build().map_err(|e| e.to_string())?; - } + apply_settings_to_window(&app, &window); Ok(()) } @@ -624,6 +692,142 @@ async fn start_drag_window(window: tauri::Window) -> Result<(), String> { window.start_dragging().map_err(|e| e.to_string()) } +// ============================================================================ +// Window Settings +// ============================================================================ + +fn find_check_menu_item( + app: &AppHandle, + id: &str, +) -> Option> { + let menu = app.menu()?; + for item in menu.items().ok()? { + if let Some(submenu) = item.as_submenu() { + for sub_item in submenu.items().ok()? { + if let Some(check) = sub_item.as_check_menuitem() { + if check.id().as_ref() == id { + return Some(check.clone()); + } + } + } + } + } + None +} + +fn apply_settings_to_window(app: &AppHandle, window: &tauri::WebviewWindow) { + if cfg!(target_os = "macos") { + return; + } + let state = app.state::(); + let config = state.config.read().unwrap(); + let temp_visible = *state.menu_temporarily_visible.read().unwrap(); + if !config.show_menu_bar && !temp_visible { + let _ = window.hide_menu(); + } + if config.hide_window_decorations { + let _ = window.set_decorations(false); + } +} + +fn handle_menu_bar_toggle(app: &AppHandle) { + if cfg!(target_os = "macos") { + return; + } + let state = app.state::(); + let show = { + let mut config = state.config.write().unwrap(); + config.show_menu_bar = !config.show_menu_bar; + let _ = save_config(&config); + config.show_menu_bar + }; + + *state.menu_temporarily_visible.write().unwrap() = false; + + for (_, window) in app.webview_windows() { + if show { + let _ = window.show_menu(); + } else { + let _ = window.hide_menu(); + } + } +} + +fn handle_decorations_toggle(app: &AppHandle) { + if cfg!(target_os = "macos") { + return; + } + let state = app.state::(); + let hide = { + let mut config = state.config.write().unwrap(); + config.hide_window_decorations = !config.hide_window_decorations; + let _ = save_config(&config); + config.hide_window_decorations + }; + + for (_, window) in app.webview_windows() { + let _ = window.set_decorations(!hide); + } +} + +#[tauri::command] +fn toggle_menu_bar(app: AppHandle) { + if cfg!(target_os = "macos") { + return; + } + handle_menu_bar_toggle(&app); + + let state = app.state::(); + let checked = state.config.read().unwrap().show_menu_bar; + if let Some(check) = find_check_menu_item(&app, MENU_SHOW_MENU_BAR_ID) { + let _ = check.set_checked(checked); + } +} + +#[tauri::command] +fn show_menu_bar_temporarily(app: AppHandle) { + if cfg!(target_os = "macos") { + return; + } + let state = app.state::(); + if state.config.read().unwrap().show_menu_bar { + return; + } + + let mut temp = state.menu_temporarily_visible.write().unwrap(); + if *temp { + return; + } + *temp = true; + drop(temp); + + for (_, window) in app.webview_windows() { + let _ = window.show_menu(); + } +} + +#[tauri::command] +fn hide_menu_bar_temporary(app: AppHandle) { + if cfg!(target_os = "macos") { + return; + } + let state = app.state::(); + let mut temp = state.menu_temporarily_visible.write().unwrap(); + if !*temp { + return; + } + *temp = false; + drop(temp); + + if state.config.read().unwrap().show_menu_bar { + return; + } + + for (_, window) in app.webview_windows() { + let _ = window.hide_menu(); + } +} + // ============================================================================ // Menu Setup // ============================================================================ @@ -667,6 +871,59 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> { menu.prepend(&file_menu)?; } + #[cfg(not(target_os = "macos"))] + { + let config = app.state::(); + let config_guard = config.config.read().unwrap(); + + let show_menu_bar_item = CheckMenuItem::with_id( + app, + MENU_SHOW_MENU_BAR_ID, + "Show Menu Bar", + true, + config_guard.show_menu_bar, + None::<&str>, + )?; + + let hide_decorations_item = CheckMenuItem::with_id( + app, + MENU_HIDE_DECORATIONS_ID, + "Hide Window Decorations", + true, + config_guard.hide_window_decorations, + None::<&str>, + )?; + + drop(config_guard); + + if let Some(window_menu) = menu + .items()? + .into_iter() + .filter_map(|item| item.as_submenu().cloned()) + .find(|submenu| submenu.text().ok().as_deref() == Some("Window")) + { + window_menu.append(&show_menu_bar_item)?; + window_menu.append(&hide_decorations_item)?; + } else { + let window_menu = SubmenuBuilder::new(app, "Window") + .item(&show_menu_bar_item) + .item(&hide_decorations_item) + .build()?; + + let items = menu.items()?; + let help_idx = items + .iter() + .position(|item| { + item.as_submenu() + .and_then(|s| s.text().ok()) + .as_deref() + == Some("Help") + }) + .unwrap_or(items.len()); + menu.insert(&window_menu, help_idx)?; + } + } + if let Some(help_menu) = menu .get(HELP_SUBMENU_ID) .and_then(|item| item.as_submenu().cloned()) @@ -801,6 +1058,7 @@ fn main() { config: RwLock::new(config), config_initialized: RwLock::new(config_initialized), app_base_url: RwLock::new(None), + menu_temporarily_visible: RwLock::new(false), }) .invoke_handler(tauri::generate_handler![ get_server_url, @@ -816,13 +1074,18 @@ fn main() { go_forward, new_window, reset_config, - start_drag_window + start_drag_window, + toggle_menu_bar, + show_menu_bar_temporarily, + hide_menu_bar_temporary ]) .on_menu_event(|app, event| match event.id().as_ref() { "open_docs" => open_docs(), "new_chat" => trigger_new_chat(app), "new_window" => trigger_new_window(app), "open_settings" => open_settings(app), + "show_menu_bar" => handle_menu_bar_toggle(app), + "hide_window_decorations" => handle_decorations_toggle(app), _ => {} }) .setup(move |app| { @@ -855,6 +1118,8 @@ fn main() { #[cfg(target_os = "macos")] inject_titlebar(window.clone()); + apply_settings_to_window(&app_handle, &window); + let _ = window.set_focus(); } @@ -863,7 +1128,27 @@ fn main() { .on_page_load(|webview: &Webview, _payload: &PageLoadPayload| { inject_chat_link_intercept(webview); - // Re-inject titlebar after every navigation/page load (macOS only) + #[cfg(not(target_os = "macos"))] + { + let _ = webview.eval(MENU_KEY_HANDLER_SCRIPT); + + let app = webview.app_handle(); + let state = app.state::(); + let config = state.config.read().unwrap(); + let temp_visible = *state.menu_temporarily_visible.read().unwrap(); + let label = webview.label().to_string(); + if !config.show_menu_bar && !temp_visible { + if let Some(win) = app.get_webview_window(&label) { + let _ = win.hide_menu(); + } + } + if config.hide_window_decorations { + if let Some(win) = app.get_webview_window(&label) { + let _ = win.set_decorations(false); + } + } + } + #[cfg(target_os = "macos")] let _ = webview.eval(TITLEBAR_SCRIPT); }) From 4d2aa096549efaf835ad8f643fa36fed9a96f76a Mon Sep 17 00:00:00 2001 From: Wenxi Date: Mon, 2 Mar 2026 09:20:23 -0800 Subject: [PATCH 006/267] feat: infinite chat session sidebar scroll (#8874) --- backend/onyx/db/chat.py | 4 + .../server/query_and_chat/chat_backend.py | 18 +- backend/onyx/server/query_and_chat/models.py | 1 + .../sidebar/ChatSessionMorePopup.tsx | 11 +- web/src/hooks/useChatSessions.ts | 171 ++++++++++++++---- web/src/layouts/app-layouts.tsx | 12 +- .../skeletons/SidebarTabSkeleton.tsx | 22 +++ web/src/sections/sidebar/AppSidebar.tsx | 83 ++++++++- web/src/sections/sidebar/ChatButton.tsx | 3 +- 9 files changed, 274 insertions(+), 51 deletions(-) create mode 100644 web/src/refresh-components/skeletons/SidebarTabSkeleton.tsx diff --git a/backend/onyx/db/chat.py b/backend/onyx/db/chat.py index 2ca7e5a2a24..ac30bc7b45a 100644 --- a/backend/onyx/db/chat.py +++ b/backend/onyx/db/chat.py @@ -98,6 +98,7 @@ def get_chat_sessions_by_user( db_session: Session, include_onyxbot_flows: bool = False, limit: int = 50, + before: datetime | None = None, project_id: int | None = None, only_non_project_chats: bool = False, include_failed_chats: bool = False, @@ -112,6 +113,9 @@ def get_chat_sessions_by_user( if deleted is not None: stmt = stmt.where(ChatSession.deleted == deleted) + if before is not None: + stmt = stmt.where(ChatSession.time_updated < before) + if limit: stmt = stmt.limit(limit) diff --git a/backend/onyx/server/query_and_chat/chat_backend.py b/backend/onyx/server/query_and_chat/chat_backend.py index d1729f111d9..97daf47b5bb 100644 --- a/backend/onyx/server/query_and_chat/chat_backend.py +++ b/backend/onyx/server/query_and_chat/chat_backend.py @@ -152,10 +152,20 @@ def get_user_chat_sessions( project_id: int | None = None, only_non_project_chats: bool = True, include_failed_chats: bool = False, + page_size: int = Query(default=50, ge=1, le=100), + before: str | None = Query(default=None), ) -> ChatSessionsResponse: user_id = user.id try: + before_dt = ( + datetime.datetime.fromisoformat(before) if before is not None else None + ) + except ValueError: + raise HTTPException(status_code=422, detail="Invalid 'before' timestamp format") + + try: + # Fetch one extra to determine if there are more results chat_sessions = get_chat_sessions_by_user( user_id=user_id, deleted=False, @@ -163,11 +173,16 @@ def get_user_chat_sessions( project_id=project_id, only_non_project_chats=only_non_project_chats, include_failed_chats=include_failed_chats, + limit=page_size + 1, + before=before_dt, ) except ValueError: raise ValueError("Chat session does not exist or has been deleted") + has_more = len(chat_sessions) > page_size + chat_sessions = chat_sessions[:page_size] + return ChatSessionsResponse( sessions=[ ChatSessionDetails( @@ -181,7 +196,8 @@ def get_user_chat_sessions( current_temperature_override=chat.temperature_override, ) for chat in chat_sessions - ] + ], + has_more=has_more, ) diff --git a/backend/onyx/server/query_and_chat/models.py b/backend/onyx/server/query_and_chat/models.py index 776dbbaa79a..76a4afd4ae0 100644 --- a/backend/onyx/server/query_and_chat/models.py +++ b/backend/onyx/server/query_and_chat/models.py @@ -192,6 +192,7 @@ def from_model(cls, model: ChatSession) -> "ChatSessionDetails": class ChatSessionsResponse(BaseModel): sessions: list[ChatSessionDetails] + has_more: bool = False class ChatMessageDetail(BaseModel): diff --git a/web/src/components/sidebar/ChatSessionMorePopup.tsx b/web/src/components/sidebar/ChatSessionMorePopup.tsx index cbc92a2b391..464e1fb7610 100644 --- a/web/src/components/sidebar/ChatSessionMorePopup.tsx +++ b/web/src/components/sidebar/ChatSessionMorePopup.tsx @@ -52,7 +52,7 @@ export function ChatSessionMorePopup({ }: ChatSessionMorePopupProps) { const [popoverOpen, setPopoverOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const { refreshChatSessions } = useChatSessions(); + const { refreshChatSessions, removeSession } = useChatSessions(); const { fetchProjects, projects } = useProjectsContext(); const [pendingMoveProjectId, setPendingMoveProjectId] = useState< @@ -79,13 +79,20 @@ export function ChatSessionMorePopup({ async (e: React.MouseEvent) => { e.stopPropagation(); await deleteChatSession(chatSession.id); + removeSession(chatSession.id); await refreshChatSessions(); await fetchProjects(); setIsDeleteModalOpen(false); setPopoverOpen(false); afterDelete?.(); }, - [chatSession, refreshChatSessions, fetchProjects, afterDelete] + [ + chatSession, + refreshChatSessions, + removeSession, + fetchProjects, + afterDelete, + ] ); const performMove = useCallback( diff --git a/web/src/hooks/useChatSessions.ts b/web/src/hooks/useChatSessions.ts index aeda60a7e98..96bb2c57c4f 100644 --- a/web/src/hooks/useChatSessions.ts +++ b/web/src/hooks/useChatSessions.ts @@ -1,7 +1,13 @@ "use client"; -import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; -import useSWR, { KeyedMutator } from "swr"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; +import useSWRInfinite from "swr/infinite"; import { ChatSession, ChatSessionSharedStatus } from "@/app/app/interfaces"; import { errorHandlingFetcher } from "@/lib/fetcher"; import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; @@ -9,8 +15,12 @@ import useAppFocus from "./useAppFocus"; import { useAgents } from "./useAgents"; import { DEFAULT_ASSISTANT_ID } from "@/lib/constants"; +const PAGE_SIZE = 50; +const MIN_LOADING_DURATION_MS = 500; + interface ChatSessionsResponse { sessions: ChatSession[]; + has_more: boolean; } export interface PendingChatSessionParams { @@ -26,17 +36,24 @@ interface UseChatSessionsOutput { agentForCurrentChatSession: MinimalPersonaSnapshot | null; isLoading: boolean; error: any; - refreshChatSessions: KeyedMutator; + refreshChatSessions: () => Promise; addPendingChatSession: (params: PendingChatSessionParams) => void; + removeSession: (sessionId: string) => void; + hasMore: boolean; + isLoadingMore: boolean; + loadMore: () => void; } -// Module-level store for pending chat sessions -// This persists across SWR revalidations and component re-renders -// Pending sessions are shown in the sidebar until the server returns them +// --------------------------------------------------------------------------- +// Shared module-level store for pending chat sessions +// --------------------------------------------------------------------------- +// Pending sessions are optimistic new sessions shown in the sidebar before +// the server returns them. This must be module-level so all hook instances +// (sidebar, ChatButton, etc.) share the same state. + const pendingSessionsStore = { sessions: new Map(), listeners: new Set<() => void>(), - // Cached snapshot to avoid creating new array references on every call cachedSnapshot: [] as ChatSession[], add(session: ChatSession) { @@ -74,7 +91,7 @@ const pendingSessionsStore = { }, }; -// Stable empty array for SSR - must be defined outside component to avoid infinite loop +// Stable empty array for SSR const EMPTY_SESSIONS: ChatSession[] = []; function usePendingSessions(): ChatSession[] { @@ -85,6 +102,10 @@ function usePendingSessions(): ChatSession[] { ); } +// --------------------------------------------------------------------------- +// Helper hooks +// --------------------------------------------------------------------------- + function useFindAgentForCurrentChatSession( currentChatSession: ChatSession | null ): MinimalPersonaSnapshot | null { @@ -111,35 +132,102 @@ function useFindAgentForCurrentChatSession( return agents.find((agent) => agent.id === agentIdToFind) ?? null; } +// --------------------------------------------------------------------------- +// Main hook +// --------------------------------------------------------------------------- + export default function useChatSessions(): UseChatSessionsOutput { - const { data, error, mutate } = useSWR( - "/api/chat/get-user-chat-sessions", + const getKey = ( + pageIndex: number, + previousPageData: ChatSessionsResponse | null + ): string | null => { + // No more pages + if (previousPageData && !previousPageData.has_more) return null; + + // First page — no cursor + if (pageIndex === 0) { + return `/api/chat/get-user-chat-sessions?page_size=${PAGE_SIZE}`; + } + + // Subsequent pages — cursor from the last session of the previous page + const lastSession = + previousPageData!.sessions[previousPageData!.sessions.length - 1]; + if (!lastSession) return null; + + const params = new URLSearchParams({ + page_size: PAGE_SIZE.toString(), + before: lastSession.time_updated, + }); + return `/api/chat/get-user-chat-sessions?${params.toString()}`; + }; + + const { data, error, setSize, mutate } = useSWRInfinite( + getKey, errorHandlingFetcher, { revalidateOnFocus: false, + revalidateFirstPage: true, + revalidateAll: false, dedupingInterval: 30000, } ); const appFocus = useAppFocus(); const pendingSessions = usePendingSessions(); - const fetchedSessions = data?.sessions ?? []; + + // Flatten all pages into a single session list + const allFetchedSessions = useMemo( + () => (data ? data.flatMap((page) => page.sessions) : []), + [data] + ); + + // hasMore: check the last loaded page + const hasMore = useMemo(() => { + if (!data || data.length === 0) return false; + const lastPage = data[data.length - 1]; + return lastPage ? lastPage.has_more : false; + }, [data]); + + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const loadMore = useCallback(async () => { + if (isLoadingMore || !hasMore) return; + + setIsLoadingMore(true); + const loadStart = Date.now(); + + try { + await setSize((s) => s + 1); + + // Enforce minimum loading duration to avoid skeleton flash + const elapsed = Date.now() - loadStart; + if (elapsed < MIN_LOADING_DURATION_MS) { + await new Promise((r) => + setTimeout(r, MIN_LOADING_DURATION_MS - elapsed) + ); + } + } catch (err) { + console.error("Failed to load more chat sessions:", err); + } finally { + setIsLoadingMore(false); + } + }, [isLoadingMore, hasMore, setSize]); // Clean up pending sessions that now appear in fetched data // (they now have messages and the server returns them) useEffect(() => { - const fetchedIds = new Set(fetchedSessions.map((s) => s.id)); + const fetchedIds = new Set(allFetchedSessions.map((s) => s.id)); pendingSessions.forEach((pending) => { if (fetchedIds.has(pending.id)) { pendingSessionsStore.remove(pending.id); } }); - }, [fetchedSessions, pendingSessions]); + }, [allFetchedSessions, pendingSessions]); - // Merge fetched sessions with pending sessions - // This ensures pending sessions persist across SWR revalidations + // Merge fetched sessions with pending sessions. + // This ensures pending sessions persist across SWR revalidations. const chatSessions = useMemo(() => { - const fetchedIds = new Set(fetchedSessions.map((s) => s.id)); + const fetchedIds = new Set(allFetchedSessions.map((s) => s.id)); // Get pending sessions that are not yet in fetched data const remainingPending = pendingSessions.filter( @@ -147,8 +235,8 @@ export default function useChatSessions(): UseChatSessionsOutput { ); // Pending sessions go first (most recent), then fetched sessions - return [...remainingPending, ...fetchedSessions]; - }, [fetchedSessions, pendingSessions]); + return [...remainingPending, ...allFetchedSessions]; + }, [allFetchedSessions, pendingSessions]); const currentChatSessionId = appFocus.isChat() ? appFocus.getId() : null; const currentChatSession = @@ -159,27 +247,18 @@ export default function useChatSessions(): UseChatSessionsOutput { const agentForCurrentChatSession = useFindAgentForCurrentChatSession(currentChatSession); - // Add a pending chat session that will persist across SWR revalidations - // The session will be automatically removed once it appears in the server response + // Add a pending chat session that will persist across SWR revalidations. + // The session will be automatically removed once it appears in the server response. const addPendingChatSession = useCallback( ({ chatSessionId, personaId, projectId }: PendingChatSessionParams) => { // Don't add sessions that belong to a project - if (projectId != null) { - return; - } + if (projectId != null) return; // Don't add if already in pending store (duplicates are also filtered during merge) - if (pendingSessionsStore.has(chatSessionId)) { - return; - } - - // Note: This check uses stale fetchedSessions due to empty deps, but is defensive - if (fetchedSessions.some((s) => s.id === chatSessionId)) { - return; - } + if (pendingSessionsStore.has(chatSessionId)) return; const now = new Date().toISOString(); - const pendingSession: ChatSession = { + pendingSessionsStore.add({ id: chatSessionId, name: "", // Empty name will display as "New Chat" via UNNAMED_CHAT constant persona_id: personaId, @@ -189,13 +268,29 @@ export default function useChatSessions(): UseChatSessionsOutput { project_id: projectId ?? null, current_alternate_model: "", current_temperature_override: null, - }; - - pendingSessionsStore.add(pendingSession); + }); }, [] ); + const removeSession = useCallback( + (sessionId: string) => { + pendingSessionsStore.remove(sessionId); + // Optimistically remove from all loaded pages + mutate( + (pages) => + pages?.map((page) => ({ + ...page, + sessions: page.sessions.filter((s) => s.id !== sessionId), + })), + { revalidate: false } + ); + }, + [mutate] + ); + + const refreshChatSessions = useCallback(() => mutate(), [mutate]); + return { chatSessions, currentChatSessionId, @@ -203,7 +298,11 @@ export default function useChatSessions(): UseChatSessionsOutput { agentForCurrentChatSession, isLoading: !error && !data, error, - refreshChatSessions: mutate, + refreshChatSessions, addPendingChatSession, + removeSession, + hasMore, + isLoadingMore, + loadMore, }; } diff --git a/web/src/layouts/app-layouts.tsx b/web/src/layouts/app-layouts.tsx index 2f2336a116c..bc036c785a8 100644 --- a/web/src/layouts/app-layouts.tsx +++ b/web/src/layouts/app-layouts.tsx @@ -104,7 +104,8 @@ function Header() { refreshCurrentProjectDetails, currentProjectId, } = useProjectsContext(); - const { currentChatSession, refreshChatSessions } = useChatSessions(); + const { currentChatSession, refreshChatSessions, removeSession } = + useChatSessions(); const router = useRouter(); const appFocus = useAppFocus(); const { classification } = useQueryController(); @@ -186,6 +187,7 @@ function Header() { if (!response.ok) { throw new Error("Failed to delete chat session"); } + removeSession(currentChatSession.id); await Promise.all([refreshChatSessions(), fetchProjects()]); router.replace("/app"); setDeleteModalOpen(false); @@ -193,7 +195,13 @@ function Header() { console.error("Failed to delete chat:", error); showErrorNotification("Failed to delete chat. Please try again."); } - }, [currentChatSession, refreshChatSessions, fetchProjects, router]); + }, [ + currentChatSession, + refreshChatSessions, + removeSession, + fetchProjects, + router, + ]); const setDeleteConfirmationModalOpen = useCallback((open: boolean) => { setDeleteModalOpen(open); diff --git a/web/src/refresh-components/skeletons/SidebarTabSkeleton.tsx b/web/src/refresh-components/skeletons/SidebarTabSkeleton.tsx new file mode 100644 index 00000000000..c23b87b87ec --- /dev/null +++ b/web/src/refresh-components/skeletons/SidebarTabSkeleton.tsx @@ -0,0 +1,22 @@ +import { cn } from "@/lib/utils"; + +interface SidebarTabSkeletonProps { + textWidth?: string; +} + +export default function SidebarTabSkeleton({ + textWidth = "w-2/3", +}: SidebarTabSkeletonProps) { + return ( +
+
+
+
+
+ ); +} diff --git a/web/src/sections/sidebar/AppSidebar.tsx b/web/src/sections/sidebar/AppSidebar.tsx index 38943408c22..09c66f9cd96 100644 --- a/web/src/sections/sidebar/AppSidebar.tsx +++ b/web/src/sections/sidebar/AppSidebar.tsx @@ -67,6 +67,7 @@ import { SvgSearchMenu, SvgSettings, } from "@opal/icons"; +import SidebarTabSkeleton from "@/refresh-components/skeletons/SidebarTabSkeleton"; import BuildModeIntroBackground from "@/app/craft/components/IntroBackground"; import BuildModeIntroContent from "@/app/craft/components/IntroContent"; import { CRAFT_PATH } from "@/app/craft/v1/constants"; @@ -99,11 +100,25 @@ function buildVisibleAgents( return [visibleAgents, currentAgentIsPinned]; } +const SKELETON_WIDTHS_BASE = ["w-4/5", "w-4/5", "w-3/5"]; + +function shuffleWidths(): string[] { + return [...SKELETON_WIDTHS_BASE].sort(() => Math.random() - 0.5); +} + interface RecentsSectionProps { chatSessions: ChatSession[]; + hasMore: boolean; + isLoadingMore: boolean; + onLoadMore: () => void; } -function RecentsSection({ chatSessions }: RecentsSectionProps) { +function RecentsSection({ + chatSessions, + hasMore, + isLoadingMore, + onLoadMore, +}: RecentsSectionProps) { const { setNodeRef, isOver } = useDroppable({ id: DRAG_TYPES.RECENTS, data: { @@ -111,6 +126,33 @@ function RecentsSection({ chatSessions }: RecentsSectionProps) { }, }); + // Re-shuffle skeleton widths each time loaded session count changes + const skeletonWidths = useMemo(shuffleWidths, [chatSessions.length]); + + // Sentinel ref for IntersectionObserver-based infinite scroll + const sentinelRef = useRef(null); + const onLoadMoreRef = useRef(onLoadMore); + onLoadMoreRef.current = onLoadMore; + + useEffect(() => { + if (!hasMore || isLoadingMore) return; + + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + onLoadMoreRef.current(); + } + }, + { threshold: 0 } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMore, isLoadingMore]); + return (
) : ( - chatSessions.map((chatSession) => ( - - )) + <> + {chatSessions.map((chatSession) => ( + + ))} + {hasMore && + skeletonWidths.map((width, i) => ( +
+ +
+ ))} + )}
@@ -157,6 +214,9 @@ const MemoizedAppSidebarInner = memo( chatSessions, refreshChatSessions, isLoading: isLoadingChatSessions, + hasMore, + isLoadingMore, + loadMore, } = useChatSessions(); const { projects, @@ -700,7 +760,12 @@ const MemoizedAppSidebarInner = memo( {/* Recents */} - + )} diff --git a/web/src/sections/sidebar/ChatButton.tsx b/web/src/sections/sidebar/ChatButton.tsx index 2630126da37..c47d980603e 100644 --- a/web/src/sections/sidebar/ChatButton.tsx +++ b/web/src/sections/sidebar/ChatButton.tsx @@ -122,7 +122,7 @@ const ChatButton = memo( const [showShareModal, setShowShareModal] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [popoverItems, setPopoverItems] = useState([]); - const { refreshChatSessions } = useChatSessions(); + const { refreshChatSessions, removeSession } = useChatSessions(); const { refreshCurrentProjectDetails, projects, @@ -302,6 +302,7 @@ const ChatButton = memo( async function handleChatDelete() { try { await deleteChatSession(chatSession.id); + removeSession(chatSession.id); if (project) { await fetchProjects(); From 9c05bd215df50ad7454a06c84586be2da2c42908 Mon Sep 17 00:00:00 2001 From: Jamison Lahman Date: Mon, 2 Mar 2026 09:22:01 -0800 Subject: [PATCH 007/267] fix(a11y): settings popover buttons prefer href (#8880) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- web/src/refresh-components/buttons/LineItem.tsx | 10 +++++++++- web/src/sections/sidebar/UserAvatarPopover.tsx | 17 ++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/web/src/refresh-components/buttons/LineItem.tsx b/web/src/refresh-components/buttons/LineItem.tsx index baa9ff116b7..30e38ed0eef 100644 --- a/web/src/refresh-components/buttons/LineItem.tsx +++ b/web/src/refresh-components/buttons/LineItem.tsx @@ -72,6 +72,8 @@ export interface LineItemProps description?: string; rightChildren?: React.ReactNode; href?: string; + rel?: string; + target?: string; ref?: React.Ref; children?: React.ReactNode; } @@ -141,6 +143,8 @@ export default function LineItem({ children, rightChildren, href, + rel, + target, ref, ...props }: LineItemProps) { @@ -241,5 +245,9 @@ export default function LineItem({ ); if (!href) return content; - return {content}; + return ( + + {content} + + ); } diff --git a/web/src/sections/sidebar/UserAvatarPopover.tsx b/web/src/sections/sidebar/UserAvatarPopover.tsx index c7413edf920..17db2aa4604 100644 --- a/web/src/sections/sidebar/UserAvatarPopover.tsx +++ b/web/src/sections/sidebar/UserAvatarPopover.tsx @@ -103,7 +103,11 @@ function SettingsPopover({ {[
- + User Settings
, @@ -119,13 +123,9 @@ function SettingsPopover({ - window.open( - "https://docs.onyx.app", - "_blank", - "noopener,noreferrer" - ) - } + href="https://docs.onyx.app" + target="_blank" + rel="noopener noreferrer" > Help & FAQ , @@ -238,7 +238,6 @@ export default function UserAvatarPopover({ { setPopupState(undefined); - router.push("/app/settings"); }} onOpenNotifications={() => setPopupState("Notifications")} /> From 59d3725fc696494e1cb5991ac418bdb2aff5233e Mon Sep 17 00:00:00 2001 From: Jamison Lahman Date: Mon, 2 Mar 2026 09:34:22 -0800 Subject: [PATCH 008/267] chore(gha): rm docker-compose.opensearch.yml ref (#8912) --- .github/workflows/pr-external-dependency-unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-external-dependency-unit-tests.yml b/.github/workflows/pr-external-dependency-unit-tests.yml index 673dbb5e200..f26aa69141d 100644 --- a/.github/workflows/pr-external-dependency-unit-tests.yml +++ b/.github/workflows/pr-external-dependency-unit-tests.yml @@ -160,7 +160,7 @@ jobs: cd deployment/docker_compose # Get list of running containers - containers=$(docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.opensearch.yml ps -q) + containers=$(docker compose -f docker-compose.yml -f docker-compose.dev.yml ps -q) # Collect logs from each container for container in $containers; do From ba79539d6dea077cd884c10bb38d0b69c1037797 Mon Sep 17 00:00:00 2001 From: Nikolas Garza <90273783+nmgarza5@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:10:13 -0800 Subject: [PATCH 009/267] feat(slack): add Slack user deactivation and seat-aware reactivation (#8887) --- backend/onyx/auth/users.py | 8 ++ .../onyxbot/slack/handlers/handle_message.py | 40 ++++++ .../users/test_slack_user_deactivation.py | 121 ++++++++++++++++++ .../admin/users/SignedUpUserTable.tsx | 30 ++--- 4 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 backend/tests/integration/tests/users/test_slack_user_deactivation.py diff --git a/backend/onyx/auth/users.py b/backend/onyx/auth/users.py index 28a48487652..46733480949 100644 --- a/backend/onyx/auth/users.py +++ b/backend/onyx/auth/users.py @@ -725,11 +725,19 @@ async def oauth_callback( if user_by_session: user = user_by_session + # If the user is inactive, check seat availability before + # upgrading role — otherwise they'd become an inactive BASIC + # user who still can't log in. + if not user.is_active: + with get_session_with_current_tenant() as sync_db: + enforce_seat_limit(sync_db) + await self.user_db.update( user, { "is_verified": is_verified_by_default, "role": UserRole.BASIC, + **({"is_active": True} if not user.is_active else {}), }, ) diff --git a/backend/onyx/onyxbot/slack/handlers/handle_message.py b/backend/onyx/onyxbot/slack/handlers/handle_message.py index 971c1dca029..f16b52c3520 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_message.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_message.py @@ -3,10 +3,12 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError +from onyx.auth.schemas import UserRole from onyx.configs.onyxbot_configs import ONYX_BOT_FEEDBACK_REMINDER from onyx.configs.onyxbot_configs import ONYX_BOT_REACT_EMOJI from onyx.db.engine.sql_engine import get_session_with_current_tenant from onyx.db.models import SlackChannelConfig +from onyx.db.user_preferences import activate_user from onyx.db.users import add_slack_user_if_not_exists from onyx.db.users import get_user_by_email from onyx.onyxbot.slack.blocks import get_feedback_reminder_blocks @@ -243,6 +245,44 @@ def handle_message( ) return False + elif ( + not existing_user.is_active + and existing_user.role == UserRole.SLACK_USER + ): + check_seat_fn = fetch_ee_implementation_or_noop( + "onyx.db.license", + "check_seat_availability", + None, + ) + seat_result = check_seat_fn(db_session=db_session) + if seat_result is not None and not seat_result.available: + logger.info( + f"Blocked inactive Slack user {message_info.email}: " + f"{seat_result.error_message}" + ) + respond_in_thread_or_channel( + client=client, + channel=channel, + thread_ts=message_info.msg_to_respond, + text=( + "We weren't able to respond because your organization " + "has reached its user seat limit. Your account is " + "currently deactivated and cannot be reactivated " + "until more seats are available. Please contact " + "your Onyx administrator." + ), + ) + return False + + activate_user(existing_user, db_session) + invalidate_license_cache_fn = fetch_ee_implementation_or_noop( + "onyx.db.license", + "invalidate_license_cache", + None, + ) + invalidate_license_cache_fn() + logger.info(f"Reactivated inactive Slack user {message_info.email}") + add_slack_user_if_not_exists(db_session, message_info.email) # first check if we need to respond with a standard answer diff --git a/backend/tests/integration/tests/users/test_slack_user_deactivation.py b/backend/tests/integration/tests/users/test_slack_user_deactivation.py new file mode 100644 index 00000000000..d96f5f34a86 --- /dev/null +++ b/backend/tests/integration/tests/users/test_slack_user_deactivation.py @@ -0,0 +1,121 @@ +"""Integration tests for Slack user deactivation and reactivation via admin endpoints. + +Verifies that: +- Slack users can be deactivated by admins +- Deactivated Slack users can be reactivated by admins +- Reactivation is blocked when the seat limit is reached +""" + +from datetime import datetime +from datetime import timedelta + +import redis +import requests + +from ee.onyx.server.license.models import LicenseMetadata +from ee.onyx.server.license.models import LicenseSource +from ee.onyx.server.license.models import PlanType +from onyx.auth.schemas import UserRole +from onyx.configs.app_configs import REDIS_DB_NUMBER +from onyx.configs.app_configs import REDIS_HOST +from onyx.configs.app_configs import REDIS_PORT +from onyx.server.settings.models import ApplicationStatus +from tests.integration.common_utils.constants import API_SERVER_URL +from tests.integration.common_utils.managers.user import UserManager +from tests.integration.common_utils.test_models import DATestUser + +_LICENSE_REDIS_KEY = "public:license:metadata" + + +def _seed_license(r: redis.Redis, seats: int) -> None: + now = datetime.utcnow() + metadata = LicenseMetadata( + tenant_id="public", + organization_name="Test Org", + seats=seats, + used_seats=0, + plan_type=PlanType.ANNUAL, + issued_at=now, + expires_at=now + timedelta(days=365), + status=ApplicationStatus.ACTIVE, + source=LicenseSource.MANUAL_UPLOAD, + ) + r.set(_LICENSE_REDIS_KEY, metadata.model_dump_json(), ex=300) + + +def _clear_license(r: redis.Redis) -> None: + r.delete(_LICENSE_REDIS_KEY) + + +def _redis() -> redis.Redis: + return redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB_NUMBER) + + +def _get_user_is_active(email: str, admin_user: DATestUser) -> bool: + """Look up a user's is_active flag via the admin users list endpoint.""" + result = UserManager.get_user_page( + user_performing_action=admin_user, + search_query=email, + ) + matching = [u for u in result.items if u.email == email] + assert len(matching) == 1, f"Expected exactly 1 user with email {email}" + return matching[0].is_active + + +def test_slack_user_deactivate_and_reactivate(reset: None) -> None: # noqa: ARG001 + """Admin can deactivate and then reactivate a Slack user.""" + admin_user = UserManager.create(name="admin_user") + + slack_user = UserManager.create(name="slack_test_user") + slack_user = UserManager.set_role( + user_to_set=slack_user, + target_role=UserRole.SLACK_USER, + user_performing_action=admin_user, + explicit_override=True, + ) + + # Deactivate the Slack user + UserManager.set_status( + slack_user, target_status=False, user_performing_action=admin_user + ) + assert _get_user_is_active(slack_user.email, admin_user) is False + + # Reactivate the Slack user + UserManager.set_status( + slack_user, target_status=True, user_performing_action=admin_user + ) + assert _get_user_is_active(slack_user.email, admin_user) is True + + +def test_slack_user_reactivation_blocked_by_seat_limit( + reset: None, # noqa: ARG001 +) -> None: + """Reactivating a deactivated Slack user returns 402 when seats are full.""" + r = _redis() + + admin_user = UserManager.create(name="admin_user") + + slack_user = UserManager.create(name="slack_test_user") + slack_user = UserManager.set_role( + user_to_set=slack_user, + target_role=UserRole.SLACK_USER, + user_performing_action=admin_user, + explicit_override=True, + ) + + UserManager.set_status( + slack_user, target_status=False, user_performing_action=admin_user + ) + + # License allows 1 seat — only admin counts + _seed_license(r, seats=1) + + try: + response = requests.patch( + url=f"{API_SERVER_URL}/manage/admin/activate-user", + json={"user_email": slack_user.email}, + headers=admin_user.headers, + ) + assert response.status_code == 402 + finally: + _clear_license(r) diff --git a/web/src/components/admin/users/SignedUpUserTable.tsx b/web/src/components/admin/users/SignedUpUserTable.tsx index 32a5aa109fd..668a2d3ed08 100644 --- a/web/src/components/admin/users/SignedUpUserTable.tsx +++ b/web/src/components/admin/users/SignedUpUserTable.tsx @@ -310,23 +310,23 @@ export default function SignedUpUserTable({ }; const renderActionButtons = (user: User) => { - if (user.role === UserRole.SLACK_USER) { - return ( - + {user.role === UserRole.SLACK_USER && ( + + )} + - ); - } - return ( - +
); }; From 15d8946f40ce3b58a25d907128acd3b5c16f6d48 Mon Sep 17 00:00:00 2001 From: Nikolas Garza <90273783+nmgarza5@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:19:23 -0800 Subject: [PATCH 010/267] =?UTF-8?q?refactor(fe):=20rename=20assistant=20?= =?UTF-8?q?=E2=86=92=20agent=20identifiers=20(#8869)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/next.config.js | 19 +++ .../CollapsibleSection.tsx | 0 .../{assistants => agents}/PersonaTable.tsx | 0 .../{assistants => agents}/interfaces.ts | 0 .../app/admin/{assistants => agents}/lib.ts | 2 +- .../app/admin/{assistants => agents}/page.tsx | 0 .../SlackChannelConfigCreationForm.tsx | 16 +-- .../channels/SlackChannelConfigFormFields.tsx | 85 ++++++----- .../bots/[bot-id]/channels/[id]/page.tsx | 16 +-- .../admin/bots/[bot-id]/channels/new/page.tsx | 23 ++- web/src/app/admin/bots/[bot-id]/lib.ts | 2 +- .../[connector]/pages/FieldRendering.tsx | 2 +- .../[guild-id]/DiscordChannelsTable.tsx | 2 +- .../app/admin/discord-bot/[guild-id]/page.tsx | 2 +- .../app/app/components/AgentDescription.tsx | 2 +- web/src/app/app/components/WelcomeMessage.tsx | 4 +- .../projects/ProjectChatSessionList.tsx | 8 +- web/src/app/app/interfaces.ts | 12 +- .../messageComponents/AgentMessage.tsx | 6 +- .../message/messageComponents/interfaces.ts | 4 +- .../renderMessageComponent.tsx | 2 +- .../timeline/AgentTimeline.tsx | 8 +- web/src/app/app/services/actionUtils.ts | 2 +- web/src/app/app/services/lib.tsx | 12 +- web/src/app/app/services/messageTree.ts | 12 +- web/src/app/app/services/searchParams.ts | 2 +- .../app/shared/[chatId]/SharedChatDisplay.tsx | 4 +- web/src/app/app/shared/[chatId]/page.tsx | 2 +- .../app/craft/components/BuildMessageList.tsx | 10 +- web/src/app/craft/components/ChatPanel.tsx | 14 +- .../craft/components/ConnectorBannersRow.tsx | 2 +- .../craft/components/SuggestionBubbles.tsx | 2 +- .../app/craft/hooks/useBuildSessionStore.ts | 50 +++---- web/src/app/craft/hooks/useBuildStreaming.ts | 10 +- web/src/app/craft/services/apiServices.ts | 4 +- web/src/app/craft/types/streamingTypes.ts | 2 +- .../usage/PersonaMessagesChart.tsx | 2 +- .../stats/[id]/AgentStats.tsx} | 55 ++++--- .../stats/[id]/page.tsx | 4 +- web/src/app/nrf/NRFPage.tsx | 38 ++--- ...{NoAssistantModal.tsx => NoAgentModal.tsx} | 4 +- .../sidebar/ChatSessionMorePopup.tsx | 7 +- web/src/components/sidebar/types.ts | 2 +- web/src/hooks/appNavigation.ts | 10 +- web/src/hooks/useAdminPersonas.ts | 2 +- web/src/hooks/useAgentController.ts | 89 +++++------- web/src/hooks/useAgentPreferences.ts | 51 ++++--- web/src/hooks/useAgents.ts | 4 +- web/src/hooks/useChatController.ts | 90 ++++++------ web/src/hooks/useChatSessionController.ts | 10 +- web/src/hooks/useChatSessions.ts | 6 +- web/src/hooks/useDeepResearchToggle.ts | 8 +- web/src/hooks/useIsDefaultAgent.ts | 20 +-- web/src/hooks/useShowOnboarding.ts | 8 +- web/src/lib/agents.ts | 40 +++--- web/src/lib/agentsSS.ts | 8 +- web/src/lib/chat/fetchAgentData.ts | 20 +++ web/src/lib/chat/fetchAssistantdata.ts | 20 --- web/src/lib/constants.ts | 2 +- web/src/lib/filters.ts | 2 +- web/src/lib/hooks.ts | 43 +++--- web/src/lib/hooks/useToolOAuthStatus.ts | 4 +- web/src/lib/llmConfig/utils.ts | 8 +- web/src/lib/search/interfaces.ts | 2 +- web/src/lib/sources.ts | 2 +- web/src/lib/types.ts | 9 +- web/src/providers/UserProvider.tsx | 32 ++--- web/src/proxy.ts | 8 +- .../avatars/AgentAvatar.tsx | 6 +- .../__tests__/useShowOnboarding.test.tsx | 8 +- .../onboarding/useOnboardingState.ts | 12 +- .../popovers/ActionsPopover/index.tsx | 80 +++++------ web/src/refresh-pages/AgentEditorPage.tsx | 8 +- .../refresh-pages/AgentsNavigationPage.tsx | 4 +- web/src/refresh-pages/AppPage.tsx | 59 ++++---- .../admin/ChatPreferencesPage.tsx | 24 ++-- web/src/sections/cards/AgentCard.tsx | 8 +- web/src/sections/chat/ChatUI.tsx | 8 +- web/src/sections/input/AppInputBar.tsx | 26 ++-- .../sections/knowledge/AgentKnowledgePane.tsx | 2 +- .../knowledge/SourceHierarchyBrowser.tsx | 2 +- web/src/sections/modals/AgentViewerModal.tsx | 4 +- web/src/sections/modals/ShareAgentModal.tsx | 4 +- web/src/sections/sidebar/AdminSidebar.tsx | 9 +- web/src/sections/sidebar/AgentButton.tsx | 4 +- web/src/sections/sidebar/AppSidebar.tsx | 10 +- .../sidebar/ChatSearchCommandMenu.tsx | 2 +- web/tests/e2e/admin/admin_pages.spec.ts | 2 +- ...ssistant.spec.ts => default-agent.spec.ts} | 2 +- ....spec.ts => disable_default_agent.spec.ts} | 46 +++--- .../admin/discord-bot/channel-config.spec.ts | 4 +- .../oauth_config/test_tool_oauth.spec.ts | 20 +-- .../create_and_edit_agent.spec.ts} | 63 ++++---- .../llm_provider_rbac.spec.ts | 0 .../user_file_attachment.spec.ts | 44 +++--- .../e2e/chat/chat_message_rendering.spec.ts | 16 +-- ...ssistant.spec.ts => current_agent.spec.ts} | 17 +-- ...ssistant.spec.ts => default_agent.spec.ts} | 30 ++-- ...e_assistant.spec.ts => live_agent.spec.ts} | 16 +-- web/tests/e2e/chat/llm_ordering.spec.ts | 2 +- .../e2e/chat/llm_runtime_selection.spec.ts | 8 +- web/tests/e2e/chat/welcome_page.spec.ts | 2 +- ...-mcp.spec.ts => default-agent-mcp.spec.ts} | 34 ++--- web/tests/e2e/mcp/mcp_oauth_flow.spec.ts | 134 ++++++++---------- .../{assistantUtils.ts => agentUtils.ts} | 16 +-- web/tests/e2e/utils/chatActions.ts | 16 +-- web/tests/e2e/utils/onyxApiClient.ts | 20 +-- .../setup/mocks/components/UserProvider.tsx | 8 +- 108 files changed, 829 insertions(+), 904 deletions(-) rename web/src/app/admin/{assistants => agents}/CollapsibleSection.tsx (100%) rename web/src/app/admin/{assistants => agents}/PersonaTable.tsx (100%) rename web/src/app/admin/{assistants => agents}/interfaces.ts (100%) rename web/src/app/admin/{assistants => agents}/lib.ts (99%) rename web/src/app/admin/{assistants => agents}/page.tsx (100%) rename web/src/app/ee/{assistants/stats/[id]/AssistantStats.tsx => agents/stats/[id]/AgentStats.tsx} (74%) rename web/src/app/ee/{assistants => agents}/stats/[id]/page.tsx (89%) rename web/src/components/modals/{NoAssistantModal.tsx => NoAgentModal.tsx} (90%) create mode 100644 web/src/lib/chat/fetchAgentData.ts delete mode 100644 web/src/lib/chat/fetchAssistantdata.ts rename web/tests/e2e/admin/{default-assistant.spec.ts => default-agent.spec.ts} (99%) rename web/tests/e2e/admin/{disable_default_assistant.spec.ts => disable_default_agent.spec.ts} (86%) rename web/tests/e2e/{assistants/create_and_edit_assistant.spec.ts => agents/create_and_edit_agent.spec.ts} (87%) rename web/tests/e2e/{assistants => agents}/llm_provider_rbac.spec.ts (100%) rename web/tests/e2e/{assistants => agents}/user_file_attachment.spec.ts (90%) rename web/tests/e2e/chat/{current_assistant.spec.ts => current_agent.spec.ts} (91%) rename web/tests/e2e/chat/{default_assistant.spec.ts => default_agent.spec.ts} (96%) rename web/tests/e2e/chat/{live_assistant.spec.ts => live_agent.spec.ts} (81%) rename web/tests/e2e/mcp/{default-assistant-mcp.spec.ts => default-agent-mcp.spec.ts} (96%) rename web/tests/e2e/utils/{assistantUtils.ts => agentUtils.ts} (91%) diff --git a/web/next.config.js b/web/next.config.js index bf092a7ab43..59fb98ab0ce 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -122,6 +122,25 @@ const nextConfig = { destination: "/app/:path*", permanent: true, }, + // Legacy /assistants → /agents redirects (added in PR #8869). + // Preserves backward compatibility for bookmarks, shared links, and + // hardcoded URLs that still reference the old /assistants paths. + // TODO: Remove these redirects in v4.0 — https://linear.app/onyx-app/issue/ENG-3771 + { + source: "/admin/assistants", + destination: "/admin/agents", + permanent: true, + }, + { + source: "/admin/assistants/:path*", + destination: "/admin/agents/:path*", + permanent: true, + }, + { + source: "/ee/assistants/:path*", + destination: "/ee/agents/:path*", + permanent: true, + }, ]; }, }; diff --git a/web/src/app/admin/assistants/CollapsibleSection.tsx b/web/src/app/admin/agents/CollapsibleSection.tsx similarity index 100% rename from web/src/app/admin/assistants/CollapsibleSection.tsx rename to web/src/app/admin/agents/CollapsibleSection.tsx diff --git a/web/src/app/admin/assistants/PersonaTable.tsx b/web/src/app/admin/agents/PersonaTable.tsx similarity index 100% rename from web/src/app/admin/assistants/PersonaTable.tsx rename to web/src/app/admin/agents/PersonaTable.tsx diff --git a/web/src/app/admin/assistants/interfaces.ts b/web/src/app/admin/agents/interfaces.ts similarity index 100% rename from web/src/app/admin/assistants/interfaces.ts rename to web/src/app/admin/agents/interfaces.ts diff --git a/web/src/app/admin/assistants/lib.ts b/web/src/app/admin/agents/lib.ts similarity index 99% rename from web/src/app/admin/assistants/lib.ts rename to web/src/app/admin/agents/lib.ts index a4b6f756eef..5986caf8da7 100644 --- a/web/src/app/admin/assistants/lib.ts +++ b/web/src/app/admin/agents/lib.ts @@ -2,7 +2,7 @@ import { MinimalPersonaSnapshot, Persona, StarterMessage, -} from "@/app/admin/assistants/interfaces"; +} from "@/app/admin/agents/interfaces"; interface PersonaUpsertRequest { name: string; diff --git a/web/src/app/admin/assistants/page.tsx b/web/src/app/admin/agents/page.tsx similarity index 100% rename from web/src/app/admin/assistants/page.tsx rename to web/src/app/admin/agents/page.tsx diff --git a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx index 0d32055781c..bceb648c3b5 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm.tsx @@ -16,7 +16,7 @@ import { } from "../lib"; import CardSection from "@/components/admin/CardSection"; import { useRouter } from "next/navigation"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE"; import { SEARCH_TOOL_ID } from "@/app/app/components/tools/constants"; import { SlackChannelConfigFormFields } from "./SlackChannelConfigFormFields"; @@ -46,7 +46,7 @@ export const SlackChannelConfigCreationForm = ({ ) : false; - const [searchEnabledAssistants, nonSearchAssistants] = useMemo(() => { + const [searchEnabledAgents, nonSearchAgents] = useMemo(() => { return personas.reduce( (acc, persona) => { if ( @@ -115,7 +115,7 @@ export const SlackChannelConfigCreationForm = ({ knowledge_source: existingSlackBotUsesPersona ? existingPersonaHasSearchTool ? "assistant" - : "non_search_assistant" + : "non_search_agent" : existingSlackChannelConfig?.persona ? "document_sets" : "all_public", @@ -165,7 +165,7 @@ export const SlackChannelConfigCreationForm = ({ "all_public", "document_sets", "assistant", - "non_search_assistant", + "non_search_agent", ]) .required(), disabled: Yup.boolean().optional().default(false), @@ -180,14 +180,14 @@ export const SlackChannelConfigCreationForm = ({ respond_member_group_list: values.respond_member_group_list, usePersona: values.knowledge_source === "assistant" || - values.knowledge_source === "non_search_assistant", + values.knowledge_source === "non_search_agent", document_sets: values.knowledge_source === "document_sets" ? values.document_sets : [], persona_id: values.knowledge_source === "assistant" || - values.knowledge_source === "non_search_assistant" + values.knowledge_source === "non_search_agent" ? values.persona_id : null, standard_answer_categories: values.standard_answer_categories.map( @@ -234,8 +234,8 @@ export const SlackChannelConfigCreationForm = ({ isUpdate={isUpdate} isDefault={isDefault} documentSets={documentSets} - searchEnabledAssistants={searchEnabledAssistants} - nonSearchAssistants={nonSearchAssistants} + searchEnabledAgents={searchEnabledAgents} + nonSearchAgents={nonSearchAgents} standardAnswerCategoryResponse={standardAnswerCategoryResponse} slack_bot_id={slack_bot_id} formikProps={formikProps} diff --git a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx index 1b715586c71..d45f4bedb60 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx @@ -12,9 +12,9 @@ import { TextFormField, } from "@/components/Field"; import Button from "@/refresh-components/buttons/Button"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import DocumentSetCard from "@/sections/cards/DocumentSetCard"; -import CollapsibleSection from "@/app/admin/assistants/CollapsibleSection"; +import CollapsibleSection from "@/app/admin/agents/CollapsibleSection"; import { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE"; import { StandardAnswerCategoryDropdownField } from "@/components/standardAnswers/StandardAnswerCategoryDropdown"; import { RadioGroup } from "@/components/ui/radio-group"; @@ -45,8 +45,8 @@ export interface SlackChannelConfigFormFieldsProps { isUpdate: boolean; isDefault: boolean; documentSets: DocumentSetSummary[]; - searchEnabledAssistants: MinimalPersonaSnapshot[]; - nonSearchAssistants: MinimalPersonaSnapshot[]; + searchEnabledAgents: MinimalPersonaSnapshot[]; + nonSearchAgents: MinimalPersonaSnapshot[]; standardAnswerCategoryResponse: StandardAnswerCategoryResponse; slack_bot_id: number; formikProps: any; @@ -56,8 +56,8 @@ export function SlackChannelConfigFormFields({ isUpdate, isDefault, documentSets, - searchEnabledAssistants, - nonSearchAssistants, + searchEnabledAgents, + nonSearchAgents, standardAnswerCategoryResponse, slack_bot_id, formikProps, @@ -65,8 +65,7 @@ export function SlackChannelConfigFormFields({ const router = useRouter(); const { values, setFieldValue } = useFormikContext(); const [viewUnselectableSets, setViewUnselectableSets] = useState(false); - const [viewSyncEnabledAssistants, setViewSyncEnabledAssistants] = - useState(false); + const [viewSyncEnabledAgents, setViewSyncEnabledAgents] = useState(false); // Helper function to check if a document set contains sync connectors const documentSetContainsSync = (documentSet: DocumentSetSummary) => { @@ -87,11 +86,11 @@ export function SlackChannelConfigFormFields({ return documentSet.cc_pair_summaries; }; - const [syncEnabledAssistants, availableAssistants] = useMemo(() => { + const [syncEnabledAgents, availableAgents] = useMemo(() => { const sync: MinimalPersonaSnapshot[] = []; const available: MinimalPersonaSnapshot[] = []; - searchEnabledAssistants.forEach((persona) => { + searchEnabledAgents.forEach((persona) => { const hasSyncSet = persona.document_sets.some(documentSetContainsSync); if (hasSyncSet) { sync.push(persona); @@ -101,7 +100,7 @@ export function SlackChannelConfigFormFields({ }); return [sync, available]; - }, [searchEnabledAssistants]); + }, [searchEnabledAgents]); const unselectableSets = useMemo(() => { return documentSets.filter(documentSetContainsSync); @@ -151,10 +150,10 @@ export function SlackChannelConfigFormFields({ ); return selectedSets.some((ds) => documentSetContainsPrivate(ds)); } else if (values.knowledge_source === "assistant") { - const chosenAssistant = searchEnabledAssistants.find( + const chosenAgent = searchEnabledAgents.find( (p) => p.id == values.persona_id ); - return chosenAssistant?.document_sets.some((ds) => + return chosenAgent?.document_sets.some((ds) => documentSetContainsPrivate(ds) ); } @@ -228,8 +227,8 @@ export function SlackChannelConfigFormFields({ sublabel="Control both the documents and the prompt to use for answering questions" /> @@ -329,7 +328,7 @@ export function SlackChannelConfigFormFields({ <> Select the search-enabled agent OnyxBot will use while answering questions in Slack. - {syncEnabledAssistants.length > 0 && ( + {syncEnabledAgents.length > 0 && ( <>
@@ -339,14 +338,13 @@ export function SlackChannelConfigFormFields({ - ) - )} + {syncEnabledAgents.map((persona: MinimalPersonaSnapshot) => ( + + ))} )} )} - {values.knowledge_source === "non_search_assistant" && ( + {values.knowledge_source === "non_search_agent" && (
<> Select the non-search agent OnyxBot will use while answering questions in Slack. - {syncEnabledAssistants.length > 0 && ( + {syncEnabledAgents.length > 0 && ( <>
@@ -406,14 +402,13 @@ export function SlackChannelConfigFormFields({
- {values.knowledge_source !== "non_search_assistant" && ( + {values.knowledge_source !== "non_search_agent" && ( Search Configuration diff --git a/web/src/app/admin/bots/[bot-id]/channels/[id]/page.tsx b/web/src/app/admin/bots/[bot-id]/channels/[id]/page.tsx index a6cb2d0ed2b..e0e69d67972 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/[id]/page.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/[id]/page.tsx @@ -10,7 +10,7 @@ import { } from "@/lib/types"; import BackButton from "@/refresh-components/buttons/BackButton"; import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; -import { FetchAssistantsResponse, fetchAssistantsSS } from "@/lib/agentsSS"; +import { FetchAgentsResponse, fetchAgentsSS } from "@/lib/agentsSS"; import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE"; async function EditslackChannelConfigPage(props: { @@ -20,18 +20,14 @@ async function EditslackChannelConfigPage(props: { const tasks = [ fetchSS("/manage/admin/slack-app/channel"), fetchSS("/manage/document-set"), - fetchAssistantsSS(), + fetchAgentsSS(), ]; const [ slackChannelsResponse, documentSetsResponse, - [assistants, assistantsFetchError], - ] = (await Promise.all(tasks)) as [ - Response, - Response, - FetchAssistantsResponse, - ]; + [assistants, agentsFetchError], + ] = (await Promise.all(tasks)) as [Response, Response, FetchAgentsResponse]; const eeStandardAnswerCategoryResponse = await getStandardAnswerCategoriesIfEE(); @@ -71,11 +67,11 @@ async function EditslackChannelConfigPage(props: { const response = await documentSetsResponse.json(); const documentSets = response as DocumentSetSummary[]; - if (assistantsFetchError) { + if (agentsFetchError) { return ( ); } diff --git a/web/src/app/admin/bots/[bot-id]/channels/new/page.tsx b/web/src/app/admin/bots/[bot-id]/channels/new/page.tsx index dae7aa1d42d..7094043e364 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/new/page.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/new/page.tsx @@ -4,7 +4,7 @@ import { fetchSS } from "@/lib/utilsSS"; import { ErrorCallout } from "@/components/ErrorCallout"; import { DocumentSetSummary, ValidSources } from "@/lib/types"; import BackButton from "@/refresh-components/buttons/BackButton"; -import { fetchAssistantsSS } from "@/lib/agentsSS"; +import { fetchAgentsSS } from "@/lib/agentsSS"; import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE"; import { redirect } from "next/navigation"; import { SourceIcon } from "@/components/SourceIcon"; @@ -22,15 +22,12 @@ async function NewChannelConfigPage(props: { return null; } - const [ - documentSetsResponse, - assistantsResponse, - standardAnswerCategoryResponse, - ] = await Promise.all([ - fetchSS("/manage/document-set") as Promise, - fetchAssistantsSS(), - getStandardAnswerCategoriesIfEE(), - ]); + const [documentSetsResponse, agentsResponse, standardAnswerCategoryResponse] = + await Promise.all([ + fetchSS("/manage/document-set") as Promise, + fetchAgentsSS(), + getStandardAnswerCategoriesIfEE(), + ]); if (!documentSetsResponse.ok) { return ( @@ -43,11 +40,11 @@ async function NewChannelConfigPage(props: { const documentSets = (await documentSetsResponse.json()) as DocumentSetSummary[]; - if (assistantsResponse[1]) { + if (agentsResponse[1]) { return ( ); } @@ -63,7 +60,7 @@ async function NewChannelConfigPage(props: { diff --git a/web/src/app/admin/bots/[bot-id]/lib.ts b/web/src/app/admin/bots/[bot-id]/lib.ts index 5a1e506b96b..2da011ecc56 100644 --- a/web/src/app/admin/bots/[bot-id]/lib.ts +++ b/web/src/app/admin/bots/[bot-id]/lib.ts @@ -1,5 +1,5 @@ import { SlackBotResponseType } from "@/lib/types"; -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; interface SlackChannelConfigCreationRequest { slack_bot_id: number; diff --git a/web/src/app/admin/connectors/[connector]/pages/FieldRendering.tsx b/web/src/app/admin/connectors/[connector]/pages/FieldRendering.tsx index 0ced4437a27..c6326570b94 100644 --- a/web/src/app/admin/connectors/[connector]/pages/FieldRendering.tsx +++ b/web/src/app/admin/connectors/[connector]/pages/FieldRendering.tsx @@ -7,7 +7,7 @@ import ListInput from "./ConnectorInput/ListInput"; import FileInput from "./ConnectorInput/FileInput"; import { ConfigurableSources } from "@/lib/types"; import { Credential } from "@/lib/connectors/credentials"; -import CollapsibleSection from "@/app/admin/assistants/CollapsibleSection"; +import CollapsibleSection from "@/app/admin/agents/CollapsibleSection"; import Tabs from "@/refresh-components/Tabs"; import { useFormikContext } from "formik"; import * as GeneralLayouts from "@/layouts/general-layouts"; diff --git a/web/src/app/admin/discord-bot/[guild-id]/DiscordChannelsTable.tsx b/web/src/app/admin/discord-bot/[guild-id]/DiscordChannelsTable.tsx index b2bdca3c674..07193c6709c 100644 --- a/web/src/app/admin/discord-bot/[guild-id]/DiscordChannelsTable.tsx +++ b/web/src/app/admin/discord-bot/[guild-id]/DiscordChannelsTable.tsx @@ -19,7 +19,7 @@ import { } from "@/app/admin/discord-bot/types"; import { SvgHash, SvgBubbleText, SvgLock } from "@opal/icons"; import { IconProps } from "@opal/types"; -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; function getChannelIcon( channelType: DiscordChannelType, diff --git a/web/src/app/admin/discord-bot/[guild-id]/page.tsx b/web/src/app/admin/discord-bot/[guild-id]/page.tsx index c3a569dc90e..f2f7f1b425e 100644 --- a/web/src/app/admin/discord-bot/[guild-id]/page.tsx +++ b/web/src/app/admin/discord-bot/[guild-id]/page.tsx @@ -26,7 +26,7 @@ import { import { DiscordChannelsTable } from "@/app/admin/discord-bot/[guild-id]/DiscordChannelsTable"; import { DiscordChannelConfig } from "@/app/admin/discord-bot/types"; import { useAdminPersonas } from "@/hooks/useAdminPersonas"; -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; interface Props { params: Promise<{ "guild-id": string }>; diff --git a/web/src/app/app/components/AgentDescription.tsx b/web/src/app/app/components/AgentDescription.tsx index 380045807a0..fb9ff6bf367 100644 --- a/web/src/app/app/components/AgentDescription.tsx +++ b/web/src/app/app/components/AgentDescription.tsx @@ -1,7 +1,7 @@ "use client"; import Text from "@/refresh-components/texts/Text"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; export interface AgentDescriptionProps { agent?: MinimalPersonaSnapshot; diff --git a/web/src/app/app/components/WelcomeMessage.tsx b/web/src/app/app/components/WelcomeMessage.tsx index 876ada6c1f9..66f087c08a9 100644 --- a/web/src/app/app/components/WelcomeMessage.tsx +++ b/web/src/app/app/components/WelcomeMessage.tsx @@ -7,7 +7,7 @@ import { } from "@/lib/chat/greetingMessages"; import AgentAvatar from "@/refresh-components/avatars/AgentAvatar"; import Text from "@/refresh-components/texts/Text"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { useState, useEffect } from "react"; import { useSettingsContext } from "@/providers/SettingsProvider"; import FrostedDiv from "@/refresh-components/FrostedDiv"; @@ -50,7 +50,7 @@ export default function WelcomeMessage({ content = ( <>
diff --git a/web/src/app/app/components/projects/ProjectChatSessionList.tsx b/web/src/app/app/components/projects/ProjectChatSessionList.tsx index 75519ff1f47..388a2b3f0a5 100644 --- a/web/src/app/app/components/projects/ProjectChatSessionList.tsx +++ b/web/src/app/app/components/projects/ProjectChatSessionList.tsx @@ -21,7 +21,7 @@ export default function ProjectChatSessionList() { refreshCurrentProjectDetails, isLoadingProjectDetails, } = useProjectsContext(); - const { agents: assistants } = useAgents(); + const { agents } = useAgents(); const [isRenamingChat, setIsRenamingChat] = React.useState( null ); @@ -78,13 +78,13 @@ export default function ProjectChatSessionList() { currentProjectDetails?.persona_id_to_featured || {}; const isFeatured = personaIdToFeatured[chat.persona_id]; if (isFeatured === false) { - const assistant = assistants.find( + const agent = agents.find( (a) => a.id === chat.persona_id ); - if (assistant) { + if (agent) { return (
- +
); } diff --git a/web/src/app/app/interfaces.ts b/web/src/app/app/interfaces.ts index 5bc3ed7b67d..51547090a6e 100644 --- a/web/src/app/app/interfaces.ts +++ b/web/src/app/app/interfaces.ts @@ -141,7 +141,7 @@ export interface Message { messageId?: number; nodeId: number; // Unique identifier for tree structure (can be negative for temp messages) message: string; - type: "user" | "assistant" | "system" | "error"; + type: "user" | "assistant" | "system" | "error"; // TODO: rename "assistant" to "agent" — https://linear.app/onyx-app/issue/ENG-3766 retrievalType?: RetrievalType; researchType?: ResearchType; query?: string | null; @@ -151,7 +151,7 @@ export interface Message { parentNodeId: number | null; childrenNodeIds?: number[]; latestChildNodeId?: number | null; - alternateAssistantID?: number | null; + alternateAgentID?: number | null; stackTrace?: string | null; errorCode?: string | null; isRetryable?: boolean; @@ -170,7 +170,7 @@ export interface Message { // feedback state currentFeedback?: FeedbackType | null; - // Duration in seconds for processing this message (assistant messages only) + // Duration in seconds for processing this message (agent messages only) processingDurationSeconds?: number; } @@ -216,13 +216,13 @@ export interface BackendMessage { context_docs: OnyxDocument[] | null; time_sent: string; overridden_model: string; - alternate_assistant_id: number | null; + alternate_assistant_id: number | null; // TODO: rename to agent — https://linear.app/onyx-app/issue/ENG-3766 chat_session_id: string; citations: CitationMap | null; files: FileDescriptor[]; tool_call: ToolCallFinalResult | null; current_feedback: string | null; - // Duration in seconds for processing this message (assistant messages only) + // Duration in seconds for processing this message (agent messages only) processing_duration_seconds?: number; sub_questions: SubQuestionDetail[]; @@ -235,7 +235,7 @@ export interface BackendMessage { export interface MessageResponseIDInfo { user_message_id: number | null; - reserved_assistant_message_id: number; + reserved_assistant_message_id: number; // TODO: rename to agent — https://linear.app/onyx-app/issue/ENG-3766 } export interface UserKnowledgeFilePacket { diff --git a/web/src/app/app/message/messageComponents/AgentMessage.tsx b/web/src/app/app/message/messageComponents/AgentMessage.tsx index 467dc07ab87..96144eb8d22 100644 --- a/web/src/app/app/message/messageComponents/AgentMessage.tsx +++ b/web/src/app/app/message/messageComponents/AgentMessage.tsx @@ -36,7 +36,7 @@ export interface AgentMessageProps { onRegenerate?: RegenerationFactory; // Parent message needed to construct regeneration request parentMessage?: Message | null; - // Duration in seconds for processing this message (assistant messages only) + // Duration in seconds for processing this message (agent messages only) processingDurationSeconds?: number; } @@ -55,7 +55,7 @@ function arePropsEqual( // Compare packetCount (primitive) instead of rawPackets.length // The array is mutated in place, so reading .length from prev and next would return same value prev.packetCount === next.packetCount && - prev.chatState.assistant?.id === next.chatState.assistant?.id && + prev.chatState.agent?.id === next.chatState.agent?.id && prev.chatState.docs === next.chatState.docs && prev.chatState.citations === next.chatState.citations && prev.chatState.overriddenModel === next.chatState.overriddenModel && @@ -137,7 +137,7 @@ const AgentMessage = React.memo(function AgentMessage({ citations: mergedCitations, }), [ - chatState.assistant, + chatState.agent, chatState.docs, chatState.setPresentingDocument, chatState.overriddenModel, diff --git a/web/src/app/app/message/messageComponents/interfaces.ts b/web/src/app/app/message/messageComponents/interfaces.ts index f8d36ef67d4..dadb518259a 100644 --- a/web/src/app/app/message/messageComponents/interfaces.ts +++ b/web/src/app/app/message/messageComponents/interfaces.ts @@ -1,5 +1,5 @@ import { JSX } from "react"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { Packet, StopReason } from "../../services/streamingModels"; import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces"; import { ProjectFile } from "../../projects/projectsService"; @@ -23,7 +23,7 @@ export enum RenderType { export type TimelineLayout = "timeline" | "content"; export interface FullChatState { - assistant: MinimalPersonaSnapshot; + agent: MinimalPersonaSnapshot; // Document-related context for citations docs?: OnyxDocument[] | null; userFiles?: ProjectFile[]; diff --git a/web/src/app/app/message/messageComponents/renderMessageComponent.tsx b/web/src/app/app/message/messageComponents/renderMessageComponent.tsx index 683d1a74786..b41576c6efd 100644 --- a/web/src/app/app/message/messageComponents/renderMessageComponent.tsx +++ b/web/src/app/app/message/messageComponents/renderMessageComponent.tsx @@ -222,7 +222,7 @@ function areRendererPropsEqual( prev.stopPacketSeen === next.stopPacketSeen && prev.stopReason === next.stopReason && prev.animate === next.animate && - prev.chatState.assistant?.id === next.chatState.assistant?.id + prev.chatState.agent?.id === next.chatState.agent?.id // Skip: onComplete, children (function refs), chatState (memoized upstream) ); } diff --git a/web/src/app/app/message/messageComponents/timeline/AgentTimeline.tsx b/web/src/app/app/message/messageComponents/timeline/AgentTimeline.tsx index 19db8daf161..9e6dc54ce76 100644 --- a/web/src/app/app/message/messageComponents/timeline/AgentTimeline.tsx +++ b/web/src/app/app/message/messageComponents/timeline/AgentTimeline.tsx @@ -36,7 +36,7 @@ import { TimelineHeaderRow } from "@/app/app/message/messageComponents/timeline/ // ============================================================================= interface TimelineContainerProps { - agent: FullChatState["assistant"]; + agent: FullChatState["agent"]; headerContent?: React.ReactNode; children?: React.ReactNode; } @@ -343,7 +343,7 @@ export const AgentTimeline = React.memo(function AgentTimeline({ if (uiState === TimelineUIState.EMPTY) { return ( ; + return ; } return ( isSearchTool(tool) || isWebSearchTool(tool)); } diff --git a/web/src/app/app/services/lib.tsx b/web/src/app/app/services/lib.tsx index 56af04d6a01..b89a29741e3 100644 --- a/web/src/app/app/services/lib.tsx +++ b/web/src/app/app/services/lib.tsx @@ -18,7 +18,7 @@ import { ToolCallMetadata, UserKnowledgeFilePacket, } from "../interfaces"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { ReadonlyURLSearchParams } from "next/navigation"; import { SEARCH_PARAM_NAMES } from "./searchParams"; import { WEB_SEARCH_TOOL_ID } from "@/app/app/components/tools/constants"; @@ -306,12 +306,12 @@ export function processRawChatHistory( const messages: Map = new Map(); const parentMessageChildrenMap: Map = new Map(); - let assistantMessageInd = 0; + let agentMessageInd = 0; rawMessages.forEach((messageInfo, _ind) => { - const packetsForMessage = packets[assistantMessageInd]; + const packetsForMessage = packets[agentMessageInd]; if (messageInfo.message_type === "assistant") { - assistantMessageInd++; + agentMessageInd++; } const hasContextDocs = (messageInfo?.context_docs || []).length > 0; @@ -334,11 +334,11 @@ export function processRawChatHistory( message: messageInfo.message, type: messageInfo.message_type as "user" | "assistant", files: messageInfo.files, - alternateAssistantID: + alternateAgentID: messageInfo.alternate_assistant_id !== null ? Number(messageInfo.alternate_assistant_id) : null, - // only include these fields if this is an assistant message so that + // only include these fields if this is an agent message so that // this is identical to what is computed at streaming time ...(messageInfo.message_type === "assistant" ? { diff --git a/web/src/app/app/services/messageTree.ts b/web/src/app/app/services/messageTree.ts index 66adb917c20..2550f0ac63e 100644 --- a/web/src/app/app/services/messageTree.ts +++ b/web/src/app/app/services/messageTree.ts @@ -320,7 +320,7 @@ export function getHumanAndAIMessageFromMessageNumber( if (!message) return { humanMessage: null, aiMessage: null }; if (message.type === "user") { - // Find its latest child that is an assistant + // Find its latest child that is an agent const potentialAiMessage = message.latestChildNodeId !== null && message.latestChildNodeId !== undefined @@ -437,7 +437,7 @@ export const buildImmediateMessages = ( messageToResend?: Message ): { initialUserNode: Message; - initialAssistantNode: Message; + initialAgentNode: Message; } => { // Always create a NEW message with a new nodeId for proper branching. // When editing (messageToResend exists), this creates a sibling to the original @@ -448,17 +448,17 @@ export const buildImmediateMessages = ( message: userInput, files, }); - const initialAssistantNode = buildEmptyMessage({ + const initialAgentNode = buildEmptyMessage({ messageType: "assistant", parentNodeId: initialUserNode.nodeId, nodeIdOffset: 1, }); - initialUserNode.childrenNodeIds = [initialAssistantNode.nodeId]; - initialUserNode.latestChildNodeId = initialAssistantNode.nodeId; + initialUserNode.childrenNodeIds = [initialAgentNode.nodeId]; + initialUserNode.latestChildNodeId = initialAgentNode.nodeId; return { initialUserNode, - initialAssistantNode, + initialAgentNode, }; }; diff --git a/web/src/app/app/services/searchParams.ts b/web/src/app/app/services/searchParams.ts index e4a04ec0b86..b45597ee840 100644 --- a/web/src/app/app/services/searchParams.ts +++ b/web/src/app/app/services/searchParams.ts @@ -4,7 +4,7 @@ import { ReadonlyURLSearchParams } from "next/navigation"; export const SEARCH_PARAM_NAMES = { CHAT_ID: "chatId", SEARCH_ID: "searchId", - PERSONA_ID: "assistantId", + PERSONA_ID: "agentId", PROJECT_ID: "projectId", ALL_MY_DOCUMENTS: "allMyDocuments", // overrides diff --git a/web/src/app/app/shared/[chatId]/SharedChatDisplay.tsx b/web/src/app/app/shared/[chatId]/SharedChatDisplay.tsx index 654ace58f6f..9649727753f 100644 --- a/web/src/app/app/shared/[chatId]/SharedChatDisplay.tsx +++ b/web/src/app/app/shared/[chatId]/SharedChatDisplay.tsx @@ -9,7 +9,7 @@ import HumanMessage from "@/app/app/message/HumanMessage"; import AgentMessage from "@/app/app/message/messageComponents/AgentMessage"; import { Callout } from "@/components/ui/callout"; import OnyxInitializingLoader from "@/components/OnyxInitializingLoader"; -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; import { MinimalOnyxDocument } from "@/lib/search/interfaces"; import TextViewModal from "@/sections/modals/TextViewModal"; import { UNNAMED_CHAT } from "@/lib/constants"; @@ -106,7 +106,7 @@ export default function SharedChatDisplay({ key={message.messageId} rawPackets={message.packets} chatState={{ - assistant: persona, + agent: persona, docs: message.documents, citations: message.citations, setPresentingDocument: setPresentingDocument, diff --git a/web/src/app/app/shared/[chatId]/page.tsx b/web/src/app/app/shared/[chatId]/page.tsx index 03d97b4298c..6047974a85c 100644 --- a/web/src/app/app/shared/[chatId]/page.tsx +++ b/web/src/app/app/shared/[chatId]/page.tsx @@ -4,7 +4,7 @@ import type { Route } from "next"; import { requireAuth } from "@/lib/auth/requireAuth"; import SharedChatDisplay from "@/app/app/shared/[chatId]/SharedChatDisplay"; import * as AppLayouts from "@/layouts/app-layouts"; -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; // This is used for rendering a persona in the shared chat display export function constructMiniFiedPersona(name: string, id: number): Persona { diff --git a/web/src/app/craft/components/BuildMessageList.tsx b/web/src/app/craft/components/BuildMessageList.tsx index 025f58d0b4f..cd7e2f90d23 100644 --- a/web/src/app/craft/components/BuildMessageList.tsx +++ b/web/src/app/craft/components/BuildMessageList.tsx @@ -78,7 +78,7 @@ interface BuildMessageListProps { * BuildMessageList - Displays the conversation history with FIFO rendering * * User messages are shown as right-aligned bubbles. - * Assistant responses render streamItems in exact chronological order: + * Agent responses render streamItems in exact chronological order: * text, thinking, and tool calls appear exactly as they arrived. */ export default function BuildMessageList({ @@ -161,8 +161,8 @@ export default function BuildMessageList({ }); }; - // Helper to render an assistant message - const renderAssistantMessage = (message: BuildMessage) => { + // Helper to render an agent message + const renderAgentMessage = (message: BuildMessage) => { // Check if we have saved stream items in message_metadata const savedStreamItems = message.message_metadata?.streamItems as | StreamItem[] @@ -189,12 +189,12 @@ export default function BuildMessageList({ return (
- {/* Render messages in order (user and assistant interleaved) */} + {/* Render messages in order (user and agent interleaved) */} {messages.map((message) => message.type === "user" ? ( ) : message.type === "assistant" ? ( - renderAssistantMessage(message) + renderAgentMessage(message) ) : null )} diff --git a/web/src/app/craft/components/ChatPanel.tsx b/web/src/app/craft/components/ChatPanel.tsx index 3abecea59ed..c8a8b04d768 100644 --- a/web/src/app/craft/components/ChatPanel.tsx +++ b/web/src/app/craft/components/ChatPanel.tsx @@ -233,18 +233,18 @@ export default function BuildChatPanel({ inputBarRef.current?.setMessage(text); }, []); - // Check if assistant has finished streaming at least one message - // Show banner only after first assistant message completes streaming + // Check if agent has finished streaming at least one message + // Show banner only after first agent message completes streaming const shouldShowConnectorBanner = useMemo(() => { // Don't show if currently streaming if (isRunning) { return false; } - // Check if there's at least one assistant message in the session - const hasAssistantMessage = session?.messages?.some( + // Check if there's at least one agent message in the session + const hasAgentMessage = session?.messages?.some( (msg) => msg.type === "assistant" ); - return hasAssistantMessage ?? false; + return hasAgentMessage ?? false; }, [isRunning, session?.messages]); const handleSubmit = useCallback( @@ -466,7 +466,7 @@ export default function BuildChatPanel({
)} - {/* Follow-up suggestion bubbles - show after first assistant message */} + {/* Follow-up suggestion bubbles - show after first agent message */} {(followupSuggestions || suggestionsLoading) && (
)} - {/* Connector banners - show after first assistant message finishes streaming */} + {/* Connector banners - show after first agent message finishes streaming */} {shouldShowConnectorBanner && ( )} diff --git a/web/src/app/craft/components/ConnectorBannersRow.tsx b/web/src/app/craft/components/ConnectorBannersRow.tsx index 24e9aa33d8f..9b044ded1b8 100644 --- a/web/src/app/craft/components/ConnectorBannersRow.tsx +++ b/web/src/app/craft/components/ConnectorBannersRow.tsx @@ -30,7 +30,7 @@ function IconWrapper({ children }: { children: React.ReactNode }) { } /** - * Row of two banners that appear above the InputBar after first assistant response. + * Row of two banners that appear above the InputBar after first agent response. * - Left: "Connect your data" - exact same look as welcome page banner, but flipped * - Right: "Get help setting up connectors" - links to cal.com booking * diff --git a/web/src/app/craft/components/SuggestionBubbles.tsx b/web/src/app/craft/components/SuggestionBubbles.tsx index fb74295ce61..2ffe3040506 100644 --- a/web/src/app/craft/components/SuggestionBubbles.tsx +++ b/web/src/app/craft/components/SuggestionBubbles.tsx @@ -24,7 +24,7 @@ function getThemeStyles(theme: string): string { } /** - * Displays follow-up suggestion bubbles after the first assistant message. + * Displays follow-up suggestion bubbles after the first agent message. * Styled like user chat messages - stacked vertically and right-aligned. * Each bubble is clickable and populates the input bar with the suggestion text. */ diff --git a/web/src/app/craft/hooks/useBuildSessionStore.ts b/web/src/app/craft/hooks/useBuildSessionStore.ts index f8c00920ef1..37445b1ca5e 100644 --- a/web/src/app/craft/hooks/useBuildSessionStore.ts +++ b/web/src/app/craft/hooks/useBuildSessionStore.ts @@ -51,7 +51,7 @@ import { parsePacket } from "@/app/craft/utils/parsePacket"; * - tool_call_progress: Full tool call data with status="completed" * - agent_plan_update: Plan entries (not rendered as stream items) * - * This function converts assistant messages to StreamItem[] for rendering. + * This function converts agent messages to StreamItem[] for rendering. */ function convertMessagesToStreamItems(messages: BuildMessage[]): StreamItem[] { const items: StreamItem[] = []; @@ -149,40 +149,38 @@ function convertMessagesToStreamItems(messages: BuildMessage[]): StreamItem[] { * Consolidate raw backend messages into proper conversation turns. * * The backend stores each streaming packet as a separate message. This function: - * 1. Groups consecutive assistant messages (between user messages) into turns + * 1. Groups consecutive agent messages (between user messages) into turns * 2. Converts each group's packets to streamItems * 3. Creates consolidated messages with streamItems in message_metadata * - * Returns: Array of consolidated messages (user messages + one assistant message per turn) + * Returns: Array of consolidated messages (user messages + one agent message per turn) */ function consolidateMessagesIntoTurns( rawMessages: BuildMessage[] ): BuildMessage[] { const consolidated: BuildMessage[] = []; - let currentAssistantPackets: BuildMessage[] = []; + let currentAgentPackets: BuildMessage[] = []; for (const message of rawMessages) { if (message.type === "user") { - // If we have accumulated assistant packets, consolidate them into one message - if (currentAssistantPackets.length > 0) { - const streamItems = convertMessagesToStreamItems( - currentAssistantPackets - ); + // If we have accumulated agent packets, consolidate them into one message + if (currentAgentPackets.length > 0) { + const streamItems = convertMessagesToStreamItems(currentAgentPackets); const textContent = streamItems .filter((item) => item.type === "text") .map((item) => item.content) .join(""); consolidated.push({ - id: currentAssistantPackets[0]?.id || genId("assistant-msg"), + id: currentAgentPackets[0]?.id || genId("agent-msg"), type: "assistant", content: textContent, - timestamp: currentAssistantPackets[0]?.timestamp || new Date(), + timestamp: currentAgentPackets[0]?.timestamp || new Date(), message_metadata: { streamItems, }, }); - currentAssistantPackets = []; + currentAgentPackets = []; } // Add the user message as-is consolidated.push(message); @@ -190,48 +188,46 @@ function consolidateMessagesIntoTurns( // Check if this message already has consolidated streamItems (from new format) if (message.message_metadata?.streamItems) { // Already consolidated, add as-is - if (currentAssistantPackets.length > 0) { + if (currentAgentPackets.length > 0) { // Flush any pending packets first - const streamItems = convertMessagesToStreamItems( - currentAssistantPackets - ); + const streamItems = convertMessagesToStreamItems(currentAgentPackets); const textContent = streamItems .filter((item) => item.type === "text") .map((item) => item.content) .join(""); consolidated.push({ - id: currentAssistantPackets[0]?.id || genId("assistant-msg"), + id: currentAgentPackets[0]?.id || genId("agent-msg"), type: "assistant", content: textContent, - timestamp: currentAssistantPackets[0]?.timestamp || new Date(), + timestamp: currentAgentPackets[0]?.timestamp || new Date(), message_metadata: { streamItems, }, }); - currentAssistantPackets = []; + currentAgentPackets = []; } consolidated.push(message); } else { // Old format - accumulate for consolidation - currentAssistantPackets.push(message); + currentAgentPackets.push(message); } } } - // Don't forget any trailing assistant packets - if (currentAssistantPackets.length > 0) { - const streamItems = convertMessagesToStreamItems(currentAssistantPackets); + // Don't forget any trailing agent packets + if (currentAgentPackets.length > 0) { + const streamItems = convertMessagesToStreamItems(currentAgentPackets); const textContent = streamItems .filter((item) => item.type === "text") .map((item) => item.content) .join(""); consolidated.push({ - id: currentAssistantPackets[0]?.id || genId("assistant-msg"), + id: currentAgentPackets[0]?.id || genId("agent-msg"), type: "assistant", content: textContent, - timestamp: currentAssistantPackets[0]?.timestamp || new Date(), + timestamp: currentAgentPackets[0]?.timestamp || new Date(), message_metadata: { streamItems, }, @@ -309,7 +305,7 @@ export interface BuildSessionData { /** Active tool calls for the current response */ toolCalls: ToolCall[]; /** - * FIFO stream items for the current assistant turn. + * FIFO stream items for the current agent turn. * Items are stored in chronological order as they arrive. * Rendered directly without transformation. */ @@ -336,7 +332,7 @@ export interface BuildSessionData { filesTabState: FilesTabState; /** Browser-style tab navigation history for back/forward */ tabHistory: TabNavigationHistory; - /** Follow-up suggestions after first assistant message */ + /** Follow-up suggestions after first agent message */ followupSuggestions: SuggestionBubble[] | null; /** Whether suggestions are currently being generated */ suggestionsLoading: boolean; diff --git a/web/src/app/craft/hooks/useBuildStreaming.ts b/web/src/app/craft/hooks/useBuildStreaming.ts index 4513f737c95..a93fc9b38b0 100644 --- a/web/src/app/craft/hooks/useBuildStreaming.ts +++ b/web/src/app/craft/hooks/useBuildStreaming.ts @@ -347,7 +347,7 @@ export function useBuildStreaming() { .map((item) => item.content) .join(""); - const isFirstAssistantMessage = + const isFirstAgentMessage = session.messages.filter((m) => m.type === "assistant") .length === 0; @@ -355,11 +355,7 @@ export function useBuildStreaming() { (m) => m.type === "user" ); - if ( - isFirstAssistantMessage && - firstUserMessage && - textContent - ) { + if (isFirstAgentMessage && firstUserMessage && textContent) { (async () => { try { setSuggestionsLoading(sessionId, true); @@ -377,7 +373,7 @@ export function useBuildStreaming() { } appendMessageToSession(sessionId, { - id: genId("assistant-msg"), + id: genId("agent-msg"), type: "assistant", content: textContent, timestamp: new Date(), diff --git a/web/src/app/craft/services/apiServices.ts b/web/src/app/craft/services/apiServices.ts index 17e04e2eca2..1c314577d1a 100644 --- a/web/src/app/craft/services/apiServices.ts +++ b/web/src/app/craft/services/apiServices.ts @@ -162,7 +162,7 @@ export interface SuggestionBubble { export async function generateFollowupSuggestions( sessionId: string, userMessage: string, - assistantMessage: string + agentMessage: string ): Promise { const res = await fetch( `${API_BASE}/sessions/${sessionId}/generate-suggestions`, @@ -171,7 +171,7 @@ export async function generateFollowupSuggestions( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_message: userMessage, - assistant_message: assistantMessage, + assistant_message: agentMessage, }), } ); diff --git a/web/src/app/craft/types/streamingTypes.ts b/web/src/app/craft/types/streamingTypes.ts index 32b51ddd6fd..216a706b905 100644 --- a/web/src/app/craft/types/streamingTypes.ts +++ b/web/src/app/craft/types/streamingTypes.ts @@ -76,7 +76,7 @@ export interface BuildMessage { timestamp: Date; /** Structured ACP event data (tool calls, thinking, plans) */ message_metadata?: Record | null; - /** Tool calls associated with this message (for assistant messages) */ + /** Tool calls associated with this message (for agent messages) */ toolCalls?: ToolCall[]; } diff --git a/web/src/app/ee/admin/performance/usage/PersonaMessagesChart.tsx b/web/src/app/ee/admin/performance/usage/PersonaMessagesChart.tsx index 096007ef06d..a13a075cff8 100644 --- a/web/src/app/ee/admin/performance/usage/PersonaMessagesChart.tsx +++ b/web/src/app/ee/admin/performance/usage/PersonaMessagesChart.tsx @@ -18,7 +18,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useState, useMemo, useEffect } from "react"; -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; export function PersonaMessagesChart({ availablePersonas, diff --git a/web/src/app/ee/assistants/stats/[id]/AssistantStats.tsx b/web/src/app/ee/agents/stats/[id]/AgentStats.tsx similarity index 74% rename from web/src/app/ee/assistants/stats/[id]/AssistantStats.tsx rename to web/src/app/ee/agents/stats/[id]/AgentStats.tsx index 92901426343..bb192c1da37 100644 --- a/web/src/app/ee/assistants/stats/[id]/AssistantStats.tsx +++ b/web/src/app/ee/agents/stats/[id]/AgentStats.tsx @@ -12,22 +12,21 @@ import AgentAvatar from "@/refresh-components/avatars/AgentAvatar"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { AreaChartDisplay } from "@/components/ui/areaChart"; -type AssistantDailyUsageEntry = { +type AgentDailyUsageEntry = { date: string; total_messages: number; total_unique_users: number; }; -type AssistantStatsResponse = { - daily_stats: AssistantDailyUsageEntry[]; +type AgentStatsResponse = { + daily_stats: AgentDailyUsageEntry[]; total_messages: number; total_unique_users: number; }; -export function AssistantStats({ assistantId }: { assistantId: number }) { - const [assistantStats, setAssistantStats] = - useState(null); - const { agents: assistants } = useAgents(); +export function AgentStats({ agentId }: { agentId: number }) { + const [agentStats, setAgentStats] = useState(null); + const { agents } = useAgents(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [dateRange, setDateRange] = useState({ @@ -35,9 +34,9 @@ export function AssistantStats({ assistantId }: { assistantId: number }) { to: new Date(), }); - const assistant = useMemo(() => { - return assistants.find((a) => a.id === assistantId); - }, [assistants, assistantId]); + const agent = useMemo(() => { + return agents.find((a) => a.id === agentId); + }, [agents, agentId]); useEffect(() => { async function fetchStats() { @@ -46,7 +45,7 @@ export function AssistantStats({ assistantId }: { assistantId: number }) { setError(null); const res = await fetch( - `/api/analytics/assistant/${assistantId}/stats?start=${ + `/api/analytics/assistant/${agentId}/stats?start=${ dateRange?.from?.toISOString() || "" }&end=${dateRange?.to?.toISOString() || ""}` ); @@ -55,11 +54,11 @@ export function AssistantStats({ assistantId }: { assistantId: number }) { if (res.status === 403) { throw new Error("You don't have permission to view these stats."); } - throw new Error("Failed to fetch assistant stats"); + throw new Error("Failed to fetch agent stats"); } - const data = (await res.json()) as AssistantStatsResponse; - setAssistantStats(data); + const data = (await res.json()) as AgentStatsResponse; + setAgentStats(data); } catch (err) { setError( err instanceof Error ? err.message : "An unknown error occurred" @@ -70,10 +69,10 @@ export function AssistantStats({ assistantId }: { assistantId: number }) { } fetchStats(); - }, [assistantId, dateRange]); + }, [agentId, dateRange]); const chartData = useMemo(() => { - if (!assistantStats?.daily_stats?.length || !dateRange) { + if (!agentStats?.daily_stats?.length || !dateRange) { return null; } @@ -81,7 +80,7 @@ export function AssistantStats({ assistantId }: { assistantId: number }) { dateRange.from || new Date( Math.min( - ...assistantStats.daily_stats.map((entry) => + ...agentStats.daily_stats.map((entry) => new Date(entry.date).getTime() ) ) @@ -91,7 +90,7 @@ export function AssistantStats({ assistantId }: { assistantId: number }) { const dateRangeList = getDatesList(initialDate); const statsMap = new Map( - assistantStats.daily_stats.map((entry) => [entry.date, entry]) + agentStats.daily_stats.map((entry) => [entry.date, entry]) ); return dateRangeList @@ -104,13 +103,13 @@ export function AssistantStats({ assistantId }: { assistantId: number }) { "Unique Users": dayData?.total_unique_users || 0, }; }); - }, [assistantStats, dateRange]); + }, [agentStats, dateRange]); - const totalMessages = assistantStats?.total_messages ?? 0; - const totalUniqueUsers = assistantStats?.total_unique_users ?? 0; + const totalMessages = agentStats?.total_messages ?? 0; + const totalUniqueUsers = agentStats?.total_unique_users ?? 0; let content; - if (isLoading || !assistant) { + if (isLoading || !agent) { content = (
@@ -122,11 +121,11 @@ export function AssistantStats({ assistantId }: { assistantId: number }) {

{error}

); - } else if (!assistantStats?.daily_stats?.length) { + } else if (!agentStats?.daily_stats?.length) { content = (

- No data found for this assistant in the selected date range + No data found for this agent in the selected date range

); @@ -157,12 +156,10 @@ export function AssistantStats({ assistantId }: { assistantId: number }) {
- {assistant && } + {agent && }
-

{assistant?.name}

-

- {assistant?.description} -

+

{agent?.name}

+

{agent?.description}

diff --git a/web/src/app/ee/assistants/stats/[id]/page.tsx b/web/src/app/ee/agents/stats/[id]/page.tsx similarity index 89% rename from web/src/app/ee/assistants/stats/[id]/page.tsx rename to web/src/app/ee/agents/stats/[id]/page.tsx index 5813df9c022..bd96ef74c01 100644 --- a/web/src/app/ee/assistants/stats/[id]/page.tsx +++ b/web/src/app/ee/agents/stats/[id]/page.tsx @@ -3,7 +3,7 @@ import { unstable_noStore as noStore } from "next/cache"; import { redirect } from "next/navigation"; import type { Route } from "next"; import { requireAuth } from "@/lib/auth/requireAuth"; -import { AssistantStats } from "./AssistantStats"; +import { AgentStats } from "./AgentStats"; import BackButton from "@/refresh-components/buttons/BackButton"; export default async function GalleryPage(props: { @@ -29,7 +29,7 @@ export default async function GalleryPage(props: {
- +
diff --git a/web/src/app/nrf/NRFPage.tsx b/web/src/app/nrf/NRFPage.tsx index 88130db4d46..533c9350577 100644 --- a/web/src/app/nrf/NRFPage.tsx +++ b/web/src/app/nrf/NRFPage.tsx @@ -67,8 +67,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) { const { refreshChatSessions } = useChatSessions(); const existingChatSessionId = null; // NRF always starts new chats - // Get agents for assistant selection - const { agents: availableAssistants } = useAgents(); + // Get agents for agent selection + const { agents: availableAgents } = useAgents(); // Projects context for file handling const { @@ -92,25 +92,25 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) { }, [lastFailedFiles, clearLastFailedFiles]); // Assistant controller - const { selectedAssistant, setSelectedAssistantFromId, liveAssistant } = + const { selectedAgent, setSelectedAgentFromId, liveAgent } = useAgentController({ selectedChatSession: undefined, - onAssistantSelect: () => {}, + onAgentSelect: () => {}, }); // LLM manager for model selection. // - currentChatSession: undefined because NRF always starts new chats - // - liveAssistant: uses the selected assistant, or undefined to fall back + // - liveAgent: uses the selected assistant, or undefined to fall back // to system-wide default LLM provider. // // If no LLM provider is configured (e.g., fresh signup), the input bar is // disabled and a "Set up an LLM" button is shown (see bottom of component). - const llmManager = useLlmManager(undefined, liveAssistant ?? undefined); + const llmManager = useLlmManager(undefined, liveAgent ?? undefined); // Deep research toggle const { deepResearchEnabled, toggleDeepResearch } = useDeepResearchToggle({ chatSessionId: existingChatSessionId, - assistantId: selectedAssistant?.id, + agentId: selectedAgent?.id, }); // State @@ -169,7 +169,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) { const hasMessages = messageHistory.length > 0; // Resolved assistant to use throughout the component - const resolvedAssistant = liveAssistant ?? undefined; + const resolvedAgent = liveAgent ?? undefined; // Auto-scroll preference from user settings (matches ChatPage pattern) const autoScrollEnabled = user?.preferences?.auto_scroll !== false; @@ -178,13 +178,13 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) { // Query controller for search/chat classification (EE feature) const { submit: submitQuery, classification } = useQueryController(); - // Determine if retrieval (search) is enabled based on the assistant + // Determine if retrieval (search) is enabled based on the agent const retrievalEnabled = useMemo(() => { - if (liveAssistant) { - return personaIncludesRetrieval(liveAssistant); + if (liveAgent) { + return personaIncludesRetrieval(liveAgent); } return false; - }, [liveAssistant]); + }, [liveAgent]); // Check if we're in search mode const isSearch = classification === "search"; @@ -248,13 +248,13 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) { useChatController({ filterManager, llmManager, - availableAssistants: availableAssistants || [], - liveAssistant, + availableAgents: availableAgents || [], + liveAgent, existingChatSessionId, selectedDocuments: [], searchParams: searchParams!, resetInputBar, - setSelectedAssistantFromId, + setSelectedAgentFromId, }); // Chat session controller for loading sessions @@ -263,7 +263,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) { searchParams: searchParams!, filterManager, firstMessage: undefined, - setSelectedAssistantFromId, + setSelectedAgentFromId, setSelectedDocuments: () => {}, // No-op: NRF doesn't support document selection setCurrentMessageFiles, chatSessionIdRef: { current: null }, @@ -437,7 +437,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) { )} > {/* Chat area with messages */} - {hasMessages && resolvedAssistant && ( + {hasMessages && resolvedAgent && ( <> {/* Fake header - pushes content below absolute settings button (non-side-panel only) */} {!isSidePanel && } @@ -449,7 +449,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) { hideScrollbar={isSidePanel} > - diff --git a/web/src/components/sidebar/ChatSessionMorePopup.tsx b/web/src/components/sidebar/ChatSessionMorePopup.tsx index 464e1fb7610..b539d8e4ab7 100644 --- a/web/src/components/sidebar/ChatSessionMorePopup.tsx +++ b/web/src/components/sidebar/ChatSessionMorePopup.tsx @@ -61,8 +61,7 @@ export function ChatSessionMorePopup({ const [showMoveCustomAgentModal, setShowMoveCustomAgentModal] = useState(false); - const isChatUsingDefaultAssistant = - chatSession.persona_id === DEFAULT_PERSONA_ID; + const isChatUsingDefaultAgent = chatSession.persona_id === DEFAULT_PERSONA_ID; const [showMoveOptions, setShowMoveOptions] = useState(false); const [searchTerm, setSearchTerm] = useState(""); @@ -114,7 +113,7 @@ export function ChatSessionMorePopup({ window.localStorage.getItem(LS_HIDE_MOVE_CUSTOM_AGENT_MODAL_KEY) === "true"; - if (!isChatUsingDefaultAssistant && !hideModal) { + if (!isChatUsingDefaultAgent && !hideModal) { setPendingMoveProjectId(targetProjectId); setShowMoveCustomAgentModal(true); return; @@ -122,7 +121,7 @@ export function ChatSessionMorePopup({ await performMove(targetProjectId); }, - [isChatUsingDefaultAssistant, performMove] + [isChatUsingDefaultAgent, performMove] ); const handleRemoveChatSessionFromProject = useCallback(async () => { diff --git a/web/src/components/sidebar/types.ts b/web/src/components/sidebar/types.ts index 6aeffb84c8e..71241b1658a 100644 --- a/web/src/components/sidebar/types.ts +++ b/web/src/components/sidebar/types.ts @@ -1 +1 @@ -export type pageType = "search" | "chat" | "assistants" | "admin" | "shared"; +export type pageType = "search" | "chat" | "agents" | "admin" | "shared"; diff --git a/web/src/hooks/appNavigation.ts b/web/src/hooks/appNavigation.ts index 04f81248030..e7fba2d34c9 100644 --- a/web/src/hooks/appNavigation.ts +++ b/web/src/hooks/appNavigation.ts @@ -9,18 +9,12 @@ interface UseAppRouterProps { chatSessionId?: string; agentId?: number; projectId?: number; - assistantId?: number; } export function useAppRouter() { const router = useRouter(); return useCallback( - ({ - chatSessionId, - agentId, - projectId, - assistantId, - }: UseAppRouterProps = {}) => { + ({ chatSessionId, agentId, projectId }: UseAppRouterProps = {}) => { const finalParams = []; if (chatSessionId) @@ -29,8 +23,6 @@ export function useAppRouter() { finalParams.push(`${SEARCH_PARAM_NAMES.PERSONA_ID}=${agentId}`); else if (projectId) finalParams.push(`${SEARCH_PARAM_NAMES.PROJECT_ID}=${projectId}`); - else if (assistantId) - finalParams.push(`${SEARCH_PARAM_NAMES.PERSONA_ID}=${assistantId}`); const finalString = finalParams.join("&"); const finalUrl = `/app?${finalString}`; diff --git a/web/src/hooks/useAdminPersonas.ts b/web/src/hooks/useAdminPersonas.ts index f8a5321162c..d6432d761de 100644 --- a/web/src/hooks/useAdminPersonas.ts +++ b/web/src/hooks/useAdminPersonas.ts @@ -3,7 +3,7 @@ import useSWR from "swr"; import { errorHandlingFetcher } from "@/lib/fetcher"; import { buildApiPath } from "@/lib/urlBuilder"; -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; interface UseAdminPersonasOptions { includeDeleted?: boolean; diff --git a/web/src/hooks/useAgentController.ts b/web/src/hooks/useAgentController.ts index 00bb1ef9062..fe221087072 100644 --- a/web/src/hooks/useAgentController.ts +++ b/web/src/hooks/useAgentController.ts @@ -1,6 +1,6 @@ "use client"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { useCallback, useMemo, useState } from "react"; import { ChatSession } from "@/app/app/interfaces"; import { useAgents, usePinnedAgents } from "@/hooks/useAgents"; @@ -10,38 +10,34 @@ import { useSettingsContext } from "@/providers/SettingsProvider"; export default function useAgentController({ selectedChatSession, - onAssistantSelect, + onAgentSelect, }: { selectedChatSession: ChatSession | null | undefined; - onAssistantSelect?: () => void; + onAgentSelect?: () => void; }) { const searchParams = useSearchParams(); - const { agents: availableAssistants } = useAgents(); - const { pinnedAgents: pinnedAssistants } = usePinnedAgents(); + const { agents: availableAgents } = useAgents(); + const { pinnedAgents: pinnedAgents } = usePinnedAgents(); const combinedSettings = useSettingsContext(); - const defaultAssistantIdRaw = searchParams?.get( - SEARCH_PARAM_NAMES.PERSONA_ID - ); - const defaultAssistantId = defaultAssistantIdRaw - ? parseInt(defaultAssistantIdRaw) + const defaultAgentIdRaw = searchParams?.get(SEARCH_PARAM_NAMES.PERSONA_ID); + const defaultAgentId = defaultAgentIdRaw + ? parseInt(defaultAgentIdRaw) : undefined; - const existingChatSessionAssistantId = selectedChatSession?.persona_id; - const [selectedAssistant, setSelectedAssistant] = useState< + const existingChatSessionAgentId = selectedChatSession?.persona_id; + const [selectedAgent, setSelectedAssistant] = useState< MinimalPersonaSnapshot | undefined >( // NOTE: look through available assistants here, so that even if the user - // has hidden this assistant it still shows the correct assistant when + // has hidden this agent it still shows the correct assistant when // going back to an old chat session - existingChatSessionAssistantId !== undefined - ? availableAssistants.find( - (assistant) => assistant.id === existingChatSessionAssistantId + existingChatSessionAgentId !== undefined + ? availableAgents.find( + (assistant) => assistant.id === existingChatSessionAgentId ) - : defaultAssistantId !== undefined - ? availableAssistants.find( - (assistant) => assistant.id === defaultAssistantId - ) + : defaultAgentId !== undefined + ? availableAgents.find((assistant) => assistant.id === defaultAgentId) : undefined ); @@ -52,8 +48,8 @@ export default function useAgentController({ // 4. First pinned assistants (ordered list of pinned assistants) // 5. Available assistants (ordered list of available assistants) // Relevant test: `live_assistant.spec.ts` - const liveAssistant: MinimalPersonaSnapshot | undefined = useMemo(() => { - if (selectedAssistant) return selectedAssistant; + const liveAgent: MinimalPersonaSnapshot | undefined = useMemo(() => { + if (selectedAgent) return selectedAgent; const disableDefaultAssistant = combinedSettings?.settings?.disable_default_assistant ?? false; @@ -61,58 +57,51 @@ export default function useAgentController({ if (disableDefaultAssistant) { // Skip unified assistant (ID 0), go straight to pinned/available // Filter out ID 0 from both pinned and available assistants - const nonDefaultPinned = pinnedAssistants.filter((a) => a.id !== 0); - const nonDefaultAvailable = availableAssistants.filter((a) => a.id !== 0); + const nonDefaultPinned = pinnedAgents.filter((a) => a.id !== 0); + const nonDefaultAvailable = availableAgents.filter((a) => a.id !== 0); return ( - nonDefaultPinned[0] || nonDefaultAvailable[0] || availableAssistants[0] // Last resort fallback + nonDefaultPinned[0] || nonDefaultAvailable[0] || availableAgents[0] // Last resort fallback ); } // Try to use the unified assistant (ID 0) as default - const unifiedAssistant = availableAssistants.find((a) => a.id === 0); - if (unifiedAssistant) return unifiedAssistant; + const unifiedAgent = availableAgents.find((a) => a.id === 0); + if (unifiedAgent) return unifiedAgent; // Fall back to pinned or available assistants - return pinnedAssistants[0] || availableAssistants[0]; - }, [ - selectedAssistant, - pinnedAssistants, - availableAssistants, - combinedSettings, - ]); + return pinnedAgents[0] || availableAgents[0]; + }, [selectedAgent, pinnedAgents, availableAgents, combinedSettings]); - const setSelectedAssistantFromId = useCallback( - (assistantId: number | null | undefined) => { + const setSelectedAgentFromId = useCallback( + (agentId: number | null | undefined) => { // NOTE: also intentionally look through available assistants here, so that - // even if the user has hidden an assistant they can still go back to it + // even if the user has hidden an agent they can still go back to it // for old chats let newAssistant = - assistantId !== null - ? availableAssistants.find( - (assistant) => assistant.id === assistantId - ) + agentId !== null + ? availableAgents.find((assistant) => assistant.id === agentId) : undefined; - // if no assistant was passed in / found, use the default assistant - if (!newAssistant && defaultAssistantId !== undefined) { - newAssistant = availableAssistants.find( - (assistant) => assistant.id === defaultAssistantId + // if no assistant was passed in / found, use the default agent + if (!newAssistant && defaultAgentId !== undefined) { + newAssistant = availableAgents.find( + (assistant) => assistant.id === defaultAgentId ); } setSelectedAssistant(newAssistant); - onAssistantSelect?.(); + onAgentSelect?.(); }, - [availableAssistants, defaultAssistantId, onAssistantSelect] + [availableAgents, defaultAgentId, onAgentSelect] ); return { // main assistant selection - selectedAssistant, - setSelectedAssistantFromId, + selectedAgent, + setSelectedAgentFromId, // final computed assistant - liveAssistant, + liveAgent, }; } diff --git a/web/src/hooks/useAgentPreferences.ts b/web/src/hooks/useAgentPreferences.ts index 399ca903de1..c52a90bf43e 100644 --- a/web/src/hooks/useAgentPreferences.ts +++ b/web/src/hooks/useAgentPreferences.ts @@ -2,24 +2,26 @@ import useSWR from "swr"; import { - UserSpecificAssistantPreference, - UserSpecificAssistantPreferences, + UserSpecificAgentPreference, + UserSpecificAgentPreferences, } from "@/lib/types"; import { errorHandlingFetcher } from "@/lib/fetcher"; import { useCallback } from "react"; -const ASSISTANT_PREFERENCES_URL = "/api/user/assistant/preferences"; +// TODO: rename to agent — https://linear.app/onyx-app/issue/ENG-3766 +const AGENT_PREFERENCES_URL = "/api/user/assistant/preferences"; -const buildUpdateAssistantPreferenceUrl = (assistantId: number) => - `/api/user/assistant/${assistantId}/preferences`; +// TODO: rename to agent — https://linear.app/onyx-app/issue/ENG-3766 +const buildUpdateAgentPreferenceUrl = (agentId: number) => + `/api/user/assistant/${agentId}/preferences`; /** - * Hook for managing user-specific assistant preferences using SWR. + * Hook for managing user-specific agent preferences using SWR. * Provides automatic caching, deduplication, and revalidation. */ export default function useAgentPreferences() { - const { data, mutate } = useSWR( - ASSISTANT_PREFERENCES_URL, + const { data, mutate } = useSWR( + AGENT_PREFERENCES_URL, errorHandlingFetcher, { revalidateOnFocus: false, @@ -27,39 +29,36 @@ export default function useAgentPreferences() { } ); - const setSpecificAssistantPreferences = useCallback( + const setSpecificAgentPreferences = useCallback( async ( - assistantId: number, - newAssistantPreference: UserSpecificAssistantPreference + agentId: number, + newAgentPreference: UserSpecificAgentPreference ) => { // Optimistic update mutate( { ...data, - [assistantId]: newAssistantPreference, + [agentId]: newAgentPreference, }, false ); try { - const response = await fetch( - buildUpdateAssistantPreferenceUrl(assistantId), - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(newAssistantPreference), - } - ); + const response = await fetch(buildUpdateAgentPreferenceUrl(agentId), { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(newAgentPreference), + }); if (!response.ok) { console.error( - `Failed to update assistant preferences: ${response.status}` + `Failed to update agent preferences: ${response.status}` ); } } catch (error) { - console.error("Error updating assistant preferences:", error); + console.error("Error updating agent preferences:", error); } // Revalidate after update @@ -69,7 +68,7 @@ export default function useAgentPreferences() { ); return { - assistantPreferences: data ?? null, - setSpecificAssistantPreferences, + agentPreferences: data ?? null, + setSpecificAgentPreferences, }; } diff --git a/web/src/hooks/useAgents.ts b/web/src/hooks/useAgents.ts index 08463cc6900..7d3b7d54bdb 100644 --- a/web/src/hooks/useAgents.ts +++ b/web/src/hooks/useAgents.ts @@ -5,7 +5,7 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import { MinimalPersonaSnapshot, FullPersona, -} from "@/app/admin/assistants/interfaces"; +} from "@/app/admin/agents/interfaces"; import { errorHandlingFetcher } from "@/lib/fetcher"; import { pinAgents } from "@/lib/agents"; import { useUser } from "@/providers/UserProvider"; @@ -169,7 +169,7 @@ export function usePinnedAgents() { /** * Hook to determine the currently active agent based on: - * 1. URL param `assistantId` + * 1. URL param `agentId` * 2. Chat session's `persona_id` * 3. Falls back to null if neither is present */ diff --git a/web/src/hooks/useChatController.ts b/web/src/hooks/useChatController.ts index 4693d28c23e..633a6d95a2b 100644 --- a/web/src/hooks/useChatController.ts +++ b/web/src/hooks/useChatController.ts @@ -17,7 +17,7 @@ import { buildImmediateMessages, buildEmptyMessage, } from "@/app/app/services/messageTree"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams"; import { SEARCH_TOOL_ID } from "@/app/app/components/tools/constants"; import { OnyxDocument } from "@/lib/search/interfaces"; @@ -102,13 +102,13 @@ interface RegenerationRequest { interface UseChatControllerProps { filterManager: FilterManager; llmManager: LlmManager; - liveAssistant: MinimalPersonaSnapshot | undefined; - availableAssistants: MinimalPersonaSnapshot[]; + liveAgent: MinimalPersonaSnapshot | undefined; + availableAgents: MinimalPersonaSnapshot[]; existingChatSessionId: string | null; selectedDocuments: OnyxDocument[]; searchParams: ReadonlyURLSearchParams; resetInputBar: () => void; - setSelectedAssistantFromId: (assistantId: number | null) => void; + setSelectedAgentFromId: (agentId: number | null) => void; } async function stopChatSession(chatSessionId: string): Promise { @@ -127,12 +127,12 @@ async function stopChatSession(chatSessionId: string): Promise { export default function useChatController({ filterManager, llmManager, - availableAssistants, - liveAssistant, + availableAgents, + liveAgent, existingChatSessionId, selectedDocuments, resetInputBar, - setSelectedAssistantFromId, + setSelectedAgentFromId, }: UseChatControllerProps) { const pathname = usePathname(); const router = useRouter(); @@ -140,7 +140,7 @@ export default function useChatController({ const params = useAppParams(); const { refreshChatSessions, addPendingChatSession } = useChatSessions(); const { pinnedAgents, togglePinnedAgent } = usePinnedAgents(); - const { assistantPreferences } = useAgentPreferences(); + const { agentPreferences } = useAgentPreferences(); const { forcedToolIds } = useForcedTools(); const { fetchProjects, setCurrentMessageFiles, beginUpload } = useProjectsContext(); @@ -462,12 +462,12 @@ export default function useChatController({ } // Auto-pin the agent to sidebar when sending a message if not already pinned - if (liveAssistant) { + if (liveAgent) { const isAlreadyPinned = pinnedAgents.some( - (agent) => agent.id === liveAssistant.id + (agent) => agent.id === liveAgent.id ); if (!isAlreadyPinned) { - togglePinnedAgent(liveAssistant, true).catch((err) => { + togglePinnedAgent(liveAgent, true).catch((err) => { console.error("Failed to auto-pin agent:", err); }); } @@ -481,14 +481,14 @@ export default function useChatController({ const searchParamBasedChatSessionName = searchParams?.get(SEARCH_PARAM_NAMES.TITLE) || null; - // Auto-name only once, after the first assistant response, and only when the chat isn't + // Auto-name only once, after the first agent response, and only when the chat isn't // already explicitly named (e.g. `?title=...`). const hadAnyUserMessagesBeforeSubmit = currentHistory.some( (m) => m.type === "user" ); if (isNewSession) { currChatSessionId = await createChatSession( - liveAssistant?.id || 0, + liveAgent?.id || 0, searchParamBasedChatSessionName, projectId ? parseInt(projectId) : null ); @@ -497,7 +497,7 @@ export default function useChatController({ // This ensures "New Chat" appears immediately, even before any messages are saved addPendingChatSession({ chatSessionId: currChatSessionId, - personaId: liveAssistant?.id || 0, + personaId: liveAgent?.id || 0, projectId: projectId ? parseInt(projectId) : null, }); } else { @@ -601,12 +601,12 @@ export default function useChatController({ // Add user message immediately to the message tree so that the chat // immediately reflects the user message let initialUserNode: Message; - let initialAssistantNode: Message; + let initialAgentNode: Message; if (regenerationRequest) { - // For regeneration: keep the existing user message, only create new assistant + // For regeneration: keep the existing user message, only create new agent initialUserNode = regenerationRequest.parentMessage; - initialAssistantNode = buildEmptyMessage({ + initialAgentNode = buildEmptyMessage({ messageType: "assistant", parentNodeId: initialUserNode.nodeId, nodeIdOffset: 1, @@ -623,13 +623,13 @@ export default function useChatController({ messageToResend ); initialUserNode = result.initialUserNode; - initialAssistantNode = result.initialAssistantNode; + initialAgentNode = result.initialAgentNode; } // make messages appear + clear input bar const messagesToUpsert = regenerationRequest - ? [initialAssistantNode] // Only upsert the new assistant for regeneration - : [initialUserNode, initialAssistantNode]; // Upsert both for normal/edit flow + ? [initialAgentNode] // Only upsert the new agent for regeneration + : [initialUserNode, initialAgentNode]; // Upsert both for normal/edit flow currentMessageTreeLocal = upsertToCompleteMessageTree({ messages: messagesToUpsert, completeMessageTreeOverride: currentMessageTreeLocal, @@ -661,18 +661,18 @@ export default function useChatController({ let packetsVersion = 0; let newUserMessageId: number | null = null; - let newAssistantMessageId: number | null = null; + let newAgentMessageId: number | null = null; try { const lastSuccessfulMessageId = getLastSuccessfulMessageId( currentMessageTreeLocal ); - const disabledToolIds = liveAssistant - ? assistantPreferences?.[liveAssistant?.id]?.disabled_tool_ids + const disabledToolIds = liveAgent + ? agentPreferences?.[liveAgent?.id]?.disabled_tool_ids : undefined; // Find the search tool's numeric ID for forceSearch - const searchToolNumericId = liveAssistant?.tools.find( + const searchToolNumericId = liveAgent?.tools.find( (tool) => tool.in_code_tool_id === SEARCH_TOOL_ID )?.id; @@ -721,8 +721,8 @@ export default function useChatController({ temperature: llmManager.temperature || undefined, deepResearch, enabledToolIds: - disabledToolIds && liveAssistant - ? liveAssistant.tools + disabledToolIds && liveAgent + ? liveAgent.tools .filter((tool) => !disabledToolIds?.includes(tool.id)) .map((tool) => tool.id) : undefined, @@ -767,7 +767,7 @@ export default function useChatController({ if (isExtension && posthog) { posthog.capture("extension_chat_query", { extension_context: extensionContext, - assistant_id: liveAssistant?.id, + assistant_id: liveAgent?.id, has_files: effectiveFileDescriptors.length > 0, deep_research: deepResearch, }); @@ -777,7 +777,7 @@ export default function useChatController({ if ( (packet as MessageResponseIDInfo).reserved_assistant_message_id ) { - newAssistantMessageId = (packet as MessageResponseIDInfo) + newAgentMessageId = (packet as MessageResponseIDInfo) .reserved_assistant_message_id; } @@ -848,7 +848,7 @@ export default function useChatController({ documents = messageStart.final_documents; updateSelectedNodeForDocDisplay( frozenSessionId, - initialAssistantNode.nodeId + initialAgentNode.nodeId ); } } @@ -869,8 +869,8 @@ export default function useChatController({ files: files, }, { - ...initialAssistantNode, - messageId: newAssistantMessageId ?? undefined, + ...initialAgentNode, + messageId: newAgentMessageId ?? undefined, message: error || answer, type: error ? "error" : "assistant", retrievalType, @@ -918,7 +918,7 @@ export default function useChatController({ packetCount: 0, }, { - nodeId: initialAssistantNode.nodeId, + nodeId: initialAgentNode.nodeId, message: errorMsg, type: "error", files: aiMessageImages || [], @@ -955,20 +955,20 @@ export default function useChatController({ llmManager.currentLlm, llmManager.temperature, // Others that affect logic - liveAssistant, - availableAssistants, + liveAgent, + availableAgents, existingChatSessionId, selectedDocuments, searchParams, resetInputBar, - setSelectedAssistantFromId, + setSelectedAgentFromId, updateSelectedNodeForDocDisplay, currentMessageTree, currentChatState, // Ensure latest forced tools are used when submitting forcedToolIds, // Keep tool preference-derived values fresh - assistantPreferences, + agentPreferences, fetchProjects, // For auto-pinning agents pinnedAgents, @@ -980,7 +980,7 @@ export default function useChatController({ async (acceptedFiles: File[]) => { const [_, llmModel] = getFinalLLM( llmManager.llmProviders || [], - liveAssistant || null, + liveAgent || null, llmManager.currentLlm ); const llmAcceptsImages = modelSupportsImageInput( @@ -1006,7 +1006,7 @@ export default function useChatController({ setCurrentMessageFiles((prev) => [...prev, ...uploadedMessageFiles]); updateChatStateAction(getCurrentSessionId(), "input"); }, - [liveAssistant, llmManager, forcedToolIds] + [liveAgent, llmManager, forcedToolIds] ); useEffect(() => { @@ -1025,13 +1025,9 @@ export default function useChatController({ useEffect(() => { if (currentMessageHistory.length === 0 && existingChatSessionId === null) { // Select from available assistants so shared assistants appear. - setSelectedAssistantFromId(null); + setSelectedAgentFromId(null); } - }, [ - existingChatSessionId, - availableAssistants, - currentMessageHistory.length, - ]); + }, [existingChatSessionId, availableAgents, currentMessageHistory.length]); useEffect(() => { const handleSlackChatRedirect = async () => { @@ -1073,11 +1069,11 @@ export default function useChatController({ // fetch # of allowed document tokens for the selected Persona useEffect(() => { - if (!liveAssistant?.id) return; // avoid calling with undefined persona id + if (!liveAgent?.id) return; // avoid calling with undefined persona id async function fetchMaxTokens() { const response = await fetch( - `/api/chat/max-selected-document-tokens?persona_id=${liveAssistant?.id}` + `/api/chat/max-selected-document-tokens?persona_id=${liveAgent?.id}` ); if (response.ok) { const maxTokens = (await response.json()).max_tokens as number; @@ -1085,7 +1081,7 @@ export default function useChatController({ } } fetchMaxTokens(); - }, [liveAssistant]); + }, [liveAgent]); // check if there's an image file in the message history so that we know // which LLMs are available to use diff --git a/web/src/hooks/useChatSessionController.ts b/web/src/hooks/useChatSessionController.ts index 215a689bee3..3f6a9d50961 100644 --- a/web/src/hooks/useChatSessionController.ts +++ b/web/src/hooks/useChatSessionController.ts @@ -38,7 +38,7 @@ interface UseChatSessionControllerProps { firstMessage?: string; // UI state setters - setSelectedAssistantFromId: (assistantId: number | null) => void; + setSelectedAgentFromId: (agentId: number | null) => void; setSelectedDocuments: (documents: OnyxDocument[]) => void; setCurrentMessageFiles: ( files: ProjectFile[] | ((prev: ProjectFile[]) => ProjectFile[]) @@ -66,7 +66,7 @@ export default function useChatSessionController({ searchParams, filterManager, firstMessage, - setSelectedAssistantFromId, + setSelectedAgentFromId, setSelectedDocuments, setCurrentMessageFiles, chatSessionIdRef, @@ -155,8 +155,8 @@ export default function useChatSessionController({ // Clear the current session in the store to show intro messages setCurrentSession(null); - // Reset the selected assistant back to default - setSelectedAssistantFromId(null); + // Reset the selected agent back to default + setSelectedAgentFromId(null); updateCurrentChatSessionSharedStatus(ChatSessionSharedStatus.Private); // If we're supposed to submit on initial load, then do that here @@ -184,7 +184,7 @@ export default function useChatSessionController({ const session = await response.json(); const chatSession = session as BackendChatSession; - setSelectedAssistantFromId(chatSession.persona_id); + setSelectedAgentFromId(chatSession.persona_id); // Ensure the current session is set to the actual session ID from the response setCurrentSession(chatSession.chat_session_id); diff --git a/web/src/hooks/useChatSessions.ts b/web/src/hooks/useChatSessions.ts index 96bb2c57c4f..d85c93fc8a7 100644 --- a/web/src/hooks/useChatSessions.ts +++ b/web/src/hooks/useChatSessions.ts @@ -10,10 +10,10 @@ import { import useSWRInfinite from "swr/infinite"; import { ChatSession, ChatSessionSharedStatus } from "@/app/app/interfaces"; import { errorHandlingFetcher } from "@/lib/fetcher"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import useAppFocus from "./useAppFocus"; import { useAgents } from "./useAgents"; -import { DEFAULT_ASSISTANT_ID } from "@/lib/constants"; +import { DEFAULT_AGENT_ID } from "@/lib/constants"; const PAGE_SIZE = 50; const MIN_LOADING_DURATION_MS = 500; @@ -121,7 +121,7 @@ function useFindAgentForCurrentChatSession( // This could be a new chat-session. Therefore, `currentChatSession` is false, but there could still be some agent. else if (appFocus.isNewSession()) { - agentIdToFind = DEFAULT_ASSISTANT_ID; + agentIdToFind = DEFAULT_AGENT_ID; } // Or this could be a new chat-session with an agent. diff --git a/web/src/hooks/useDeepResearchToggle.ts b/web/src/hooks/useDeepResearchToggle.ts index 883b136dc9a..bd8f9c6407a 100644 --- a/web/src/hooks/useDeepResearchToggle.ts +++ b/web/src/hooks/useDeepResearchToggle.ts @@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; interface UseDeepResearchToggleProps { chatSessionId: string | null; - assistantId: number | undefined; + agentId: number | undefined; } /** @@ -17,12 +17,12 @@ interface UseDeepResearchToggleProps { * The toggle is preserved when transitioning from no chat session to a new session. * * @param chatSessionId - The current chat session ID - * @param assistantId - The current assistant ID + * @param agentId - The current agent ID * @returns An object containing the toggle state and toggle function */ export default function useDeepResearchToggle({ chatSessionId, - assistantId, + agentId, }: UseDeepResearchToggleProps) { const [deepResearchEnabled, setDeepResearchEnabled] = useState(false); const previousChatSessionId = useRef(chatSessionId); @@ -41,7 +41,7 @@ export default function useDeepResearchToggle({ // Reset when switching assistants useEffect(() => { setDeepResearchEnabled(false); - }, [assistantId]); + }, [agentId]); const toggleDeepResearch = useCallback(() => { setDeepResearchEnabled(!deepResearchEnabled); diff --git a/web/src/hooks/useIsDefaultAgent.ts b/web/src/hooks/useIsDefaultAgent.ts index 3fa949b53ea..aacf829bb08 100644 --- a/web/src/hooks/useIsDefaultAgent.ts +++ b/web/src/hooks/useIsDefaultAgent.ts @@ -5,22 +5,22 @@ import { useSearchParams } from "next/navigation"; import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams"; import { CombinedSettings } from "@/interfaces/settings"; import { ChatSession } from "@/app/app/interfaces"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; -import { DEFAULT_ASSISTANT_ID } from "@/lib/constants"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; +import { DEFAULT_AGENT_ID } from "@/lib/constants"; /** * Determines if the current assistant is the default agent based on: * 1. Whether default agent is disabled in settings - * 2. If URL has an assistantId specified + * 2. If URL has an agentId specified * 3. Based on the current chat session */ export default function useIsDefaultAgent({ - liveAssistant, + liveAgent, existingChatSessionId, selectedChatSession, settings, }: { - liveAssistant: MinimalPersonaSnapshot | undefined; + liveAgent: MinimalPersonaSnapshot | undefined; existingChatSessionId: string | null; selectedChatSession: ChatSession | undefined; settings: CombinedSettings | null; @@ -29,15 +29,15 @@ export default function useIsDefaultAgent({ const urlAssistantId = searchParams?.get(SEARCH_PARAM_NAMES.PERSONA_ID); return useMemo(() => { - // If default assistant is disabled, it can never be the default agent + // If default agent is disabled, it can never be the default agent if (settings?.settings?.disable_default_assistant) { return false; } - // If URL has an assistantId, it's explicitly selected, not default + // If URL has an agentId, it's explicitly selected, not default if ( urlAssistantId !== null && - urlAssistantId !== DEFAULT_ASSISTANT_ID.toString() + urlAssistantId !== DEFAULT_AGENT_ID.toString() ) { return false; } @@ -45,7 +45,7 @@ export default function useIsDefaultAgent({ // If there's an existing chat session with a persona_id, it's not default if ( existingChatSessionId && - selectedChatSession?.persona_id !== DEFAULT_ASSISTANT_ID + selectedChatSession?.persona_id !== DEFAULT_AGENT_ID ) { return false; } @@ -57,6 +57,6 @@ export default function useIsDefaultAgent({ urlAssistantId, existingChatSessionId, selectedChatSession?.persona_id, - liveAssistant?.id, + liveAgent?.id, ]); } diff --git a/web/src/hooks/useShowOnboarding.ts b/web/src/hooks/useShowOnboarding.ts index 2c7b981e789..8254f014dbd 100644 --- a/web/src/hooks/useShowOnboarding.ts +++ b/web/src/hooks/useShowOnboarding.ts @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { useOnboardingState } from "@/refresh-components/onboarding/useOnboardingState"; function getOnboardingCompletedKey(userId: string): string { @@ -9,7 +9,7 @@ function getOnboardingCompletedKey(userId: string): string { } interface UseShowOnboardingParams { - liveAssistant: MinimalPersonaSnapshot | undefined; + liveAgent: MinimalPersonaSnapshot | undefined; isLoadingProviders: boolean; hasAnyProvider: boolean | undefined; isLoadingChatSessions: boolean; @@ -18,7 +18,7 @@ interface UseShowOnboardingParams { } export function useShowOnboarding({ - liveAssistant, + liveAgent, isLoadingProviders, hasAnyProvider, isLoadingChatSessions, @@ -42,7 +42,7 @@ export function useShowOnboarding({ actions: onboardingActions, llmDescriptors, isLoading: isLoadingOnboarding, - } = useOnboardingState(liveAssistant); + } = useOnboardingState(liveAgent); // Track which user we've already evaluated onboarding for. // Re-check when userId changes (logout/login, account switching without full reload). diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts index 0a631d9b13e..3141b3116dd 100644 --- a/web/src/lib/agents.ts +++ b/web/src/lib/agents.ts @@ -1,48 +1,45 @@ -import { - MinimalPersonaSnapshot, - Persona, -} from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot, Persona } from "@/app/admin/agents/interfaces"; import { User } from "./types"; import { checkUserIsNoAuthUser } from "./user"; -import { personaComparator } from "@/app/admin/assistants/lib"; +import { personaComparator } from "@/app/admin/agents/lib"; /** * Checks if the given user owns the specified assistant. * * @param user - The user to check ownership for, or null if no user is logged in * @param assistant - The assistant to check ownership of - * @returns true if the user owns the assistant (or no auth is required), false otherwise + * @returns true if the user owns the agent (or no auth is required), false otherwise */ -export function checkUserOwnsAssistant( +export function checkUserOwnsAgent( user: User | null, - assistant: MinimalPersonaSnapshot | Persona + agent: MinimalPersonaSnapshot | Persona ) { - return checkUserIdOwnsAssistant(user?.id, assistant); + return checkUserIdOwnsAgent(user?.id, agent); } /** * Checks if the given user ID owns the specified assistant. * * Returns true if a valid user ID is provided and any of the following conditions - * are met (and the assistant is not built-in): + * are met (and the agent is not built-in): * - The user is a no-auth user (authentication is disabled) - * - The user ID matches the assistant owner's ID + * - The user ID matches the agent owner's ID * * Returns false if userId is undefined (e.g., user is loading or unauthenticated) * to prevent granting ownership access prematurely. * * @param userId - The user ID to check ownership for * @param assistant - The assistant to check ownership of - * @returns true if the user owns the assistant, false otherwise + * @returns true if the user owns the agent, false otherwise */ -export function checkUserIdOwnsAssistant( +export function checkUserIdOwnsAgent( userId: string | undefined, - assistant: MinimalPersonaSnapshot | Persona + agent: MinimalPersonaSnapshot | Persona ) { return ( !!userId && - (checkUserIsNoAuthUser(userId) || assistant.owner?.id === userId) && - !assistant.builtin_persona + (checkUserIsNoAuthUser(userId) || agent.owner?.id === userId) && + !agent.builtin_persona ); } @@ -53,13 +50,14 @@ export function checkUserIdOwnsAssistant( * @throws Error if the API request fails */ export async function pinAgents(pinnedAgentIds: number[]) { + // TODO: rename to agent — https://linear.app/onyx-app/issue/ENG-3766 const response = await fetch(`/api/user/pinned-assistants`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - ordered_assistant_ids: pinnedAgentIds, + ordered_assistant_ids: pinnedAgentIds, // TODO: rename to agent — https://linear.app/onyx-app/issue/ENG-3766 }), }); if (!response.ok) { @@ -75,13 +73,11 @@ export async function pinAgents(pinnedAgentIds: number[]) { * @param assistants - Array of assistants to filter * @returns Filtered and sorted array of visible assistants */ -export function filterAssistants( +export function filterAgents( assistants: MinimalPersonaSnapshot[] ): MinimalPersonaSnapshot[] { - let filteredAssistants = assistants.filter( - (assistant) => assistant.is_visible - ); - return filteredAssistants.sort(personaComparator); + let filteredAgents = assistants.filter((assistant) => assistant.is_visible); + return filteredAgents.sort(personaComparator); } /** diff --git a/web/src/lib/agentsSS.ts b/web/src/lib/agentsSS.ts index 2238c7ca8d9..84c8fe2528d 100644 --- a/web/src/lib/agentsSS.ts +++ b/web/src/lib/agentsSS.ts @@ -1,10 +1,10 @@ -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { fetchSS } from "./utilsSS"; -export type FetchAssistantsResponse = [MinimalPersonaSnapshot[], string | null]; +export type FetchAgentsResponse = [MinimalPersonaSnapshot[], string | null]; -// Fetch assistants server-side -export async function fetchAssistantsSS(): Promise { +// Fetch agents server-side +export async function fetchAgentsSS(): Promise { const response = await fetchSS("/persona"); if (response.ok) { return [(await response.json()) as MinimalPersonaSnapshot[], null]; diff --git a/web/src/lib/chat/fetchAgentData.ts b/web/src/lib/chat/fetchAgentData.ts new file mode 100644 index 00000000000..8f504b24519 --- /dev/null +++ b/web/src/lib/chat/fetchAgentData.ts @@ -0,0 +1,20 @@ +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; +import { filterAgents } from "@/lib/agents"; +import { fetchAgentsSS } from "@/lib/agentsSS"; + +export async function fetchAgentData(): Promise { + try { + // Fetch core assistants data + const [assistants, agentsFetchError] = await fetchAgentsSS(); + if (agentsFetchError) { + // This is not a critical error and occurs when the user is not logged in + console.warn(`Failed to fetch agents - ${agentsFetchError}`); + return []; + } + + return filterAgents(assistants); + } catch (error) { + console.error("Unexpected error in fetchAgentData:", error); + return []; + } +} diff --git a/web/src/lib/chat/fetchAssistantdata.ts b/web/src/lib/chat/fetchAssistantdata.ts deleted file mode 100644 index 741aa54bbbe..00000000000 --- a/web/src/lib/chat/fetchAssistantdata.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; -import { filterAssistants } from "@/lib/agents"; -import { fetchAssistantsSS } from "@/lib/agentsSS"; - -export async function fetchAssistantData(): Promise { - try { - // Fetch core assistants data - const [assistants, assistantsFetchError] = await fetchAssistantsSS(); - if (assistantsFetchError) { - // This is not a critical error and occurs when the user is not logged in - console.warn(`Failed to fetch assistants - ${assistantsFetchError}`); - return []; - } - - return filterAssistants(assistants); - } catch (error) { - console.error("Unexpected error in fetchAssistantData:", error); - return []; - } -} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 28c83c8c71e..eed061a0af5 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -111,7 +111,7 @@ export const MODAL_ROOT_ID = "modal-root"; export const ANONYMOUS_USER_NAME = "Anonymous"; export const UNNAMED_CHAT = "New Chat"; -export const DEFAULT_ASSISTANT_ID = 0; +export const DEFAULT_AGENT_ID = 0; export const GENERAL_ASSISTANT_ID = -1; export const IMAGE_ASSISTANT_ID = -2; export const ART_ASSISTANT_ID = -3; diff --git a/web/src/lib/filters.ts b/web/src/lib/filters.ts index e11267f92cc..d7a74956b17 100644 --- a/web/src/lib/filters.ts +++ b/web/src/lib/filters.ts @@ -1,4 +1,4 @@ -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; import { DocumentSetSummary, ValidSources } from "./types"; import { getSourcesForPersona } from "./sources"; diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts index 9a7fdf5fccd..f7b291f385f 100644 --- a/web/src/lib/hooks.ts +++ b/web/src/lib/hooks.ts @@ -30,7 +30,7 @@ import { SettingsContext } from "@/providers/SettingsProvider"; import { MinimalPersonaSnapshot, PersonaLabel, -} from "@/app/admin/assistants/interfaces"; +} from "@/app/admin/agents/interfaces"; import { DefaultModel, LLMProviderDescriptor } from "@/interfaces/llm"; import { isAnthropic } from "@/app/admin/configuration/llm/utils"; import { getSourceMetadataForSources } from "./sources"; @@ -486,7 +486,7 @@ export interface LlmManager { updateModelOverrideBasedOnChatSession: (chatSession?: ChatSession) => void; imageFilesPresent: boolean; updateImageFilesPresent: (present: boolean) => void; - liveAssistant: MinimalPersonaSnapshot | null; + liveAgent: MinimalPersonaSnapshot | null; maxTemperature: number; llmProviders: LLMProviderDescriptor[] | undefined; isLoadingProviders: boolean; @@ -514,8 +514,8 @@ Thus, the input should be - Current assistant Changes take place as -- liveAssistant or currentChatSession changes (and the associated model override is set) -- (updateCurrentLlm) User explicitly setting a model override (and we explicitly override and set the userSpecifiedOverride which we'll use in place of the user preferences unless overridden by an assistant) +- liveAgent or currentChatSession changes (and the associated model override is set) +- (updateCurrentLlm) User explicitly setting a model override (and we explicitly override and set the userSpecifiedOverride which we'll use in place of the user preferences unless overridden by an agent) If we have a live assistant, we should use that model override @@ -638,7 +638,7 @@ export function getValidLlmDescriptorForProviders( export function useLlmManager( currentChatSession?: ChatSession, - liveAssistant?: MinimalPersonaSnapshot + liveAgent?: MinimalPersonaSnapshot ): LlmManager { const { user } = useUser(); @@ -650,9 +650,8 @@ export function useLlmManager( isLoading: isLoadingAllProviders, } = useLLMProviders(); // Fetch persona-specific providers to enforce RBAC restrictions per assistant - // Only fetch if we have an assistant selected - const personaId = - liveAssistant?.id !== undefined ? liveAssistant.id : undefined; + // Only fetch if we have an agent selected + const personaId = liveAgent?.id !== undefined ? liveAgent.id : undefined; const { llmProviders: personaProviders, defaultText: personaDefaultText, @@ -674,20 +673,20 @@ export function useLlmManager( }); // Track the previous assistant ID to detect when it changes - const prevAssistantIdRef = useRef(undefined); + const prevAgentIdRef = useRef(undefined); // Reset manual override when switching to a different assistant useEffect(() => { if ( - liveAssistant?.id !== undefined && - prevAssistantIdRef.current !== undefined && - liveAssistant.id !== prevAssistantIdRef.current + liveAgent?.id !== undefined && + prevAgentIdRef.current !== undefined && + liveAgent.id !== prevAgentIdRef.current ) { // User switched to a different assistant - reset manual override setUserHasManuallyOverriddenLLM(false); } - prevAssistantIdRef.current = liveAssistant?.id; - }, [liveAssistant?.id]); + prevAgentIdRef.current = liveAgent?.id; + }, [liveAgent?.id]); const llmUpdate = () => { /* Should be called when the live assistant or current chat session changes */ @@ -710,9 +709,9 @@ export function useLlmManager( setCurrentLlm( getValidLlmDescriptor(currentChatSession.current_alternate_model) ); - } else if (liveAssistant?.llm_model_version_override) { + } else if (liveAgent?.llm_model_version_override) { setCurrentLlm( - getValidLlmDescriptor(liveAssistant.llm_model_version_override) + getValidLlmDescriptor(liveAgent.llm_model_version_override) ); } else if (userHasManuallyOverriddenLLM) { // if the user has an override and there's nothing special about the @@ -774,9 +773,7 @@ export function useLlmManager( currentChatSession.current_temperature_override, isAnthropicModel ? 1.0 : 2.0 ); - } else if ( - liveAssistant?.tools.some((tool) => tool.name === SEARCH_TOOL_ID) - ) { + } else if (liveAgent?.tools.some((tool) => tool.name === SEARCH_TOOL_ID)) { return 0; } return 0.5; @@ -823,15 +820,13 @@ export function useLlmManager( if (currentChatSession?.current_temperature_override) { setTemperature(currentChatSession.current_temperature_override); - } else if ( - liveAssistant?.tools.some((tool) => tool.name === SEARCH_TOOL_ID) - ) { + } else if (liveAgent?.tools.some((tool) => tool.name === SEARCH_TOOL_ID)) { setTemperature(0); } else { setTemperature(0.5); } }, [ - liveAssistant, + liveAgent, currentChatSession, llmProviders, user?.preferences?.default_model, @@ -858,7 +853,7 @@ export function useLlmManager( updateTemperature, imageFilesPresent, updateImageFilesPresent, - liveAssistant: liveAssistant ?? null, + liveAgent: liveAgent ?? null, maxTemperature, llmProviders, isLoadingProviders: diff --git a/web/src/lib/hooks/useToolOAuthStatus.ts b/web/src/lib/hooks/useToolOAuthStatus.ts index 91874954a98..2d5203f7b3d 100644 --- a/web/src/lib/hooks/useToolOAuthStatus.ts +++ b/web/src/lib/hooks/useToolOAuthStatus.ts @@ -9,7 +9,7 @@ export interface ToolAuthStatus { isTokenExpired: boolean; } -export function useToolOAuthStatus(assistantId?: number) { +export function useToolOAuthStatus(agentId?: number) { const [oauthTokenStatuses, setOauthTokenStatuses] = useState< OAuthTokenStatus[] >([]); @@ -32,7 +32,7 @@ export function useToolOAuthStatus(assistantId?: number) { useEffect(() => { fetchOAuthStatus(); - }, [assistantId, fetchOAuthStatus]); + }, [agentId, fetchOAuthStatus]); /** * Get OAuth status for a specific tool diff --git a/web/src/lib/llmConfig/utils.ts b/web/src/lib/llmConfig/utils.ts index 8c8635ed8bc..150b33bf8b8 100644 --- a/web/src/lib/llmConfig/utils.ts +++ b/web/src/lib/llmConfig/utils.ts @@ -1,4 +1,4 @@ -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { DefaultModel, LLMProviderDescriptor, @@ -45,11 +45,11 @@ export function getFinalLLM( } export function getLLMProviderOverrideForPersona( - liveAssistant: MinimalPersonaSnapshot, + liveAgent: MinimalPersonaSnapshot, llmProviders: LLMProviderDescriptor[] ): LlmDescriptor | null { - const overrideProvider = liveAssistant.llm_model_provider_override; - const overrideModel = liveAssistant.llm_model_version_override; + const overrideProvider = liveAgent.llm_model_provider_override; + const overrideModel = liveAgent.llm_model_version_override; if (!overrideModel) { return null; diff --git a/web/src/lib/search/interfaces.ts b/web/src/lib/search/interfaces.ts index b5a0e3d40e2..c54c2f64c17 100644 --- a/web/src/lib/search/interfaces.ts +++ b/web/src/lib/search/interfaces.ts @@ -1,6 +1,6 @@ import { DateRangePickerValue } from "@/components/dateRangeSelectors/AdminDateRangeSelector"; import { Tag, ValidSources } from "../types"; -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; export const FlowType = { SEARCH: "search", diff --git a/web/src/lib/sources.ts b/web/src/lib/sources.ts index d5aa566fee7..429b82e26c3 100644 --- a/web/src/lib/sources.ts +++ b/web/src/lib/sources.ts @@ -50,7 +50,7 @@ import { } from "@/components/icons/icons"; import { ValidSources } from "./types"; import { SourceCategory, SourceMetadata } from "./search/interfaces"; -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; import React from "react"; import { DOCS_ADMINS_PATH } from "./constants"; import { SvgFileText, SvgGlobe } from "@opal/icons"; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 7bf9edba493..1d39aabdf5f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,15 +1,15 @@ -import { Persona } from "@/app/admin/assistants/interfaces"; +import { Persona } from "@/app/admin/agents/interfaces"; import { Credential } from "./connectors/credentials"; import { Connector } from "./connectors/connectors"; import { ConnectorCredentialPairStatus } from "@/app/admin/connector/[ccPairId]/types"; -export interface UserSpecificAssistantPreference { +export interface UserSpecificAgentPreference { disabled_tool_ids?: number[]; } -export type UserSpecificAssistantPreferences = Record< +export type UserSpecificAgentPreferences = Record< number, - UserSpecificAssistantPreference + UserSpecificAgentPreference >; export enum ThemePreference { @@ -19,6 +19,7 @@ export enum ThemePreference { } interface UserPreferences { + // TODO: rename to agent — https://linear.app/onyx-app/issue/ENG-3766 chosen_assistants: number[] | null; visible_assistants: number[]; hidden_assistants: number[]; diff --git a/web/src/providers/UserProvider.tsx b/web/src/providers/UserProvider.tsx index 14aee86e9eb..af2fbfcf04f 100644 --- a/web/src/providers/UserProvider.tsx +++ b/web/src/providers/UserProvider.tsx @@ -31,9 +31,9 @@ interface UserContextType { authTypeMetadata: AuthTypeMetadata; updateUserAutoScroll: (autoScroll: boolean) => Promise; updateUserShortcuts: (enabled: boolean) => Promise; - toggleAssistantPinnedStatus: ( - currentPinnedAssistantIDs: number[], - assistantId: number, + toggleAgentPinnedStatus: ( + currentPinnedAgentIDs: number[], + agentId: number, isPinned: boolean ) => Promise; updateUserTemperatureOverrideEnabled: (enabled: boolean) => Promise; @@ -282,9 +282,9 @@ export function UserProvider({ } }; - const toggleAssistantPinnedStatus = async ( - currentPinnedAssistantIDs: number[], - assistantId: number, + const toggleAgentPinnedStatus = async ( + currentPinnedAgentIDs: number[], + agentId: number, isPinned: boolean ) => { setUpToDateUser((prevUser) => { @@ -294,21 +294,15 @@ export function UserProvider({ preferences: { ...prevUser.preferences, pinned_assistants: isPinned - ? [...currentPinnedAssistantIDs, assistantId] - : currentPinnedAssistantIDs.filter((id) => id !== assistantId), + ? [...currentPinnedAgentIDs, agentId] + : currentPinnedAgentIDs.filter((id) => id !== agentId), }, }; }); - let updatedPinnedAssistantsIds = currentPinnedAssistantIDs; - - if (isPinned) { - updatedPinnedAssistantsIds.push(assistantId); - } else { - updatedPinnedAssistantsIds = updatedPinnedAssistantsIds.filter( - (id) => id !== assistantId - ); - } + let updatedPinnedAgentsIds = isPinned + ? [...currentPinnedAgentIDs, agentId] + : currentPinnedAgentIDs.filter((id) => id !== agentId); try { const response = await fetch(`/api/user/pinned-assistants`, { method: "PATCH", @@ -316,7 +310,7 @@ export function UserProvider({ "Content-Type": "application/json", }, body: JSON.stringify({ - ordered_assistant_ids: updatedPinnedAssistantsIds, + ordered_assistant_ids: updatedPinnedAgentsIds, }), }); @@ -484,7 +478,7 @@ export function UserProvider({ updateUserChatBackground, updateUserDefaultModel, updateUserDefaultAppMode, - toggleAssistantPinnedStatus, + toggleAgentPinnedStatus, isAdmin: upToDateUser?.role === UserRole.ADMIN, // Curator status applies for either global or basic curator isCurator: diff --git a/web/src/proxy.ts b/web/src/proxy.ts index 379c1d2e5b3..02e4efd83b4 100644 --- a/web/src/proxy.ts +++ b/web/src/proxy.ts @@ -11,7 +11,7 @@ const FASTAPI_USERS_AUTH_COOKIE_NAME = "fastapiusersauth"; const ANONYMOUS_USER_COOKIE_NAME = "onyx_anonymous_user"; // Protected route prefixes (require authentication) -const PROTECTED_ROUTES = ["/app", "/admin", "/assistants", "/connector"]; +const PROTECTED_ROUTES = ["/app", "/admin", "/agents", "/connector"]; // Public route prefixes (no authentication required) const PUBLIC_ROUTES = ["/auth", "/anonymous", "/_next", "/api"]; @@ -23,7 +23,7 @@ export const config = { // Auth-protected routes (for middleware auth check) "/app/:path*", "/admin/:path*", - "/assistants/:path*", + "/agents/:path*", "/connector/:path*", // Enterprise Edition routes (for /ee rewriting) @@ -34,7 +34,7 @@ export const config = { "/admin/theme/:path*", "/admin/performance/custom-analytics/:path*", "/admin/standard-answer/:path*", - "/assistants/stats/:path*", + "/agents/stats/:path*", // Cloud only "/admin/billing/:path*", @@ -49,7 +49,7 @@ const EE_ROUTES = [ "/admin/theme", "/admin/performance/custom-analytics", "/admin/standard-answer", - "/assistants/stats", + "/agents/stats", ]; export async function proxy(request: NextRequest) { diff --git a/web/src/refresh-components/avatars/AgentAvatar.tsx b/web/src/refresh-components/avatars/AgentAvatar.tsx index e42fb919e8e..d9ed6252552 100644 --- a/web/src/refresh-components/avatars/AgentAvatar.tsx +++ b/web/src/refresh-components/avatars/AgentAvatar.tsx @@ -1,12 +1,12 @@ "use client"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { buildImgUrl } from "@/app/app/components/files/images/utils"; import { OnyxIcon } from "@/components/icons/icons"; import { useSettingsContext } from "@/providers/SettingsProvider"; import { DEFAULT_AGENT_AVATAR_SIZE_PX, - DEFAULT_ASSISTANT_ID, + DEFAULT_AGENT_ID, } from "@/lib/constants"; import CustomAgentAvatar from "@/refresh-components/avatars/CustomAgentAvatar"; import Image from "next/image"; @@ -23,7 +23,7 @@ export default function AgentAvatar({ }: AgentAvatarProps) { const settings = useSettingsContext(); - if (agent.id === DEFAULT_ASSISTANT_ID) { + if (agent.id === DEFAULT_AGENT_ID) { return settings.enterpriseSettings?.use_custom_logo ? (
{ // Re-render with same userId but provider data now available rerender({ - liveAssistant: undefined, + liveAgent: undefined, isLoadingProviders: false, hasAnyProvider: true, isLoadingChatSessions: false, @@ -146,7 +146,7 @@ describe("useShowOnboarding", () => { // Re-render with same userId but provider data now available rerender({ - liveAssistant: undefined, + liveAgent: undefined, isLoadingProviders: false, hasAnyProvider: true, isLoadingChatSessions: false, @@ -168,7 +168,7 @@ describe("useShowOnboarding", () => { // Change to a new userId with providers available rerender({ - liveAssistant: undefined, + liveAgent: undefined, isLoadingProviders: false, hasAnyProvider: true, isLoadingChatSessions: false, diff --git a/web/src/refresh-components/onboarding/useOnboardingState.ts b/web/src/refresh-components/onboarding/useOnboardingState.ts index 2b602c86fa9..12fff6f682c 100644 --- a/web/src/refresh-components/onboarding/useOnboardingState.ts +++ b/web/src/refresh-components/onboarding/useOnboardingState.ts @@ -10,11 +10,11 @@ import { import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm"; import { updateUserPersonalization } from "@/lib/userSettings"; import { useUser } from "@/providers/UserProvider"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { useLLMProviders } from "@/hooks/useLLMProviders"; import { useProviderStatus } from "@/components/chat/ProviderContext"; -export function useOnboardingState(liveAssistant?: MinimalPersonaSnapshot): { +export function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): { state: OnboardingState; llmDescriptors: WellKnownLLMProviderDescriptor[]; actions: OnboardingActions; @@ -33,9 +33,7 @@ export function useOnboardingState(liveAssistant?: MinimalPersonaSnapshot): { } = useProviderStatus(); // Only fetch persona-specific providers (different endpoint) - const { refetch: refreshPersonaProviders } = useLLMProviders( - liveAssistant?.id - ); + const { refetch: refreshPersonaProviders } = useLLMProviders(liveAgent?.id); const userName = user?.personalization?.name; const llmDescriptors = providerOptions; @@ -121,7 +119,7 @@ export function useOnboardingState(liveAssistant?: MinimalPersonaSnapshot): { if (state.currentStep === OnboardingStep.LlmSetup) { refreshProviderInfo(); - if (liveAssistant) { + if (liveAgent) { refreshPersonaProviders(); } } @@ -237,6 +235,6 @@ export function useOnboardingState(liveAssistant?: MinimalPersonaSnapshot): { setError, reset, }, - isLoading: isLoadingProviders || !!liveAssistant, + isLoading: isLoadingProviders || !!liveAgent, }; } diff --git a/web/src/refresh-components/popovers/ActionsPopover/index.tsx b/web/src/refresh-components/popovers/ActionsPopover/index.tsx index 7f058c5b615..dad2dc53417 100644 --- a/web/src/refresh-components/popovers/ActionsPopover/index.tsx +++ b/web/src/refresh-components/popovers/ActionsPopover/index.tsx @@ -12,7 +12,7 @@ import Popover, { PopoverMenu } from "@/refresh-components/Popover"; import SwitchList, { SwitchListItem, } from "@/refresh-components/popovers/ActionsPopover/SwitchList"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { MCPAuthenticationType, MCPAuthenticationPerformer, @@ -133,14 +133,14 @@ type SecondaryViewState = | { type: "mcp"; serverId: number }; export interface ActionsPopoverProps { - selectedAssistant: MinimalPersonaSnapshot; + selectedAgent: MinimalPersonaSnapshot; filterManager: FilterManager; availableSources?: ValidSources[]; disabled?: boolean; } export default function ActionsPopover({ - selectedAssistant, + selectedAgent, filterManager, availableSources = [], disabled = false, @@ -157,7 +157,7 @@ export default function ActionsPopover({ // Use the OAuth hook const { getToolAuthStatus, authenticateTool } = useToolOAuthStatus( - selectedAssistant.id + selectedAgent.id ); const { @@ -176,10 +176,10 @@ export default function ActionsPopover({ // Store previously enabled sources when search tool is disabled const previouslyEnabledSourcesRef = useRef([]); - const isDefaultAgent = selectedAssistant.id === 0; + const isDefaultAgent = selectedAgent.id === 0; // Check if the search tool is explicitly enabled on this persona (admin enabled "Use Knowledge") - const hasSearchTool = selectedAssistant.tools.some( + const hasSearchTool = selectedAgent.tools.some( (tool) => tool.in_code_tool_id === SEARCH_TOOL_ID ); @@ -193,7 +193,7 @@ export default function ActionsPopover({ const sourceSet = new Set(); // Add sources from document sets - selectedAssistant.document_sets.forEach((docSet) => { + selectedAgent.document_sets.forEach((docSet) => { // Check cc_pair_summaries (regular connectors) docSet.cc_pair_summaries?.forEach((ccPair) => { // Normalize by removing federated_ prefix @@ -210,7 +210,7 @@ export default function ActionsPopover({ }); // Add sources from hierarchy nodes and attached documents (via knowledge_sources) - selectedAssistant.knowledge_sources?.forEach((source) => { + selectedAgent.knowledge_sources?.forEach((source) => { // Normalize by removing federated_ prefix const normalized = source.replace("federated_", ""); sourceSet.add(normalized); @@ -224,8 +224,8 @@ export default function ActionsPopover({ return sourceSet; }, [ isDefaultAgent, - selectedAssistant.document_sets, - selectedAssistant.knowledge_sources, + selectedAgent.document_sets, + selectedAgent.knowledge_sources, hasSearchTool, ]); @@ -235,11 +235,11 @@ export default function ActionsPopover({ const hasNoKnowledgeSources = !isDefaultAgent && !hasSearchTool && - selectedAssistant.document_sets.length === 0 && - (selectedAssistant.hierarchy_node_count ?? 0) === 0 && - (selectedAssistant.attached_document_count ?? 0) === 0; + selectedAgent.document_sets.length === 0 && + (selectedAgent.hierarchy_node_count ?? 0) === 0 && + (selectedAgent.attached_document_count ?? 0) === 0; - // Store MCP server auth/loading state (tools are part of selectedAssistant.tools) + // Store MCP server auth/loading state (tools are part of selectedAgent.tools) const [mcpServerData, setMcpServerData] = useState<{ [serverId: number]: { isAuthenticated: boolean; @@ -264,15 +264,15 @@ export default function ActionsPopover({ isAuthenticated: false, }); - // Get the assistant preference for this assistant - const { assistantPreferences, setSpecificAssistantPreferences } = + // Get the agent preference for this assistant + const { agentPreferences, setSpecificAgentPreferences } = useAgentPreferences(); const { forcedToolIds, setForcedToolIds } = useForcedTools(); // Reset state when assistant changes useEffect(() => { setForcedToolIds([]); - }, [selectedAssistant.id, setForcedToolIds]); + }, [selectedAgent.id, setForcedToolIds]); const { isAdmin, isCurator } = useUser(); const settings = useSettingsContext(); @@ -286,11 +286,11 @@ export default function ActionsPopover({ // Check if there are any connectors available const hasNoConnectors = ccPairs.length === 0; - const assistantPreference = assistantPreferences?.[selectedAssistant.id]; - const disabledToolIds = assistantPreference?.disabled_tool_ids || []; - const toggleToolForCurrentAssistant = (toolId: number) => { + const agentPreference = agentPreferences?.[selectedAgent.id]; + const disabledToolIds = agentPreference?.disabled_tool_ids || []; + const toggleToolForCurrentAgent = (toolId: number) => { const disabled = disabledToolIds.includes(toolId); - setSpecificAssistantPreferences(selectedAssistant.id, { + setSpecificAgentPreferences(selectedAgent.id, { disabled_tool_ids: disabled ? disabledToolIds.filter((id) => id !== toolId) : [...disabledToolIds, toolId], @@ -315,10 +315,10 @@ export default function ActionsPopover({ // Get internal search tool reference for auto-pin logic const internalSearchTool = useMemo( () => - selectedAssistant.tools.find( + selectedAgent.tools.find( (tool) => tool.in_code_tool_id === SEARCH_TOOL_ID && !tool.mcp_server_id ), - [selectedAssistant.tools] + [selectedAgent.tools] ); // Handle explicit force toggle from ActionLineItem @@ -425,7 +425,7 @@ export default function ActionsPopover({ // Filter out MCP tools from the main list (they have mcp_server_id) // Also filter out internal search tool for basic users when there are no connectors // Also filter out tools that are not chat-selectable (e.g., OpenURL) - const displayTools = selectedAssistant.tools.filter((tool) => { + const displayTools = selectedAgent.tools.filter((tool) => { // Filter out MCP tools if (tool.mcp_server_id) return false; @@ -468,16 +468,16 @@ export default function ActionsPopover({ displayTools.find((tool) => tool.in_code_tool_id === SEARCH_TOOL_ID)?.id ?? null; - // Fetch MCP servers for the assistant on mount + // Fetch MCP servers for the agent on mount useEffect(() => { - if (selectedAssistant == null || selectedAssistant.id == null) return; + if (selectedAgent == null || selectedAgent.id == null) return; const abortController = new AbortController(); const fetchMCPServers = async () => { try { const response = await fetch( - `/api/mcp/servers/persona/${selectedAssistant.id}`, + `/api/mcp/servers/persona/${selectedAgent.id}`, { signal: abortController.signal, } @@ -511,9 +511,9 @@ export default function ActionsPopover({ return () => { abortController.abort(); }; - }, [selectedAssistant?.id]); + }, [selectedAgent?.id]); - // No separate MCP tool loading; tools already exist in selectedAssistant.tools + // No separate MCP tool loading; tools already exist in selectedAgent.tools // Handle MCP authentication const handleMCPAuthenticate = async ( @@ -678,7 +678,7 @@ export default function ActionsPopover({ : undefined; const selectedMcpTools = selectedMcpServerId !== null - ? selectedAssistant.tools.filter( + ? selectedAgent.tools.filter( (t) => t.mcp_server_id === Number(selectedMcpServerId) ) : []; @@ -703,7 +703,7 @@ export default function ActionsPopover({ label: tool.display_name || tool.name, description: tool.description, isEnabled: !disabledToolIds.includes(tool.id), - onToggle: () => toggleToolForCurrentAssistant(tool.id), + onToggle: () => toggleToolForCurrentAgent(tool.id), })); const mcpAllDisabled = selectedMcpTools.every((tool) => @@ -714,7 +714,7 @@ export default function ActionsPopover({ if (!selectedMcpServer) return; const serverToolIds = selectedMcpTools.map((tool) => tool.id); const merged = Array.from(new Set([...disabledToolIds, ...serverToolIds])); - setSpecificAssistantPreferences(selectedAssistant.id, { + setSpecificAgentPreferences(selectedAgent.id, { disabled_tool_ids: merged, }); setForcedToolIds(forcedToolIds.filter((id) => !serverToolIds.includes(id))); @@ -723,7 +723,7 @@ export default function ActionsPopover({ const enableAllToolsForSelectedServer = () => { if (!selectedMcpServer) return; const serverToolIdSet = new Set(selectedMcpTools.map((tool) => tool.id)); - setSpecificAssistantPreferences(selectedAssistant.id, { + setSpecificAgentPreferences(selectedAgent.id, { disabled_tool_ids: disabledToolIds.filter( (id) => !serverToolIdSet.has(id) ), @@ -771,17 +771,17 @@ export default function ActionsPopover({ const hasEnabledSources = numSourcesEnabled > 0; if (hasEnabledSources && searchToolDisabled) { // Sources are enabled but search tool is disabled - enable it - toggleToolForCurrentAssistant(searchToolId); + toggleToolForCurrentAgent(searchToolId); } else if (!hasEnabledSources && !searchToolDisabled) { // No sources enabled but search tool is enabled - disable it - toggleToolForCurrentAssistant(searchToolId); + toggleToolForCurrentAgent(searchToolId); } }, [ searchToolId, numSourcesEnabled, searchToolDisabled, sourcesInitialized, - toggleToolForCurrentAssistant, + toggleToolForCurrentAgent, ]); // Set search tool to a specific enabled/disabled state (only toggles if needed) @@ -789,9 +789,9 @@ export default function ActionsPopover({ if (searchToolId === null) return; if (enabled && searchToolDisabled) { - toggleToolForCurrentAssistant(searchToolId); + toggleToolForCurrentAgent(searchToolId); } else if (!enabled && !searchToolDisabled) { - toggleToolForCurrentAssistant(searchToolId); + toggleToolForCurrentAgent(searchToolId); } }; @@ -815,7 +815,7 @@ export default function ActionsPopover({ const handleToggleTool = (toolId: number) => { const wasDisabled = disabledToolIds.includes(toolId); - toggleToolForCurrentAssistant(toolId); + toggleToolForCurrentAgent(toolId); if (toolId === searchToolId) { if (wasDisabled) { @@ -935,7 +935,7 @@ export default function ActionsPopover({ }; // Tools for this server come from assistant.tools - const serverTools = selectedAssistant.tools.filter( + const serverTools = selectedAgent.tools.filter( (t) => t.mcp_server_id === Number(server.id) ); const enabledTools = serverTools.filter( diff --git a/web/src/refresh-pages/AgentEditorPage.tsx b/web/src/refresh-pages/AgentEditorPage.tsx index 1394ce625d6..4eff2ff33a2 100644 --- a/web/src/refresh-pages/AgentEditorPage.tsx +++ b/web/src/refresh-pages/AgentEditorPage.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import * as SettingsLayouts from "@/layouts/settings-layouts"; import * as GeneralLayouts from "@/layouts/general-layouts"; import Button from "@/refresh-components/buttons/Button"; -import { FullPersona } from "@/app/admin/assistants/interfaces"; +import { FullPersona } from "@/app/admin/agents/interfaces"; import { buildImgUrl } from "@/app/app/components/files/images/utils"; import { Formik, Form, FieldArray } from "formik"; import * as Yup from "yup"; @@ -69,7 +69,7 @@ import { createPersona, updatePersona, PersonaUpsertParameters, -} from "@/app/admin/assistants/lib"; +} from "@/app/admin/agents/lib"; import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor"; import useOpenApiTools from "@/hooks/useOpenApiTools"; import { useAvailableTools } from "@/hooks/useAvailableTools"; @@ -587,9 +587,9 @@ export default function AgentEditorPage({ replace_base_system_prompt: existingAgent?.replace_base_system_prompt ?? false, reminders: existingAgent?.task_prompt ?? "", - // For new assistants, default to false for optional tools to avoid + // For new agents, default to false for optional tools to avoid // "Tool not available" errors when the tool isn't configured. - // For existing assistants, preserve the current tool configuration. + // For existing agents, preserve the current tool configuration. image_generation: !!imageGenTool && (existingAgent?.tools?.some( diff --git a/web/src/refresh-pages/AgentsNavigationPage.tsx b/web/src/refresh-pages/AgentsNavigationPage.tsx index 826c750de73..719a6c8dfdf 100644 --- a/web/src/refresh-pages/AgentsNavigationPage.tsx +++ b/web/src/refresh-pages/AgentsNavigationPage.tsx @@ -3,9 +3,9 @@ import { useMemo, useState, useRef, useEffect } from "react"; import AgentCard from "@/sections/cards/AgentCard"; import { useUser } from "@/providers/UserProvider"; -import { checkUserOwnsAssistant as checkUserOwnsAgent } from "@/lib/agents"; +import { checkUserOwnsAgent as checkUserOwnsAgent } from "@/lib/agents"; import { useAgents } from "@/hooks/useAgents"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import Text from "@/refresh-components/texts/Text"; import InputTypeIn from "@/refresh-components/inputs/InputTypeIn"; import * as SettingsLayouts from "@/layouts/settings-layouts"; diff --git a/web/src/refresh-pages/AppPage.tsx b/web/src/refresh-pages/AppPage.tsx index fe6d6a33d47..0813bc7a050 100644 --- a/web/src/refresh-pages/AppPage.tsx +++ b/web/src/refresh-pages/AppPage.tsx @@ -24,7 +24,7 @@ import { useAgents } from "@/hooks/useAgents"; import { AppPopup } from "@/app/app/components/AppPopup"; import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal"; import { useUser } from "@/providers/UserProvider"; -import NoAssistantModal from "@/components/modals/NoAssistantModal"; +import NoAgentModal from "@/components/modals/NoAgentModal"; import PreviewModal from "@/sections/modals/PreviewModal"; import Modal from "@/refresh-components/Modal"; import { useSendMessageToParent } from "@/lib/extension/utils"; @@ -196,12 +196,12 @@ export default function AppPage({ firstMessage }: ChatPageProps) { } } - const { selectedAssistant, setSelectedAssistantFromId, liveAssistant } = + const { selectedAgent, setSelectedAgentFromId, liveAgent } = useAgentController({ selectedChatSession: currentChatSession, - onAssistantSelect: () => { - // Only remove project context if user explicitly selected an assistant - // (i.e., assistantId is present). Avoid clearing project when assistantId was removed. + onAgentSelect: () => { + // Only remove project context if user explicitly selected an agent + // (i.e., agentId is present). Avoid clearing project when agentId was removed. const newSearchParams = new URLSearchParams( searchParams?.toString() || "" ); @@ -214,16 +214,13 @@ export default function AppPage({ firstMessage }: ChatPageProps) { const { deepResearchEnabled, toggleDeepResearch } = useDeepResearchToggle({ chatSessionId: currentChatSessionId, - assistantId: selectedAssistant?.id, + agentId: selectedAgent?.id, }); const [presentingDocument, setPresentingDocument] = useState(null); - const llmManager = useLlmManager( - currentChatSession ?? undefined, - liveAssistant - ); + const llmManager = useLlmManager(currentChatSession ?? undefined, liveAgent); const { showOnboarding, @@ -235,7 +232,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { finishOnboarding, hideOnboarding, } = useShowOnboarding({ - liveAssistant, + liveAgent, isLoadingProviders: llmManager.isLoadingProviders, hasAnyProvider: llmManager.hasAnyProvider, isLoadingChatSessions, @@ -243,7 +240,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { userId: user?.id, }); - const noAssistants = liveAssistant === null || liveAssistant === undefined; + const noAgents = liveAgent === null || liveAgent === undefined; const availableSources: ValidSources[] = useMemo(() => { return ccPairs.map((ccPair) => ccPair.source); @@ -292,7 +289,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { const filterManager = useFilters(); const isDefaultAgent = useIsDefaultAgent({ - liveAssistant, + liveAgent, existingChatSessionId: currentChatSessionId, selectedChatSession: currentChatSession ?? undefined, settings, @@ -372,13 +369,13 @@ export default function AppPage({ firstMessage }: ChatPageProps) { useChatController({ filterManager, llmManager, - availableAssistants: agents, - liveAssistant, + availableAgents: agents, + liveAgent, existingChatSessionId: currentChatSessionId, selectedDocuments, searchParams, resetInputBar, - setSelectedAssistantFromId, + setSelectedAgentFromId, }); const { onMessageSelection, currentSessionFileTokenCount } = @@ -387,7 +384,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { searchParams, filterManager, firstMessage, - setSelectedAssistantFromId, + setSelectedAgentFromId, setSelectedDocuments, setCurrentMessageFiles, chatSessionIdRef, @@ -402,11 +399,11 @@ export default function AppPage({ firstMessage }: ChatPageProps) { useSendMessageToParent(); const retrievalEnabled = useMemo(() => { - if (liveAssistant) { - return personaIncludesRetrieval(liveAssistant); + if (liveAgent) { + return personaIncludesRetrieval(liveAgent); } return false; - }, [liveAssistant]); + }, [liveAgent]); useEffect(() => { if ( @@ -607,7 +604,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { (available ?? DEFAULT_CONTEXT_TOKENS) * 0.5; if (!cancelled) setAvailableContextTokens(capped_context_tokens); } else { - const personaId = (selectedAssistant || liveAssistant)?.id; + const personaId = (selectedAgent || liveAgent)?.id; if (personaId !== undefined && personaId !== null) { const maxTokens = await getMaxSelectedDocumentTokens(personaId); const capped_context_tokens = @@ -625,20 +622,20 @@ export default function AppPage({ firstMessage }: ChatPageProps) { return () => { cancelled = true; }; - }, [currentChatSessionId, selectedAssistant?.id, liveAssistant?.id]); + }, [currentChatSessionId, selectedAgent?.id, liveAgent?.id]); // handle error case where no assistants are available // Only show this after agents have loaded to prevent flash during initial load - if (noAssistants && !isLoadingAgents) { + if (noAgents && !isLoadingAgents) { return ( <> - + ); } - const hasStarterMessages = (liveAssistant?.starter_messages?.length ?? 0) > 0; + const hasStarterMessages = (liveAgent?.starter_messages?.length ?? 0) > 0; const isSearch = classification === "search"; const gridStyle = { @@ -726,9 +723,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { {/* ChatUI */} @@ -741,7 +736,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { onScrollButtonVisibilityChange={setShowScrollButton} > @@ -860,7 +855,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { : projectContextTokenCount } availableContextTokens={availableContextTokens} - selectedAssistant={selectedAssistant || liveAssistant} + selectedAgent={selectedAgent || liveAgent} handleFileUpload={handleMessageSpecificFileUpload} setPresentingDocument={setPresentingDocument} // Intentionally enabled during name-only onboarding (showOnboarding=false) @@ -891,7 +886,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { !isDefaultAgent && ( <> - + )} diff --git a/web/src/refresh-pages/admin/ChatPreferencesPage.tsx b/web/src/refresh-pages/admin/ChatPreferencesPage.tsx index 3f5625c7647..131c33ccd13 100644 --- a/web/src/refresh-pages/admin/ChatPreferencesPage.tsx +++ b/web/src/refresh-pages/admin/ChatPreferencesPage.tsx @@ -55,7 +55,7 @@ import useFilter from "@/hooks/useFilter"; import { MCPServer } from "@/lib/tools/interfaces"; import type { IconProps } from "@opal/types"; -interface DefaultAssistantConfiguration { +interface DefaultAgentConfiguration { tool_ids: number[]; system_prompt: string | null; default_system_prompt: string; @@ -223,14 +223,14 @@ function ChatPreferencesForm() { })), })); - // Default assistant configuration (system prompt) - const { data: defaultAssistantConfig, mutate: mutateDefaultAssistant } = - useSWR( + // Default agent configuration (system prompt) + const { data: defaultAgentConfig, mutate: mutateDefaultAgent } = + useSWR( "/api/admin/default-assistant/configuration", errorHandlingFetcher ); - const enabledToolIds = defaultAssistantConfig?.tool_ids ?? []; + const enabledToolIds = defaultAgentConfig?.tool_ids ?? []; const isToolEnabled = useCallback( (toolDbId: number) => enabledToolIds.includes(toolDbId), @@ -240,11 +240,11 @@ function ChatPreferencesForm() { const saveToolIds = useCallback( async (newToolIds: number[]) => { // Optimistic update so subsequent toggles read fresh state - const optimisticData = defaultAssistantConfig - ? { ...defaultAssistantConfig, tool_ids: newToolIds } + const optimisticData = defaultAgentConfig + ? { ...defaultAgentConfig, tool_ids: newToolIds } : undefined; try { - await mutateDefaultAssistant( + await mutateDefaultAgent( async () => { const response = await fetch("/api/admin/default-assistant", { method: "PATCH", @@ -264,7 +264,7 @@ function ChatPreferencesForm() { toast.error("Failed to update tools"); } }, - [defaultAssistantConfig, mutateDefaultAssistant] + [defaultAgentConfig, mutateDefaultAgent] ); const toggleTool = useCallback( @@ -385,8 +385,8 @@ function ChatPreferencesForm() { icon={SvgAddLines} onClick={() => { setSystemPromptValue( - defaultAssistantConfig?.system_prompt ?? - defaultAssistantConfig?.default_system_prompt ?? + defaultAgentConfig?.system_prompt ?? + defaultAgentConfig?.default_system_prompt ?? "" ); setSystemPromptModalOpen(true); @@ -784,7 +784,7 @@ function ChatPreferencesForm() { const errorMsg = (await response.json()).detail; throw new Error(errorMsg); } - await mutateDefaultAssistant(); + await mutateDefaultAgent(); setSystemPromptModalOpen(false); toast.success("System prompt updated"); } catch { diff --git a/web/src/sections/cards/AgentCard.tsx b/web/src/sections/cards/AgentCard.tsx index 2229c9a88c8..78a56c6c24b 100644 --- a/web/src/sections/cards/AgentCard.tsx +++ b/web/src/sections/cards/AgentCard.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useCallback } from "react"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import AgentAvatar from "@/refresh-components/avatars/AgentAvatar"; import Button from "@/refresh-components/buttons/Button"; import { useAppRouter } from "@/hooks/appNavigation"; @@ -12,7 +12,7 @@ import { useRouter } from "next/navigation"; import type { Route } from "next"; import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; import { - checkUserOwnsAssistant, + checkUserOwnsAgent, updateAgentSharedStatus, updateAgentFeaturedStatus, } from "@/lib/agents"; @@ -51,7 +51,7 @@ export default function AgentCard({ agent }: AgentCardProps) { const { user, isAdmin, isCurator } = useUser(); const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled(); const canUpdateFeaturedStatus = isAdmin || isCurator; - const isOwnedByUser = checkUserOwnsAssistant(user, agent); + const isOwnedByUser = checkUserOwnsAgent(user, agent); const shareAgentModal = useCreateModal(); const agentViewerModal = useCreateModal(); const { agent: fullAgent, refresh: refreshAgent } = useAgent(agent.id); @@ -150,7 +150,7 @@ export default function AgentCard({ agent }: AgentCardProps) { icon={SvgBarChart} tertiary onClick={noProp(() => - router.push(`/ee/assistants/stats/${agent.id}` as Route) + router.push(`/ee/agents/stats/${agent.id}` as Route) )} tooltip="View Agent Stats" className="hidden group-hover/AgentCard:flex" diff --git a/web/src/sections/chat/ChatUI.tsx b/web/src/sections/chat/ChatUI.tsx index 80161dacd9b..fd206cba8d2 100644 --- a/web/src/sections/chat/ChatUI.tsx +++ b/web/src/sections/chat/ChatUI.tsx @@ -5,7 +5,7 @@ import { Message } from "@/app/app/interfaces"; import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces"; import HumanMessage from "@/app/app/message/HumanMessage"; import { ErrorBanner } from "@/app/app/message/Resubmit"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { LlmDescriptor, LlmManager } from "@/lib/hooks"; import AgentMessage from "@/app/app/message/messageComponents/AgentMessage"; import Spacer from "@/refresh-components/Spacer"; @@ -18,7 +18,7 @@ import { } from "@/app/app/stores/useChatSessionStore"; export interface ChatUIProps { - liveAssistant: MinimalPersonaSnapshot; + liveAgent: MinimalPersonaSnapshot; llmManager: LlmManager; setPresentingDocument: (doc: MinimalOnyxDocument | null) => void; onMessageSelection: (nodeId: number) => void; @@ -52,7 +52,7 @@ export interface ChatUIProps { const ChatUI = React.memo( ({ - liveAssistant, + liveAgent, llmManager, setPresentingDocument, onMessageSelection, @@ -165,7 +165,7 @@ const ChatUI = React.memo( const previousMessage = i !== 0 ? messages[i - 1] : null; const chatStateData = { - assistant: liveAssistant, + agent: liveAgent, docs: message.documents ?? emptyDocs, citations: message.citations, setPresentingDocument, diff --git a/web/src/sections/input/AppInputBar.tsx b/web/src/sections/input/AppInputBar.tsx index de99d815eef..831aac93544 100644 --- a/web/src/sections/input/AppInputBar.tsx +++ b/web/src/sections/input/AppInputBar.tsx @@ -9,7 +9,7 @@ import React, { useState, } from "react"; import LineItem from "@/refresh-components/buttons/LineItem"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import LLMPopover from "@/refresh-components/popovers/LLMPopover"; import { InputPrompt } from "@/app/app/interfaces"; import { FilterManager, LlmManager, useFederatedConnectors } from "@/lib/hooks"; @@ -117,8 +117,8 @@ export interface AppInputBarProps { currentSessionFileTokenCount: number; availableContextTokens: number; - // assistants - selectedAssistant: MinimalPersonaSnapshot | undefined; + // agents + selectedAgent: MinimalPersonaSnapshot | undefined; toggleDocumentSidebar: () => void; handleFileUpload: (files: File[]) => void; @@ -148,8 +148,8 @@ const AppInputBar = React.memo( chatState, currentSessionFileTokenCount, availableContextTokens, - // assistants - selectedAssistant, + // agents + selectedAgent, handleFileUpload, llmManager, @@ -304,7 +304,7 @@ const AppInputBar = React.memo( const controlsLoading = ccPairsLoading || federatedLoading || - !selectedAssistant || + !selectedAgent || llmManager.isLoadingProviders; const [showPrompts, setShowPrompts] = useState(false); @@ -398,17 +398,17 @@ const AppInputBar = React.memo( [currentMessageFiles] ); - // Check if the assistant has search tools available (internal search or web search) + // Check if the agent has search tools available (internal search or web search) // AND if deep research is globally enabled in admin settings const showDeepResearch = useMemo(() => { const deepResearchGloballyEnabled = combinedSettings?.settings?.deep_research_enabled ?? true; return ( deepResearchGloballyEnabled && - hasSearchToolsAvailable(selectedAssistant?.tools || []) + hasSearchToolsAvailable(selectedAgent?.tools || []) ); }, [ - selectedAssistant?.tools, + selectedAgent?.tools, combinedSettings?.settings?.deep_research_enabled, ]); @@ -710,9 +710,9 @@ const AppInputBar = React.memo( controlsLoading && "invisible" )} > - {selectedAssistant && selectedAssistant.tools.length > 0 && ( + {selectedAgent && selectedAgent.tools.length > 0 && ( 0 && forcedToolIds.map((toolId) => { - const tool = selectedAssistant.tools.find( + const tool = selectedAgent.tools.find( (tool) => tool.id === toolId ); if (!tool) { diff --git a/web/src/sections/knowledge/AgentKnowledgePane.tsx b/web/src/sections/knowledge/AgentKnowledgePane.tsx index 6bfbef134ac..d00b0f4459b 100644 --- a/web/src/sections/knowledge/AgentKnowledgePane.tsx +++ b/web/src/sections/knowledge/AgentKnowledgePane.tsx @@ -36,7 +36,7 @@ import { ProjectFile } from "@/app/app/projects/projectsService"; import { AttachedDocumentSnapshot, HierarchyNodeSnapshot, -} from "@/app/admin/assistants/interfaces"; +} from "@/app/admin/agents/interfaces"; import { timeAgo } from "@/lib/time"; import Spacer from "@/refresh-components/Spacer"; import { Disabled } from "@/refresh-components/Disabled"; diff --git a/web/src/sections/knowledge/SourceHierarchyBrowser.tsx b/web/src/sections/knowledge/SourceHierarchyBrowser.tsx index 2ec7c3c4688..0fb7c4ba200 100644 --- a/web/src/sections/knowledge/SourceHierarchyBrowser.tsx +++ b/web/src/sections/knowledge/SourceHierarchyBrowser.tsx @@ -45,7 +45,7 @@ import { fetchHierarchyNodes, fetchHierarchyNodeDocuments, } from "@/lib/hierarchy/svc"; -import { AttachedDocumentSnapshot } from "@/app/admin/assistants/interfaces"; +import { AttachedDocumentSnapshot } from "@/app/admin/agents/interfaces"; import { timeAgo } from "@/lib/time"; import Spacer from "@/refresh-components/Spacer"; diff --git a/web/src/sections/modals/AgentViewerModal.tsx b/web/src/sections/modals/AgentViewerModal.tsx index b87d057aafa..adceaef1728 100644 --- a/web/src/sections/modals/AgentViewerModal.tsx +++ b/web/src/sections/modals/AgentViewerModal.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import type { Route } from "next"; -import { FullPersona } from "@/app/admin/assistants/interfaces"; +import { FullPersona } from "@/app/admin/agents/interfaces"; import { useModal } from "@/refresh-components/contexts/ModalContext"; import Modal from "@/refresh-components/Modal"; import { Section } from "@/layouts/general-layouts"; @@ -136,7 +136,7 @@ function AgentChatInput({ agent, onSubmit }: AgentChatInputProps) { llmManager={llmManager} chatState="input" filterManager={filterManager} - selectedAssistant={agent} + selectedAgent={agent} selectedDocuments={EMPTY_DOCS} removeDocs={() => {}} stopGenerating={() => {}} diff --git a/web/src/sections/modals/ShareAgentModal.tsx b/web/src/sections/modals/ShareAgentModal.tsx index 50dff82bf02..6c77f66f43a 100644 --- a/web/src/sections/modals/ShareAgentModal.tsx +++ b/web/src/sections/modals/ShareAgentModal.tsx @@ -30,7 +30,7 @@ import { Formik, useFormikContext } from "formik"; import { useAgent } from "@/hooks/useAgents"; import { Button as OpalButton } from "@opal/components"; import { useLabels } from "@/lib/hooks"; -import { PersonaLabel } from "@/app/admin/assistants/interfaces"; +import { PersonaLabel } from "@/app/admin/agents/interfaces"; const YOUR_ORGANIZATION_TAB = "Your Organization"; const USERS_AND_GROUPS_TAB = "Users & Groups"; @@ -112,7 +112,7 @@ function ShareAgentFormContent({ agentId }: ShareAgentFormContentProps) { function handleCopyLink() { if (!agentId) return; - const url = `${window.location.origin}/chat?assistantId=${agentId}`; + const url = `${window.location.origin}/chat?agentId=${agentId}`; navigator.clipboard.writeText(url); } diff --git a/web/src/sections/sidebar/AdminSidebar.tsx b/web/src/sections/sidebar/AdminSidebar.tsx index d790413d2b7..e2764d27bce 100644 --- a/web/src/sections/sidebar/AdminSidebar.tsx +++ b/web/src/sections/sidebar/AdminSidebar.tsx @@ -86,15 +86,12 @@ const document_management_items = () => [ }, ]; -const custom_assistants_items = ( - isCurator: boolean, - enableEnterprise: boolean -) => { +const custom_agents_items = (isCurator: boolean, enableEnterprise: boolean) => { const items = [ { name: "Agents", icon: SvgOnyxOctagon, - link: "/admin/assistants", + link: "/admin/agents", }, ]; @@ -167,7 +164,7 @@ const collections = ( : []), { name: "Custom Agents", - items: custom_assistants_items(isCurator, enableEnterprise), + items: custom_agents_items(isCurator, enableEnterprise), }, ...(isCurator && enableEnterprise ? [ diff --git a/web/src/sections/sidebar/AgentButton.tsx b/web/src/sections/sidebar/AgentButton.tsx index d9271af4a72..bb523fd66b8 100644 --- a/web/src/sections/sidebar/AgentButton.tsx +++ b/web/src/sections/sidebar/AgentButton.tsx @@ -1,7 +1,7 @@ "use client"; import React, { memo } from "react"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import { usePinnedAgents, useCurrentAgent } from "@/hooks/useAgents"; import { cn, noProp } from "@/lib/utils"; import SidebarTab from "@/refresh-components/buttons/SidebarTab"; @@ -64,7 +64,7 @@ const AgentButton = memo(({ agent }: AgentButtonProps) => { } - href={`/app?assistantId=${agent.id}`} + href={`/app?agentId=${agent.id}`} onClick={handleClick} transient={isCurrentAgent} rightChildren={ diff --git a/web/src/sections/sidebar/AppSidebar.tsx b/web/src/sections/sidebar/AppSidebar.tsx index 09c66f9cd96..52bc6d9783b 100644 --- a/web/src/sections/sidebar/AppSidebar.tsx +++ b/web/src/sections/sidebar/AppSidebar.tsx @@ -4,7 +4,7 @@ import { useCallback, memo, useMemo, useState, useEffect, useRef } from "react"; import useSWR from "swr"; import { useRouter } from "next/navigation"; import { useSettingsContext } from "@/providers/SettingsProvider"; -import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces"; +import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces"; import Text from "@/refresh-components/texts/Text"; import ChatButton from "@/sections/sidebar/ChatButton"; import AgentButton from "@/sections/sidebar/AgentButton"; @@ -438,10 +438,10 @@ const MemoizedAppSidebarInner = memo( LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL ) === "true"; - const isChatUsingDefaultAssistant = + const isChatUsingDefaultAgent = chatSession.persona_id === DEFAULT_PERSONA_ID; - if (!isChatUsingDefaultAssistant && !hideModal) { + if (!isChatUsingDefaultAgent && !hideModal) { setPendingMoveChatSession(chatSession); setPendingMoveProjectId(targetProject.id); setShowMoveCustomAgentModal(true); @@ -495,7 +495,7 @@ const MemoizedAppSidebarInner = memo( const newSessionButton = useMemo(() => { const href = combinedSettings?.settings?.disable_default_assistant && currentAgent - ? `/app?assistantId=${currentAgent.id}` + ? `/app?agentId=${currentAgent.id}` : "/app"; return (
@@ -592,7 +592,7 @@ const MemoizedAppSidebarInner = memo( combinedSettings?.settings?.vector_db_enabled !== false; const adminDefaultHref = vectorDbEnabled ? "/admin/indexing/status" - : "/admin/assistants"; + : "/admin/agents"; const settingsButton = useMemo( () => ( diff --git a/web/src/sections/sidebar/ChatSearchCommandMenu.tsx b/web/src/sections/sidebar/ChatSearchCommandMenu.tsx index 329c2cd7099..8f8d7d593cc 100644 --- a/web/src/sections/sidebar/ChatSearchCommandMenu.tsx +++ b/web/src/sections/sidebar/ChatSearchCommandMenu.tsx @@ -156,7 +156,7 @@ export default function ChatSearchCommandMenu({ const handleNewSession = useCallback(() => { const href = combinedSettings?.settings?.disable_default_assistant && currentAgent - ? `/app?assistantId=${currentAgent.id}` + ? `/app?agentId=${currentAgent.id}` : "/app"; router.push(href as Route); setOpen(false); diff --git a/web/tests/e2e/admin/admin_pages.spec.ts b/web/tests/e2e/admin/admin_pages.spec.ts index 655bc1c5207..e5d95aacd5a 100644 --- a/web/tests/e2e/admin/admin_pages.spec.ts +++ b/web/tests/e2e/admin/admin_pages.spec.ts @@ -30,7 +30,7 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [ }, { name: "Custom Agents - Agents", - path: "assistants", + path: "agents", pageTitle: "Agents", options: { paragraphText: diff --git a/web/tests/e2e/admin/default-assistant.spec.ts b/web/tests/e2e/admin/default-agent.spec.ts similarity index 99% rename from web/tests/e2e/admin/default-assistant.spec.ts rename to web/tests/e2e/admin/default-agent.spec.ts index 385152bb929..09b5522a6e7 100644 --- a/web/tests/e2e/admin/default-assistant.spec.ts +++ b/web/tests/e2e/admin/default-agent.spec.ts @@ -621,7 +621,7 @@ test.describe("Chat Preferences Admin Page", () => { ); const configData = await configResp.json(); console.log( - `[toggle-all] Default assistant config: ${JSON.stringify(configData)}` + `[toggle-all] Default agent config: ${JSON.stringify(configData)}` ); } catch (e) { console.warn(`[toggle-all] Failed to fetch config: ${e}`); diff --git a/web/tests/e2e/admin/disable_default_assistant.spec.ts b/web/tests/e2e/admin/disable_default_agent.spec.ts similarity index 86% rename from web/tests/e2e/admin/disable_default_assistant.spec.ts rename to web/tests/e2e/admin/disable_default_agent.spec.ts index 8fcf091a8fe..17be175817f 100644 --- a/web/tests/e2e/admin/disable_default_assistant.spec.ts +++ b/web/tests/e2e/admin/disable_default_agent.spec.ts @@ -1,6 +1,6 @@ import { test, expect, Page } from "@playwright/test"; import { loginAs } from "@tests/e2e/utils/auth"; -import { createAssistant } from "@tests/e2e/utils/assistantUtils"; +import { createAgent } from "@tests/e2e/utils/agentUtils"; import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient"; const MAX_SETTING_SAVE_ATTEMPTS = 5; @@ -32,7 +32,7 @@ async function expandAdvancedOptions(page: Page): Promise { } /** - * Toggle the "Always Start with an Agent" setting (formerly "Disable Default Assistant") + * Toggle the "Always Start with an Agent" setting (formerly "Disable Default Agent") * on the Chat Preferences page. Uses auto-save via the SwitchField. * * The switch is a SwitchField with name="disable_default_assistant" which renders @@ -91,7 +91,7 @@ async function setDisableDefaultAssistantSetting( ); } -test.describe("Disable Default Assistant Setting @exclusive", () => { +test.describe("Disable Default Agent Setting @exclusive", () => { let createdAssistantId: number | null = null; test.beforeEach(async ({ page }) => { @@ -104,11 +104,11 @@ test.describe("Disable Default Assistant Setting @exclusive", () => { // Clean up any assistant created during the test if (createdAssistantId !== null) { const client = new OnyxApiClient(page.request); - await client.deleteAssistant(createdAssistantId); + await client.deleteAgent(createdAssistantId); createdAssistantId = null; } - // Ensure default assistant is enabled (switch unchecked) after each test + // Ensure default agent is enabled (switch unchecked) after each test // to avoid interfering with other tests await setDisableDefaultAssistantSetting(page, false); }); @@ -129,21 +129,21 @@ test.describe("Disable Default Assistant Setting @exclusive", () => { // Navigate to app and create a new assistant to ensure there's one besides the default await page.goto("/app"); - const assistantName = `Test Assistant ${Date.now()}`; - await createAssistant(page, { - name: assistantName, + const agentName = `Test Assistant ${Date.now()}`; + await createAgent(page, { + name: agentName, description: "Test assistant for new session button test", instructions: "You are a helpful test assistant.", }); // Extract the assistant ID from the URL const currentUrl = page.url(); - const assistantIdMatch = currentUrl.match(/assistantId=(\d+)/); - expect(assistantIdMatch).toBeTruthy(); + const agentIdMatch = currentUrl.match(/agentId=(\d+)/); + expect(agentIdMatch).toBeTruthy(); // Store for cleanup - if (assistantIdMatch) { - createdAssistantId = Number(assistantIdMatch[1]); + if (agentIdMatch) { + createdAssistantId = Number(agentIdMatch[1]); } // Click the "New Session" button @@ -152,11 +152,11 @@ test.describe("Disable Default Assistant Setting @exclusive", () => { ); await newSessionButton.click(); - // Verify the WelcomeMessage shown is NOT from the default assistant - // Default assistant shows onyx-logo, custom assistants show assistant-name-display + // Verify the WelcomeMessage shown is NOT from the default agent + // Default agent shows onyx-logo, custom agents show agent-name-display await expect(page.locator('[data-testid="onyx-logo"]')).not.toBeVisible(); await expect( - page.locator('[data-testid="assistant-name-display"]') + page.locator('[data-testid="agent-name-display"]') ).toBeVisible(); }); @@ -169,12 +169,12 @@ test.describe("Disable Default Assistant Setting @exclusive", () => { // Navigate directly to /app await page.goto("/app"); - // Verify that we didn't land on the default assistant (ID 0) + // Verify that we didn't land on the default agent (ID 0) // The assistant selection should be a pinned or available assistant (not ID 0) const currentUrl = page.url(); - // If assistantId is in URL, it should not be 0 - if (currentUrl.includes("assistantId=")) { - expect(currentUrl).not.toContain("assistantId=0"); + // If agentId is in URL, it should not be 0 + if (currentUrl.includes("agentId=")) { + expect(currentUrl).not.toContain("agentId=0"); } }); @@ -228,7 +228,7 @@ test.describe("Disable Default Assistant Setting @exclusive", () => { ); }); - test("default assistant is available again when setting is disabled", async ({ + test("default agent is available again when setting is disabled", async ({ page, }) => { // Navigate to settings and ensure setting is disabled @@ -237,18 +237,18 @@ test.describe("Disable Default Assistant Setting @exclusive", () => { // Navigate directly to /app without parameters await page.goto("/app"); - // The default assistant (ID 0) should be available + // The default agent (ID 0) should be available // We can verify this by checking that the app loads successfully // and doesn't force navigation to a specific assistant expect(page.url()).toContain("/app"); - // Verify the new session button navigates to /app without assistantId + // Verify the new session button navigates to /app without agentId const newSessionButton = page.locator( '[data-testid="AppSidebar/new-session"]' ); await newSessionButton.click(); - // Should navigate to /app without assistantId parameter + // Should navigate to /app without agentId parameter const newUrl = page.url(); expect(newUrl).toContain("/app"); }); diff --git a/web/tests/e2e/admin/discord-bot/channel-config.spec.ts b/web/tests/e2e/admin/discord-bot/channel-config.spec.ts index 05de7fffeb2..7ee14122132 100644 --- a/web/tests/e2e/admin/discord-bot/channel-config.spec.ts +++ b/web/tests/e2e/admin/discord-bot/channel-config.spec.ts @@ -51,9 +51,7 @@ test.describe("Guild Detail Page & Channel Configuration", () => { }); // Find the persona/agent dropdown (InputSelect) - const agentDropdown = adminPage.locator( - 'button:has-text("Default Assistant")' - ); + const agentDropdown = adminPage.locator('button:has-text("Default Agent")'); if (await agentDropdown.isVisible({ timeout: 5000 }).catch(() => false)) { await agentDropdown.click(); diff --git a/web/tests/e2e/admin/oauth_config/test_tool_oauth.spec.ts b/web/tests/e2e/admin/oauth_config/test_tool_oauth.spec.ts index f8c7b172094..4b1337b37ba 100644 --- a/web/tests/e2e/admin/oauth_config/test_tool_oauth.spec.ts +++ b/web/tests/e2e/admin/oauth_config/test_tool_oauth.spec.ts @@ -61,7 +61,7 @@ test.afterAll(async ({ browser }: { browser: Browser }) => { // Delete the assistant first (it references the tool) if (createdAssistantId !== null) { - await client.deleteAssistant(createdAssistantId); + await client.deleteAgent(createdAssistantId); } // Then delete the tool @@ -211,12 +211,12 @@ test("Tool OAuth Configuration: Creation, Selection, and Assistant Integration", await page.waitForLoadState("networkidle"); // Fill in basic assistant details - const assistantName = `Test Assistant ${Date.now()}`; - const assistantDescription = "Assistant with OAuth tool"; + const agentName = `Test Assistant ${Date.now()}`; + const agentDescription = "Assistant with OAuth tool"; const assistantInstructions = "Use the tool when needed"; - await page.locator('input[name="name"]').fill(assistantName); - await page.locator('textarea[name="description"]').fill(assistantDescription); + await page.locator('input[name="name"]').fill(agentName); + await page.locator('textarea[name="description"]').fill(agentDescription); await page .locator('textarea[name="instructions"]') .fill(assistantInstructions); @@ -241,14 +241,14 @@ test("Tool OAuth Configuration: Creation, Selection, and Assistant Integration", await createButton.click(); // Verify redirection to app page with the new assistant ID - await page.waitForURL(/.*\/app\?assistantId=\d+.*/, { timeout: 10000 }); + await page.waitForURL(/.*\/app\?agentId=\d+.*/, { timeout: 10000 }); const assistantUrl = page.url(); - const assistantIdMatch = assistantUrl.match(/assistantId=(\d+)/); - expect(assistantIdMatch).toBeTruthy(); + const agentIdMatch = assistantUrl.match(/agentId=(\d+)/); + expect(agentIdMatch).toBeTruthy(); // Store assistant ID for cleanup - if (assistantIdMatch) { - createdAssistantId = Number(assistantIdMatch[1]); + if (agentIdMatch) { + createdAssistantId = Number(agentIdMatch[1]); } // Test complete! We've verified: diff --git a/web/tests/e2e/assistants/create_and_edit_assistant.spec.ts b/web/tests/e2e/agents/create_and_edit_agent.spec.ts similarity index 87% rename from web/tests/e2e/assistants/create_and_edit_assistant.spec.ts rename to web/tests/e2e/agents/create_and_edit_agent.spec.ts index e07ed24cde0..068b5e9e6b1 100644 --- a/web/tests/e2e/assistants/create_and_edit_assistant.spec.ts +++ b/web/tests/e2e/agents/create_and_edit_agent.spec.ts @@ -129,7 +129,7 @@ test.describe("Assistant Creation and Edit Verification", () => { }); const page = await context.newPage(); const cleanupClient = new OnyxApiClient(page.request); - await cleanupClient.deleteAssistant(userFilesAssistantId); + await cleanupClient.deleteAgent(userFilesAssistantId); await context.close(); console.log( "[test] Cleanup completed - deleted User Files Only assistant" @@ -143,16 +143,15 @@ test.describe("Assistant Creation and Edit Verification", () => { await page.context().clearCookies(); await loginAsWorkerUser(page, testInfo.workerIndex); - const assistantName = "E2E User Files Assistant"; - const assistantDescription = - "Testing user file uploads without connectors"; + const agentName = "E2E User Files Assistant"; + const agentDescription = "Testing user file uploads without connectors"; const assistantInstructions = "Help users with their documents."; await page.goto("/app/agents/create"); // Fill in basic assistant details - await getNameInput(page).fill(assistantName); - await getDescriptionInput(page).fill(assistantDescription); + await getNameInput(page).fill(agentName); + await getDescriptionInput(page).fill(agentDescription); await getInstructionsTextarea(page).fill(assistantInstructions); // Enable Knowledge toggle @@ -174,18 +173,18 @@ test.describe("Assistant Creation and Edit Verification", () => { await getCreateSubmitButton(page).click(); // Verify redirection to chat page with the new assistant - await page.waitForURL(/.*\/app\?assistantId=\d+.*/); + await page.waitForURL(/.*\/app\?agentId=\d+.*/); const url = page.url(); - const assistantIdMatch = url.match(/assistantId=(\d+)/); - expect(assistantIdMatch).toBeTruthy(); + const agentIdMatch = url.match(/agentId=(\d+)/); + expect(agentIdMatch).toBeTruthy(); // Store assistant ID for cleanup - if (assistantIdMatch) { - userFilesAssistantId = Number(assistantIdMatch[1]); + if (agentIdMatch) { + userFilesAssistantId = Number(agentIdMatch[1]); } console.log( - `[test] Successfully created assistant without connectors: ${assistantName}` + `[test] Successfully created assistant without connectors: ${agentName}` ); }); }); @@ -204,7 +203,7 @@ test.describe("Assistant Creation and Edit Verification", () => { const cleanupClient = new OnyxApiClient(page.request); if (knowledgeAssistantId !== null) { - await cleanupClient.deleteAssistant(knowledgeAssistantId); + await cleanupClient.deleteAgent(knowledgeAssistantId); } if (ccPairId && documentSetId) { await cleanupClient.deleteDocumentSet(documentSetId); @@ -241,8 +240,8 @@ test.describe("Assistant Creation and Edit Verification", () => { await loginAsWorkerUser(page, testInfo.workerIndex); // --- Initial Values --- - const assistantName = "Test Assistant 1"; - const assistantDescription = "This is a test assistant description."; + const agentName = "Test Assistant 1"; + const agentDescription = "This is a test assistant description."; const assistantInstructions = "These are the test instructions."; const assistantReminder = "Initial reminder."; const assistantStarterMessage = "Initial starter message?"; @@ -258,8 +257,8 @@ test.describe("Assistant Creation and Edit Verification", () => { await page.goto("/app/agents/create"); // --- Fill in Initial Assistant Details --- - await getNameInput(page).fill(assistantName); - await getDescriptionInput(page).fill(assistantDescription); + await getNameInput(page).fill(agentName); + await getDescriptionInput(page).fill(agentDescription); await getInstructionsTextarea(page).fill(assistantInstructions); // Reminder @@ -287,24 +286,24 @@ test.describe("Assistant Creation and Edit Verification", () => { await getCreateSubmitButton(page).click(); // Verify redirection to chat page with the new assistant ID - await page.waitForURL(/.*\/app\?assistantId=\d+.*/); + await page.waitForURL(/.*\/app\?agentId=\d+.*/); const url = page.url(); - const assistantIdMatch = url.match(/assistantId=(\d+)/); - expect(assistantIdMatch).toBeTruthy(); - const assistantId = assistantIdMatch ? assistantIdMatch[1] : null; - expect(assistantId).not.toBeNull(); + const agentIdMatch = url.match(/agentId=(\d+)/); + expect(agentIdMatch).toBeTruthy(); + const agentId = agentIdMatch ? agentIdMatch[1] : null; + expect(agentId).not.toBeNull(); await expectScreenshot(page, { name: "welcome-page-with-assistant" }); // Store assistant ID for cleanup - knowledgeAssistantId = Number(assistantId); + knowledgeAssistantId = Number(agentId); // Navigate directly to the edit page - await page.goto(`/app/agents/edit/${assistantId}`); - await page.waitForURL(`**/app/agents/edit/${assistantId}`); + await page.goto(`/app/agents/edit/${agentId}`); + await page.waitForURL(`**/app/agents/edit/${agentId}`); // Verify basic fields - await expect(getNameInput(page)).toHaveValue(assistantName); - await expect(getDescriptionInput(page)).toHaveValue(assistantDescription); + await expect(getNameInput(page)).toHaveValue(agentName); + await expect(getDescriptionInput(page)).toHaveValue(agentDescription); await expect(getInstructionsTextarea(page)).toHaveValue( assistantInstructions ); @@ -341,12 +340,12 @@ test.describe("Assistant Creation and Edit Verification", () => { await getUpdateSubmitButton(page).click(); // Verify redirection back to the chat page - await page.waitForURL(/.*\/app\?assistantId=\d+.*/); - expect(page.url()).toContain(`assistantId=${assistantId}`); + await page.waitForURL(/.*\/app\?agentId=\d+.*/); + expect(page.url()).toContain(`agentId=${agentId}`); // --- Navigate to Edit Page Again and Verify Edited Values --- - await page.goto(`/app/agents/edit/${assistantId}`); - await page.waitForURL(`**/app/agents/edit/${assistantId}`); + await page.goto(`/app/agents/edit/${agentId}`); + await page.waitForURL(`**/app/agents/edit/${agentId}`); // Verify basic fields await expect(getNameInput(page)).toHaveValue(editedAssistantName); @@ -381,7 +380,7 @@ test.describe("Assistant Creation and Edit Verification", () => { ); console.log( - `[test] Successfully tested Knowledge-enabled assistant: ${assistantName}` + `[test] Successfully tested Knowledge-enabled assistant: ${agentName}` ); }); }); diff --git a/web/tests/e2e/assistants/llm_provider_rbac.spec.ts b/web/tests/e2e/agents/llm_provider_rbac.spec.ts similarity index 100% rename from web/tests/e2e/assistants/llm_provider_rbac.spec.ts rename to web/tests/e2e/agents/llm_provider_rbac.spec.ts diff --git a/web/tests/e2e/assistants/user_file_attachment.spec.ts b/web/tests/e2e/agents/user_file_attachment.spec.ts similarity index 90% rename from web/tests/e2e/assistants/user_file_attachment.spec.ts rename to web/tests/e2e/agents/user_file_attachment.spec.ts index fe6085d2c06..7ff71309041 100644 --- a/web/tests/e2e/assistants/user_file_attachment.spec.ts +++ b/web/tests/e2e/agents/user_file_attachment.spec.ts @@ -39,7 +39,7 @@ const extractAssistantIdFromCreateResponse = ( return null; }; -const createAssistantAndGetId = async (page: Page): Promise => { +const createAgentAndGetId = async (page: Page): Promise => { const createResponsePromise = page.waitForResponse( (response) => { if (response.request().method() !== "POST" || !response.ok()) { @@ -62,23 +62,23 @@ const createAssistantAndGetId = async (page: Page): Promise => { await page.waitForURL( (url) => { const href = typeof url === "string" ? url : url.toString(); - return /\/app\?assistantId=\d+/.test(href) || /\/app\?chatId=/.test(href); + return /\/app\?agentId=\d+/.test(href) || /\/app\?chatId=/.test(href); }, { timeout: 20000 } ); - const assistantIdFromUrl = page.url().match(/assistantId=(\d+)/); - if (assistantIdFromUrl?.[1]) { - return Number(assistantIdFromUrl[1]); + const agentIdFromUrl = page.url().match(/agentId=(\d+)/); + if (agentIdFromUrl?.[1]) { + return Number(agentIdFromUrl[1]); } const createPayload = (await createResponse .json() .catch(() => null)) as Record | null; - const assistantIdFromResponse = + const agentIdFromResponse = extractAssistantIdFromCreateResponse(createPayload); - if (assistantIdFromResponse !== null) { - return assistantIdFromResponse; + if (agentIdFromResponse !== null) { + return agentIdFromResponse; } throw new Error( @@ -245,8 +245,8 @@ test.describe("User File Attachment to Assistant", () => { await page.context().clearCookies(); await loginAsRandomUser(page); - const assistantName = `User File Test ${Date.now()}`; - const assistantDescription = "Testing user file persistence"; + const agentName = `User File Test ${Date.now()}`; + const agentDescription = "Testing user file persistence"; const assistantInstructions = "Help users with their uploaded files."; const testFileName = `test-file-${Date.now()}.txt`; const testFileContent = @@ -257,8 +257,8 @@ test.describe("User File Attachment to Assistant", () => { await page.waitForLoadState("networkidle"); // Fill in basic assistant details - await getNameInput(page).fill(assistantName); - await getDescriptionInput(page).fill(assistantDescription); + await getNameInput(page).fill(agentName); + await getDescriptionInput(page).fill(agentDescription); await getInstructionsTextarea(page).fill(assistantInstructions); // Enable Knowledge toggle @@ -282,15 +282,15 @@ test.describe("User File Attachment to Assistant", () => { await expect(fileText).toBeVisible(); // Submit the assistant creation form and resolve assistant ID from URL or API response. - const assistantId = await createAssistantAndGetId(page); + const agentId = await createAgentAndGetId(page); console.log( - `[test] Created assistant ${assistantName} with ID ${assistantId}, now verifying file persistence...` + `[test] Created assistant ${agentName} with ID ${agentId}, now verifying file persistence...` ); // Navigate to the edit page for the assistant - await page.goto(`/app/agents/edit/${assistantId}`); - await page.waitForURL(`**/app/agents/edit/${assistantId}`); + await page.goto(`/app/agents/edit/${agentId}`); + await page.waitForURL(`**/app/agents/edit/${agentId}`); await page.waitForLoadState("networkidle"); // Verify knowledge toggle is still enabled @@ -325,7 +325,7 @@ test.describe("User File Attachment to Assistant", () => { await expect(fileRowAfterEdit).toBeVisible({ timeout: 5000 }); console.log( - `[test] Successfully verified user file ${testFileName} is persisted and selected for assistant ${assistantName}` + `[test] Successfully verified user file ${testFileName} is persisted and selected for assistant ${agentName}` ); }); @@ -338,7 +338,7 @@ test.describe("User File Attachment to Assistant", () => { await page.context().clearCookies(); await loginAsRandomUser(page); - const assistantName = `Multi-File Test ${Date.now()}`; + const agentName = `Multi-File Test ${Date.now()}`; const testFileName1 = `test-file-1-${Date.now()}.txt`; const testFileName2 = `test-file-2-${Date.now()}.txt`; const testFileContent = "Test content for multi-file test."; @@ -348,7 +348,7 @@ test.describe("User File Attachment to Assistant", () => { await page.waitForLoadState("networkidle"); // Fill in basic assistant details - await getNameInput(page).fill(assistantName); + await getNameInput(page).fill(agentName); await getDescriptionInput(page).fill("Testing multiple user files"); await getInstructionsTextarea(page).fill("Help with multiple files."); @@ -370,10 +370,10 @@ test.describe("User File Attachment to Assistant", () => { // already adds files to user_file_ids. Clicking would toggle them OFF. // Create the assistant and resolve assistant ID from URL or API response. - const assistantId = await createAssistantAndGetId(page); + const agentId = await createAgentAndGetId(page); // Go to edit page - await page.goto(`/app/agents/edit/${assistantId}`); + await page.goto(`/app/agents/edit/${agentId}`); await page.waitForLoadState("networkidle"); // Navigate to files view @@ -398,7 +398,7 @@ test.describe("User File Attachment to Assistant", () => { } console.log( - `[test] Successfully verified multiple user files are persisted for assistant ${assistantName}` + `[test] Successfully verified multiple user files are persisted for assistant ${agentName}` ); }); }); diff --git a/web/tests/e2e/chat/chat_message_rendering.spec.ts b/web/tests/e2e/chat/chat_message_rendering.spec.ts index 634b07f1c07..018011725cc 100644 --- a/web/tests/e2e/chat/chat_message_rendering.spec.ts +++ b/web/tests/e2e/chat/chat_message_rendering.spec.ts @@ -116,18 +116,18 @@ let turnCounter = 0; function buildMockStream(content: string): string { turnCounter += 1; const userMessageId = turnCounter * 100 + 1; - const assistantMessageId = turnCounter * 100 + 2; + const agentMessageId = turnCounter * 100 + 2; const packets = [ { user_message_id: userMessageId, - reserved_assistant_message_id: assistantMessageId, + reserved_assistant_message_id: agentMessageId, }, { placement: { turn_index: 0, tab_index: 0 }, obj: { type: "message_start", - id: `mock-${assistantMessageId}`, + id: `mock-${agentMessageId}`, content, final_documents: null, }, @@ -137,7 +137,7 @@ function buildMockStream(content: string): string { obj: { type: "stop", stop_reason: "finished" }, }, { - message_id: assistantMessageId, + message_id: agentMessageId, citations: {}, files: [], }, @@ -149,7 +149,7 @@ function buildMockStream(content: string): string { function buildMockSearchStream(options: SearchMockOptions): string { turnCounter += 1; const userMessageId = turnCounter * 100 + 1; - const assistantMessageId = turnCounter * 100 + 2; + const agentMessageId = turnCounter * 100 + 2; const fullDocs = options.documents.map((doc) => ({ ...doc, @@ -167,7 +167,7 @@ function buildMockSearchStream(options: SearchMockOptions): string { const packets: Record[] = [ { user_message_id: userMessageId, - reserved_assistant_message_id: assistantMessageId, + reserved_assistant_message_id: agentMessageId, }, { placement: { turn_index: 0, tab_index: 0 }, @@ -194,7 +194,7 @@ function buildMockSearchStream(options: SearchMockOptions): string { placement: { turn_index: 1, tab_index: 0 }, obj: { type: "message_start", - id: `mock-${assistantMessageId}`, + id: `mock-${agentMessageId}`, content: options.content, final_documents: fullDocs, }, @@ -212,7 +212,7 @@ function buildMockSearchStream(options: SearchMockOptions): string { obj: { type: "stop", stop_reason: "finished" }, }, { - message_id: assistantMessageId, + message_id: agentMessageId, citations: options.citations, files: [], }, diff --git a/web/tests/e2e/chat/current_assistant.spec.ts b/web/tests/e2e/chat/current_agent.spec.ts similarity index 91% rename from web/tests/e2e/chat/current_assistant.spec.ts rename to web/tests/e2e/chat/current_agent.spec.ts index 7006e2b5697..bb78b7ad4e7 100644 --- a/web/tests/e2e/chat/current_assistant.spec.ts +++ b/web/tests/e2e/chat/current_agent.spec.ts @@ -1,10 +1,7 @@ import { test, expect } from "@playwright/test"; import { dragElementAbove, dragElementBelow } from "@tests/e2e/utils/dragUtils"; import { loginAsRandomUser } from "@tests/e2e/utils/auth"; -import { - createAssistant, - pinAssistantByName, -} from "@tests/e2e/utils/assistantUtils"; +import { createAgent, pinAgentByName } from "@tests/e2e/utils/agentUtils"; // TODO (chris): figure out why this test is flakey test.skip("Assistant Drag and Drop", async ({ page }) => { @@ -19,32 +16,32 @@ test.skip("Assistant Drag and Drop", async ({ page }) => { const nameA = `E2E Assistant A ${ts}`; const nameB = `E2E Assistant B ${ts}`; const nameC = `E2E Assistant C ${ts}`; - await createAssistant(page, { + await createAgent(page, { name: nameA, description: "E2E-created assistant A", instructions: "Assistant A instructions", }); - await pinAssistantByName(page, nameA); + await pinAgentByName(page, nameA); await expect( page.locator('[data-testid^="assistant-["]').filter({ hasText: nameA }) ).toBeVisible(); - await createAssistant(page, { + await createAgent(page, { name: nameB, description: "E2E-created assistant B", instructions: "Assistant B instructions", }); - await pinAssistantByName(page, nameB); + await pinAgentByName(page, nameB); await expect( page.locator('[data-testid^="assistant-["]').filter({ hasText: nameB }) ).toBeVisible(); - await createAssistant(page, { + await createAgent(page, { name: nameC, description: "E2E-created assistant C", instructions: "Assistant C instructions", }); - await pinAssistantByName(page, nameC); + await pinAgentByName(page, nameC); await expect( page.locator('[data-testid^="assistant-["]').filter({ hasText: nameC }) ).toBeVisible(); diff --git a/web/tests/e2e/chat/default_assistant.spec.ts b/web/tests/e2e/chat/default_agent.spec.ts similarity index 96% rename from web/tests/e2e/chat/default_assistant.spec.ts rename to web/tests/e2e/chat/default_agent.spec.ts index 48089c4bde3..fbfedf6f90f 100644 --- a/web/tests/e2e/chat/default_assistant.spec.ts +++ b/web/tests/e2e/chat/default_agent.spec.ts @@ -4,8 +4,8 @@ import { loginAsRandomUser, loginAs } from "@tests/e2e/utils/auth"; import { sendMessage, startNewChat, - verifyAssistantIsChosen, - verifyDefaultAssistantIsChosen, + verifyAgentIsChosen, + verifyDefaultAgentIsChosen, } from "@tests/e2e/utils/chatActions"; import { TOOL_IDS, @@ -119,7 +119,7 @@ test.describe("Default Agent Tests", () => { await page.getByRole("button", { name: "Create" }).click(); // Wait for agent to be created and selected - await verifyAssistantIsChosen(page, "Custom Test Agent"); + await verifyAgentIsChosen(page, "Custom Test Agent"); // Greeting should NOT appear for custom agent const customGreeting = await page.$('[data-testid="onyx-logo"]'); @@ -137,10 +137,10 @@ test.describe("Default Agent Tests", () => { expect(logoElement).toBeTruthy(); // Should NOT show agent name for default agent - const assistantNameElement = await page.$( - '[data-testid="assistant-name-display"]' + const agentNameElement = await page.$( + '[data-testid="agent-name-display"]' ); - expect(assistantNameElement).toBeNull(); + expect(agentNameElement).toBeNull(); }); test("custom agents should show name and icon instead of logo", async ({ @@ -162,14 +162,14 @@ test.describe("Default Agent Tests", () => { await page.getByRole("button", { name: "Create" }).click(); // Wait for agent to be created and selected - await verifyAssistantIsChosen(page, "Custom Agent"); + await verifyAgentIsChosen(page, "Custom Agent"); // Should show agent name and icon, not Onyx logo - const assistantNameElement = await page.waitForSelector( - '[data-testid="assistant-name-display"]', + const agentNameElement = await page.waitForSelector( + '[data-testid="agent-name-display"]', { timeout: 5000 } ); - const nameText = await assistantNameElement.textContent(); + const nameText = await agentNameElement.textContent(); expect(nameText).toContain("Custom Agent"); // Onyx logo should NOT be shown @@ -211,7 +211,7 @@ test.describe("Default Agent Tests", () => { await page.getByRole("button", { name: "Create" }).click(); // Wait for assistant to be created and selected - await verifyAssistantIsChosen(page, "Test Agent with Starters"); + await verifyAgentIsChosen(page, "Test Agent with Starters"); // Starter messages container might exist but be empty for custom agents const starterMessagesContainer = await page.$( @@ -231,7 +231,7 @@ test.describe("Default Agent Tests", () => { test.describe("Agent Selection", () => { test("default agent should be selected for new chats", async ({ page }) => { // Verify the input placeholder indicates default agent (Onyx) - await verifyDefaultAssistantIsChosen(page); + await verifyDefaultAgentIsChosen(page); }); test("default agent should NOT appear in agent selector", async ({ @@ -247,7 +247,7 @@ test.describe("Default Agent Tests", () => { .waitFor({ state: "visible", timeout: 5000 }); // Look for default agent by name - it should NOT be there - const assistantElements = await page.$$('[data-testid^="assistant-"]'); + const assistantElements = await page.$$('[data-testid^="agent-"]'); const assistantTexts = await Promise.all( assistantElements.map((el) => el.textContent()) ); @@ -284,13 +284,13 @@ test.describe("Default Agent Tests", () => { await page.getByRole("button", { name: "Create" }).click(); // Verify switched to custom agent - await verifyAssistantIsChosen(page, "Switch Test Agent"); + await verifyAgentIsChosen(page, "Switch Test Agent"); // Start new chat to go back to default await startNewChat(page); // Should be back to default agent - await verifyDefaultAssistantIsChosen(page); + await verifyDefaultAgentIsChosen(page); }); }); diff --git a/web/tests/e2e/chat/live_assistant.spec.ts b/web/tests/e2e/chat/live_agent.spec.ts similarity index 81% rename from web/tests/e2e/chat/live_assistant.spec.ts rename to web/tests/e2e/chat/live_agent.spec.ts index 939de45d317..d044b26bbb6 100644 --- a/web/tests/e2e/chat/live_assistant.spec.ts +++ b/web/tests/e2e/chat/live_agent.spec.ts @@ -3,8 +3,8 @@ import { loginAsRandomUser } from "@tests/e2e/utils/auth"; import { sendMessage, startNewChat, - verifyAssistantIsChosen, - verifyDefaultAssistantIsChosen, + verifyAgentIsChosen, + verifyDefaultAgentIsChosen, } from "@tests/e2e/utils/chatActions"; test("Chat workflow", async ({ page }) => { @@ -12,7 +12,7 @@ test("Chat workflow", async ({ page }) => { await page.context().clearCookies(); // Use waitForSelector for robustness instead of expect().toBeVisible() // await page.waitForSelector( - // `//div[@aria-label="Agents Modal"]//*[contains(text(), "${assistantName}") and not(contains(@class, 'invisible'))]`, + // `//div[@aria-label="Agents Modal"]//*[contains(text(), "${agentName}") and not(contains(@class, 'invisible'))]`, // { state: "visible", timeout: 10000 } // ); await loginAsRandomUser(page); @@ -21,14 +21,14 @@ test("Chat workflow", async ({ page }) => { await page.goto("/app"); await page.waitForLoadState("networkidle"); - // Test interaction with the Default assistant + // Test interaction with the Default agent await sendMessage(page, "Hi"); // Start a new chat session await startNewChat(page); // Verify the presence of the expected text - await verifyDefaultAssistantIsChosen(page); + await verifyDefaultAgentIsChosen(page); // Test creation of a new assistant await page.getByTestId("AppSidebar/more-agents").click(); @@ -46,12 +46,12 @@ test("Chat workflow", async ({ page }) => { await page.getByRole("button", { name: "Create" }).click(); // Verify the successful creation of the new assistant - await verifyAssistantIsChosen(page, "Test Assistant"); + await verifyAgentIsChosen(page, "Test Assistant"); // Start another new chat session await startNewChat(page); await page.waitForLoadState("networkidle"); - // Verify the presence of the default assistant text - await verifyDefaultAssistantIsChosen(page); + // Verify the presence of the default agent text + await verifyDefaultAgentIsChosen(page); }); diff --git a/web/tests/e2e/chat/llm_ordering.spec.ts b/web/tests/e2e/chat/llm_ordering.spec.ts index f9ab482e8f4..bbe67bda8f2 100644 --- a/web/tests/e2e/chat/llm_ordering.spec.ts +++ b/web/tests/e2e/chat/llm_ordering.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; import { loginAs } from "@tests/e2e/utils/auth"; import { verifyCurrentModel } from "@tests/e2e/utils/chatActions"; -import { ensureImageGenerationEnabled } from "@tests/e2e/utils/assistantUtils"; +import { ensureImageGenerationEnabled } from "@tests/e2e/utils/agentUtils"; import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient"; test.describe("LLM Ordering", () => { diff --git a/web/tests/e2e/chat/llm_runtime_selection.spec.ts b/web/tests/e2e/chat/llm_runtime_selection.spec.ts index 47b48dd2388..af669eb94c2 100644 --- a/web/tests/e2e/chat/llm_runtime_selection.spec.ts +++ b/web/tests/e2e/chat/llm_runtime_selection.spec.ts @@ -130,18 +130,18 @@ async function waitForModelOnProvider( function buildMockStreamResponse(turn: number): string { const userMessageId = turn * 100 + 1; - const assistantMessageId = turn * 100 + 2; + const agentMessageId = turn * 100 + 2; const packets = [ { user_message_id: userMessageId, - reserved_assistant_message_id: assistantMessageId, + reserved_assistant_message_id: agentMessageId, }, { placement: { turn_index: 0, tab_index: 0 }, obj: { type: "message_start", - id: `mock-${assistantMessageId}`, + id: `mock-${agentMessageId}`, content: "Mock response for provider collision assertion.", final_documents: null, }, @@ -151,7 +151,7 @@ function buildMockStreamResponse(turn: number): string { obj: { type: "stop", stop_reason: "finished" }, }, { - message_id: assistantMessageId, + message_id: agentMessageId, citations: {}, files: [], }, diff --git a/web/tests/e2e/chat/welcome_page.spec.ts b/web/tests/e2e/chat/welcome_page.spec.ts index d0e44010901..16f7837ce14 100644 --- a/web/tests/e2e/chat/welcome_page.spec.ts +++ b/web/tests/e2e/chat/welcome_page.spec.ts @@ -66,7 +66,7 @@ for (const theme of THEMES) { // ── Content assertions ──────────────────────────────────────────── - test("displays greeting from default assistant", async ({ page }) => { + test("displays greeting from default agent", async ({ page }) => { const greetingContainer = page.getByTestId("onyx-logo"); await greetingContainer.waitFor({ state: "visible", timeout: 10000 }); diff --git a/web/tests/e2e/mcp/default-assistant-mcp.spec.ts b/web/tests/e2e/mcp/default-agent-mcp.spec.ts similarity index 96% rename from web/tests/e2e/mcp/default-assistant-mcp.spec.ts rename to web/tests/e2e/mcp/default-agent-mcp.spec.ts index 46728f4cb4b..f2eed126fa2 100644 --- a/web/tests/e2e/mcp/default-assistant-mcp.spec.ts +++ b/web/tests/e2e/mcp/default-agent-mcp.spec.ts @@ -84,7 +84,7 @@ async function fetchMcpToolIdByName( return matchedTool!.id; } -test.describe("Default Assistant MCP Integration", () => { +test.describe("Default Agent MCP Integration", () => { test.describe.configure({ mode: "serial" }); let serverProcess: McpServerProcess | null = null; @@ -170,7 +170,7 @@ test.describe("Default Assistant MCP Integration", () => { } }); - test("Admin configures API key MCP server and adds tools to default assistant", async ({ + test("Admin configures API key MCP server and adds tools to default agent", async ({ page, }) => { await page.context().clearCookies(); @@ -319,7 +319,7 @@ test.describe("Default Assistant MCP Integration", () => { ); }); - test("Admin adds MCP tools to default assistant via chat preferences page", async ({ + test("Admin adds MCP tools to default agent via chat preferences page", async ({ page, }) => { test.skip(!serverId, "MCP server must be created first"); @@ -369,10 +369,10 @@ test.describe("Default Assistant MCP Integration", () => { timeout: 10000, }); } - console.log(`[test] MCP tools successfully added to default assistant`); + console.log(`[test] MCP tools successfully added to default agent`); }); - test("Basic user can see and toggle MCP tools in default assistant", async ({ + test("Basic user can see and toggle MCP tools in default agent", async ({ page, }) => { test.skip(!serverId, "MCP server must be configured first"); @@ -382,7 +382,7 @@ test.describe("Default Assistant MCP Integration", () => { await apiLogin(page, basicUserEmail, basicUserPassword); console.log(`[test] Logged in as basic user: ${basicUserEmail}`); - // Navigate to chat (which uses default assistant for new users) + // Navigate to chat (which uses default agent for new users) await page.goto("/app"); await page.waitForURL("**/app**"); await ensureOnboardingComplete(page); @@ -499,8 +499,8 @@ test.describe("Default Assistant MCP Integration", () => { await page.getByLabel("AgentsPage/new-agent-button").click(); await page.waitForURL("**/app/agents/create"); - const assistantName = `MCP Assistant ${Date.now()}`; - await page.locator('input[name="name"]').fill(assistantName); + const agentName = `MCP Assistant ${Date.now()}`; + await page.locator('input[name="name"]').fill(agentName); await page .locator('textarea[name="description"]') .fill("Assistant with MCP actions attached."); @@ -529,14 +529,14 @@ test.describe("Default Assistant MCP Integration", () => { await page.getByRole("button", { name: "Create" }).click(); - await page.waitForURL(/.*\/app\?assistantId=\d+.*/); - const assistantIdMatch = page.url().match(/assistantId=(\d+)/); - expect(assistantIdMatch).toBeTruthy(); - const assistantId = assistantIdMatch ? assistantIdMatch[1] : null; - expect(assistantId).not.toBeNull(); + await page.waitForURL(/.*\/app\?agentId=\d+.*/); + const agentIdMatch = page.url().match(/agentId=(\d+)/); + expect(agentIdMatch).toBeTruthy(); + const agentId = agentIdMatch ? agentIdMatch[1] : null; + expect(agentId).not.toBeNull(); const client = new OnyxApiClient(page.request); - const assistant = await client.getAssistant(Number(assistantId)); + const assistant = await client.getAssistant(Number(agentId)); const hasMcpTool = assistant.tools.some( (tool) => tool.mcp_server_id === serverId ); @@ -629,7 +629,7 @@ test.describe("Default Assistant MCP Integration", () => { expect(disabledCounts.debug).toBe(0); }); - test("Admin can modify MCP tools in default assistant", async ({ page }) => { + test("Admin can modify MCP tools in default agent", async ({ page }) => { test.skip(!serverId, "MCP server must be configured first"); await page.context().clearCookies(); @@ -779,7 +779,7 @@ test.describe("Default Assistant MCP Integration", () => { await modalAfter.getByRole("button", { name: "Cancel" }).click(); }); - test("MCP tools appear in basic user's chat actions after being added to default assistant", async ({ + test("MCP tools appear in basic user's chat actions after being added to default agent", async ({ page, }) => { test.skip(!serverId, "MCP server must be configured first"); @@ -822,7 +822,7 @@ test.describe("Default Assistant MCP Integration", () => { expect(toolCount).toBeGreaterThan(0); console.log( - `[test] Basic user can see ${toolCount} MCP tools from default assistant` + `[test] Basic user can see ${toolCount} MCP tools from default agent` ); }); }); diff --git a/web/tests/e2e/mcp/mcp_oauth_flow.spec.ts b/web/tests/e2e/mcp/mcp_oauth_flow.spec.ts index 127ec933d2e..ee9a0c16b50 100644 --- a/web/tests/e2e/mcp/mcp_oauth_flow.spec.ts +++ b/web/tests/e2e/mcp/mcp_oauth_flow.spec.ts @@ -62,8 +62,8 @@ type Credentials = { type FlowArtifacts = { serverId: number; serverName: string; - assistantId: number; - assistantName: string; + agentId: number; + agentName: string; toolName: string; toolId: number | null; }; @@ -615,9 +615,9 @@ async function completeOauthFlow( if (url.includes(returnSubstring)) { return true; } - // Re-auth flows can return to a chat session URL instead of assistantId URL. + // Re-auth flows can return to a chat session URL instead of agentId URL. if ( - returnSubstring.includes("/app?assistantId=") && + returnSubstring.includes("/app?agentId=") && url.includes("/app?chatId=") ) { return true; @@ -962,11 +962,11 @@ async function openActionsPopover(page: Page) { await ensureActionPopoverInPrimaryView(page); } -async function restoreAssistantContext(page: Page, assistantId: number) { - const assistantPath = `/app?assistantId=${assistantId}`; +async function restoreAssistantContext(page: Page, agentId: number) { + const assistantPath = `/app?agentId=${agentId}`; logOauthEvent( page, - `Restoring assistant context for assistantId=${assistantId} (current url=${page.url()})` + `Restoring assistant context for agentId=${agentId} (current url=${page.url()})` ); // Clear chat-focused URL state first, then explicitly reselect assistant. @@ -975,14 +975,12 @@ async function restoreAssistantContext(page: Page, assistantId: number) { .waitForLoadState("networkidle", { timeout: 10000 }) .catch(() => {}); - const assistantLink = page - .locator(`a[href*="assistantId=${assistantId}"]`) - .first(); + const assistantLink = page.locator(`a[href*="agentId=${agentId}"]`).first(); if ((await assistantLink.count()) > 0) { await clickAndWaitForPossibleUrlChange( page, () => assistantLink.click(), - `Restore assistant ${assistantId} from sidebar` + `Restore assistant ${agentId} from sidebar` ); } else { await page.goto(`${APP_BASE_URL}${assistantPath}`, { @@ -1343,7 +1341,7 @@ async function ensureServerVisibleInActions( page: Page, serverName: string, options?: { - assistantId?: number; + agentId?: number; } ) { for (let attempt = 0; attempt < 2; attempt++) { @@ -1366,12 +1364,12 @@ async function ensureServerVisibleInActions( ); await page.keyboard.press("Escape").catch(() => {}); - if (attempt === 0 && options?.assistantId) { + if (attempt === 0 && options?.agentId) { logOauthEvent( page, - `Server ${serverName} missing in actions, retrying after restoring assistant ${options.assistantId} context` + `Server ${serverName} missing in actions, retrying after restoring assistant ${options.agentId} context` ); - await restoreAssistantContext(page, options.assistantId); + await restoreAssistantContext(page, options.agentId); continue; } @@ -1397,12 +1395,12 @@ async function waitForUserRecord( async function waitForAssistantByName( client: OnyxApiClient, - assistantName: string, + agentName: string, timeoutMs: number = 20_000 ) { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const assistant = await client.findAssistantByName(assistantName, { + const assistant = await client.findAgentByName(agentName, { getEditable: true, }); if (assistant) { @@ -1410,18 +1408,18 @@ async function waitForAssistantByName( } await new Promise((resolve) => setTimeout(resolve, 500)); } - throw new Error(`Timed out waiting for assistant ${assistantName}`); + throw new Error(`Timed out waiting for assistant ${agentName}`); } async function waitForAssistantTools( client: OnyxApiClient, - assistantName: string, + agentName: string, requiredToolNames: string[], timeoutMs: number = 30_000 ) { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const assistant = await client.findAssistantByName(assistantName, { + const assistant = await client.findAgentByName(agentName, { getEditable: true, }); if ( @@ -1441,7 +1439,7 @@ async function waitForAssistantTools( await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error( - `Timed out waiting for assistant ${assistantName} to include tools: ${requiredToolNames.join( + `Timed out waiting for assistant ${agentName} to include tools: ${requiredToolNames.join( ", " )}` ); @@ -1614,11 +1612,11 @@ async function openAssistantEditor( options.logStep("Assistant editor loaded"); } -async function createAssistantAndWaitForTool( +async function createAgentAndWaitForTool( page: Page, options: { apiClient: OnyxApiClient; - assistantName: string; + agentName: string; instructions: string; description: string; serverId: number; @@ -1628,7 +1626,7 @@ async function createAssistantAndWaitForTool( ): Promise { const { apiClient, - assistantName, + agentName, instructions, description, serverId, @@ -1636,7 +1634,7 @@ async function createAssistantAndWaitForTool( logStep, } = options; - await page.locator('input[name="name"]').fill(assistantName); + await page.locator('input[name="name"]').fill(agentName); await page.locator('textarea[name="instructions"]').fill(instructions); await page.locator('textarea[name="description"]').fill(description); await selectMcpTools(page, serverId); @@ -1645,32 +1643,26 @@ async function createAssistantAndWaitForTool( await page.waitForURL( (url) => { const href = typeof url === "string" ? url : url.toString(); - return ( - /\/app\?assistantId=\d+/.test(href) || - href.includes("/admin/assistants") - ); + return /\/app\?agentId=\d+/.test(href) || href.includes("/admin/agents"); }, { timeout: 20000 } ); - let assistantId = getNumericQueryParam(page.url(), "assistantId"); - if (assistantId === null) { - const assistantRecord = await waitForAssistantByName( - apiClient, - assistantName - ); - assistantId = assistantRecord.id; - await page.goto(`/app?assistantId=${assistantId}`); - await page.waitForURL(/\/app\?assistantId=\d+/, { timeout: 20000 }); + let agentId = getNumericQueryParam(page.url(), "agentId"); + if (agentId === null) { + const assistantRecord = await waitForAssistantByName(apiClient, agentName); + agentId = assistantRecord.id; + await page.goto(`/app?agentId=${agentId}`); + await page.waitForURL(/\/app\?agentId=\d+/, { timeout: 20000 }); } - if (assistantId === null) { + if (agentId === null) { throw new Error("Assistant ID could not be determined"); } - logStep(`Assistant created with id ${assistantId}`); + logStep(`Assistant created with id ${agentId}`); - await waitForAssistantTools(apiClient, assistantName, [toolName]); + await waitForAssistantTools(apiClient, agentName, [toolName]); logStep("Confirmed assistant tools are available"); - return assistantId; + return agentId; } test.describe("MCP OAuth flows", () => { @@ -1787,15 +1779,15 @@ test.describe("MCP OAuth flows", () => { const adminPage = await adminContext.newPage(); const adminClient = new OnyxApiClient(adminPage.request); - if (adminArtifacts?.assistantId) { - await adminClient.deleteAssistant(adminArtifacts.assistantId); + if (adminArtifacts?.agentId) { + await adminClient.deleteAgent(adminArtifacts.agentId); } if (adminArtifacts?.serverId) { await adminClient.deleteMcpServer(adminArtifacts.serverId); } - if (curatorArtifacts?.assistantId) { - await adminClient.deleteAssistant(curatorArtifacts.assistantId); + if (curatorArtifacts?.agentId) { + await adminClient.deleteAgent(curatorArtifacts.agentId); } if (curatorArtifacts?.serverId) { await adminClient.deleteMcpServer(curatorArtifacts.serverId); @@ -1836,7 +1828,7 @@ test.describe("MCP OAuth flows", () => { logStep("Logged in as admin"); const serverName = `PW MCP Admin ${Date.now()}`; - const assistantName = `PW Admin Assistant ${Date.now()}`; + const agentName = `PW Admin Assistant ${Date.now()}`; const serverId = await configureOauthServerAndEnableTool(page, { serverName, @@ -1859,9 +1851,9 @@ test.describe("MCP OAuth flows", () => { }, }); - const assistantId = await createAssistantAndWaitForTool(page, { + const agentId = await createAgentAndWaitForTool(page, { apiClient: adminApiClient, - assistantName, + agentName, instructions: "Assist with MCP OAuth testing.", description: "Playwright admin MCP assistant.", serverId, @@ -1874,7 +1866,7 @@ test.describe("MCP OAuth flows", () => { TOOL_NAMES.admin ); - await ensureServerVisibleInActions(page, serverName, { assistantId }); + await ensureServerVisibleInActions(page, serverName, { agentId }); await verifyMcpToolRowVisible(page, serverName, TOOL_NAMES.admin); await ensureMcpToolEnabledInActions(page, serverName, TOOL_NAMES.admin); logStep("Verified admin MCP tool row visible before reauth"); @@ -1886,12 +1878,8 @@ test.describe("MCP OAuth flows", () => { ); logStep("Verified admin MCP tool invocation before reauth"); - await reauthenticateFromChat( - page, - serverName, - `/app?assistantId=${assistantId}` - ); - await ensureServerVisibleInActions(page, serverName, { assistantId }); + await reauthenticateFromChat(page, serverName, `/app?agentId=${agentId}`); + await ensureServerVisibleInActions(page, serverName, { agentId }); await verifyMcpToolRowVisible(page, serverName, TOOL_NAMES.admin); await ensureMcpToolEnabledInActions(page, serverName, TOOL_NAMES.admin); logStep("Verified admin MCP tool row visible after reauth"); @@ -1914,8 +1902,8 @@ test.describe("MCP OAuth flows", () => { adminArtifacts = { serverId, serverName, - assistantId, - assistantName, + agentId, + agentName, toolName: TOOL_NAMES.admin, toolId: adminToolId, }; @@ -1954,7 +1942,7 @@ test.describe("MCP OAuth flows", () => { const curatorApiClient = new OnyxApiClient(page.request); const serverName = `PW MCP Curator ${Date.now()}`; - const assistantName = `PW Curator Assistant ${Date.now()}`; + const agentName = `PW Curator Assistant ${Date.now()}`; let curatorServerProcess: McpServerProcess | null = null; let curatorRuntimeMcpServerUrl = runtimeMcpServerUrl; @@ -1980,9 +1968,9 @@ test.describe("MCP OAuth flows", () => { await openAssistantEditor(page, { logStep }); - const assistantId = await createAssistantAndWaitForTool(page, { + const agentId = await createAgentAndWaitForTool(page, { apiClient: curatorApiClient, - assistantName, + agentName, instructions: "Curator MCP OAuth assistant.", description: "Playwright curator MCP assistant.", serverId, @@ -1990,24 +1978,20 @@ test.describe("MCP OAuth flows", () => { logStep, }); - await ensureServerVisibleInActions(page, serverName, { assistantId }); + await ensureServerVisibleInActions(page, serverName, { agentId }); await verifyMcpToolRowVisible(page, serverName, TOOL_NAMES.curator); logStep("Verified curator MCP tool row visible before reauth"); - await reauthenticateFromChat( - page, - serverName, - `/app?assistantId=${assistantId}` - ); - await ensureServerVisibleInActions(page, serverName, { assistantId }); + await reauthenticateFromChat(page, serverName, `/app?agentId=${agentId}`); + await ensureServerVisibleInActions(page, serverName, { agentId }); await verifyMcpToolRowVisible(page, serverName, TOOL_NAMES.curator); logStep("Verified curator MCP tool row visible after reauth"); curatorArtifacts = { serverId, serverName, - assistantId, - assistantName, + agentId, + agentName, toolName: TOOL_NAMES.curator, toolId: null, }; @@ -2064,14 +2048,14 @@ test.describe("MCP OAuth flows", () => { await loginAsWorkerUser(page, testInfo.workerIndex); logStep("Logged in as worker user"); - const assistantId = adminArtifacts!.assistantId; + const agentId = adminArtifacts!.agentId; const serverName = adminArtifacts!.serverName; const toolName = adminArtifacts!.toolName; - await page.goto(`/app?assistantId=${assistantId}`, { + await page.goto(`/app?agentId=${agentId}`, { waitUntil: "load", }); - await ensureServerVisibleInActions(page, serverName, { assistantId }); + await ensureServerVisibleInActions(page, serverName, { agentId }); logStep("Opened chat as user and ensured server visible"); await openActionsPopover(page); @@ -2111,11 +2095,11 @@ test.describe("MCP OAuth flows", () => { } await completeOauthFlow(page, { - expectReturnPathContains: `/app?assistantId=${assistantId}`, + expectReturnPathContains: `/app?agentId=${agentId}`, }); logStep("Completed user OAuth reauthentication"); - await ensureServerVisibleInActions(page, serverName, { assistantId }); + await ensureServerVisibleInActions(page, serverName, { agentId }); await verifyMcpToolRowVisible(page, serverName, toolName); await ensureMcpToolEnabledInActions(page, serverName, toolName); logStep("Verified user MCP tool row visible after reauth"); diff --git a/web/tests/e2e/utils/assistantUtils.ts b/web/tests/e2e/utils/agentUtils.ts similarity index 91% rename from web/tests/e2e/utils/assistantUtils.ts rename to web/tests/e2e/utils/agentUtils.ts index dcdf2ca8040..a23ac4c8ed8 100644 --- a/web/tests/e2e/utils/assistantUtils.ts +++ b/web/tests/e2e/utils/agentUtils.ts @@ -1,15 +1,15 @@ import { Page } from "@playwright/test"; import { expect } from "@playwright/test"; -import { verifyAssistantIsChosen } from "./chatActions"; +import { verifyAgentIsChosen } from "./chatActions"; -export type AssistantParams = { +export type AgentParams = { name: string; description?: string; instructions?: string; // system_prompt }; // Create an assistant via the UI from the app page and wait until it is active -export async function createAssistant(page: Page, params: AssistantParams) { +export async function createAgent(page: Page, params: AgentParams) { const { name, description = "", instructions = "Test Instructions" } = params; // Navigate to creation flow @@ -33,18 +33,18 @@ export async function createAssistant(page: Page, params: AssistantParams) { await page.getByRole("button", { name: "Create" }).click(); // Verify it is selected in chat (placeholder contains assistant name) - await verifyAssistantIsChosen(page, name); + await verifyAgentIsChosen(page, name); } // Pin an assistant by its visible name in the sidebar list. // If already pinned, this will leave it pinned (no-op). -export async function pinAssistantByName( +export async function pinAgentByName( page: Page, - assistantName: string + agentName: string ): Promise { const row = page .locator('[data-testid^="assistant-["]') - .filter({ hasText: assistantName }) + .filter({ hasText: agentName }) .first(); await row.waitFor({ state: "visible", timeout: 10000 }); @@ -70,7 +70,7 @@ export async function pinAssistantByName( } /** - * Ensures the Image Generation tool is enabled in the default assistant configuration. + * Ensures the Image Generation tool is enabled in the default agent configuration. * If it's not enabled, it will toggle it on. * * Navigates to the Chat Preferences page and toggles the Image Generation switch diff --git a/web/tests/e2e/utils/chatActions.ts b/web/tests/e2e/utils/chatActions.ts index 62d81a009ce..3f96ec3c875 100644 --- a/web/tests/e2e/utils/chatActions.ts +++ b/web/tests/e2e/utils/chatActions.ts @@ -1,30 +1,30 @@ import { Page } from "@playwright/test"; import { expect } from "@playwright/test"; -export async function verifyDefaultAssistantIsChosen(page: Page) { +export async function verifyDefaultAgentIsChosen(page: Page) { await expect(page.getByTestId("onyx-logo")).toBeVisible({ timeout: 5000 }); } -export async function verifyAssistantIsChosen( +export async function verifyAgentIsChosen( page: Page, - assistantName: string, + agentName: string, timeout: number = 5000 ) { await expect( - page.getByTestId("assistant-name-display").getByText(assistantName) + page.getByTestId("agent-name-display").getByText(agentName) ).toBeVisible({ timeout }); } -export async function navigateToAssistantInHistorySidebar( +export async function navigateToAgentInHistorySidebar( page: Page, testId: string, - assistantName: string + agentName: string ) { await page.getByTestId(`assistant-${testId}`).click(); try { - await verifyAssistantIsChosen(page, assistantName); + await verifyAgentIsChosen(page, agentName); } catch (error) { - console.error("Error in navigateToAssistantInHistorySidebar:", error); + console.error("Error in navigateToAgentInHistorySidebar:", error); const pageText = await page.textContent("body"); console.log("Page text:", pageText); throw error; diff --git a/web/tests/e2e/utils/onyxApiClient.ts b/web/tests/e2e/utils/onyxApiClient.ts index bb15f7f368e..6b6f1a67a80 100644 --- a/web/tests/e2e/utils/onyxApiClient.ts +++ b/web/tests/e2e/utils/onyxApiClient.ts @@ -640,28 +640,28 @@ export class OnyxApiClient { return tools.find((tool) => tool.name === name) ?? null; } - async deleteAssistant(assistantId: number): Promise { + async deleteAgent(agentId: number): Promise { const response = await this.request.delete( - `${this.baseUrl}/persona/${assistantId}` + `${this.baseUrl}/persona/${agentId}` ); const success = await this.handleResponseSoft( response, - `Failed to delete assistant ${assistantId}` + `Failed to delete assistant ${agentId}` ); if (success) { - this.log(`Deleted assistant ${assistantId}`); + this.log(`Deleted assistant ${agentId}`); } return success; } - async getAssistant(assistantId: number): Promise<{ + async getAssistant(agentId: number): Promise<{ id: number; tools: Array<{ id: number; mcp_server_id?: number | null }>; }> { - const response = await this.get(`/persona/${assistantId}`); + const response = await this.get(`/persona/${agentId}`); return await this.handleResponse( response, - `Failed to fetch assistant ${assistantId}` + `Failed to fetch assistant ${agentId}` ); } @@ -674,7 +674,7 @@ export class OnyxApiClient { return data.mcp_servers; } - async listAssistants(options?: { + async listAgents(options?: { includeDeleted?: boolean; getEditable?: boolean; }): Promise { @@ -695,11 +695,11 @@ export class OnyxApiClient { ); } - async findAssistantByName( + async findAgentByName( name: string, options?: { includeDeleted?: boolean; getEditable?: boolean } ): Promise { - const assistants = await this.listAssistants(options); + const assistants = await this.listAgents(options); return assistants.find((assistant) => assistant.name === name) ?? null; } diff --git a/web/tests/setup/mocks/components/UserProvider.tsx b/web/tests/setup/mocks/components/UserProvider.tsx index 079fd2ec9be..ccf8d652cb8 100644 --- a/web/tests/setup/mocks/components/UserProvider.tsx +++ b/web/tests/setup/mocks/components/UserProvider.tsx @@ -25,9 +25,9 @@ interface UserContextType { isCloudSuperuser: boolean; updateUserAutoScroll: (autoScroll: boolean) => Promise; updateUserShortcuts: (enabled: boolean) => Promise; - toggleAssistantPinnedStatus: ( - currentPinnedAssistantIDs: number[], - assistantId: number, + toggleAgentPinnedStatus: ( + currentPinnedAgentIDs: number[], + agentId: number, isPinned: boolean ) => Promise; updateUserTemperatureOverrideEnabled: (enabled: boolean) => Promise; @@ -42,7 +42,7 @@ const mockUserContext: UserContextType = { isCloudSuperuser: false, updateUserAutoScroll: async () => {}, updateUserShortcuts: async () => {}, - toggleAssistantPinnedStatus: async () => true, + toggleAgentPinnedStatus: async () => true, updateUserTemperatureOverrideEnabled: async () => {}, updateUserPersonalization: async () => {}, }; From a9769757fe107abed9c42be3df83e5a20bdb4860 Mon Sep 17 00:00:00 2001 From: Nikolas Garza <90273783+nmgarza5@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:20:03 -0800 Subject: [PATCH 011/267] fix(llm): enforce persona restrictions on public LLM providers (#8846) Co-authored-by: Dane --- backend/onyx/db/llm.py | 55 ++++----- backend/onyx/server/manage/llm/api.py | 12 +- .../test_llm_provider_access_control.py | 110 ++++++++++++++++++ web/src/lib/hooks.ts | 6 +- 4 files changed, 144 insertions(+), 39 deletions(-) diff --git a/backend/onyx/db/llm.py b/backend/onyx/db/llm.py index 4ee1e88251e..3b6e239f40a 100644 --- a/backend/onyx/db/llm.py +++ b/backend/onyx/db/llm.py @@ -109,45 +109,38 @@ def can_user_access_llm_provider( is_admin: If True, bypass user group restrictions but still respect persona restrictions Access logic: - 1. If is_public=True → everyone has access (public override) - 2. If is_public=False: - - Both groups AND personas set → must satisfy BOTH (AND logic, admins bypass group check) - - Only groups set → must be in one of the groups (OR across groups, admins bypass) - - Only personas set → must use one of the personas (OR across personas, applies to admins) - - Neither set → NOBODY has access unless admin (locked, admin-only) + - is_public controls USER access (group bypass): when True, all users can access + regardless of group membership. When False, user must be in a whitelisted group + (or be admin). + - Persona restrictions are ALWAYS enforced when set, regardless of is_public. + This allows admins to make a provider available to all users while still + restricting which personas (assistants) can use it. + + Decision matrix: + 1. is_public=True, no personas set → everyone has access + 2. is_public=True, personas set → all users, but only whitelisted personas + 3. is_public=False, groups+personas set → must satisfy BOTH (admins bypass groups) + 4. is_public=False, only groups set → must be in group (admins bypass) + 5. is_public=False, only personas set → must use whitelisted persona + 6. is_public=False, neither set → admin-only (locked) """ - # Public override - everyone has access - if provider.is_public: - return True - - # Extract IDs once to avoid multiple iterations - provider_group_ids = ( - {group.id for group in provider.groups} if provider.groups else set() - ) - provider_persona_ids = ( - {p.id for p in provider.personas} if provider.personas else set() - ) - + provider_group_ids = {g.id for g in (provider.groups or [])} + provider_persona_ids = {p.id for p in (provider.personas or [])} has_groups = bool(provider_group_ids) has_personas = bool(provider_persona_ids) - # Both groups AND personas set → AND logic (must satisfy both) - if has_groups and has_personas: - # Admins bypass group check but still must satisfy persona restrictions - user_in_group = is_admin or bool(user_group_ids & provider_group_ids) - persona_allowed = persona.id in provider_persona_ids if persona else False - return user_in_group and persona_allowed + # Persona restrictions are always enforced when set, regardless of is_public + if has_personas and not (persona and persona.id in provider_persona_ids): + return False + + if provider.is_public: + return True - # Only groups set → user must be in one of the groups (admins bypass) if has_groups: return is_admin or bool(user_group_ids & provider_group_ids) - # Only personas set → persona must be in allowed list (applies to admins too) - if has_personas: - return persona.id in provider_persona_ids if persona else False - - # Neither groups nor personas set, and not public → admins can access - return is_admin + # No groups: either persona-whitelisted (already passed) or admin-only if locked + return has_personas or is_admin def validate_persona_ids_exist( diff --git a/backend/onyx/server/manage/llm/api.py b/backend/onyx/server/manage/llm/api.py index 0fe5bad013f..9503a828bf8 100644 --- a/backend/onyx/server/manage/llm/api.py +++ b/backend/onyx/server/manage/llm/api.py @@ -603,9 +603,9 @@ def list_llm_provider_basics( for provider in all_providers: # Use centralized access control logic with persona=None since we're # listing providers without a specific persona context. This correctly: - # - Includes all public providers + # - Includes public providers WITHOUT persona restrictions # - Includes providers user can access via group membership - # - Excludes persona-only restricted providers (requires specific persona) + # - Excludes providers with persona restrictions (requires specific persona) # - Excludes non-public providers with no restrictions (admin-only) if can_user_access_llm_provider( provider, user_group_ids, persona=None, is_admin=is_admin @@ -638,7 +638,7 @@ def get_valid_model_names_for_persona( Returns a list of model names (e.g., ["gpt-4o", "claude-3-5-sonnet"]) that are available to the user when using this persona, respecting all RBAC restrictions. - Public providers are always included. + Public providers are included unless they have persona restrictions that exclude this persona. """ persona = fetch_persona_with_groups(db_session, persona_id) if not persona: @@ -652,7 +652,7 @@ def get_valid_model_names_for_persona( valid_models = [] for llm_provider_model in all_providers: - # Public providers always included, restricted checked via RBAC + # Check access with persona context — respects all RBAC restrictions if can_user_access_llm_provider( llm_provider_model, user_group_ids, persona, is_admin=is_admin ): @@ -673,7 +673,7 @@ def list_llm_providers_for_persona( """Get LLM providers for a specific persona. Returns providers that the user can access when using this persona: - - All public providers (is_public=True) - ALWAYS included + - Public providers (respecting persona restrictions if set) - Restricted providers user can access via group/persona restrictions This endpoint is used for background fetching of restricted providers @@ -702,7 +702,7 @@ def list_llm_providers_for_persona( llm_provider_list: list[LLMProviderDescriptor] = [] for llm_provider_model in all_providers: - # Use simplified access check - public providers always included + # Check access with persona context — respects persona restrictions if can_user_access_llm_provider( llm_provider_model, user_group_ids, persona, is_admin=is_admin ): diff --git a/backend/tests/integration/tests/llm_provider/test_llm_provider_access_control.py b/backend/tests/integration/tests/llm_provider/test_llm_provider_access_control.py index b1ce4bf34e9..f66e19fef24 100644 --- a/backend/tests/integration/tests/llm_provider/test_llm_provider_access_control.py +++ b/backend/tests/integration/tests/llm_provider/test_llm_provider_access_control.py @@ -243,6 +243,116 @@ def test_can_user_access_llm_provider_or_logic( ) +def test_public_provider_with_persona_restrictions( + users: tuple[DATestUser, DATestUser], +) -> None: + """Public providers should still enforce persona restrictions. + + Regression test for the bug where is_public=True caused + can_user_access_llm_provider() to return True immediately, + bypassing persona whitelist checks entirely. + """ + admin_user, _basic_user = users + + with get_session_with_current_tenant() as db_session: + # Public provider with persona restrictions + public_restricted = _create_llm_provider( + db_session, + name="public-persona-restricted", + default_model_name="gpt-4o", + is_public=True, + is_default=True, + ) + + whitelisted_persona = _create_persona( + db_session, + name="whitelisted-persona", + provider_name=public_restricted.name, + ) + non_whitelisted_persona = _create_persona( + db_session, + name="non-whitelisted-persona", + provider_name=public_restricted.name, + ) + + # Only whitelist one persona + db_session.add( + LLMProvider__Persona( + llm_provider_id=public_restricted.id, + persona_id=whitelisted_persona.id, + ) + ) + db_session.flush() + db_session.refresh(public_restricted) + + admin_model = db_session.get(User, admin_user.id) + assert admin_model is not None + admin_group_ids = fetch_user_group_ids(db_session, admin_model) + + # Whitelisted persona — should be allowed + assert can_user_access_llm_provider( + public_restricted, + admin_group_ids, + whitelisted_persona, + ) + + # Non-whitelisted persona — should be denied despite is_public=True + assert not can_user_access_llm_provider( + public_restricted, + admin_group_ids, + non_whitelisted_persona, + ) + + # No persona context (e.g. global provider list) — should be denied + # because provider has persona restrictions set + assert not can_user_access_llm_provider( + public_restricted, + admin_group_ids, + persona=None, + ) + + +def test_public_provider_without_persona_restrictions( + users: tuple[DATestUser, DATestUser], +) -> None: + """Public providers with no persona restrictions remain accessible to all.""" + admin_user, basic_user = users + + with get_session_with_current_tenant() as db_session: + public_unrestricted = _create_llm_provider( + db_session, + name="public-unrestricted", + default_model_name="gpt-4o", + is_public=True, + is_default=True, + ) + + any_persona = _create_persona( + db_session, + name="any-persona", + provider_name=public_unrestricted.name, + ) + + admin_model = db_session.get(User, admin_user.id) + basic_model = db_session.get(User, basic_user.id) + assert admin_model is not None + assert basic_model is not None + + admin_group_ids = fetch_user_group_ids(db_session, admin_model) + basic_group_ids = fetch_user_group_ids(db_session, basic_model) + + # Any user, any persona — all allowed + assert can_user_access_llm_provider( + public_unrestricted, admin_group_ids, any_persona + ) + assert can_user_access_llm_provider( + public_unrestricted, basic_group_ids, any_persona + ) + assert can_user_access_llm_provider( + public_unrestricted, admin_group_ids, persona=None + ) + + def test_get_llm_for_persona_falls_back_when_access_denied( users: tuple[DATestUser, DATestUser], ) -> None: diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts index f7b291f385f..93e67799b72 100644 --- a/web/src/lib/hooks.ts +++ b/web/src/lib/hooks.ts @@ -842,8 +842,10 @@ export function useLlmManager( } }; - // Track if any provider exists (for onboarding checks) - const hasAnyProvider = (allUserProviders?.length ?? 0) > 0; + // Track if any provider exists for the current persona context. + // Uses the persona-aware list so chat input reflects actual access, + // falling back to the global list when no persona is selected. + const hasAnyProvider = (llmProviders?.length ?? 0) > 0; return { updateModelOverrideBasedOnChatSession, From 0c35dfc0e47f5c457c18d27d505e6e90b7783b9c Mon Sep 17 00:00:00 2001 From: Jamison Lahman Date: Mon, 2 Mar 2026 10:59:02 -0800 Subject: [PATCH 012/267] fix(search): re-sync search/chat preference on user data load (#8868) --- web/src/ee/providers/AppModeProvider.tsx | 18 ++++++++---- web/src/layouts/app-layouts.tsx | 1 + web/src/refresh-pages/AppPage.tsx | 9 +++--- web/tests/e2e/chat/default_app_mode.spec.ts | 31 +++++++++++++++++++++ web/tests/e2e/utils/onyxApiClient.ts | 19 +++++++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 web/tests/e2e/chat/default_app_mode.spec.ts diff --git a/web/src/ee/providers/AppModeProvider.tsx b/web/src/ee/providers/AppModeProvider.tsx index d637ca9ed52..2e9468c7be1 100644 --- a/web/src/ee/providers/AppModeProvider.tsx +++ b/web/src/ee/providers/AppModeProvider.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useEffect } from "react"; import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; import { AppModeContext, AppMode } from "@/providers/AppModeProvider"; import { useUser } from "@/providers/UserProvider"; @@ -27,12 +27,18 @@ export function AppModeProvider({ children }: AppModeProviderProps) { const { isSearchModeAvailable } = settings; const persistedMode = user?.preferences?.default_app_mode; - const initialMode: AppMode = - isPaidEnterpriseFeaturesEnabled && isSearchModeAvailable && persistedMode - ? (persistedMode.toLowerCase() as AppMode) - : "chat"; + const [appMode, setAppModeState] = useState("chat"); - const [appMode, setAppModeState] = useState(initialMode); + useEffect(() => { + if (!isPaidEnterpriseFeaturesEnabled || !isSearchModeAvailable) { + setAppModeState("chat"); + return; + } + + if (persistedMode) { + setAppModeState(persistedMode.toLowerCase() as AppMode); + } + }, [isPaidEnterpriseFeaturesEnabled, isSearchModeAvailable, persistedMode]); const setAppMode = useCallback( (mode: AppMode) => { diff --git a/web/src/layouts/app-layouts.tsx b/web/src/layouts/app-layouts.tsx index bc036c785a8..61264e34a5c 100644 --- a/web/src/layouts/app-layouts.tsx +++ b/web/src/layouts/app-layouts.tsx @@ -327,6 +327,7 @@ function Header() { { - if (appFocus.isNewSession()) setAppMode(defaultAppMode); - if (!appFocus.isNewSession() && classification === "search") - resetInputBar(); - }, [appFocus.isNewSession()]); + if (isNewSession) setAppMode(defaultAppMode); + if (!isNewSession && classification === "search") resetInputBar(); + }, [isNewSession, defaultAppMode, classification, resetInputBar, setAppMode]); const handleSearchDocumentClick = useCallback( (doc: MinimalOnyxDocument) => setPresentingDocument(doc), diff --git a/web/tests/e2e/chat/default_app_mode.spec.ts b/web/tests/e2e/chat/default_app_mode.spec.ts new file mode 100644 index 00000000000..5a3fd670e43 --- /dev/null +++ b/web/tests/e2e/chat/default_app_mode.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "@playwright/test"; +import { loginAs } from "@tests/e2e/utils/auth"; +import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient"; + +test.describe("Default App Mode", () => { + test("loads persisted Search mode after refresh", async ({ page }) => { + await page.context().clearCookies(); + await loginAs(page, "admin"); + + // Arrange + const apiClient = new OnyxApiClient(page.request); + const ccPairId = await apiClient.createFileConnector( + "Default App Mode Test Connector" + ); + await apiClient.setDefaultAppMode("SEARCH"); + + try { + // Act + await page.goto("/app"); + await page.waitForLoadState("networkidle"); + + // Assert + const appModeButton = page.getByLabel("Change app mode"); + await appModeButton.waitFor({ state: "visible", timeout: 10000 }); + await expect(appModeButton).toHaveText(/Search/); + } finally { + await apiClient.setDefaultAppMode("CHAT"); + await apiClient.deleteCCPair(ccPairId); + } + }); +}); diff --git a/web/tests/e2e/utils/onyxApiClient.ts b/web/tests/e2e/utils/onyxApiClient.ts index 6b6f1a67a80..63b336b7368 100644 --- a/web/tests/e2e/utils/onyxApiClient.ts +++ b/web/tests/e2e/utils/onyxApiClient.ts @@ -1124,4 +1124,23 @@ export class OnyxApiClient { ); this.log(`Deleted project: ${projectId}`); } + + /** + * Sets the current user's default app mode preference. + * + * @param mode - The default mode to persist ("CHAT" or "SEARCH") + */ + async setDefaultAppMode(mode: "CHAT" | "SEARCH"): Promise { + const response = await this.request.patch( + `${this.baseUrl}/user/default-app-mode`, + { + data: { default_app_mode: mode }, + } + ); + await this.handleResponse( + response, + `Failed to set default app mode to ${mode}` + ); + this.log(`Set default app mode: ${mode}`); + } } From 31aef36f78992ffd7f30b68616e032aa65fc6990 Mon Sep 17 00:00:00 2001 From: Justin Tahara <105671973+justin-tahara@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:28:43 -0800 Subject: [PATCH 013/267] chore(llm): Use AWS Secrrets Manager (#8913) --- .../workflows/nightly-llm-provider-chat.yml | 13 +- .../reusable-nightly-llm-provider-chat.yml | 133 ++++++++++++------ 2 files changed, 93 insertions(+), 53 deletions(-) diff --git a/.github/workflows/nightly-llm-provider-chat.yml b/.github/workflows/nightly-llm-provider-chat.yml index a6fbdea6569..ff5914eb8e8 100644 --- a/.github/workflows/nightly-llm-provider-chat.yml +++ b/.github/workflows/nightly-llm-provider-chat.yml @@ -15,6 +15,9 @@ permissions: jobs: provider-chat-test: uses: ./.github/workflows/reusable-nightly-llm-provider-chat.yml + permissions: + contents: read + id-token: write with: openai_models: ${{ vars.NIGHTLY_LLM_OPENAI_MODELS }} anthropic_models: ${{ vars.NIGHTLY_LLM_ANTHROPIC_MODELS }} @@ -25,16 +28,6 @@ jobs: ollama_models: ${{ vars.NIGHTLY_LLM_OLLAMA_MODELS }} openrouter_models: ${{ vars.NIGHTLY_LLM_OPENROUTER_MODELS }} strict: true - secrets: - openai_api_key: ${{ secrets.OPENAI_API_KEY }} - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - bedrock_api_key: ${{ secrets.BEDROCK_API_KEY }} - vertex_ai_custom_config_json: ${{ secrets.NIGHTLY_LLM_VERTEX_AI_CUSTOM_CONFIG_JSON }} - azure_api_key: ${{ secrets.AZURE_API_KEY }} - ollama_api_key: ${{ secrets.OLLAMA_API_KEY }} - openrouter_api_key: ${{ secrets.OPENROUTER_API_KEY }} - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} notify-slack-on-failure: needs: [provider-chat-test] diff --git a/.github/workflows/reusable-nightly-llm-provider-chat.yml b/.github/workflows/reusable-nightly-llm-provider-chat.yml index 20417e2e88c..35b41116f50 100644 --- a/.github/workflows/reusable-nightly-llm-provider-chat.yml +++ b/.github/workflows/reusable-nightly-llm-provider-chat.yml @@ -48,28 +48,10 @@ on: required: false default: true type: boolean - secrets: - openai_api_key: - required: false - anthropic_api_key: - required: false - bedrock_api_key: - required: false - vertex_ai_custom_config_json: - required: false - azure_api_key: - required: false - ollama_api_key: - required: false - openrouter_api_key: - required: false - DOCKER_USERNAME: - required: true - DOCKER_TOKEN: - required: true permissions: contents: read + id-token: write jobs: build-backend-image: @@ -81,6 +63,7 @@ jobs: "extras=ecr-cache", ] timeout-minutes: 45 + environment: ci-protected steps: - uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2 @@ -89,6 +72,19 @@ jobs: with: persist-credentials: false + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 + with: + role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} + aws-region: us-east-2 + + - name: Get AWS Secrets + uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 + with: + secret-ids: | + DOCKER_USERNAME, test/docker-username + DOCKER_TOKEN, test/docker-token + - name: Build backend image uses: ./.github/actions/build-backend-image with: @@ -97,8 +93,8 @@ jobs: pr-number: ${{ github.event.pull_request.number }} github-sha: ${{ github.sha }} run-id: ${{ github.run_id }} - docker-username: ${{ secrets.DOCKER_USERNAME }} - docker-token: ${{ secrets.DOCKER_TOKEN }} + docker-username: ${{ env.DOCKER_USERNAME }} + docker-token: ${{ env.DOCKER_TOKEN }} docker-no-cache: ${{ vars.DOCKER_NO_CACHE == 'true' && 'true' || 'false' }} build-model-server-image: @@ -110,6 +106,7 @@ jobs: "extras=ecr-cache", ] timeout-minutes: 45 + environment: ci-protected steps: - uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2 @@ -118,6 +115,19 @@ jobs: with: persist-credentials: false + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 + with: + role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} + aws-region: us-east-2 + + - name: Get AWS Secrets + uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 + with: + secret-ids: | + DOCKER_USERNAME, test/docker-username + DOCKER_TOKEN, test/docker-token + - name: Build model server image uses: ./.github/actions/build-model-server-image with: @@ -126,8 +136,8 @@ jobs: pr-number: ${{ github.event.pull_request.number }} github-sha: ${{ github.sha }} run-id: ${{ github.run_id }} - docker-username: ${{ secrets.DOCKER_USERNAME }} - docker-token: ${{ secrets.DOCKER_TOKEN }} + docker-username: ${{ env.DOCKER_USERNAME }} + docker-token: ${{ env.DOCKER_TOKEN }} build-integration-image: runs-on: @@ -138,6 +148,7 @@ jobs: "extras=ecr-cache", ] timeout-minutes: 45 + environment: ci-protected steps: - uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2 @@ -146,6 +157,19 @@ jobs: with: persist-credentials: false + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 + with: + role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} + aws-region: us-east-2 + + - name: Get AWS Secrets + uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 + with: + secret-ids: | + DOCKER_USERNAME, test/docker-username + DOCKER_TOKEN, test/docker-token + - name: Build integration image uses: ./.github/actions/build-integration-image with: @@ -154,8 +178,8 @@ jobs: pr-number: ${{ github.event.pull_request.number }} github-sha: ${{ github.sha }} run-id: ${{ github.run_id }} - docker-username: ${{ secrets.DOCKER_USERNAME }} - docker-token: ${{ secrets.DOCKER_TOKEN }} + docker-username: ${{ env.DOCKER_USERNAME }} + docker-token: ${{ env.DOCKER_TOKEN }} provider-chat-test: needs: @@ -170,56 +194,56 @@ jobs: include: - provider: openai models: ${{ inputs.openai_models }} - api_key_secret: openai_api_key - custom_config_secret: "" + api_key_env: OPENAI_API_KEY + custom_config_env: "" api_base: "" api_version: "" deployment_name: "" required: true - provider: anthropic models: ${{ inputs.anthropic_models }} - api_key_secret: anthropic_api_key - custom_config_secret: "" + api_key_env: ANTHROPIC_API_KEY + custom_config_env: "" api_base: "" api_version: "" deployment_name: "" required: true - provider: bedrock models: ${{ inputs.bedrock_models }} - api_key_secret: bedrock_api_key - custom_config_secret: "" + api_key_env: BEDROCK_API_KEY + custom_config_env: "" api_base: "" api_version: "" deployment_name: "" required: false - provider: vertex_ai models: ${{ inputs.vertex_ai_models }} - api_key_secret: "" - custom_config_secret: vertex_ai_custom_config_json + api_key_env: "" + custom_config_env: NIGHTLY_LLM_VERTEX_AI_CUSTOM_CONFIG_JSON api_base: "" api_version: "" deployment_name: "" required: false - provider: azure models: ${{ inputs.azure_models }} - api_key_secret: azure_api_key - custom_config_secret: "" + api_key_env: AZURE_API_KEY + custom_config_env: "" api_base: ${{ inputs.azure_api_base }} api_version: "2025-04-01-preview" deployment_name: "" required: false - provider: ollama_chat models: ${{ inputs.ollama_models }} - api_key_secret: ollama_api_key - custom_config_secret: "" + api_key_env: OLLAMA_API_KEY + custom_config_env: "" api_base: "https://ollama.com" api_version: "" deployment_name: "" required: false - provider: openrouter models: ${{ inputs.openrouter_models }} - api_key_secret: openrouter_api_key - custom_config_secret: "" + api_key_env: OPENROUTER_API_KEY + custom_config_env: "" api_base: "https://openrouter.ai/api/v1" api_version: "" deployment_name: "" @@ -230,6 +254,7 @@ jobs: - "run-id=${{ github.run_id }}-nightly-${{ matrix.provider }}-provider-chat-test" - extras=ecr-cache timeout-minutes: 45 + environment: ci-protected steps: - uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2 @@ -238,21 +263,43 @@ jobs: with: persist-credentials: false + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 + with: + role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} + aws-region: us-east-2 + + - name: Get AWS Secrets + uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 + with: + # Keep JSON values unparsed so vertex custom config is passed as raw JSON. + parse-json-secrets: false + secret-ids: | + DOCKER_USERNAME, test/docker-username + DOCKER_TOKEN, test/docker-token + OPENAI_API_KEY, test/openai-api-key + ANTHROPIC_API_KEY, test/anthropic-api-key + BEDROCK_API_KEY, test/bedrock-api-key + NIGHTLY_LLM_VERTEX_AI_CUSTOM_CONFIG_JSON, test/nightly-llm-vertex-ai-custom-config-json + AZURE_API_KEY, test/azure-api-key + OLLAMA_API_KEY, test/ollama-api-key + OPENROUTER_API_KEY, test/openrouter-api-key + - name: Run nightly provider chat test uses: ./.github/actions/run-nightly-provider-chat-test with: provider: ${{ matrix.provider }} models: ${{ matrix.models }} - provider-api-key: ${{ matrix.api_key_secret && secrets[matrix.api_key_secret] || '' }} + provider-api-key: ${{ matrix.api_key_env && env[matrix.api_key_env] || '' }} strict: ${{ inputs.strict && 'true' || 'false' }} api-base: ${{ matrix.api_base }} api-version: ${{ matrix.api_version }} deployment-name: ${{ matrix.deployment_name }} - custom-config-json: ${{ matrix.custom_config_secret && secrets[matrix.custom_config_secret] || '' }} + custom-config-json: ${{ matrix.custom_config_env && env[matrix.custom_config_env] || '' }} runs-on-ecr-cache: ${{ env.RUNS_ON_ECR_CACHE }} run-id: ${{ github.run_id }} - docker-username: ${{ secrets.DOCKER_USERNAME }} - docker-token: ${{ secrets.DOCKER_TOKEN }} + docker-username: ${{ env.DOCKER_USERNAME }} + docker-token: ${{ env.DOCKER_TOKEN }} - name: Dump API server logs if: always() From 0cdd438f463f32e929750126021f39f042354f03 Mon Sep 17 00:00:00 2001 From: Justin Tahara <105671973+justin-tahara@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:28:49 -0800 Subject: [PATCH 014/267] chore(ui): Update the Share Agent Modal (#8915) --- web/src/sections/modals/ShareAgentModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/sections/modals/ShareAgentModal.tsx b/web/src/sections/modals/ShareAgentModal.tsx index 6c77f66f43a..901d32c8355 100644 --- a/web/src/sections/modals/ShareAgentModal.tsx +++ b/web/src/sections/modals/ShareAgentModal.tsx @@ -350,12 +350,12 @@ function ShareAgentFormContent({ agentId }: ShareAgentFormContentProps) { } cancel={ } submit={ } /> From c93617df5dbb3b66acbae7f493037732d8a1e078 Mon Sep 17 00:00:00 2001 From: Nikolas Garza <90273783+nmgarza5@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:38:33 -0800 Subject: [PATCH 015/267] test(scim): add integration tests for SCIM user CRUD (#8825) --- .../common_utils/managers/scim_client.py | 66 +++ .../common_utils/managers/scim_token.py | 27 - .../tests/scim/test_scim_tokens.py | 20 +- .../integration/tests/scim/test_scim_users.py | 517 ++++++++++++++++++ backend/tests/unit/ee/onyx/db/test_license.py | 114 ++++ .../tests/unit/onyx/server/scim/conftest.py | 13 +- 6 files changed, 715 insertions(+), 42 deletions(-) create mode 100644 backend/tests/integration/common_utils/managers/scim_client.py create mode 100644 backend/tests/integration/tests/scim/test_scim_users.py diff --git a/backend/tests/integration/common_utils/managers/scim_client.py b/backend/tests/integration/common_utils/managers/scim_client.py new file mode 100644 index 00000000000..e1becbf752b --- /dev/null +++ b/backend/tests/integration/common_utils/managers/scim_client.py @@ -0,0 +1,66 @@ +import requests + +from tests.integration.common_utils.constants import API_SERVER_URL +from tests.integration.common_utils.constants import GENERAL_HEADERS + + +class ScimClient: + """HTTP client for making authenticated SCIM v2 requests.""" + + @staticmethod + def _headers(raw_token: str) -> dict[str, str]: + return { + **GENERAL_HEADERS, + "Authorization": f"Bearer {raw_token}", + } + + @staticmethod + def get(path: str, raw_token: str) -> requests.Response: + return requests.get( + f"{API_SERVER_URL}/scim/v2{path}", + headers=ScimClient._headers(raw_token), + timeout=60, + ) + + @staticmethod + def post(path: str, raw_token: str, json: dict) -> requests.Response: + return requests.post( + f"{API_SERVER_URL}/scim/v2{path}", + json=json, + headers=ScimClient._headers(raw_token), + timeout=60, + ) + + @staticmethod + def put(path: str, raw_token: str, json: dict) -> requests.Response: + return requests.put( + f"{API_SERVER_URL}/scim/v2{path}", + json=json, + headers=ScimClient._headers(raw_token), + timeout=60, + ) + + @staticmethod + def patch(path: str, raw_token: str, json: dict) -> requests.Response: + return requests.patch( + f"{API_SERVER_URL}/scim/v2{path}", + json=json, + headers=ScimClient._headers(raw_token), + timeout=60, + ) + + @staticmethod + def delete(path: str, raw_token: str) -> requests.Response: + return requests.delete( + f"{API_SERVER_URL}/scim/v2{path}", + headers=ScimClient._headers(raw_token), + timeout=60, + ) + + @staticmethod + def get_no_auth(path: str) -> requests.Response: + return requests.get( + f"{API_SERVER_URL}/scim/v2{path}", + headers=GENERAL_HEADERS, + timeout=60, + ) diff --git a/backend/tests/integration/common_utils/managers/scim_token.py b/backend/tests/integration/common_utils/managers/scim_token.py index 3ea020a07e2..1894ed4f321 100644 --- a/backend/tests/integration/common_utils/managers/scim_token.py +++ b/backend/tests/integration/common_utils/managers/scim_token.py @@ -1,7 +1,6 @@ import requests from tests.integration.common_utils.constants import API_SERVER_URL -from tests.integration.common_utils.constants import GENERAL_HEADERS from tests.integration.common_utils.test_models import DATestScimToken from tests.integration.common_utils.test_models import DATestUser @@ -51,29 +50,3 @@ def get_active( created_at=data["created_at"], last_used_at=data.get("last_used_at"), ) - - @staticmethod - def get_scim_headers(raw_token: str) -> dict[str, str]: - return { - **GENERAL_HEADERS, - "Authorization": f"Bearer {raw_token}", - } - - @staticmethod - def scim_get( - path: str, - raw_token: str, - ) -> requests.Response: - return requests.get( - f"{API_SERVER_URL}/scim/v2{path}", - headers=ScimTokenManager.get_scim_headers(raw_token), - timeout=60, - ) - - @staticmethod - def scim_get_no_auth(path: str) -> requests.Response: - return requests.get( - f"{API_SERVER_URL}/scim/v2{path}", - headers=GENERAL_HEADERS, - timeout=60, - ) diff --git a/backend/tests/integration/tests/scim/test_scim_tokens.py b/backend/tests/integration/tests/scim/test_scim_tokens.py index 19c95acbbdc..9476df86ef7 100644 --- a/backend/tests/integration/tests/scim/test_scim_tokens.py +++ b/backend/tests/integration/tests/scim/test_scim_tokens.py @@ -15,6 +15,7 @@ import requests from tests.integration.common_utils.constants import API_SERVER_URL +from tests.integration.common_utils.managers.scim_client import ScimClient from tests.integration.common_utils.managers.scim_token import ScimTokenManager from tests.integration.common_utils.managers.user import UserManager from tests.integration.common_utils.test_models import DATestUser @@ -39,7 +40,7 @@ def test_scim_token_lifecycle(admin_user: DATestUser) -> None: assert active == token.model_copy(update={"raw_token": None}) # Token works for SCIM requests - response = ScimTokenManager.scim_get("/Users", token.raw_token) + response = ScimClient.get("/Users", token.raw_token) assert response.status_code == 200 body = response.json() assert "Resources" in body @@ -54,7 +55,7 @@ def test_scim_token_rotation_revokes_previous(admin_user: DATestUser) -> None: ) assert first.raw_token is not None - response = ScimTokenManager.scim_get("/Users", first.raw_token) + response = ScimClient.get("/Users", first.raw_token) assert response.status_code == 200 # Create second token — should revoke first @@ -69,25 +70,22 @@ def test_scim_token_rotation_revokes_previous(admin_user: DATestUser) -> None: assert active == second.model_copy(update={"raw_token": None}) # First token rejected, second works - assert ScimTokenManager.scim_get("/Users", first.raw_token).status_code == 401 - assert ScimTokenManager.scim_get("/Users", second.raw_token).status_code == 200 + assert ScimClient.get("/Users", first.raw_token).status_code == 401 + assert ScimClient.get("/Users", second.raw_token).status_code == 200 def test_scim_request_without_token_rejected( admin_user: DATestUser, # noqa: ARG001 ) -> None: """SCIM endpoints reject requests with no Authorization header.""" - assert ScimTokenManager.scim_get_no_auth("/Users").status_code == 401 + assert ScimClient.get_no_auth("/Users").status_code == 401 def test_scim_request_with_bad_token_rejected( admin_user: DATestUser, # noqa: ARG001 ) -> None: """SCIM endpoints reject requests with an invalid token.""" - assert ( - ScimTokenManager.scim_get("/Users", "onyx_scim_bogus_token_value").status_code - == 401 - ) + assert ScimClient.get("/Users", "onyx_scim_bogus_token_value").status_code == 401 def test_non_admin_cannot_create_token( @@ -139,7 +137,7 @@ def test_service_discovery_no_auth_required( ) -> None: """Service discovery endpoints work without any authentication.""" for path in ["/ServiceProviderConfig", "/ResourceTypes", "/Schemas"]: - response = ScimTokenManager.scim_get_no_auth(path) + response = ScimClient.get_no_auth(path) assert response.status_code == 200, f"{path} returned {response.status_code}" @@ -158,7 +156,7 @@ def test_last_used_at_updated_after_scim_request( assert active.last_used_at is None # Make a SCIM request, then verify last_used_at is set - assert ScimTokenManager.scim_get("/Users", token.raw_token).status_code == 200 + assert ScimClient.get("/Users", token.raw_token).status_code == 200 time.sleep(0.5) active_after = ScimTokenManager.get_active(user_performing_action=admin_user) diff --git a/backend/tests/integration/tests/scim/test_scim_users.py b/backend/tests/integration/tests/scim/test_scim_users.py new file mode 100644 index 00000000000..7a242960686 --- /dev/null +++ b/backend/tests/integration/tests/scim/test_scim_users.py @@ -0,0 +1,517 @@ +"""Integration tests for SCIM user provisioning endpoints. + +Covers the full user lifecycle as driven by an IdP (Okta / Azure AD): +1. Create a user via POST /Users +2. Retrieve a user via GET /Users/{id} +3. List, filter, and paginate users via GET /Users +4. Replace a user via PUT /Users/{id} +5. Patch a user (deactivate/reactivate) via PATCH /Users/{id} +6. Delete a user via DELETE /Users/{id} +7. Error cases: missing externalId, duplicate email, not-found, seat limit + +All tests are parameterized across IdP request styles: +- **Okta**: lowercase PATCH ops, minimal payloads (core schema only). +- **Entra**: capitalized ops (``"Replace"``), enterprise extension data + (department, manager), and structured email arrays. + +The server normalizes both — these tests verify that all IdP-specific fields +are accepted and round-tripped correctly. + +Auth, revoked-token, and service-discovery tests live in test_scim_tokens.py. +""" + +from datetime import datetime +from datetime import timedelta +from datetime import timezone + +import pytest +import redis +import requests + +from ee.onyx.server.license.models import LicenseMetadata +from ee.onyx.server.license.models import LicenseSource +from ee.onyx.server.license.models import PlanType +from onyx.auth.schemas import UserRole +from onyx.configs.app_configs import REDIS_DB_NUMBER +from onyx.configs.app_configs import REDIS_HOST +from onyx.configs.app_configs import REDIS_PORT +from onyx.server.settings.models import ApplicationStatus +from tests.integration.common_utils.managers.scim_client import ScimClient +from tests.integration.common_utils.managers.scim_token import ScimTokenManager + + +SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_ENTERPRISE_USER_SCHEMA = ( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" +) +SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + +_LICENSE_REDIS_KEY = "public:license:metadata" + + +@pytest.fixture(scope="module", params=["okta", "entra"]) +def idp_style(request: pytest.FixtureRequest) -> str: + """Parameterized IdP style — runs every test with both Okta and Entra request formats.""" + return request.param + + +@pytest.fixture(scope="module") +def scim_token(idp_style: str) -> str: + """Create a single SCIM token shared across all tests in this module. + + Creating a new token revokes the previous one, so we create exactly once + per IdP-style run and reuse. Uses UserManager directly to avoid + fixture-scope conflicts with the function-scoped admin_user fixture. + """ + from tests.integration.common_utils.constants import ADMIN_USER_NAME + from tests.integration.common_utils.constants import GENERAL_HEADERS + from tests.integration.common_utils.managers.user import build_email + from tests.integration.common_utils.managers.user import DEFAULT_PASSWORD + from tests.integration.common_utils.managers.user import UserManager + from tests.integration.common_utils.test_models import DATestUser + + try: + admin = UserManager.create(name=ADMIN_USER_NAME) + except Exception: + admin = UserManager.login_as_user( + DATestUser( + id="", + email=build_email(ADMIN_USER_NAME), + password=DEFAULT_PASSWORD, + headers=GENERAL_HEADERS, + role=UserRole.ADMIN, + is_active=True, + ) + ) + + token = ScimTokenManager.create( + name=f"scim-user-tests-{idp_style}", + user_performing_action=admin, + ).raw_token + assert token is not None + return token + + +def _make_user_resource( + email: str, + external_id: str, + given_name: str = "Test", + family_name: str = "User", + active: bool = True, + idp_style: str = "okta", + department: str | None = None, + manager_id: str | None = None, +) -> dict: + """Build a SCIM UserResource payload appropriate for the IdP style. + + Entra sends richer payloads including enterprise extension data (department, + manager), structured email arrays, and the enterprise schema URN. Okta sends + minimal payloads with just core user fields. + """ + resource: dict = { + "schemas": [SCIM_USER_SCHEMA], + "userName": email, + "externalId": external_id, + "name": { + "givenName": given_name, + "familyName": family_name, + }, + "active": active, + } + if idp_style == "entra": + dept = department or "Engineering" + mgr = manager_id or "mgr-ext-001" + resource["schemas"].append(SCIM_ENTERPRISE_USER_SCHEMA) + resource[SCIM_ENTERPRISE_USER_SCHEMA] = { + "department": dept, + "manager": {"value": mgr}, + } + resource["emails"] = [ + {"value": email, "type": "work", "primary": True}, + ] + return resource + + +def _make_patch_request(operations: list[dict], idp_style: str = "okta") -> dict: + """Build a SCIM PatchOp payload, applying IdP-specific operation casing. + + Entra sends capitalized operations (e.g. ``"Replace"`` instead of + ``"replace"``). The server's ``normalize_operation`` validator lowercases + them — these tests verify that both casings are accepted. + """ + cased_operations = [] + for operation in operations: + cased = dict(operation) + if idp_style == "entra": + cased["op"] = operation["op"].capitalize() + cased_operations.append(cased) + return { + "schemas": [SCIM_PATCH_SCHEMA], + "Operations": cased_operations, + } + + +def _create_scim_user( + token: str, + email: str, + external_id: str, + idp_style: str = "okta", +) -> requests.Response: + return ScimClient.post( + "/Users", + token, + json=_make_user_resource(email, external_id, idp_style=idp_style), + ) + + +def _assert_entra_extension( + body: dict, + expected_department: str = "Engineering", + expected_manager: str = "mgr-ext-001", +) -> None: + """Assert that Entra enterprise extension fields round-tripped correctly.""" + assert SCIM_ENTERPRISE_USER_SCHEMA in body["schemas"] + ext = body[SCIM_ENTERPRISE_USER_SCHEMA] + assert ext["department"] == expected_department + assert ext["manager"]["value"] == expected_manager + + +def _assert_entra_emails(body: dict, expected_email: str) -> None: + """Assert that structured email metadata round-tripped correctly.""" + emails = body["emails"] + assert len(emails) >= 1 + work_email = next(e for e in emails if e.get("type") == "work") + assert work_email["value"] == expected_email + assert work_email["primary"] is True + + +# ------------------------------------------------------------------ +# Lifecycle: create -> get -> list -> replace -> patch -> delete +# ------------------------------------------------------------------ + + +def test_create_user(scim_token: str, idp_style: str) -> None: + """POST /Users creates a provisioned user and returns 201.""" + email = f"scim_create_{idp_style}@example.com" + ext_id = f"ext-create-{idp_style}" + resp = _create_scim_user(scim_token, email, ext_id, idp_style) + assert resp.status_code == 201 + + body = resp.json() + assert body["userName"] == email + assert body["externalId"] == ext_id + assert body["active"] is True + assert body["id"] # UUID assigned by server + assert body["meta"]["resourceType"] == "User" + assert body["name"]["givenName"] == "Test" + assert body["name"]["familyName"] == "User" + + if idp_style == "entra": + _assert_entra_extension(body) + _assert_entra_emails(body, email) + + +def test_get_user(scim_token: str, idp_style: str) -> None: + """GET /Users/{id} returns the user resource with all stored fields.""" + email = f"scim_get_{idp_style}@example.com" + ext_id = f"ext-get-{idp_style}" + created = _create_scim_user(scim_token, email, ext_id, idp_style).json() + + resp = ScimClient.get(f"/Users/{created['id']}", scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["id"] == created["id"] + assert body["userName"] == email + assert body["externalId"] == ext_id + assert body["name"]["givenName"] == "Test" + assert body["name"]["familyName"] == "User" + + if idp_style == "entra": + _assert_entra_extension(body) + _assert_entra_emails(body, email) + + +def test_list_users(scim_token: str, idp_style: str) -> None: + """GET /Users returns a ListResponse containing provisioned users.""" + email = f"scim_list_{idp_style}@example.com" + _create_scim_user(scim_token, email, f"ext-list-{idp_style}", idp_style) + + resp = ScimClient.get("/Users", scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["totalResults"] >= 1 + emails = [r["userName"] for r in body["Resources"]] + assert email in emails + + +def test_list_users_pagination(scim_token: str, idp_style: str) -> None: + """GET /Users with startIndex and count returns correct pagination.""" + _create_scim_user( + scim_token, + f"scim_page1_{idp_style}@example.com", + f"ext-page-1-{idp_style}", + idp_style, + ) + _create_scim_user( + scim_token, + f"scim_page2_{idp_style}@example.com", + f"ext-page-2-{idp_style}", + idp_style, + ) + + resp = ScimClient.get("/Users?startIndex=1&count=1", scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["startIndex"] == 1 + assert body["itemsPerPage"] == 1 + assert body["totalResults"] >= 2 + assert len(body["Resources"]) == 1 + + +def test_filter_users_by_username(scim_token: str, idp_style: str) -> None: + """GET /Users?filter=userName eq '...' returns only matching users.""" + email = f"scim_filter_{idp_style}@example.com" + _create_scim_user(scim_token, email, f"ext-filter-{idp_style}", idp_style) + + resp = ScimClient.get(f'/Users?filter=userName eq "{email}"', scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["totalResults"] == 1 + assert body["Resources"][0]["userName"] == email + + +def test_replace_user(scim_token: str, idp_style: str) -> None: + """PUT /Users/{id} replaces the user resource including enterprise fields.""" + email = f"scim_replace_{idp_style}@example.com" + ext_id = f"ext-replace-{idp_style}" + created = _create_scim_user(scim_token, email, ext_id, idp_style).json() + + updated_resource = _make_user_resource( + email=email, + external_id=ext_id, + given_name="Updated", + family_name="Name", + idp_style=idp_style, + department="Product", + ) + resp = ScimClient.put(f"/Users/{created['id']}", scim_token, json=updated_resource) + assert resp.status_code == 200 + + body = resp.json() + assert body["name"]["givenName"] == "Updated" + assert body["name"]["familyName"] == "Name" + + if idp_style == "entra": + _assert_entra_extension(body, expected_department="Product") + _assert_entra_emails(body, email) + + +def test_patch_deactivate_user(scim_token: str, idp_style: str) -> None: + """PATCH /Users/{id} with active=false deactivates the user.""" + created = _create_scim_user( + scim_token, + f"scim_deactivate_{idp_style}@example.com", + f"ext-deactivate-{idp_style}", + idp_style, + ).json() + assert created["active"] is True + + resp = ScimClient.patch( + f"/Users/{created['id']}", + scim_token, + json=_make_patch_request( + [{"op": "replace", "path": "active", "value": False}], idp_style + ), + ) + assert resp.status_code == 200 + assert resp.json()["active"] is False + + # Confirm via GET + get_resp = ScimClient.get(f"/Users/{created['id']}", scim_token) + assert get_resp.json()["active"] is False + + +def test_patch_reactivate_user(scim_token: str, idp_style: str) -> None: + """PATCH active=true reactivates a previously deactivated user.""" + created = _create_scim_user( + scim_token, + f"scim_reactivate_{idp_style}@example.com", + f"ext-reactivate-{idp_style}", + idp_style, + ).json() + + # Deactivate + deactivate_resp = ScimClient.patch( + f"/Users/{created['id']}", + scim_token, + json=_make_patch_request( + [{"op": "replace", "path": "active", "value": False}], idp_style + ), + ) + assert deactivate_resp.status_code == 200 + assert deactivate_resp.json()["active"] is False + + # Reactivate + resp = ScimClient.patch( + f"/Users/{created['id']}", + scim_token, + json=_make_patch_request( + [{"op": "replace", "path": "active", "value": True}], idp_style + ), + ) + assert resp.status_code == 200 + assert resp.json()["active"] is True + + +def test_delete_user(scim_token: str, idp_style: str) -> None: + """DELETE /Users/{id} deactivates and removes the SCIM mapping.""" + created = _create_scim_user( + scim_token, + f"scim_delete_{idp_style}@example.com", + f"ext-delete-{idp_style}", + idp_style, + ).json() + + resp = ScimClient.delete(f"/Users/{created['id']}", scim_token) + assert resp.status_code == 204 + + # Second DELETE returns 404 per RFC 7644 §3.6 (mapping removed) + resp2 = ScimClient.delete(f"/Users/{created['id']}", scim_token) + assert resp2.status_code == 404 + + +# ------------------------------------------------------------------ +# Error cases +# ------------------------------------------------------------------ + + +def test_create_user_missing_external_id(scim_token: str) -> None: + """POST /Users without externalId returns 400.""" + resp = ScimClient.post( + "/Users", + scim_token, + json={ + "schemas": [SCIM_USER_SCHEMA], + "userName": "scim_no_extid@example.com", + "active": True, + }, + ) + assert resp.status_code == 400 + assert "externalId" in resp.json()["detail"] + + +def test_create_user_duplicate_email(scim_token: str, idp_style: str) -> None: + """POST /Users with an already-taken email returns 409.""" + email = f"scim_dup_{idp_style}@example.com" + resp1 = _create_scim_user(scim_token, email, f"ext-dup-1-{idp_style}", idp_style) + assert resp1.status_code == 201 + + resp2 = _create_scim_user(scim_token, email, f"ext-dup-2-{idp_style}", idp_style) + assert resp2.status_code == 409 + + +def test_get_nonexistent_user(scim_token: str) -> None: + """GET /Users/{bad-id} returns 404.""" + resp = ScimClient.get("/Users/00000000-0000-0000-0000-000000000000", scim_token) + assert resp.status_code == 404 + + +def test_filter_users_by_external_id(scim_token: str, idp_style: str) -> None: + """GET /Users?filter=externalId eq '...' returns the matching user.""" + ext_id = f"ext-unique-filter-id-{idp_style}" + _create_scim_user( + scim_token, f"scim_extfilter_{idp_style}@example.com", ext_id, idp_style + ) + + resp = ScimClient.get(f'/Users?filter=externalId eq "{ext_id}"', scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["totalResults"] == 1 + assert body["Resources"][0]["externalId"] == ext_id + + +# ------------------------------------------------------------------ +# Seat-limit enforcement +# ------------------------------------------------------------------ + + +def _seed_license(r: redis.Redis, seats: int) -> None: + """Write a LicenseMetadata entry into Redis with the given seat cap.""" + now = datetime.now(timezone.utc) + metadata = LicenseMetadata( + tenant_id="public", + organization_name="Test Org", + seats=seats, + used_seats=0, # check_seat_availability recalculates from DB + plan_type=PlanType.ANNUAL, + issued_at=now, + expires_at=now + timedelta(days=365), + status=ApplicationStatus.ACTIVE, + source=LicenseSource.MANUAL_UPLOAD, + ) + r.set(_LICENSE_REDIS_KEY, metadata.model_dump_json(), ex=300) + + +def test_create_user_seat_limit(scim_token: str, idp_style: str) -> None: + """POST /Users returns 403 when the seat limit is reached.""" + r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB_NUMBER) + + # admin_user already occupies 1 seat; cap at 1 -> full + _seed_license(r, seats=1) + + try: + resp = _create_scim_user( + scim_token, + f"scim_blocked_{idp_style}@example.com", + f"ext-blocked-{idp_style}", + idp_style, + ) + assert resp.status_code == 403 + assert "seat" in resp.json()["detail"].lower() + finally: + r.delete(_LICENSE_REDIS_KEY) + + +def test_reactivate_user_seat_limit(scim_token: str, idp_style: str) -> None: + """PATCH active=true returns 403 when the seat limit is reached.""" + # Create and deactivate a user (before license is seeded) + created = _create_scim_user( + scim_token, + f"scim_reactivate_blocked_{idp_style}@example.com", + f"ext-reactivate-blocked-{idp_style}", + idp_style, + ).json() + assert created["active"] is True + + deactivate_resp = ScimClient.patch( + f"/Users/{created['id']}", + scim_token, + json=_make_patch_request( + [{"op": "replace", "path": "active", "value": False}], idp_style + ), + ) + assert deactivate_resp.status_code == 200 + assert deactivate_resp.json()["active"] is False + + r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB_NUMBER) + + # Seed license capped at current active users -> reactivation should fail + _seed_license(r, seats=1) + + try: + resp = ScimClient.patch( + f"/Users/{created['id']}", + scim_token, + json=_make_patch_request( + [{"op": "replace", "path": "active", "value": True}], idp_style + ), + ) + assert resp.status_code == 403 + assert "seat" in resp.json()["detail"].lower() + finally: + r.delete(_LICENSE_REDIS_KEY) diff --git a/backend/tests/unit/ee/onyx/db/test_license.py b/backend/tests/unit/ee/onyx/db/test_license.py index c05a3b9481e..9dae27d08d1 100644 --- a/backend/tests/unit/ee/onyx/db/test_license.py +++ b/backend/tests/unit/ee/onyx/db/test_license.py @@ -1,11 +1,20 @@ """Tests for license database CRUD operations.""" +from datetime import datetime +from datetime import timedelta +from datetime import timezone from unittest.mock import MagicMock +from unittest.mock import patch +from ee.onyx.db.license import check_seat_availability from ee.onyx.db.license import delete_license from ee.onyx.db.license import get_license from ee.onyx.db.license import upsert_license +from ee.onyx.server.license.models import LicenseMetadata +from ee.onyx.server.license.models import LicenseSource +from ee.onyx.server.license.models import PlanType from onyx.db.models import License +from onyx.server.settings.models import ApplicationStatus class TestGetLicense: @@ -100,3 +109,108 @@ def test_delete_no_license(self) -> None: assert result is False mock_session.delete.assert_not_called() mock_session.commit.assert_not_called() + + +def _make_license_metadata(seats: int = 10) -> LicenseMetadata: + now = datetime.now(timezone.utc) + return LicenseMetadata( + tenant_id="public", + seats=seats, + used_seats=0, + plan_type=PlanType.ANNUAL, + issued_at=now, + expires_at=now + timedelta(days=365), + status=ApplicationStatus.ACTIVE, + source=LicenseSource.MANUAL_UPLOAD, + ) + + +class TestCheckSeatAvailabilitySelfHosted: + """Seat checks for self-hosted (MULTI_TENANT=False).""" + + @patch("ee.onyx.db.license.get_license_metadata", return_value=None) + def test_no_license_means_unlimited(self, _mock_meta: MagicMock) -> None: + result = check_seat_availability(MagicMock(), seats_needed=1) + assert result.available is True + + @patch("ee.onyx.db.license.get_used_seats", return_value=5) + @patch("ee.onyx.db.license.get_license_metadata") + def test_seats_available(self, mock_meta: MagicMock, _mock_used: MagicMock) -> None: + mock_meta.return_value = _make_license_metadata(seats=10) + result = check_seat_availability(MagicMock(), seats_needed=1) + assert result.available is True + + @patch("ee.onyx.db.license.get_used_seats", return_value=10) + @patch("ee.onyx.db.license.get_license_metadata") + def test_seats_full_blocks_creation( + self, mock_meta: MagicMock, _mock_used: MagicMock + ) -> None: + mock_meta.return_value = _make_license_metadata(seats=10) + result = check_seat_availability(MagicMock(), seats_needed=1) + assert result.available is False + assert result.error_message is not None + assert "10 of 10" in result.error_message + + @patch("ee.onyx.db.license.get_used_seats", return_value=10) + @patch("ee.onyx.db.license.get_license_metadata") + def test_exactly_at_capacity_allows_no_more( + self, mock_meta: MagicMock, _mock_used: MagicMock + ) -> None: + """Filling to 100% is allowed; exceeding is not.""" + mock_meta.return_value = _make_license_metadata(seats=10) + result = check_seat_availability(MagicMock(), seats_needed=1) + assert result.available is False + + @patch("ee.onyx.db.license.get_used_seats", return_value=9) + @patch("ee.onyx.db.license.get_license_metadata") + def test_filling_to_capacity_is_allowed( + self, mock_meta: MagicMock, _mock_used: MagicMock + ) -> None: + mock_meta.return_value = _make_license_metadata(seats=10) + result = check_seat_availability(MagicMock(), seats_needed=1) + assert result.available is True + + +class TestCheckSeatAvailabilityMultiTenant: + """Seat checks for multi-tenant cloud (MULTI_TENANT=True). + + Verifies that get_used_seats takes the MULTI_TENANT branch + and delegates to get_tenant_count. + """ + + @patch("ee.onyx.db.license.MULTI_TENANT", True) + @patch( + "ee.onyx.server.tenants.user_mapping.get_tenant_count", + return_value=5, + ) + @patch("ee.onyx.db.license.get_license_metadata") + def test_seats_available_multi_tenant( + self, + mock_meta: MagicMock, + mock_tenant_count: MagicMock, + ) -> None: + mock_meta.return_value = _make_license_metadata(seats=10) + result = check_seat_availability( + MagicMock(), seats_needed=1, tenant_id="tenant-abc" + ) + assert result.available is True + mock_tenant_count.assert_called_once_with("tenant-abc") + + @patch("ee.onyx.db.license.MULTI_TENANT", True) + @patch( + "ee.onyx.server.tenants.user_mapping.get_tenant_count", + return_value=10, + ) + @patch("ee.onyx.db.license.get_license_metadata") + def test_seats_full_multi_tenant( + self, + mock_meta: MagicMock, + mock_tenant_count: MagicMock, + ) -> None: + mock_meta.return_value = _make_license_metadata(seats=10) + result = check_seat_availability( + MagicMock(), seats_needed=1, tenant_id="tenant-abc" + ) + assert result.available is False + assert result.error_message is not None + mock_tenant_count.assert_called_once_with("tenant-abc") diff --git a/backend/tests/unit/onyx/server/scim/conftest.py b/backend/tests/unit/onyx/server/scim/conftest.py index 351a0fcec0c..515d94eb776 100644 --- a/backend/tests/unit/onyx/server/scim/conftest.py +++ b/backend/tests/unit/onyx/server/scim/conftest.py @@ -19,6 +19,7 @@ from ee.onyx.server.scim.models import ScimName from ee.onyx.server.scim.models import ScimUserResource from ee.onyx.server.scim.providers.base import ScimProvider +from ee.onyx.server.scim.providers.entra import EntraProvider from ee.onyx.server.scim.providers.okta import OktaProvider from onyx.db.models import ScimToken from onyx.db.models import ScimUserMapping @@ -26,6 +27,10 @@ from onyx.db.models import UserGroup from onyx.db.models import UserRole +# Every supported SCIM provider must appear here so that all endpoint tests +# run against it. When adding a new provider, add its class to this list. +SCIM_PROVIDERS: list[type[ScimProvider]] = [OktaProvider, EntraProvider] + @pytest.fixture def mock_db_session() -> MagicMock: @@ -41,10 +46,10 @@ def mock_token() -> MagicMock: return token -@pytest.fixture -def provider() -> ScimProvider: - """An OktaProvider instance for endpoint tests.""" - return OktaProvider() +@pytest.fixture(params=SCIM_PROVIDERS, ids=[p.__name__ for p in SCIM_PROVIDERS]) +def provider(request: pytest.FixtureRequest) -> ScimProvider: + """Parameterized provider — runs each test with every provider in SCIM_PROVIDERS.""" + return request.param() @pytest.fixture From 11c54bafb503f3560223f0d9940be3ca5a53f1fa Mon Sep 17 00:00:00 2001 From: Evan Lohn Date: Mon, 2 Mar 2026 12:01:26 -0800 Subject: [PATCH 016/267] chore: no vector db deployment (#8867) --- backend/onyx/background/periodic_poller.py | 5 +- .../background/test_periodic_task_claim.py | 6 +- .../test_no_vectordb_file_lifecycle.py | 160 ++++++++++++++++++ .../docker-compose.no-vectordb.yml | 17 +- .../charts/onyx/templates/celery-beat.yaml | 2 +- .../templates/celery-worker-heavy-hpa.yaml | 2 +- .../celery-worker-heavy-scaledobject.yaml | 2 +- .../onyx/templates/celery-worker-heavy.yaml | 2 +- .../templates/celery-worker-light-hpa.yaml | 2 +- .../celery-worker-light-scaledobject.yaml | 2 +- .../onyx/templates/celery-worker-light.yaml | 2 +- .../celery-worker-monitoring-hpa.yaml | 2 +- ...celery-worker-monitoring-scaledobject.yaml | 2 +- .../templates/celery-worker-monitoring.yaml | 2 +- .../templates/celery-worker-primary-hpa.yaml | 2 +- .../celery-worker-primary-scaledobject.yaml | 2 +- .../onyx/templates/celery-worker-primary.yaml | 2 +- ...elery-worker-user-file-processing-hpa.yaml | 2 +- ...ker-user-file-processing-scaledobject.yaml | 2 +- .../celery-worker-user-file-processing.yaml | 2 +- deployment/helm/charts/onyx/values.yaml | 4 +- 21 files changed, 196 insertions(+), 28 deletions(-) create mode 100644 backend/tests/integration/tests/no_vectordb/test_no_vectordb_file_lifecycle.py diff --git a/backend/onyx/background/periodic_poller.py b/backend/onyx/background/periodic_poller.py index 9580fb7d652..f7a836e6dda 100644 --- a/backend/onyx/background/periodic_poller.py +++ b/backend/onyx/background/periodic_poller.py @@ -32,13 +32,16 @@ # ------------------------------------------------------------------ +_NEVER_RAN: float = -1e18 + + @dataclass class _PeriodicTaskDef: name: str interval_seconds: float lock_id: int run_fn: Callable[[], None] - last_run_at: float = field(default=0.0) + last_run_at: float = field(default=_NEVER_RAN) def _run_auto_llm_update() -> None: diff --git a/backend/tests/external_dependency_unit/background/test_periodic_task_claim.py b/backend/tests/external_dependency_unit/background/test_periodic_task_claim.py index 92132e1db0d..de5fc44edf4 100644 --- a/backend/tests/external_dependency_unit/background/test_periodic_task_claim.py +++ b/backend/tests/external_dependency_unit/background/test_periodic_task_claim.py @@ -46,10 +46,10 @@ def _make_task( run_fn: MagicMock | None = None, ) -> _PeriodicTaskDef: return _PeriodicTaskDef( - name=name or f"test-{uuid4().hex[:8]}", + name=name if name is not None else f"test-{uuid4().hex[:8]}", interval_seconds=interval, - lock_id=lock_id or _TEST_LOCK_BASE, - run_fn=run_fn or MagicMock(), + lock_id=lock_id if lock_id is not None else _TEST_LOCK_BASE, + run_fn=run_fn if run_fn is not None else MagicMock(), ) diff --git a/backend/tests/integration/tests/no_vectordb/test_no_vectordb_file_lifecycle.py b/backend/tests/integration/tests/no_vectordb/test_no_vectordb_file_lifecycle.py new file mode 100644 index 00000000000..fa552b4d7aa --- /dev/null +++ b/backend/tests/integration/tests/no_vectordb/test_no_vectordb_file_lifecycle.py @@ -0,0 +1,160 @@ +"""Integration test for the full user-file lifecycle in no-vector-DB mode. + +Covers: upload → COMPLETED → unlink from project → delete → gone. + +The entire lifecycle is handled by FastAPI BackgroundTasks (no Celery workers +needed). The conftest-level ``pytestmark`` ensures these tests are skipped +when the server is running with vector DB enabled. +""" + +import time +from uuid import UUID + +import requests + +from onyx.db.enums import UserFileStatus +from tests.integration.common_utils.constants import API_SERVER_URL +from tests.integration.common_utils.managers.project import ProjectManager +from tests.integration.common_utils.test_models import DATestLLMProvider +from tests.integration.common_utils.test_models import DATestUser + +POLL_INTERVAL_SECONDS = 1 +POLL_TIMEOUT_SECONDS = 30 + + +def _poll_file_status( + file_id: UUID, + user: DATestUser, + target_status: UserFileStatus, + timeout: int = POLL_TIMEOUT_SECONDS, +) -> None: + """Poll GET /user/projects/file/{file_id} until the file reaches *target_status*.""" + deadline = time.time() + timeout + while time.time() < deadline: + resp = requests.get( + f"{API_SERVER_URL}/user/projects/file/{file_id}", + headers=user.headers, + ) + if resp.ok: + status = resp.json().get("status") + if status == target_status.value: + return + time.sleep(POLL_INTERVAL_SECONDS) + raise TimeoutError( + f"File {file_id} did not reach {target_status.value} within {timeout}s" + ) + + +def _file_is_gone(file_id: UUID, user: DATestUser, timeout: int = 15) -> None: + """Poll until GET /user/projects/file/{file_id} returns 404.""" + deadline = time.time() + timeout + while time.time() < deadline: + resp = requests.get( + f"{API_SERVER_URL}/user/projects/file/{file_id}", + headers=user.headers, + ) + if resp.status_code == 404: + return + time.sleep(POLL_INTERVAL_SECONDS) + raise TimeoutError( + f"File {file_id} still accessible after {timeout}s (expected 404)" + ) + + +def test_file_upload_process_delete_lifecycle( + reset: None, # noqa: ARG001 + admin_user: DATestUser, + llm_provider: DATestLLMProvider, # noqa: ARG001 +) -> None: + """Full lifecycle: upload → COMPLETED → unlink → delete → 404. + + Validates that the API server handles all background processing + (via FastAPI BackgroundTasks) without any Celery workers running. + """ + project = ProjectManager.create( + name="lifecycle-test", user_performing_action=admin_user + ) + + file_content = b"Integration test file content for lifecycle verification." + upload_result = ProjectManager.upload_files( + project_id=project.id, + files=[("lifecycle.txt", file_content)], + user_performing_action=admin_user, + ) + assert upload_result.user_files, "Expected at least one file in upload response" + + user_file = upload_result.user_files[0] + file_id = user_file.id + + _poll_file_status(file_id, admin_user, UserFileStatus.COMPLETED) + + project_files = ProjectManager.get_project_files(project.id, admin_user) + assert any( + f.id == file_id for f in project_files + ), "File should be listed in project files after processing" + + # Unlink the file from the project so the delete endpoint will proceed + unlink_resp = requests.delete( + f"{API_SERVER_URL}/user/projects/{project.id}/files/{file_id}", + headers=admin_user.headers, + ) + assert ( + unlink_resp.status_code == 204 + ), f"Expected 204 on unlink, got {unlink_resp.status_code}: {unlink_resp.text}" + + delete_resp = requests.delete( + f"{API_SERVER_URL}/user/projects/file/{file_id}", + headers=admin_user.headers, + ) + assert ( + delete_resp.ok + ), f"Delete request failed: {delete_resp.status_code} {delete_resp.text}" + body = delete_resp.json() + assert ( + body["has_associations"] is False + ), f"File still has associations after unlink: {body}" + + _file_is_gone(file_id, admin_user) + + project_files_after = ProjectManager.get_project_files(project.id, admin_user) + assert not any( + f.id == file_id for f in project_files_after + ), "Deleted file should not appear in project files" + + +def test_delete_blocked_while_associated( + reset: None, # noqa: ARG001 + admin_user: DATestUser, + llm_provider: DATestLLMProvider, # noqa: ARG001 +) -> None: + """Deleting a file that still belongs to a project should return + has_associations=True without actually deleting the file.""" + project = ProjectManager.create( + name="assoc-test", user_performing_action=admin_user + ) + + upload_result = ProjectManager.upload_files( + project_id=project.id, + files=[("assoc.txt", b"associated file content")], + user_performing_action=admin_user, + ) + file_id = upload_result.user_files[0].id + + _poll_file_status(file_id, admin_user, UserFileStatus.COMPLETED) + + # Attempt to delete while still linked + delete_resp = requests.delete( + f"{API_SERVER_URL}/user/projects/file/{file_id}", + headers=admin_user.headers, + ) + assert delete_resp.ok + body = delete_resp.json() + assert body["has_associations"] is True, "Should report existing associations" + assert project.name in body["project_names"] + + # File should still be accessible + get_resp = requests.get( + f"{API_SERVER_URL}/user/projects/file/{file_id}", + headers=admin_user.headers, + ) + assert get_resp.status_code == 200, "File should still exist after blocked delete" diff --git a/deployment/docker_compose/docker-compose.no-vectordb.yml b/deployment/docker_compose/docker-compose.no-vectordb.yml index 2709700df46..1c7fd766280 100644 --- a/deployment/docker_compose/docker-compose.no-vectordb.yml +++ b/deployment/docker_compose/docker-compose.no-vectordb.yml @@ -16,12 +16,15 @@ # This overlay: # - Moves Vespa (index), both model servers, and code-interpreter to profiles # so they do not start by default -# - Makes the depends_on references to those services optional -# - Sets DISABLE_VECTOR_DB=true on backend services +# - Moves the background worker to the "background" profile (the API server +# handles all background work via FastAPI BackgroundTasks) +# - Makes the depends_on references to removed services optional +# - Sets DISABLE_VECTOR_DB=true on the api_server # # To selectively bring services back: # --profile vectordb Vespa + indexing model server # --profile inference Inference model server +# --profile background Background worker (Celery) # --profile code-interpreter Code interpreter # ============================================================================= @@ -43,20 +46,20 @@ services: - DISABLE_VECTOR_DB=true - FILE_STORE_BACKEND=postgres + # Move the background worker to a profile so it does not start by default. + # The API server handles all background work in NO_VECTOR_DB mode. background: + profiles: ["background"] depends_on: index: condition: service_started required: false - indexing_model_server: + inference_model_server: condition: service_started required: false - inference_model_server: + indexing_model_server: condition: service_started required: false - environment: - - DISABLE_VECTOR_DB=true - - FILE_STORE_BACKEND=postgres # Move Vespa and indexing model server to a profile so they do not start. index: diff --git a/deployment/helm/charts/onyx/templates/celery-beat.yaml b/deployment/helm/charts/onyx/templates/celery-beat.yaml index 49a72014cd8..eb8ef2eeafa 100644 --- a/deployment/helm/charts/onyx/templates/celery-beat.yaml +++ b/deployment/helm/charts/onyx/templates/celery-beat.yaml @@ -1,4 +1,4 @@ -{{- if gt (int .Values.celery_beat.replicaCount) 0 }} +{{- if and .Values.vectorDB.enabled (gt (int .Values.celery_beat.replicaCount) 0) }} apiVersion: apps/v1 kind: Deployment metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-heavy-hpa.yaml b/deployment/helm/charts/onyx/templates/celery-worker-heavy-hpa.yaml index 832ef85686d..ca8924a9aa1 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-heavy-hpa.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-heavy-hpa.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_heavy.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_heavy.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-heavy-scaledobject.yaml b/deployment/helm/charts/onyx/templates/celery-worker-heavy-scaledobject.yaml index d06fa2895ad..1d29781f11a 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-heavy-scaledobject.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-heavy-scaledobject.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_heavy.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_heavy.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-heavy.yaml b/deployment/helm/charts/onyx/templates/celery-worker-heavy.yaml index 9a6877b5b0f..3b4b8bc46c8 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-heavy.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-heavy.yaml @@ -1,4 +1,4 @@ -{{- if gt (int .Values.celery_worker_heavy.replicaCount) 0 }} +{{- if and .Values.vectorDB.enabled (gt (int .Values.celery_worker_heavy.replicaCount) 0) }} apiVersion: apps/v1 kind: Deployment metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-light-hpa.yaml b/deployment/helm/charts/onyx/templates/celery-worker-light-hpa.yaml index 8a9e06b297f..388cc5827c5 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-light-hpa.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-light-hpa.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_light.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_light.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-light-scaledobject.yaml b/deployment/helm/charts/onyx/templates/celery-worker-light-scaledobject.yaml index 6bc1215eb6b..0897dd7f724 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-light-scaledobject.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-light-scaledobject.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_light.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_light.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-light.yaml b/deployment/helm/charts/onyx/templates/celery-worker-light.yaml index 802be2607e6..db4d72cc392 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-light.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-light.yaml @@ -1,4 +1,4 @@ -{{- if gt (int .Values.celery_worker_light.replicaCount) 0 }} +{{- if and .Values.vectorDB.enabled (gt (int .Values.celery_worker_light.replicaCount) 0) }} apiVersion: apps/v1 kind: Deployment metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-monitoring-hpa.yaml b/deployment/helm/charts/onyx/templates/celery-worker-monitoring-hpa.yaml index 60c487b0045..c5bc0ae4ac8 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-monitoring-hpa.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-monitoring-hpa.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_monitoring.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_monitoring.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-monitoring-scaledobject.yaml b/deployment/helm/charts/onyx/templates/celery-worker-monitoring-scaledobject.yaml index f2870e5c2e1..61e53214365 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-monitoring-scaledobject.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-monitoring-scaledobject.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_monitoring.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_monitoring.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-monitoring.yaml b/deployment/helm/charts/onyx/templates/celery-worker-monitoring.yaml index 203139ab6da..f1e6e3a20b4 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-monitoring.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-monitoring.yaml @@ -1,4 +1,4 @@ -{{- if gt (int .Values.celery_worker_monitoring.replicaCount) 0 }} +{{- if and .Values.vectorDB.enabled (gt (int .Values.celery_worker_monitoring.replicaCount) 0) }} apiVersion: apps/v1 kind: Deployment metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-primary-hpa.yaml b/deployment/helm/charts/onyx/templates/celery-worker-primary-hpa.yaml index 9902d137728..529efde05ab 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-primary-hpa.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-primary-hpa.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_primary.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_primary.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-primary-scaledobject.yaml b/deployment/helm/charts/onyx/templates/celery-worker-primary-scaledobject.yaml index 464be55aa67..a6995f2224d 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-primary-scaledobject.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-primary-scaledobject.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_primary.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_primary.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-primary.yaml b/deployment/helm/charts/onyx/templates/celery-worker-primary.yaml index 618a779a2c4..16260b44523 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-primary.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-primary.yaml @@ -1,4 +1,4 @@ -{{- if gt (int .Values.celery_worker_primary.replicaCount) 0 }} +{{- if and .Values.vectorDB.enabled (gt (int .Values.celery_worker_primary.replicaCount) 0) }} apiVersion: apps/v1 kind: Deployment metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing-hpa.yaml b/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing-hpa.yaml index b04b85f37aa..81ea5e146ba 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing-hpa.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing-hpa.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_user_file_processing.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_user_file_processing.autoscaling.enabled) (ne (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing-scaledobject.yaml b/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing-scaledobject.yaml index e380083038d..3d7e83bb79b 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing-scaledobject.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing-scaledobject.yaml @@ -1,4 +1,4 @@ -{{- if and (.Values.celery_worker_user_file_processing.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} +{{- if and .Values.vectorDB.enabled (.Values.celery_worker_user_file_processing.autoscaling.enabled) (eq (include "onyx.autoscaling.engine" .) "keda") }} apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: diff --git a/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing.yaml b/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing.yaml index 47209772265..9f837050e31 100644 --- a/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing.yaml +++ b/deployment/helm/charts/onyx/templates/celery-worker-user-file-processing.yaml @@ -1,4 +1,4 @@ -{{- if gt (int .Values.celery_worker_user_file_processing.replicaCount) 0 }} +{{- if and .Values.vectorDB.enabled (gt (int .Values.celery_worker_user_file_processing.replicaCount) 0) }} apiVersion: apps/v1 kind: Deployment metadata: diff --git a/deployment/helm/charts/onyx/values.yaml b/deployment/helm/charts/onyx/values.yaml index cf6bd686f33..e4c602a3d05 100644 --- a/deployment/helm/charts/onyx/values.yaml +++ b/deployment/helm/charts/onyx/values.yaml @@ -28,7 +28,9 @@ postgresql: # -- Master toggle for vector database support. When false: # - Sets DISABLE_VECTOR_DB=true on all backend pods # - Skips the indexing model server deployment (embeddings not needed) -# - Skips docprocessing and docfetching celery workers +# - Skips ALL celery worker deployments (beat, primary, light, heavy, +# monitoring, user-file-processing, docprocessing, docfetching) — the +# API server handles background work via FastAPI BackgroundTasks # - You should also set vespa.enabled=false and opensearch.enabled=false # to prevent those subcharts from deploying vectorDB: From fd322a8a10d6c7ee78d554c0ae11efb64041c73e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:03:36 +0000 Subject: [PATCH 017/267] chore(deps): bump lxml-html-clean from 0.4.3 to 0.4.4 (#8919) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jamison Lahman --- backend/requirements/default.txt | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/requirements/default.txt b/backend/requirements/default.txt index 799d2ac3bae..438e56bde09 100644 --- a/backend/requirements/default.txt +++ b/backend/requirements/default.txt @@ -528,7 +528,7 @@ lxml==5.3.0 # unstructured # xmlsec # zeep -lxml-html-clean==0.4.3 +lxml-html-clean==0.4.4 # via lxml magika==0.6.3 # via markitdown diff --git a/uv.lock b/uv.lock index 82a6c50d14f..5848b04425a 100644 --- a/uv.lock +++ b/uv.lock @@ -3426,14 +3426,14 @@ html-clean = [ [[package]] name = "lxml-html-clean" -version = "0.4.3" +version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lxml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/cb/c9c5bb2a9c47292e236a808dd233a03531f53b626f36259dcd32b49c76da/lxml_html_clean-0.4.3.tar.gz", hash = "sha256:c9df91925b00f836c807beab127aac82575110eacff54d0a75187914f1bd9d8c", size = 21498, upload-time = "2025-10-02T20:49:24.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/a4/5c62acfacd69ff4f5db395100f5cfb9b54e7ac8c69a235e4e939fd13f021/lxml_html_clean-0.4.4.tar.gz", hash = "sha256:58f39a9d632711202ed1d6d0b9b47a904e306c85de5761543b90e3e3f736acfb", size = 23899, upload-time = "2026-02-27T09:35:52.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/4a/63a9540e3ca73709f4200564a737d63a4c8c9c4dd032bab8535f507c190a/lxml_html_clean-0.4.3-py3-none-any.whl", hash = "sha256:63fd7b0b9c3a2e4176611c2ca5d61c4c07ffca2de76c14059a81a2825833731e", size = 14177, upload-time = "2025-10-02T20:49:23.749Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/7ffc1d3005cf7749123bc47cb3ea343cd97b0ac2211bab40f57283577d0e/lxml_html_clean-0.4.4-py3-none-any.whl", hash = "sha256:ce2ef506614ecb85ee1c5fe0a2aa45b06a19514ec7949e9c8f34f06925cfabcb", size = 14565, upload-time = "2026-02-27T09:35:51.86Z" }, ] [[package]] From 897e181d677457cb8ca804cdd95c5b874f5cc6a9 Mon Sep 17 00:00:00 2001 From: Raunak Bhagat Date: Mon, 2 Mar 2026 12:04:35 -0800 Subject: [PATCH 018/267] refactor(opal): update `ModalHeader` to use `Content` (#8885) --- web/src/refresh-components/Modal.tsx | 100 +++++++++--------- web/tests/e2e/chat/file_preview_modal.spec.ts | 14 ++- 2 files changed, 62 insertions(+), 52 deletions(-) diff --git a/web/src/refresh-components/Modal.tsx b/web/src/refresh-components/Modal.tsx index 2f7f15b2000..30244982591 100644 --- a/web/src/refresh-components/Modal.tsx +++ b/web/src/refresh-components/Modal.tsx @@ -3,9 +3,9 @@ import React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { cn } from "@/lib/utils"; -import type { IconProps } from "@opal/types"; -import Text from "@/refresh-components/texts/Text"; +import type { IconFunctionComponent } from "@opal/types"; import { Button } from "@opal/components"; +import { Content } from "@opal/layouts"; import { SvgX } from "@opal/icons"; import { WithoutStyles } from "@/types"; import { Section, SectionProps } from "@/layouts/general-layouts"; @@ -407,17 +407,29 @@ ModalContent.displayName = DialogPrimitive.Content.displayName; * ``` */ interface ModalHeaderProps extends WithoutStyles { - icon?: React.FunctionComponent; + icon?: IconFunctionComponent; + moreIcon1?: IconFunctionComponent; + moreIcon2?: IconFunctionComponent; title: string; description?: string; onClose?: () => void; } const ModalHeader = React.forwardRef( - ({ icon: Icon, title, description, onClose, children, ...props }, ref) => { + ( + { + icon, + moreIcon1, + moreIcon2, + title, + description, + onClose, + children, + ...props + }, + ref + ) => { const { closeButtonRef, setHasDescription } = useModalContext(); - // useLayoutEffect ensures aria-describedby is set before paint, - // so screen readers announce the description when the dialog opens React.useLayoutEffect(() => { setHasDescription(!!description); }, [description, setHasDescription]); @@ -440,52 +452,40 @@ const ModalHeader = React.forwardRef( return (
-
- {Icon && ( -
- {/* - The `h-[1.5rem]` and `w-[1.5rem]` were added as backups here. - However, prop-resolution technically resolves to choosing classNames over size props, so technically the `size={24}` is the backup. - We specify both to be safe. - - # Note - 1.5rem === 24px - */} - - {closeButton} -
- )} - -
-
- - {title} - - {description && ( - - +
+
+ {/* Close button is absolutely positioned because: + 1. Figma mocks place it overlapping the top-right of the content area + 2. Using ContentAction with rightChildren causes the description + to wrap to the second line early due to the button reserving space */} +
{closeButton}
+ +
+ + {description && ( + {description} - - - )} -
- {!Icon && closeButton} -
+ + )} +
+ +
+ {children} ); diff --git a/web/tests/e2e/chat/file_preview_modal.spec.ts b/web/tests/e2e/chat/file_preview_modal.spec.ts index 69eb88aab27..9db6dce2bd3 100644 --- a/web/tests/e2e/chat/file_preview_modal.spec.ts +++ b/web/tests/e2e/chat/file_preview_modal.spec.ts @@ -181,8 +181,18 @@ test.describe("File preview modal from chat file links", () => { await expect(modal.getByText("app.py")).toBeVisible(); // Verify the header description shows language and line info - await expect(modal.getByText(/python/i)).toBeVisible(); - await expect(modal.getByText("2 lines", { exact: true })).toBeVisible(); + await expect( + modal + .locator("div") + .filter({ hasText: /python/i }) + .first() + ).toBeVisible(); + await expect( + modal + .locator("div") + .filter({ hasText: /2 lines/ }) + .first() + ).toBeVisible(); // Verify the code content is rendered await expect(modal.getByText("Hello, world!")).toBeVisible(); From 580d41dc23427d8a5d424dbbe6384fdcd30e3c4c Mon Sep 17 00:00:00 2001 From: Jamison Lahman Date: Mon, 2 Mar 2026 12:04:54 -0800 Subject: [PATCH 019/267] chore(mypy): run from repro root in CI (#6995) --- .github/workflows/pr-python-checks.yml | 21 ++++++------ backend/pyproject.toml | 34 -------------------- pyproject.toml | 34 ++++++++++++++++++++ tools/ods/internal/openapi/openapi_schema.py | 2 +- 4 files changed, 45 insertions(+), 46 deletions(-) diff --git a/.github/workflows/pr-python-checks.yml b/.github/workflows/pr-python-checks.yml index a9f95d985af..b1289f51b2d 100644 --- a/.github/workflows/pr-python-checks.yml +++ b/.github/workflows/pr-python-checks.yml @@ -8,7 +8,7 @@ on: pull_request: branches: - main - - 'release/**' + - "release/**" push: tags: - "v*.*.*" @@ -21,7 +21,13 @@ jobs: # See https://runs-on.com/runners/linux/ # Note: Mypy seems quite optimized for x64 compared to arm64. # Similarly, mypy is single-threaded and incremental, so 2cpu is sufficient. - runs-on: [runs-on, runner=2cpu-linux-x64, "run-id=${{ github.run_id }}-mypy-check", "extras=s3-cache"] + runs-on: + [ + runs-on, + runner=2cpu-linux-x64, + "run-id=${{ github.run_id }}-mypy-check", + "extras=s3-cache", + ] timeout-minutes: 45 steps: @@ -52,21 +58,14 @@ jobs: if: ${{ vars.DISABLE_MYPY_CACHE != 'true' }} uses: runs-on/cache@50350ad4242587b6c8c2baa2e740b1bc11285ff4 # ratchet:runs-on/cache@v4 with: - path: backend/.mypy_cache - key: mypy-${{ runner.os }}-${{ github.base_ref || github.event.merge_group.base_ref || 'main' }}-${{ hashFiles('**/*.py', '**/*.pyi', 'backend/pyproject.toml') }} + path: .mypy_cache + key: mypy-${{ runner.os }}-${{ github.base_ref || github.event.merge_group.base_ref || 'main' }}-${{ hashFiles('**/*.py', '**/*.pyi', 'pyproject.toml') }} restore-keys: | mypy-${{ runner.os }}-${{ github.base_ref || github.event.merge_group.base_ref || 'main' }}- mypy-${{ runner.os }}- - name: Run MyPy - working-directory: ./backend env: MYPY_FORCE_COLOR: 1 TERM: xterm-256color run: mypy . - - - name: Run MyPy (tools/) - env: - MYPY_FORCE_COLOR: 1 - TERM: xterm-256color - run: mypy tools/ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 56069c8f064..8a1b00b3e61 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,37 +8,3 @@ dependencies = [ [tool.uv.sources] onyx = { workspace = true } - -[tool.mypy] -plugins = "sqlalchemy.ext.mypy.plugin" -mypy_path = "backend" -explicit_package_bases = true -disallow_untyped_defs = true -warn_unused_ignores = true -enable_error_code = ["possibly-undefined"] -strict_equality = true -# Patterns match paths whether mypy is run from backend/ (CI) or repo root (e.g. VS Code extension with target ./backend) -exclude = [ - "(?:^|/)generated/", - "(?:^|/)\\.venv/", - "(?:^|/)onyx/server/features/build/sandbox/kubernetes/docker/skills/", - "(?:^|/)onyx/server/features/build/sandbox/kubernetes/docker/templates/", -] - -[[tool.mypy.overrides]] -module = "alembic.versions.*" -disable_error_code = ["var-annotated"] - -[[tool.mypy.overrides]] -module = "alembic_tenants.versions.*" -disable_error_code = ["var-annotated"] - -[[tool.mypy.overrides]] -module = "generated.*" -follow_imports = "silent" -ignore_errors = true - -[[tool.mypy.overrides]] -module = "transformers.*" -follow_imports = "skip" -ignore_errors = true diff --git a/pyproject.toml b/pyproject.toml index 9338f290c81..203a85c9eb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -191,6 +191,40 @@ model_server = [ "sentry-sdk[fastapi,celery,starlette]==2.14.0", ] +[tool.mypy] +plugins = "sqlalchemy.ext.mypy.plugin" +mypy_path = "backend" +explicit_package_bases = true +disallow_untyped_defs = true +warn_unused_ignores = true +enable_error_code = ["possibly-undefined"] +strict_equality = true +# Patterns match paths whether mypy is run from backend/ (CI) or repo root (e.g. VS Code extension with target ./backend) +exclude = [ + "(?:^|/)generated/", + "(?:^|/)\\.venv/", + "(?:^|/)onyx/server/features/build/sandbox/kubernetes/docker/skills/", + "(?:^|/)onyx/server/features/build/sandbox/kubernetes/docker/templates/", +] + +[[tool.mypy.overrides]] +module = "alembic.versions.*" +disable_error_code = ["var-annotated"] + +[[tool.mypy.overrides]] +module = "alembic_tenants.versions.*" +disable_error_code = ["var-annotated"] + +[[tool.mypy.overrides]] +module = "generated.*" +follow_imports = "silent" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "transformers.*" +follow_imports = "skip" +ignore_errors = true + [tool.uv.workspace] members = ["backend", "tools/ods"] diff --git a/tools/ods/internal/openapi/openapi_schema.py b/tools/ods/internal/openapi/openapi_schema.py index ff0775343b3..a01f0d39513 100644 --- a/tools/ods/internal/openapi/openapi_schema.py +++ b/tools/ods/internal/openapi/openapi_schema.py @@ -33,7 +33,7 @@ def generate_schema(output_path: str, tagged_for_docs: str | None = None) -> boo try: # Import here to avoid requiring backend dependencies when not generating schema from fastapi.openapi.utils import get_openapi - from onyx.main import app as app_fn # type: ignore + from onyx.main import app as app_fn except ImportError as e: print(f"Error: Failed to import required modules: {e}", file=sys.stderr) print( From 4f3c54f282c29e403d474daabbf3c9fde26ee4fc Mon Sep 17 00:00:00 2001 From: Jamison Lahman Date: Mon, 2 Mar 2026 12:10:21 -0800 Subject: [PATCH 020/267] chore(playwright): hide actions toolbar buttons in screenshots (#8914) --- web/src/sections/input/AppInputBar.tsx | 1 + web/tests/e2e/utils/visualRegression.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/sections/input/AppInputBar.tsx b/web/src/sections/input/AppInputBar.tsx index 831aac93544..3ab7e96699b 100644 --- a/web/src/sections/input/AppInputBar.tsx +++ b/web/src/sections/input/AppInputBar.tsx @@ -705,6 +705,7 @@ const AppInputBar = React.memo( {/* Controls that load in when data is ready */}
Date: Mon, 2 Mar 2026 12:50:13 -0800 Subject: [PATCH 021/267] refactor: add abstraction for cache backend (#8870) --- backend/onyx/cache/factory.py | 45 ++++++++++++++ backend/onyx/cache/interface.py | 89 ++++++++++++++++++++++++++++ backend/onyx/cache/redis_backend.py | 92 +++++++++++++++++++++++++++++ backend/onyx/configs/app_configs.py | 7 +++ backend/onyx/main.py | 17 ++++++ 5 files changed, 250 insertions(+) create mode 100644 backend/onyx/cache/factory.py create mode 100644 backend/onyx/cache/interface.py create mode 100644 backend/onyx/cache/redis_backend.py diff --git a/backend/onyx/cache/factory.py b/backend/onyx/cache/factory.py new file mode 100644 index 00000000000..02a231ac719 --- /dev/null +++ b/backend/onyx/cache/factory.py @@ -0,0 +1,45 @@ +from collections.abc import Callable + +from onyx.cache.interface import CacheBackend +from onyx.cache.interface import CacheBackendType +from onyx.configs.app_configs import CACHE_BACKEND + + +def _build_redis_backend(tenant_id: str) -> CacheBackend: + from onyx.cache.redis_backend import RedisCacheBackend + from onyx.redis.redis_pool import redis_pool + + return RedisCacheBackend(redis_pool.get_client(tenant_id)) + + +_BACKEND_BUILDERS: dict[CacheBackendType, Callable[[str], CacheBackend]] = { + CacheBackendType.REDIS: _build_redis_backend, + # CacheBackendType.POSTGRES will be added in a follow-up PR. +} + + +def get_cache_backend(*, tenant_id: str | None = None) -> CacheBackend: + """Return a tenant-aware ``CacheBackend``. + + If *tenant_id* is ``None``, the current tenant is read from the + thread-local context variable (same behaviour as ``get_redis_client``). + """ + if tenant_id is None: + from shared_configs.contextvars import get_current_tenant_id + + tenant_id = get_current_tenant_id() + + builder = _BACKEND_BUILDERS.get(CACHE_BACKEND) + if builder is None: + raise ValueError( + f"Unsupported CACHE_BACKEND={CACHE_BACKEND!r}. " + f"Supported values: {[t.value for t in CacheBackendType]}" + ) + return builder(tenant_id) + + +def get_shared_cache_backend() -> CacheBackend: + """Return a ``CacheBackend`` in the shared (cross-tenant) namespace.""" + from shared_configs.configs import DEFAULT_REDIS_PREFIX + + return get_cache_backend(tenant_id=DEFAULT_REDIS_PREFIX) diff --git a/backend/onyx/cache/interface.py b/backend/onyx/cache/interface.py new file mode 100644 index 00000000000..3f19781d2b7 --- /dev/null +++ b/backend/onyx/cache/interface.py @@ -0,0 +1,89 @@ +import abc +from enum import Enum + + +class CacheBackendType(str, Enum): + REDIS = "redis" + POSTGRES = "postgres" + + +class CacheLock(abc.ABC): + """Abstract distributed lock returned by CacheBackend.lock().""" + + @abc.abstractmethod + def acquire( + self, + blocking: bool = True, + blocking_timeout: float | None = None, + ) -> bool: + raise NotImplementedError + + @abc.abstractmethod + def release(self) -> None: + raise NotImplementedError + + @abc.abstractmethod + def owned(self) -> bool: + raise NotImplementedError + + +class CacheBackend(abc.ABC): + """Thin abstraction over a key-value cache with TTL, locks, and blocking lists. + + Covers the subset of Redis operations used outside of Celery. When + CACHE_BACKEND=postgres, a PostgreSQL-backed implementation is used instead. + """ + + # -- basic key/value --------------------------------------------------- + + @abc.abstractmethod + def get(self, key: str) -> bytes | None: + raise NotImplementedError + + @abc.abstractmethod + def set( + self, + key: str, + value: str | bytes | int | float, + ex: int | None = None, + ) -> None: + raise NotImplementedError + + @abc.abstractmethod + def delete(self, key: str) -> None: + raise NotImplementedError + + @abc.abstractmethod + def exists(self, key: str) -> bool: + raise NotImplementedError + + # -- TTL --------------------------------------------------------------- + + @abc.abstractmethod + def expire(self, key: str, seconds: int) -> None: + raise NotImplementedError + + @abc.abstractmethod + def ttl(self, key: str) -> int: + """Return remaining TTL in seconds. -1 if no expiry, -2 if key missing.""" + raise NotImplementedError + + # -- distributed lock -------------------------------------------------- + + @abc.abstractmethod + def lock(self, name: str, timeout: float | None = None) -> CacheLock: + raise NotImplementedError + + # -- blocking list (used by MCP OAuth BLPOP pattern) ------------------- + + @abc.abstractmethod + def rpush(self, key: str, value: str | bytes) -> None: + raise NotImplementedError + + @abc.abstractmethod + def blpop(self, keys: list[str], timeout: int = 0) -> tuple[bytes, bytes] | None: + """Block until a value is available on one of *keys*, or *timeout* expires. + + Returns ``(key, value)`` or ``None`` on timeout. + """ + raise NotImplementedError diff --git a/backend/onyx/cache/redis_backend.py b/backend/onyx/cache/redis_backend.py new file mode 100644 index 00000000000..6730307aee6 --- /dev/null +++ b/backend/onyx/cache/redis_backend.py @@ -0,0 +1,92 @@ +from typing import cast + +from redis.client import Redis +from redis.lock import Lock as RedisLock + +from onyx.cache.interface import CacheBackend +from onyx.cache.interface import CacheLock + + +class RedisCacheLock(CacheLock): + """Wraps ``redis.lock.Lock`` behind the ``CacheLock`` interface.""" + + def __init__(self, lock: RedisLock) -> None: + self._lock = lock + + def acquire( + self, + blocking: bool = True, + blocking_timeout: float | None = None, + ) -> bool: + return bool( + self._lock.acquire( + blocking=blocking, + blocking_timeout=blocking_timeout, + ) + ) + + def release(self) -> None: + self._lock.release() + + def owned(self) -> bool: + return bool(self._lock.owned()) + + +class RedisCacheBackend(CacheBackend): + """``CacheBackend`` implementation that delegates to a ``redis.Redis`` client. + + This is a thin pass-through — every method maps 1-to-1 to the underlying + Redis command. ``TenantRedis`` key-prefixing is handled by the client + itself (provided by ``get_redis_client``). + """ + + def __init__(self, redis_client: Redis) -> None: + self._r = redis_client + + # -- basic key/value --------------------------------------------------- + + def get(self, key: str) -> bytes | None: + val = self._r.get(key) + if val is None: + return None + if isinstance(val, bytes): + return val + return str(val).encode() + + def set( + self, + key: str, + value: str | bytes | int | float, + ex: int | None = None, + ) -> None: + self._r.set(key, value, ex=ex) + + def delete(self, key: str) -> None: + self._r.delete(key) + + def exists(self, key: str) -> bool: + return bool(self._r.exists(key)) + + # -- TTL --------------------------------------------------------------- + + def expire(self, key: str, seconds: int) -> None: + self._r.expire(key, seconds) + + def ttl(self, key: str) -> int: + return cast(int, self._r.ttl(key)) + + # -- distributed lock -------------------------------------------------- + + def lock(self, name: str, timeout: float | None = None) -> CacheLock: + return RedisCacheLock(self._r.lock(name, timeout=timeout)) + + # -- blocking list (MCP OAuth BLPOP pattern) --------------------------- + + def rpush(self, key: str, value: str | bytes) -> None: + self._r.rpush(key, value) + + def blpop(self, keys: list[str], timeout: int = 0) -> tuple[bytes, bytes] | None: + result = cast(list[bytes] | None, self._r.blpop(keys, timeout=timeout)) + if result is None: + return None + return (result[0], result[1]) diff --git a/backend/onyx/configs/app_configs.py b/backend/onyx/configs/app_configs.py index 7f54659192c..67e89b0eff0 100644 --- a/backend/onyx/configs/app_configs.py +++ b/backend/onyx/configs/app_configs.py @@ -6,6 +6,7 @@ from typing import cast from onyx.auth.schemas import AuthBackend +from onyx.cache.interface import CacheBackendType from onyx.configs.constants import AuthType from onyx.configs.constants import QueryHistoryType from onyx.file_processing.enums import HtmlBasedConnectorTransformLinksStrategy @@ -54,6 +55,12 @@ # are disabled but core chat, tools, user file uploads, and Projects still work. DISABLE_VECTOR_DB = os.environ.get("DISABLE_VECTOR_DB", "").lower() == "true" +# Which backend to use for caching, locks, and ephemeral state. +# "redis" (default) or "postgres" (only valid when DISABLE_VECTOR_DB=true). +CACHE_BACKEND = CacheBackendType( + os.environ.get("CACHE_BACKEND", CacheBackendType.REDIS) +) + # Maximum token count for a single uploaded file. Files exceeding this are rejected. # Defaults to 100k tokens (or 10M when vector DB is disabled). _DEFAULT_FILE_TOKEN_LIMIT = 10_000_000 if DISABLE_VECTOR_DB else 100_000 diff --git a/backend/onyx/main.py b/backend/onyx/main.py index 2b692abb0d9..078e11652b5 100644 --- a/backend/onyx/main.py +++ b/backend/onyx/main.py @@ -32,11 +32,13 @@ from onyx.auth.users import auth_backend from onyx.auth.users import create_onyx_oauth_router from onyx.auth.users import fastapi_users +from onyx.cache.interface import CacheBackendType from onyx.configs.app_configs import APP_API_PREFIX from onyx.configs.app_configs import APP_HOST from onyx.configs.app_configs import APP_PORT from onyx.configs.app_configs import AUTH_RATE_LIMITING_ENABLED from onyx.configs.app_configs import AUTH_TYPE +from onyx.configs.app_configs import CACHE_BACKEND from onyx.configs.app_configs import DISABLE_VECTOR_DB from onyx.configs.app_configs import LOG_ENDPOINT_LATENCY from onyx.configs.app_configs import OAUTH_CLIENT_ID @@ -255,6 +257,20 @@ def include_auth_router_with_prefix( ) +def validate_cache_backend_settings() -> None: + """Validate that CACHE_BACKEND=postgres is only used with DISABLE_VECTOR_DB. + + The Postgres cache backend eliminates the Redis dependency, but only works + when Celery is not running (which requires DISABLE_VECTOR_DB=true). + """ + if CACHE_BACKEND == CacheBackendType.POSTGRES and not DISABLE_VECTOR_DB: + raise RuntimeError( + "CACHE_BACKEND=postgres requires DISABLE_VECTOR_DB=true. " + "The Postgres cache backend is only supported in no-vector-DB " + "deployments where Celery is replaced by the in-process task runner." + ) + + def validate_no_vector_db_settings() -> None: """Validate that DISABLE_VECTOR_DB is not combined with incompatible settings. @@ -286,6 +302,7 @@ def validate_no_vector_db_settings() -> None: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001 validate_no_vector_db_settings() + validate_cache_backend_settings() # Set recursion limit if SYSTEM_RECURSION_LIMIT is not None: From 13d60dcb0e98393cf54651ab8b13e8a457964361 Mon Sep 17 00:00:00 2001 From: Nikolas Garza <90273783+nmgarza5@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:51:59 -0800 Subject: [PATCH 022/267] test(scim): add integration tests for SCIM group CRUD (#8830) --- .../tests/scim/test_scim_groups.py | 552 ++++++++++++++++++ 1 file changed, 552 insertions(+) create mode 100644 backend/tests/integration/tests/scim/test_scim_groups.py diff --git a/backend/tests/integration/tests/scim/test_scim_groups.py b/backend/tests/integration/tests/scim/test_scim_groups.py new file mode 100644 index 00000000000..03b8b8e576d --- /dev/null +++ b/backend/tests/integration/tests/scim/test_scim_groups.py @@ -0,0 +1,552 @@ +"""Integration tests for SCIM group provisioning endpoints. + +Covers the full group lifecycle as driven by an IdP (Okta / Azure AD): +1. Create a group via POST /Groups +2. Retrieve a group via GET /Groups/{id} +3. List, filter, and paginate groups via GET /Groups +4. Replace a group via PUT /Groups/{id} +5. Patch a group (add/remove members, rename) via PATCH /Groups/{id} +6. Delete a group via DELETE /Groups/{id} +7. Error cases: duplicate name, not-found, invalid member IDs + +All tests are parameterized across IdP request styles (Okta sends lowercase +PATCH ops; Entra sends capitalized ops like ``"Replace"``). The server +normalizes both — these tests verify that. + +Auth tests live in test_scim_tokens.py. +User lifecycle tests live in test_scim_users.py. +""" + +import pytest +import requests + +from onyx.auth.schemas import UserRole +from tests.integration.common_utils.managers.scim_client import ScimClient +from tests.integration.common_utils.managers.scim_token import ScimTokenManager + + +SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" +SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" +SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + + +@pytest.fixture(scope="module", params=["okta", "entra"]) +def idp_style(request: pytest.FixtureRequest) -> str: + """Parameterized IdP style — runs every test with both Okta and Entra request formats.""" + return request.param + + +@pytest.fixture(scope="module") +def scim_token(idp_style: str) -> str: + """Create a single SCIM token shared across all tests in this module. + + Creating a new token revokes the previous one, so we create exactly once + per IdP-style run and reuse. Uses UserManager directly to avoid + fixture-scope conflicts with the function-scoped admin_user fixture. + """ + from tests.integration.common_utils.constants import ADMIN_USER_NAME + from tests.integration.common_utils.constants import GENERAL_HEADERS + from tests.integration.common_utils.managers.user import build_email + from tests.integration.common_utils.managers.user import DEFAULT_PASSWORD + from tests.integration.common_utils.managers.user import UserManager + from tests.integration.common_utils.test_models import DATestUser + + try: + admin = UserManager.create(name=ADMIN_USER_NAME) + except Exception: + admin = UserManager.login_as_user( + DATestUser( + id="", + email=build_email(ADMIN_USER_NAME), + password=DEFAULT_PASSWORD, + headers=GENERAL_HEADERS, + role=UserRole.ADMIN, + is_active=True, + ) + ) + + token = ScimTokenManager.create( + name=f"scim-group-tests-{idp_style}", + user_performing_action=admin, + ).raw_token + assert token is not None + return token + + +def _make_group_resource( + display_name: str, + external_id: str | None = None, + members: list[dict] | None = None, +) -> dict: + """Build a minimal SCIM GroupResource payload.""" + resource: dict = { + "schemas": [SCIM_GROUP_SCHEMA], + "displayName": display_name, + } + if external_id is not None: + resource["externalId"] = external_id + if members is not None: + resource["members"] = members + return resource + + +def _make_user_resource(email: str, external_id: str) -> dict: + """Build a minimal SCIM UserResource payload for member creation.""" + return { + "schemas": [SCIM_USER_SCHEMA], + "userName": email, + "externalId": external_id, + "name": {"givenName": "Test", "familyName": "User"}, + "active": True, + } + + +def _make_patch_request(operations: list[dict], idp_style: str = "okta") -> dict: + """Build a SCIM PatchOp payload, applying IdP-specific operation casing. + + Entra sends capitalized operations (e.g. ``"Replace"`` instead of + ``"replace"``). The server's ``normalize_operation`` validator lowercases + them — these tests verify that both casings are accepted. + """ + cased_operations = [] + for operation in operations: + cased = dict(operation) + if idp_style == "entra": + cased["op"] = operation["op"].capitalize() + cased_operations.append(cased) + return { + "schemas": [SCIM_PATCH_SCHEMA], + "Operations": cased_operations, + } + + +def _create_scim_user(token: str, email: str, external_id: str) -> requests.Response: + return ScimClient.post( + "/Users", token, json=_make_user_resource(email, external_id) + ) + + +def _create_scim_group( + token: str, + display_name: str, + external_id: str | None = None, + members: list[dict] | None = None, +) -> requests.Response: + return ScimClient.post( + "/Groups", + token, + json=_make_group_resource(display_name, external_id, members), + ) + + +# ------------------------------------------------------------------ +# Lifecycle: create → get → list → replace → patch → delete +# ------------------------------------------------------------------ + + +def test_create_group(scim_token: str, idp_style: str) -> None: + """POST /Groups creates a group and returns 201.""" + name = f"Engineering {idp_style}" + resp = _create_scim_group(scim_token, name, external_id=f"ext-eng-{idp_style}") + assert resp.status_code == 201 + + body = resp.json() + assert body["displayName"] == name + assert body["externalId"] == f"ext-eng-{idp_style}" + assert body["id"] # integer ID assigned by server + assert body["meta"]["resourceType"] == "Group" + + +def test_create_group_with_members(scim_token: str, idp_style: str) -> None: + """POST /Groups with members populates the member list.""" + user = _create_scim_user( + scim_token, f"grp_member1_{idp_style}@example.com", f"ext-gm-{idp_style}" + ).json() + + resp = _create_scim_group( + scim_token, + f"Backend Team {idp_style}", + external_id=f"ext-backend-{idp_style}", + members=[{"value": user["id"]}], + ) + assert resp.status_code == 201 + + body = resp.json() + member_ids = [m["value"] for m in body["members"]] + assert user["id"] in member_ids + + +def test_get_group(scim_token: str, idp_style: str) -> None: + """GET /Groups/{id} returns the group resource including members.""" + user = _create_scim_user( + scim_token, f"grp_get_m_{idp_style}@example.com", f"ext-ggm-{idp_style}" + ).json() + created = _create_scim_group( + scim_token, + f"Frontend Team {idp_style}", + external_id=f"ext-fe-{idp_style}", + members=[{"value": user["id"]}], + ).json() + + resp = ScimClient.get(f"/Groups/{created['id']}", scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["id"] == created["id"] + assert body["displayName"] == f"Frontend Team {idp_style}" + assert body["externalId"] == f"ext-fe-{idp_style}" + member_ids = [m["value"] for m in body["members"]] + assert user["id"] in member_ids + + +def test_list_groups(scim_token: str, idp_style: str) -> None: + """GET /Groups returns a ListResponse containing provisioned groups.""" + name = f"DevOps Team {idp_style}" + _create_scim_group(scim_token, name, external_id=f"ext-devops-{idp_style}") + + resp = ScimClient.get("/Groups", scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["totalResults"] >= 1 + names = [r["displayName"] for r in body["Resources"]] + assert name in names + + +def test_list_groups_pagination(scim_token: str, idp_style: str) -> None: + """GET /Groups with startIndex and count returns correct pagination.""" + _create_scim_group( + scim_token, f"Page Group A {idp_style}", external_id=f"ext-page-a-{idp_style}" + ) + _create_scim_group( + scim_token, f"Page Group B {idp_style}", external_id=f"ext-page-b-{idp_style}" + ) + + resp = ScimClient.get("/Groups?startIndex=1&count=1", scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["startIndex"] == 1 + assert body["itemsPerPage"] == 1 + assert body["totalResults"] >= 2 + assert len(body["Resources"]) == 1 + + +def test_filter_groups_by_display_name(scim_token: str, idp_style: str) -> None: + """GET /Groups?filter=displayName eq '...' returns only matching groups.""" + name = f"Unique QA Team {idp_style}" + _create_scim_group(scim_token, name, external_id=f"ext-qa-filter-{idp_style}") + + resp = ScimClient.get(f'/Groups?filter=displayName eq "{name}"', scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["totalResults"] == 1 + assert body["Resources"][0]["displayName"] == name + + +def test_filter_groups_by_external_id(scim_token: str, idp_style: str) -> None: + """GET /Groups?filter=externalId eq '...' returns the matching group.""" + ext_id = f"ext-unique-group-id-{idp_style}" + _create_scim_group( + scim_token, f"ExtId Filter Group {idp_style}", external_id=ext_id + ) + + resp = ScimClient.get(f'/Groups?filter=externalId eq "{ext_id}"', scim_token) + assert resp.status_code == 200 + + body = resp.json() + assert body["totalResults"] == 1 + assert body["Resources"][0]["externalId"] == ext_id + + +def test_replace_group(scim_token: str, idp_style: str) -> None: + """PUT /Groups/{id} replaces the group resource.""" + created = _create_scim_group( + scim_token, + f"Original Name {idp_style}", + external_id=f"ext-replace-g-{idp_style}", + ).json() + + user = _create_scim_user( + scim_token, f"grp_replace_m_{idp_style}@example.com", f"ext-grm-{idp_style}" + ).json() + + updated_resource = _make_group_resource( + display_name=f"Renamed Group {idp_style}", + external_id=f"ext-replace-g-{idp_style}", + members=[{"value": user["id"]}], + ) + resp = ScimClient.put(f"/Groups/{created['id']}", scim_token, json=updated_resource) + assert resp.status_code == 200 + + body = resp.json() + assert body["displayName"] == f"Renamed Group {idp_style}" + member_ids = [m["value"] for m in body["members"]] + assert user["id"] in member_ids + + +def test_replace_group_clears_members(scim_token: str, idp_style: str) -> None: + """PUT /Groups/{id} with empty members removes all memberships.""" + user = _create_scim_user( + scim_token, f"grp_clear_m_{idp_style}@example.com", f"ext-gcm-{idp_style}" + ).json() + created = _create_scim_group( + scim_token, + f"Clear Members Group {idp_style}", + external_id=f"ext-clear-g-{idp_style}", + members=[{"value": user["id"]}], + ).json() + + assert len(created["members"]) == 1 + + resp = ScimClient.put( + f"/Groups/{created['id']}", + scim_token, + json=_make_group_resource( + f"Clear Members Group {idp_style}", f"ext-clear-g-{idp_style}", members=[] + ), + ) + assert resp.status_code == 200 + assert resp.json()["members"] == [] + + +def test_patch_add_member(scim_token: str, idp_style: str) -> None: + """PATCH /Groups/{id} with op=add adds a member.""" + created = _create_scim_group( + scim_token, + f"Patch Add Group {idp_style}", + external_id=f"ext-patch-add-{idp_style}", + ).json() + user = _create_scim_user( + scim_token, f"grp_patch_add_{idp_style}@example.com", f"ext-gpa-{idp_style}" + ).json() + + resp = ScimClient.patch( + f"/Groups/{created['id']}", + scim_token, + json=_make_patch_request( + [{"op": "add", "path": "members", "value": [{"value": user["id"]}]}], + idp_style, + ), + ) + assert resp.status_code == 200 + + member_ids = [m["value"] for m in resp.json()["members"]] + assert user["id"] in member_ids + + +def test_patch_remove_member(scim_token: str, idp_style: str) -> None: + """PATCH /Groups/{id} with op=remove removes a specific member.""" + user = _create_scim_user( + scim_token, f"grp_patch_rm_{idp_style}@example.com", f"ext-gpr-{idp_style}" + ).json() + created = _create_scim_group( + scim_token, + f"Patch Remove Group {idp_style}", + external_id=f"ext-patch-rm-{idp_style}", + members=[{"value": user["id"]}], + ).json() + assert len(created["members"]) == 1 + + resp = ScimClient.patch( + f"/Groups/{created['id']}", + scim_token, + json=_make_patch_request( + [ + { + "op": "remove", + "path": f'members[value eq "{user["id"]}"]', + } + ], + idp_style, + ), + ) + assert resp.status_code == 200 + assert resp.json()["members"] == [] + + +def test_patch_replace_members(scim_token: str, idp_style: str) -> None: + """PATCH /Groups/{id} with op=replace on members swaps the entire list.""" + user_a = _create_scim_user( + scim_token, f"grp_repl_a_{idp_style}@example.com", f"ext-gra-{idp_style}" + ).json() + user_b = _create_scim_user( + scim_token, f"grp_repl_b_{idp_style}@example.com", f"ext-grb-{idp_style}" + ).json() + created = _create_scim_group( + scim_token, + f"Patch Replace Group {idp_style}", + external_id=f"ext-patch-repl-{idp_style}", + members=[{"value": user_a["id"]}], + ).json() + + # Replace member list: swap A for B + resp = ScimClient.patch( + f"/Groups/{created['id']}", + scim_token, + json=_make_patch_request( + [ + { + "op": "replace", + "path": "members", + "value": [{"value": user_b["id"]}], + } + ], + idp_style, + ), + ) + assert resp.status_code == 200 + + member_ids = [m["value"] for m in resp.json()["members"]] + assert user_b["id"] in member_ids + assert user_a["id"] not in member_ids + + +def test_patch_rename_group(scim_token: str, idp_style: str) -> None: + """PATCH /Groups/{id} with op=replace on displayName renames the group.""" + created = _create_scim_group( + scim_token, + f"Old Group Name {idp_style}", + external_id=f"ext-rename-g-{idp_style}", + ).json() + + new_name = f"New Group Name {idp_style}" + resp = ScimClient.patch( + f"/Groups/{created['id']}", + scim_token, + json=_make_patch_request( + [{"op": "replace", "path": "displayName", "value": new_name}], + idp_style, + ), + ) + assert resp.status_code == 200 + assert resp.json()["displayName"] == new_name + + # Confirm via GET + get_resp = ScimClient.get(f"/Groups/{created['id']}", scim_token) + assert get_resp.json()["displayName"] == new_name + + +def test_delete_group(scim_token: str, idp_style: str) -> None: + """DELETE /Groups/{id} removes the group.""" + created = _create_scim_group( + scim_token, + f"Delete Me Group {idp_style}", + external_id=f"ext-del-g-{idp_style}", + ).json() + + resp = ScimClient.delete(f"/Groups/{created['id']}", scim_token) + assert resp.status_code == 204 + + # Second DELETE returns 404 (group hard-deleted) + resp2 = ScimClient.delete(f"/Groups/{created['id']}", scim_token) + assert resp2.status_code == 404 + + +def test_delete_group_preserves_members(scim_token: str, idp_style: str) -> None: + """DELETE /Groups/{id} removes memberships but does not deactivate users.""" + user = _create_scim_user( + scim_token, f"grp_del_member_{idp_style}@example.com", f"ext-gdm-{idp_style}" + ).json() + created = _create_scim_group( + scim_token, + f"Delete With Members {idp_style}", + external_id=f"ext-del-wm-{idp_style}", + members=[{"value": user["id"]}], + ).json() + + resp = ScimClient.delete(f"/Groups/{created['id']}", scim_token) + assert resp.status_code == 204 + + # User should still be active and retrievable + user_resp = ScimClient.get(f"/Users/{user['id']}", scim_token) + assert user_resp.status_code == 200 + assert user_resp.json()["active"] is True + + +# ------------------------------------------------------------------ +# Error cases +# ------------------------------------------------------------------ + + +def test_create_group_duplicate_name(scim_token: str, idp_style: str) -> None: + """POST /Groups with an already-taken displayName returns 409.""" + name = f"Dup Name Group {idp_style}" + resp1 = _create_scim_group(scim_token, name, external_id=f"ext-dup-g1-{idp_style}") + assert resp1.status_code == 201 + + resp2 = _create_scim_group(scim_token, name, external_id=f"ext-dup-g2-{idp_style}") + assert resp2.status_code == 409 + + +def test_get_nonexistent_group(scim_token: str) -> None: + """GET /Groups/{bad-id} returns 404.""" + resp = ScimClient.get("/Groups/999999999", scim_token) + assert resp.status_code == 404 + + +def test_create_group_with_invalid_member(scim_token: str, idp_style: str) -> None: + """POST /Groups with a non-existent member UUID returns 400.""" + resp = _create_scim_group( + scim_token, + f"Bad Member Group {idp_style}", + external_id=f"ext-bad-m-{idp_style}", + members=[{"value": "00000000-0000-0000-0000-000000000000"}], + ) + assert resp.status_code == 400 + assert "not found" in resp.json()["detail"].lower() + + +def test_patch_add_nonexistent_member(scim_token: str, idp_style: str) -> None: + """PATCH /Groups/{id} adding a non-existent member returns 400.""" + created = _create_scim_group( + scim_token, + f"Patch Bad Member Group {idp_style}", + external_id=f"ext-pbm-{idp_style}", + ).json() + + resp = ScimClient.patch( + f"/Groups/{created['id']}", + scim_token, + json=_make_patch_request( + [ + { + "op": "add", + "path": "members", + "value": [{"value": "00000000-0000-0000-0000-000000000000"}], + } + ], + idp_style, + ), + ) + assert resp.status_code == 400 + assert "not found" in resp.json()["detail"].lower() + + +def test_patch_add_duplicate_member_is_idempotent( + scim_token: str, idp_style: str +) -> None: + """PATCH /Groups/{id} adding an already-present member succeeds silently.""" + user = _create_scim_user( + scim_token, f"grp_dup_add_{idp_style}@example.com", f"ext-gda-{idp_style}" + ).json() + created = _create_scim_group( + scim_token, + f"Idempotent Add Group {idp_style}", + external_id=f"ext-idem-g-{idp_style}", + members=[{"value": user["id"]}], + ).json() + assert len(created["members"]) == 1 + + # Add same member again + resp = ScimClient.patch( + f"/Groups/{created['id']}", + scim_token, + json=_make_patch_request( + [{"op": "add", "path": "members", "value": [{"value": user["id"]}]}], + idp_style, + ), + ) + assert resp.status_code == 200 + assert len(resp.json()["members"]) == 1 # still just one member From dfa27c08ef2520f957ebc2699842c0450fd0f38e Mon Sep 17 00:00:00 2001 From: Jamison Lahman Date: Mon, 2 Mar 2026 12:58:46 -0800 Subject: [PATCH 023/267] chore(deployment): optimize layer caching (#8924) --- .github/workflows/deployment.yml | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 01c135d527f..ea8ad0fc290 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -426,8 +426,9 @@ jobs: ONYX_VERSION=${{ github.ref_name }} NODE_OPTIONS=--max-old-space-size=8192 cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:web-cache-amd64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:web-cache-amd64,mode=max @@ -499,8 +500,9 @@ jobs: ONYX_VERSION=${{ github.ref_name }} NODE_OPTIONS=--max-old-space-size=8192 cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:web-cache-arm64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:web-cache-arm64,mode=max @@ -646,8 +648,8 @@ jobs: NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK=true NODE_OPTIONS=--max-old-space-size=8192 cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-amd64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-amd64,mode=max @@ -728,8 +730,8 @@ jobs: NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK=true NODE_OPTIONS=--max-old-space-size=8192 cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-arm64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:cloudweb-cache-arm64,mode=max @@ -862,8 +864,9 @@ jobs: build-args: | ONYX_VERSION=${{ github.ref_name }} cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-cache-amd64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-cache-amd64,mode=max @@ -934,8 +937,9 @@ jobs: build-args: | ONYX_VERSION=${{ github.ref_name }} cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-cache-arm64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-cache-arm64,mode=max @@ -1072,8 +1076,8 @@ jobs: ONYX_VERSION=${{ github.ref_name }} ENABLE_CRAFT=true cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-craft-cache-amd64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-craft-cache-amd64,mode=max @@ -1145,8 +1149,8 @@ jobs: ONYX_VERSION=${{ github.ref_name }} ENABLE_CRAFT=true cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-craft-cache-arm64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:backend-craft-cache-arm64,mode=max @@ -1287,8 +1291,9 @@ jobs: build-args: | ONYX_VERSION=${{ github.ref_name }} cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:model-server-cache-amd64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:model-server-cache-amd64,mode=max @@ -1366,8 +1371,9 @@ jobs: build-args: | ONYX_VERSION=${{ github.ref_name }} cache-from: | - type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:model-server-cache-arm64 + type=registry,ref=${{ env.REGISTRY_IMAGE }}:edge + type=registry,ref=${{ env.REGISTRY_IMAGE }}:latest cache-to: | type=inline type=registry,ref=${{ env.RUNS_ON_ECR_CACHE }}:model-server-cache-arm64,mode=max From 709e3f4ca77c7b7bddfc72192b2459239de7de27 Mon Sep 17 00:00:00 2001 From: Raunak Bhagat Date: Mon, 2 Mar 2026 14:04:36 -0800 Subject: [PATCH 024/267] chore(icons): add SvgCreditCard and SvgNetworkGraph to @opal/icons (#8927) --- web/lib/opal/src/icons/credit-card.tsx | 20 ++++++++++++++++++ web/lib/opal/src/icons/index.ts | 2 ++ web/lib/opal/src/icons/network-graph.tsx | 27 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 web/lib/opal/src/icons/credit-card.tsx create mode 100644 web/lib/opal/src/icons/network-graph.tsx diff --git a/web/lib/opal/src/icons/credit-card.tsx b/web/lib/opal/src/icons/credit-card.tsx new file mode 100644 index 00000000000..5240c8080dc --- /dev/null +++ b/web/lib/opal/src/icons/credit-card.tsx @@ -0,0 +1,20 @@ +import type { IconProps } from "@opal/types"; +const SvgCreditCard = ({ size, ...props }: IconProps) => ( + + + +); +export default SvgCreditCard; diff --git a/web/lib/opal/src/icons/index.ts b/web/lib/opal/src/icons/index.ts index 1a861744f1f..583ca87bca3 100644 --- a/web/lib/opal/src/icons/index.ts +++ b/web/lib/opal/src/icons/index.ts @@ -51,6 +51,7 @@ export { default as SvgCode } from "@opal/icons/code"; export { default as SvgCopy } from "@opal/icons/copy"; export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot"; export { default as SvgCpu } from "@opal/icons/cpu"; +export { default as SvgCreditCard } from "@opal/icons/credit-card"; export { default as SvgDashboard } from "@opal/icons/dashboard"; export { default as SvgDevKit } from "@opal/icons/dev-kit"; export { default as SvgDownload } from "@opal/icons/download"; @@ -106,6 +107,7 @@ export { default as SvgMinusCircle } from "@opal/icons/minus-circle"; export { default as SvgMoon } from "@opal/icons/moon"; export { default as SvgMoreHorizontal } from "@opal/icons/more-horizontal"; export { default as SvgMusicSmall } from "@opal/icons/music-small"; +export { default as SvgNetworkGraph } from "@opal/icons/network-graph"; export { default as SvgNotificationBubble } from "@opal/icons/notification-bubble"; export { default as SvgOllama } from "@opal/icons/ollama"; export { default as SvgOnyxLogo } from "@opal/icons/onyx-logo"; diff --git a/web/lib/opal/src/icons/network-graph.tsx b/web/lib/opal/src/icons/network-graph.tsx new file mode 100644 index 00000000000..226a1d39f70 --- /dev/null +++ b/web/lib/opal/src/icons/network-graph.tsx @@ -0,0 +1,27 @@ +import type { IconProps } from "@opal/types"; +const SvgNetworkGraph = ({ size, ...props }: IconProps) => ( + + + + + + + + + + +); +export default SvgNetworkGraph; From 3c1d29d3cf2320a7c40c41639128c09b2c1bba7d Mon Sep 17 00:00:00 2001 From: acaprau <48705707+acaprau@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:16:05 -0800 Subject: [PATCH 025/267] chore(opensearch): Configure index settings for multitenant cloud (#8921) --- .../opensearch/opensearch_document_index.py | 8 +--- .../onyx/document_index/opensearch/schema.py | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/backend/onyx/document_index/opensearch/opensearch_document_index.py b/backend/onyx/document_index/opensearch/opensearch_document_index.py index 2013f5ede90..7eb0d20cf8b 100644 --- a/backend/onyx/document_index/opensearch/opensearch_document_index.py +++ b/backend/onyx/document_index/opensearch/opensearch_document_index.py @@ -6,7 +6,6 @@ from opensearchpy import NotFoundError from onyx.access.models import DocumentAccess -from onyx.configs.app_configs import USING_AWS_MANAGED_OPENSEARCH from onyx.configs.app_configs import VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT from onyx.configs.chat_configs import NUM_RETURNED_HITS from onyx.configs.chat_configs import TITLE_CONTENT_RATIO @@ -563,12 +562,7 @@ def verify_and_create_index_if_necessary( ) if not self._client.index_exists(): - if USING_AWS_MANAGED_OPENSEARCH: - index_settings = ( - DocumentSchema.get_index_settings_for_aws_managed_opensearch() - ) - else: - index_settings = DocumentSchema.get_index_settings() + index_settings = DocumentSchema.get_index_settings_based_on_environment() self._client.create_index( mappings=expected_mappings, settings=index_settings, diff --git a/backend/onyx/document_index/opensearch/schema.py b/backend/onyx/document_index/opensearch/schema.py index cac59aaad09..a43ec897747 100644 --- a/backend/onyx/document_index/opensearch/schema.py +++ b/backend/onyx/document_index/opensearch/schema.py @@ -12,6 +12,7 @@ from pydantic import SerializerFunctionWrapHandler from onyx.configs.app_configs import OPENSEARCH_TEXT_ANALYZER +from onyx.configs.app_configs import USING_AWS_MANAGED_OPENSEARCH from onyx.document_index.interfaces_new import TenantState from onyx.document_index.opensearch.constants import DEFAULT_MAX_CHUNK_SIZE from onyx.document_index.opensearch.constants import EF_CONSTRUCTION @@ -525,7 +526,7 @@ def get_index_settings() -> dict[str, Any]: } @staticmethod - def get_index_settings_for_aws_managed_opensearch() -> dict[str, Any]: + def get_index_settings_for_aws_managed_opensearch_st_dev() -> dict[str, Any]: """ Settings for AWS-managed OpenSearch. @@ -546,3 +547,41 @@ def get_index_settings_for_aws_managed_opensearch() -> dict[str, Any]: "knn.algo_param.ef_search": EF_SEARCH, } } + + @staticmethod + def get_index_settings_for_aws_managed_opensearch_mt_cloud() -> dict[str, Any]: + """ + Settings for AWS-managed OpenSearch in multi-tenant cloud. + + 324 shards very roughly targets a storage load of ~30Gb per shard, which + according to AWS OpenSearch documentation is within a good target range. + + As documented above we need 2 replicas for a total of 3 copies of the + data because the cluster is configured with 3-AZ awareness. + """ + return { + "index": { + "number_of_shards": 324, + "number_of_replicas": 2, + # Required for vector search. + "knn": True, + "knn.algo_param.ef_search": EF_SEARCH, + } + } + + @staticmethod + def get_index_settings_based_on_environment() -> dict[str, Any]: + """ + Returns the index settings based on the environment. + """ + if USING_AWS_MANAGED_OPENSEARCH: + if MULTI_TENANT: + return ( + DocumentSchema.get_index_settings_for_aws_managed_opensearch_mt_cloud() + ) + else: + return ( + DocumentSchema.get_index_settings_for_aws_managed_opensearch_st_dev() + ) + else: + return DocumentSchema.get_index_settings() From 1f5050f9f6219bb73b6680757db503f81889f5c7 Mon Sep 17 00:00:00 2001 From: Raunak Bhagat Date: Mon, 2 Mar 2026 14:21:47 -0800 Subject: [PATCH 026/267] refactor(admin): update admin-page `HealthCheckBanner` (#8922) --- .../embeddings/pages/EmbeddingFormPage.tsx | 5 +- web/src/app/auth/impersonate/page.tsx | 6 +- web/src/app/auth/join/page.tsx | 2 - web/src/app/auth/login/page.tsx | 5 -- web/src/app/auth/signup/page.tsx | 2 - web/src/app/auth/verify-email/Verify.tsx | 4 -- .../app/auth/waiting-on-verification/page.tsx | 5 +- web/src/app/layout.tsx | 6 +- web/src/components/admin/Title.tsx | 4 -- web/src/components/health/refreshUtils.ts | 64 ------------------- web/src/hooks/useCurrentUser.ts | 42 ++++++++++++ web/src/lib/user.ts | 41 ++++++++++++ web/src/refresh-pages/AppPage.tsx | 10 +-- .../AppHealthBanner.tsx} | 44 ++++++------- 14 files changed, 110 insertions(+), 130 deletions(-) delete mode 100644 web/src/components/health/refreshUtils.ts create mode 100644 web/src/hooks/useCurrentUser.ts rename web/src/{components/health/healthcheck.tsx => sections/AppHealthBanner.tsx} (84%) diff --git a/web/src/app/admin/embeddings/pages/EmbeddingFormPage.tsx b/web/src/app/admin/embeddings/pages/EmbeddingFormPage.tsx index 831a4d54456..bea3929c16c 100644 --- a/web/src/app/admin/embeddings/pages/EmbeddingFormPage.tsx +++ b/web/src/app/admin/embeddings/pages/EmbeddingFormPage.tsx @@ -1,7 +1,7 @@ "use client"; import { toast } from "@/hooks/useToast"; -import { HealthCheckBanner } from "@/components/health/healthcheck"; + import EmbeddingModelSelection from "../EmbeddingModelSelectionForm"; import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import Text from "@/refresh-components/texts/Text"; @@ -481,9 +481,6 @@ export default function EmbeddingForm() { return (
-
- -
{formStep == 0 && ( <> diff --git a/web/src/app/auth/impersonate/page.tsx b/web/src/app/auth/impersonate/page.tsx index de5f3541841..81d20f195e4 100644 --- a/web/src/app/auth/impersonate/page.tsx +++ b/web/src/app/auth/impersonate/page.tsx @@ -1,7 +1,7 @@ "use client"; import AuthFlowContainer from "@/components/auth/AuthFlowContainer"; -import { HealthCheckBanner } from "@/components/health/healthcheck"; + import { useUser } from "@/providers/UserProvider"; import { redirect, useRouter } from "next/navigation"; import type { Route } from "next"; @@ -61,10 +61,6 @@ export default function ImpersonatePage() { return ( -
- -
-
diff --git a/web/src/app/auth/join/page.tsx b/web/src/app/auth/join/page.tsx index 448762eca17..1dd5aec90c2 100644 --- a/web/src/app/auth/join/page.tsx +++ b/web/src/app/auth/join/page.tsx @@ -1,4 +1,3 @@ -import { HealthCheckBanner } from "@/components/health/healthcheck"; import { User } from "@/lib/types"; import { getCurrentUserSS, @@ -65,7 +64,6 @@ const Page = async (props: { return ( - <> diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx index d64ba3f5a46..7b0c9d489dc 100644 --- a/web/src/app/auth/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -1,4 +1,3 @@ -import { HealthCheckBanner } from "@/components/health/healthcheck"; import { User } from "@/lib/types"; import { getCurrentUserSS, @@ -105,10 +104,6 @@ export default async function Page(props: PageProps) { authState="login" footerContent={ssoLoginFooterContent} > -
- -
- - <> diff --git a/web/src/app/auth/verify-email/Verify.tsx b/web/src/app/auth/verify-email/Verify.tsx index 146cbc46b94..f8d792d6189 100644 --- a/web/src/app/auth/verify-email/Verify.tsx +++ b/web/src/app/auth/verify-email/Verify.tsx @@ -1,6 +1,5 @@ "use client"; -import { HealthCheckBanner } from "@/components/health/healthcheck"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import Text from "@/components/ui/text"; @@ -63,9 +62,6 @@ export default function Verify({ user }: VerifyProps) { return (
-
- -
{!error ? ( diff --git a/web/src/app/auth/waiting-on-verification/page.tsx b/web/src/app/auth/waiting-on-verification/page.tsx index 2597c85fccc..42fe919ede6 100644 --- a/web/src/app/auth/waiting-on-verification/page.tsx +++ b/web/src/app/auth/waiting-on-verification/page.tsx @@ -4,7 +4,7 @@ import { getCurrentUserSS, } from "@/lib/userSS"; import { redirect } from "next/navigation"; -import { HealthCheckBanner } from "@/components/health/healthcheck"; + import { User } from "@/lib/types"; import Text from "@/components/ui/text"; import { RequestNewVerificationEmail } from "./RequestNewVerificationEmail"; @@ -35,9 +35,6 @@ export default async function Page() { return (
-
- -
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index b7fdd9a9267..a65a09459e1 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -30,6 +30,7 @@ import GatedContentWrapper from "@/components/GatedContentWrapper"; import { TooltipProvider } from "@/components/ui/tooltip"; import { fetchAppSidebarMetadata } from "@/lib/appSidebarSS"; import StatsOverlayLoader from "@/components/dev/StatsOverlayLoader"; +import AppHealthBanner from "@/sections/AppHealthBanner"; const inter = Inter({ subsets: ["latin"], @@ -128,7 +129,10 @@ export default async function RootLayout({ >
- {content} + + + {content} +
diff --git a/web/src/components/admin/Title.tsx b/web/src/components/admin/Title.tsx index 0d4072b1e89..988aeceaf8e 100644 --- a/web/src/components/admin/Title.tsx +++ b/web/src/components/admin/Title.tsx @@ -1,7 +1,6 @@ "use client"; import { JSX } from "react"; -import { HealthCheckBanner } from "../health/healthcheck"; import Separator from "@/refresh-components/Separator"; import type { IconProps } from "@opal/types"; import Text from "@/refresh-components/texts/Text"; @@ -21,9 +20,6 @@ export function AdminPageTitle({ }: AdminPageTitleProps) { return (
-
- -
{typeof Icon === "function" ? ( diff --git a/web/src/components/health/refreshUtils.ts b/web/src/components/health/refreshUtils.ts deleted file mode 100644 index 0ce66fdece7..00000000000 --- a/web/src/components/health/refreshUtils.ts +++ /dev/null @@ -1,64 +0,0 @@ -export interface CustomRefreshTokenResponse { - access_token: string; - refresh_token: string; - session: { - exp: number; - }; - userinfo: { - sub: string; - familyName: string; - givenName: string; - fullName: string; - userId: string; - email: string; - }; -} - -export function mockedRefreshToken(): CustomRefreshTokenResponse { - /** - * This function mocks the response from a token refresh endpoint. - * It generates a mock access token, refresh token, and user information - * with an expiration time set to 1 hour from now. - * This is useful for testing or development when the actual refresh endpoint is not available. - */ - const mockExp = Date.now() + 3600000; // 1 hour from now in milliseconds - const data: CustomRefreshTokenResponse = { - access_token: "Mock access token", - refresh_token: "Mock refresh token", - session: { exp: mockExp }, - userinfo: { - sub: "Mock email", - familyName: "Mock name", - givenName: "Mock name", - fullName: "Mock name", - userId: "Mock User ID", - email: "email@onyx.app", - }, - }; - return data; -} - -export async function refreshToken( - customRefreshUrl: string -): Promise { - try { - console.debug("Sending request to custom refresh URL"); - // support both absolute and relative - const url = customRefreshUrl.startsWith("http") - ? new URL(customRefreshUrl) - : new URL(customRefreshUrl, window.location.origin); - url.searchParams.append("info", "json"); - url.searchParams.append("access_token_refresh_interval", "3600"); - - const response = await fetch(url.toString()); - if (!response.ok) { - console.error(`Failed to refresh token: ${await response.text()}`); - return null; - } - - return await response.json(); - } catch (error) { - console.error("Error refreshing token:", error); - throw error; - } -} diff --git a/web/src/hooks/useCurrentUser.ts b/web/src/hooks/useCurrentUser.ts new file mode 100644 index 00000000000..57c493e366e --- /dev/null +++ b/web/src/hooks/useCurrentUser.ts @@ -0,0 +1,42 @@ +import useSWR, { type KeyedMutator } from "swr"; +import { errorHandlingFetcher } from "@/lib/fetcher"; +import { User } from "@/lib/types"; + +/** + * Fetches the current authenticated user via SWR (`/api/me`). + * + * This hook is intentionally configured with conservative revalidation + * settings to avoid hammering the backend on every focus/reconnect event: + * + * - `revalidateOnFocus: false` — tab switches won't trigger a refetch + * - `revalidateOnReconnect: false` — network recovery won't trigger a refetch + * - `dedupingInterval: 30_000` — duplicate requests within 30 s are deduped + * + * The returned `mutateUser` handle lets callers imperatively refetch (e.g. + * after a token refresh) without changing the global SWR config. + * + * @example + * ```ts + * const { user, mutateUser, userError } = useCurrentUser(); + * ``` + */ +export function useCurrentUser(): { + /** The authenticated user, or `undefined` while loading. */ + user: User | undefined; + /** Imperatively revalidate / update the cached user. */ + mutateUser: KeyedMutator; + /** The error thrown by the fetcher, if any. */ + userError: (Error & { status?: number }) | undefined; +} { + const { data, mutate, error } = useSWR( + "/api/me", + errorHandlingFetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + dedupingInterval: 30_000, + } + ); + + return { user: data, mutateUser: mutate, userError: error }; +} diff --git a/web/src/lib/user.ts b/web/src/lib/user.ts index beab90b6834..78c6fb26043 100644 --- a/web/src/lib/user.ts +++ b/web/src/lib/user.ts @@ -72,3 +72,44 @@ export const basicSignup = async ( }); return response; }; + +export interface CustomRefreshTokenResponse { + access_token: string; + refresh_token: string; + session: { + exp: number; + }; + userinfo: { + sub: string; + familyName: string; + givenName: string; + fullName: string; + userId: string; + email: string; + }; +} + +export async function refreshToken( + customRefreshUrl: string +): Promise { + try { + console.debug("Sending request to custom refresh URL"); + // support both absolute and relative + const url = customRefreshUrl.startsWith("http") + ? new URL(customRefreshUrl) + : new URL(customRefreshUrl, window.location.origin); + url.searchParams.append("info", "json"); + url.searchParams.append("access_token_refresh_interval", "3600"); + + const response = await fetch(url.toString()); + if (!response.ok) { + console.error(`Failed to refresh token: ${await response.text()}`); + return null; + } + + return await response.json(); + } catch (error) { + console.error("Error refreshing token:", error); + throw error; + } +} diff --git a/web/src/refresh-pages/AppPage.tsx b/web/src/refresh-pages/AppPage.tsx index cca29af3c39..130195c9120 100644 --- a/web/src/refresh-pages/AppPage.tsx +++ b/web/src/refresh-pages/AppPage.tsx @@ -1,7 +1,6 @@ "use client"; import { redirect, useRouter, useSearchParams } from "next/navigation"; -import { HealthCheckBanner } from "@/components/health/healthcheck"; import { personaIncludesRetrieval, getAvailableContextTokens, @@ -628,12 +627,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) { // handle error case where no assistants are available // Only show this after agents have loaded to prevent flash during initial load if (noAgents && !isLoadingAgents) { - return ( - <> - - - - ); + return ; } const hasStarterMessages = (liveAgent?.starter_messages?.length ?? 0) > 0; @@ -654,8 +648,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) { return ( <> - - {retrievalEnabled && documentSidebarVisible && settings.isMobile && ( diff --git a/web/src/components/health/healthcheck.tsx b/web/src/sections/AppHealthBanner.tsx similarity index 84% rename from web/src/components/health/healthcheck.tsx rename to web/src/sections/AppHealthBanner.tsx index e8cf0dc4821..d5527af1cdd 100644 --- a/web/src/components/health/healthcheck.tsx +++ b/web/src/sections/AppHealthBanner.tsx @@ -5,14 +5,16 @@ import useSWR from "swr"; import Modal from "@/refresh-components/Modal"; import { useCallback, useEffect, useState, useRef } from "react"; import { getSecondsUntilExpiration } from "@/lib/time"; -import { User } from "@/lib/types"; -import { refreshToken } from "./refreshUtils"; +import { refreshToken } from "@/lib/user"; import { NEXT_PUBLIC_CUSTOM_REFRESH_URL } from "@/lib/constants"; import Button from "@/refresh-components/buttons/Button"; import { logout } from "@/lib/user"; import { usePathname, useRouter } from "next/navigation"; -import { SvgLogOut } from "@opal/icons"; -export const HealthCheckBanner = () => { +import { SvgAlertTriangle, SvgLogOut } from "@opal/icons"; +import { Content } from "@opal/layouts"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; + +export default function AppHealthBanner() { const router = useRouter(); const { error } = useSWR("/api/health", errorHandlingFetcher); const [expired, setExpired] = useState(false); @@ -21,16 +23,7 @@ export const HealthCheckBanner = () => { const expirationTimeoutRef = useRef(null); const refreshIntervalRef = useRef(null); - // Reduce revalidation frequency with dedicated SWR config - const { - data: user, - mutate: mutateUser, - error: userError, - } = useSWR("/api/me", errorHandlingFetcher, { - revalidateOnFocus: false, - revalidateOnReconnect: false, - dedupingInterval: 30000, // 30 seconds - }); + const { user, mutateUser, userError } = useCurrentUser(); // Handle 403 errors from the /api/me endpoint useEffect(() => { @@ -44,10 +37,10 @@ export const HealthCheckBanner = () => { }, [userError, pathname]); // Function to handle the "Log in" button click - const handleLogin = () => { + function handleLogin() { setShowLoggedOutModal(false); router.push("/auth/login"); - }; + } // Function to set up expiration timeout const setupExpirationTimeout = useCallback( @@ -211,16 +204,15 @@ export const HealthCheckBanner = () => { return null; } else { return ( -
-

The backend is currently unavailable.

- -

- If this is your initial setup or you just updated your Onyx - deployment, this is likely because the backend is still starting up. - Give it a minute or two, and then refresh the page. If that does not - work, make sure the backend is setup and/or contact an administrator. -

+
+
); } -}; +} From 28332fa24b3ac54b7f50d0d2278cdce325cf53ed Mon Sep 17 00:00:00 2001 From: Justin Tahara <105671973+justin-tahara@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:44:09 -0800 Subject: [PATCH 027/267] fix(ui): InputComboBox search for users/groups (#8928) --- .../inputs/InputComboBox/InputComboBox.test.tsx | 14 ++++++-------- .../inputs/InputComboBox/InputComboBox.tsx | 9 ++++++--- .../inputs/InputComboBox/types.ts | 5 +++++ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/web/src/refresh-components/inputs/InputComboBox/InputComboBox.test.tsx b/web/src/refresh-components/inputs/InputComboBox/InputComboBox.test.tsx index 03a6210941c..53fd7d9c2a5 100644 --- a/web/src/refresh-components/inputs/InputComboBox/InputComboBox.test.tsx +++ b/web/src/refresh-components/inputs/InputComboBox/InputComboBox.test.tsx @@ -195,12 +195,11 @@ describe("InputComboBox", () => { await user.type(input, "app"); - // Get all options and check the first one contains Apple + // Search should only show matching options by default const options = screen.getAllByRole("option"); - expect(options.length).toBeGreaterThan(0); + expect(options.length).toBe(1); expect(options[0]!.textContent).toBe("Apple"); - // Banana and Cherry should be in "Other options" section - expect(screen.getByText("Banana")).toBeInTheDocument(); + expect(screen.queryByText("Banana")).not.toBeInTheDocument(); }); test("shows 'No options found' when no matches and strict mode", async () => { @@ -217,12 +216,10 @@ describe("InputComboBox", () => { await user.type(input, "xyz"); - // In strict mode with no matches, all options go to "Other options" section - // which shows all options (not "No options found") - expect(screen.getByText("Other options")).toBeInTheDocument(); + expect(screen.getByText("No options found")).toBeInTheDocument(); }); - test("shows separator between matched and unmatched options", async () => { + test("shows separator between matched and unmatched options when enabled", async () => { const user = setupUser(); render( { value="" options={mockOptions} separatorLabel="Other fruits" + showOtherOptions /> ); const input = screen.getByPlaceholderText("Select"); diff --git a/web/src/refresh-components/inputs/InputComboBox/InputComboBox.tsx b/web/src/refresh-components/inputs/InputComboBox/InputComboBox.tsx index 7a3ae079090..3f2c5e8b91d 100644 --- a/web/src/refresh-components/inputs/InputComboBox/InputComboBox.tsx +++ b/web/src/refresh-components/inputs/InputComboBox/InputComboBox.tsx @@ -129,6 +129,7 @@ const InputComboBox = ({ leftSearchIcon = false, rightSection, separatorLabel = "Other options", + showOtherOptions = false, ...rest }: WithoutStyles) => { const inputRef = useRef(null); @@ -152,6 +153,8 @@ const InputComboBox = ({ // Filtering Hook const { matchedOptions, unmatchedOptions, hasSearchTerm } = useOptionFiltering({ options, inputValue }); + const visibleUnmatchedOptions = + hasSearchTerm && showOtherOptions ? unmatchedOptions : []; // Whether to show the create option (only when no partial matches) const showCreateOption = @@ -162,13 +165,13 @@ const InputComboBox = ({ // Combined list for keyboard navigation (includes create option when shown) const allVisibleOptions = useMemo(() => { - const baseOptions = [...matchedOptions, ...unmatchedOptions]; + const baseOptions = [...matchedOptions, ...visibleUnmatchedOptions]; if (showCreateOption) { // Prepend a synthetic option for the "create new" item return [{ value: inputValue, label: inputValue }, ...baseOptions]; } return baseOptions; - }, [matchedOptions, unmatchedOptions, showCreateOption, inputValue]); + }, [matchedOptions, visibleUnmatchedOptions, showCreateOption, inputValue]); // Floating UI for dropdown positioning const { refs, floatingStyles } = useFloating({ @@ -418,7 +421,7 @@ const InputComboBox = ({ fieldId={fieldId} placeholder={placeholder} matchedOptions={matchedOptions} - unmatchedOptions={unmatchedOptions} + unmatchedOptions={visibleUnmatchedOptions} hasSearchTerm={hasSearchTerm} separatorLabel={separatorLabel} value={value} diff --git a/web/src/refresh-components/inputs/InputComboBox/types.ts b/web/src/refresh-components/inputs/InputComboBox/types.ts index 5febbc1ce3d..3e9ea5f234a 100644 --- a/web/src/refresh-components/inputs/InputComboBox/types.ts +++ b/web/src/refresh-components/inputs/InputComboBox/types.ts @@ -40,4 +40,9 @@ export interface InputComboBoxProps rightSection?: React.ReactNode; /** Label for the separator between matched and unmatched options */ separatorLabel?: string; + /** + * When true, keep non-matching options visible under a separator while searching. + * Defaults to false so search results are strictly filtered. + */ + showOtherOptions?: boolean; } From 30fa43b5fcd1ea0b8b00a77029dc758c175f4905 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:48:54 +0000 Subject: [PATCH 028/267] chore(deps): bump pypdf from 6.7.3 to 6.7.4 (#8905) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jamison Lahman --- backend/requirements/default.txt | 2 +- pyproject.toml | 2 +- uv.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/requirements/default.txt b/backend/requirements/default.txt index 438e56bde09..efdbad05411 100644 --- a/backend/requirements/default.txt +++ b/backend/requirements/default.txt @@ -809,7 +809,7 @@ pypandoc-binary==1.16.2 # via onyx pyparsing==3.2.5 # via httplib2 -pypdf==6.7.3 +pypdf==6.7.4 # via # onyx # unstructured-client diff --git a/pyproject.toml b/pyproject.toml index 203a85c9eb7..f0cfd09feac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ backend = [ "python-gitlab==5.6.0", "python-pptx==0.6.23", "pypandoc_binary==1.16.2", - "pypdf==6.7.3", + "pypdf==6.7.4", "pytest-mock==3.12.0", "pytest-playwright==0.7.0", "python-docx==1.1.2", diff --git a/uv.lock b/uv.lock index 5848b04425a..ad93c9ebc18 100644 --- a/uv.lock +++ b/uv.lock @@ -4678,7 +4678,7 @@ requires-dist = [ { name = "pygithub", marker = "extra == 'backend'", specifier = "==2.5.0" }, { name = "pympler", marker = "extra == 'backend'", specifier = "==1.1" }, { name = "pypandoc-binary", marker = "extra == 'backend'", specifier = "==1.16.2" }, - { name = "pypdf", marker = "extra == 'backend'", specifier = "==6.7.3" }, + { name = "pypdf", marker = "extra == 'backend'", specifier = "==6.7.4" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.5" }, { name = "pytest-alembic", marker = "extra == 'dev'", specifier = "==0.12.1" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, @@ -5925,11 +5925,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.7.3" +version = "6.7.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/9b/63e767042fc852384dc71e5ff6f990ee4e1b165b1526cf3f9c23a4eebb47/pypdf-6.7.3.tar.gz", hash = "sha256:eca55c78d0ec7baa06f9288e2be5c4e8242d5cbb62c7a4b94f2716f8e50076d2", size = 5303304, upload-time = "2026-02-24T17:23:11.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/dc/f52deef12797ad58b88e4663f097a343f53b9361338aef6573f135ac302f/pypdf-6.7.4.tar.gz", hash = "sha256:9edd1cd47938bb35ec87795f61225fd58a07cfaf0c5699018ae1a47d6f8ab0e3", size = 5304821, upload-time = "2026-02-27T10:44:39.395Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/90/3308a9b8b46c1424181fdf3f4580d2b423c5471425799e7fc62f92d183f4/pypdf-6.7.3-py3-none-any.whl", hash = "sha256:cd25ac508f20b554a9fafd825186e3ba29591a69b78c156783c5d8a2d63a1c0a", size = 331263, upload-time = "2026-02-24T17:23:09.932Z" }, + { url = "https://files.pythonhosted.org/packages/c1/be/cded021305f5c81b47265b8c5292b99388615a4391c21ff00fd538d34a56/pypdf-6.7.4-py3-none-any.whl", hash = "sha256:527d6da23274a6c70a9cb59d1986d93946ba8e36a6bc17f3f7cce86331492dda", size = 331496, upload-time = "2026-02-27T10:44:37.527Z" }, ] [[package]] From 1a65217bafe196b758448f3ab48db1c1d55e353b Mon Sep 17 00:00:00 2001 From: Nikolas Garza <90273783+nmgarza5@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:03:38 -0800 Subject: [PATCH 029/267] fix(scim): pass Okta Runscope spec test for OIN submission (#8925) --- backend/ee/onyx/main.py | 2 + backend/ee/onyx/server/scim/api.py | 42 +++++++++++++------ backend/ee/onyx/server/scim/auth.py | 31 ++++++++------ backend/ee/onyx/server/scim/providers/base.py | 14 ++++--- .../integration/tests/scim/test_scim_users.py | 13 +++--- .../tests/unit/onyx/server/scim/test_auth.py | 10 ++--- .../unit/onyx/server/scim/test_providers.py | 4 +- .../onyx/server/scim/test_user_endpoints.py | 13 ++++-- 8 files changed, 82 insertions(+), 47 deletions(-) diff --git a/backend/ee/onyx/main.py b/backend/ee/onyx/main.py index 612db0efeb7..575c159250a 100644 --- a/backend/ee/onyx/main.py +++ b/backend/ee/onyx/main.py @@ -31,6 +31,7 @@ from ee.onyx.server.query_and_chat.search_backend import router as search_router from ee.onyx.server.query_history.api import router as query_history_router from ee.onyx.server.reporting.usage_export_api import router as usage_export_router +from ee.onyx.server.scim.api import register_scim_exception_handlers from ee.onyx.server.scim.api import scim_router from ee.onyx.server.seeding import seed_db from ee.onyx.server.tenants.api import router as tenants_router @@ -167,6 +168,7 @@ def get_application() -> FastAPI: # they use their own SCIM bearer token auth). # Not behind APP_API_PREFIX because IdPs expect /scim/v2/... directly. application.include_router(scim_router) + register_scim_exception_handlers(application) # Ensure all routes have auth enabled or are explicitly marked as public check_ee_router_auth(application) diff --git a/backend/ee/onyx/server/scim/api.py b/backend/ee/onyx/server/scim/api.py index 3f9fc55f050..b8a5b8225e3 100644 --- a/backend/ee/onyx/server/scim/api.py +++ b/backend/ee/onyx/server/scim/api.py @@ -15,7 +15,9 @@ from fastapi import APIRouter from fastapi import Depends +from fastapi import FastAPI from fastapi import Query +from fastapi import Request from fastapi import Response from fastapi.responses import JSONResponse from fastapi_users.password import PasswordHelper @@ -24,6 +26,7 @@ from sqlalchemy.orm import Session from ee.onyx.db.scim import ScimDAL +from ee.onyx.server.scim.auth import ScimAuthError from ee.onyx.server.scim.auth import verify_scim_token from ee.onyx.server.scim.filtering import parse_scim_filter from ee.onyx.server.scim.models import SCIM_LIST_RESPONSE_SCHEMA @@ -77,6 +80,22 @@ class ScimJSONResponse(JSONResponse): _pw_helper = PasswordHelper() +def register_scim_exception_handlers(app: FastAPI) -> None: + """Register SCIM-specific exception handlers on the FastAPI app. + + Call this after ``app.include_router(scim_router)`` so that auth + failures from ``verify_scim_token`` return RFC 7644 §3.12 error + envelopes (with ``schemas`` and ``status`` fields) instead of + FastAPI's default ``{"detail": "..."}`` format. + """ + + @app.exception_handler(ScimAuthError) + async def _handle_scim_auth_error( + _request: Request, exc: ScimAuthError + ) -> ScimJSONResponse: + return _scim_error_response(exc.status_code, exc.detail) + + def _get_provider( _token: ScimToken = Depends(verify_scim_token), ) -> ScimProvider: @@ -404,12 +423,6 @@ def create_user( email = user_resource.userName.strip() - # externalId is how the IdP correlates this user on subsequent requests. - # Without it, the IdP can't find the user and will try to re-create, - # hitting a 409 conflict — so we require it up front. - if not user_resource.externalId: - return _scim_error_response(400, "externalId is required") - # Enforce seat limit seat_error = _check_seat_availability(dal) if seat_error: @@ -436,16 +449,19 @@ def create_user( dal.rollback() return _scim_error_response(409, f"User with email {email} already exists") - # Create SCIM mapping (externalId is validated above, always present) + # Create SCIM mapping when externalId is provided — this is how the IdP + # correlates this user on subsequent requests. Per RFC 7643, externalId + # is optional and assigned by the provisioning client. external_id = user_resource.externalId scim_username = user_resource.userName.strip() fields = _fields_from_resource(user_resource) - dal.create_user_mapping( - external_id=external_id, - user_id=user.id, - scim_username=scim_username, - fields=fields, - ) + if external_id: + dal.create_user_mapping( + external_id=external_id, + user_id=user.id, + scim_username=scim_username, + fields=fields, + ) dal.commit() diff --git a/backend/ee/onyx/server/scim/auth.py b/backend/ee/onyx/server/scim/auth.py index d05a1bd140b..e8965815053 100644 --- a/backend/ee/onyx/server/scim/auth.py +++ b/backend/ee/onyx/server/scim/auth.py @@ -19,7 +19,6 @@ import secrets from fastapi import Depends -from fastapi import HTTPException from fastapi import Request from sqlalchemy.orm import Session @@ -28,6 +27,21 @@ from onyx.db.engine.sql_engine import get_session from onyx.db.models import ScimToken + +class ScimAuthError(Exception): + """Raised when SCIM bearer token authentication fails. + + Unlike HTTPException, this carries the status and detail so the SCIM + exception handler can wrap them in an RFC 7644 §3.12 error envelope + with ``schemas`` and ``status`` fields. + """ + + def __init__(self, status_code: int, detail: str) -> None: + self.status_code = status_code + self.detail = detail + super().__init__(detail) + + SCIM_TOKEN_PREFIX = "onyx_scim_" SCIM_TOKEN_LENGTH = 48 @@ -82,23 +96,14 @@ def verify_scim_token( """ hashed = _get_hashed_scim_token_from_request(request) if not hashed: - raise HTTPException( - status_code=401, - detail="Missing or invalid SCIM bearer token", - ) + raise ScimAuthError(401, "Missing or invalid SCIM bearer token") token = dal.get_token_by_hash(hashed) if not token: - raise HTTPException( - status_code=401, - detail="Invalid SCIM bearer token", - ) + raise ScimAuthError(401, "Invalid SCIM bearer token") if not token.is_active: - raise HTTPException( - status_code=401, - detail="SCIM token has been revoked", - ) + raise ScimAuthError(401, "SCIM token has been revoked") return token diff --git a/backend/ee/onyx/server/scim/providers/base.py b/backend/ee/onyx/server/scim/providers/base.py index 5dc5fac3049..8f738c0d134 100644 --- a/backend/ee/onyx/server/scim/providers/base.py +++ b/backend/ee/onyx/server/scim/providers/base.py @@ -153,26 +153,28 @@ def build_scim_name( self, user: User, fields: ScimMappingFields, - ) -> ScimName | None: + ) -> ScimName: """Build SCIM name components for the response. Round-trips stored ``given_name``/``family_name`` when available (so the IdP gets back what it sent). Falls back to splitting ``personal_name`` for users provisioned before we stored components. + Always returns a ScimName — Okta's spec tests expect ``name`` + (with ``givenName``/``familyName``) on every user resource. Providers may override for custom behavior. """ if fields.given_name is not None or fields.family_name is not None: return ScimName( - givenName=fields.given_name, - familyName=fields.family_name, - formatted=user.personal_name, + givenName=fields.given_name or "", + familyName=fields.family_name or "", + formatted=user.personal_name or "", ) if not user.personal_name: - return None + return ScimName(givenName="", familyName="", formatted="") parts = user.personal_name.split(" ", 1) return ScimName( givenName=parts[0], - familyName=parts[1] if len(parts) > 1 else None, + familyName=parts[1] if len(parts) > 1 else "", formatted=user.personal_name, ) diff --git a/backend/tests/integration/tests/scim/test_scim_users.py b/backend/tests/integration/tests/scim/test_scim_users.py index 7a242960686..c7c844175c9 100644 --- a/backend/tests/integration/tests/scim/test_scim_users.py +++ b/backend/tests/integration/tests/scim/test_scim_users.py @@ -389,19 +389,22 @@ def test_delete_user(scim_token: str, idp_style: str) -> None: # ------------------------------------------------------------------ -def test_create_user_missing_external_id(scim_token: str) -> None: - """POST /Users without externalId returns 400.""" +def test_create_user_missing_external_id(scim_token: str, idp_style: str) -> None: + """POST /Users without externalId succeeds (RFC 7643: externalId is optional).""" + email = f"scim_no_extid_{idp_style}@example.com" resp = ScimClient.post( "/Users", scim_token, json={ "schemas": [SCIM_USER_SCHEMA], - "userName": "scim_no_extid@example.com", + "userName": email, "active": True, }, ) - assert resp.status_code == 400 - assert "externalId" in resp.json()["detail"] + assert resp.status_code == 201 + body = resp.json() + assert body["userName"] == email + assert body.get("externalId") is None def test_create_user_duplicate_email(scim_token: str, idp_style: str) -> None: diff --git a/backend/tests/unit/onyx/server/scim/test_auth.py b/backend/tests/unit/onyx/server/scim/test_auth.py index a482ac18981..ac645c843d1 100644 --- a/backend/tests/unit/onyx/server/scim/test_auth.py +++ b/backend/tests/unit/onyx/server/scim/test_auth.py @@ -1,11 +1,11 @@ from unittest.mock import MagicMock import pytest -from fastapi import HTTPException from ee.onyx.server.scim.auth import _hash_scim_token from ee.onyx.server.scim.auth import generate_scim_token from ee.onyx.server.scim.auth import SCIM_TOKEN_PREFIX +from ee.onyx.server.scim.auth import ScimAuthError from ee.onyx.server.scim.auth import verify_scim_token @@ -60,7 +60,7 @@ def _make_dal(self, token: MagicMock | None = None) -> MagicMock: def test_missing_header_raises_401(self) -> None: request = self._make_request(None) dal = self._make_dal() - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ScimAuthError) as exc_info: verify_scim_token(request, dal) assert exc_info.value.status_code == 401 assert "Missing" in str(exc_info.value.detail) @@ -68,7 +68,7 @@ def test_missing_header_raises_401(self) -> None: def test_wrong_prefix_raises_401(self) -> None: request = self._make_request("Bearer on_some_api_key") dal = self._make_dal() - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ScimAuthError) as exc_info: verify_scim_token(request, dal) assert exc_info.value.status_code == 401 @@ -76,7 +76,7 @@ def test_token_not_in_db_raises_401(self) -> None: raw, _, _ = generate_scim_token() request = self._make_request(f"Bearer {raw}") dal = self._make_dal(token=None) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ScimAuthError) as exc_info: verify_scim_token(request, dal) assert exc_info.value.status_code == 401 assert "Invalid" in str(exc_info.value.detail) @@ -87,7 +87,7 @@ def test_inactive_token_raises_401(self) -> None: mock_token = MagicMock() mock_token.is_active = False dal = self._make_dal(token=mock_token) - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(ScimAuthError) as exc_info: verify_scim_token(request, dal) assert exc_info.value.status_code == 401 assert "revoked" in str(exc_info.value.detail) diff --git a/backend/tests/unit/onyx/server/scim/test_providers.py b/backend/tests/unit/onyx/server/scim/test_providers.py index bccd57fc765..60bbff89aae 100644 --- a/backend/tests/unit/onyx/server/scim/test_providers.py +++ b/backend/tests/unit/onyx/server/scim/test_providers.py @@ -109,7 +109,7 @@ def test_build_user_resource_single_name(self) -> None: result = provider.build_user_resource(user, None) assert result.name == ScimName( - givenName="Madonna", familyName=None, formatted="Madonna" + givenName="Madonna", familyName="", formatted="Madonna" ) def test_build_user_resource_no_name(self) -> None: @@ -117,7 +117,7 @@ def test_build_user_resource_no_name(self) -> None: user = _make_mock_user(personal_name=None) result = provider.build_user_resource(user, None) - assert result.name is None + assert result.name == ScimName(givenName="", familyName="", formatted="") assert result.displayName is None def test_build_user_resource_scim_username_preserves_case(self) -> None: diff --git a/backend/tests/unit/onyx/server/scim/test_user_endpoints.py b/backend/tests/unit/onyx/server/scim/test_user_endpoints.py index d0d70b867d0..b1bd5e07fa9 100644 --- a/backend/tests/unit/onyx/server/scim/test_user_endpoints.py +++ b/backend/tests/unit/onyx/server/scim/test_user_endpoints.py @@ -214,13 +214,16 @@ def test_success( mock_dal.add_user.assert_called_once() mock_dal.commit.assert_called_once() - def test_missing_external_id_returns_400( + @patch("ee.onyx.server.scim.api._check_seat_availability", return_value=None) + def test_missing_external_id_creates_user_without_mapping( self, + mock_seats: MagicMock, # noqa: ARG002 mock_db_session: MagicMock, mock_token: MagicMock, - mock_dal: MagicMock, # noqa: ARG002 + mock_dal: MagicMock, provider: ScimProvider, ) -> None: + mock_dal.get_user_by_email.return_value = None resource = make_scim_user(externalId=None) result = create_user( @@ -230,7 +233,11 @@ def test_missing_external_id_returns_400( db_session=mock_db_session, ) - assert_scim_error(result, 400) + parsed = parse_scim_user(result, status=201) + assert parsed.userName is not None + mock_dal.add_user.assert_called_once() + mock_dal.create_user_mapping.assert_not_called() + mock_dal.commit.assert_called_once() @patch("ee.onyx.server.scim.api._check_seat_availability", return_value=None) def test_duplicate_email_returns_409( From 71a1faa47ec44bf6aba38c66233a27ed9f4ae6e4 Mon Sep 17 00:00:00 2001 From: Jamison Lahman Date: Mon, 2 Mar 2026 15:47:35 -0800 Subject: [PATCH 030/267] fix(fe): break long words in human messages (#8929) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- web/src/app/app/message/HumanMessage.tsx | 2 +- web/tailwind-themes/tailwind.config.js | 7 ++++ .../e2e/chat/chat_message_rendering.spec.ts | 33 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/web/src/app/app/message/HumanMessage.tsx b/web/src/app/app/message/HumanMessage.tsx index bedd8f2c841..097145cd20e 100644 --- a/web/src/app/app/message/HumanMessage.tsx +++ b/web/src/app/app/message/HumanMessage.tsx @@ -195,7 +195,7 @@ const HumanMessage = React.memo(function HumanMessage({
{ const selection = window.getSelection(); diff --git a/web/tailwind-themes/tailwind.config.js b/web/tailwind-themes/tailwind.config.js index 261c192ca6f..ca3d2d75e04 100644 --- a/web/tailwind-themes/tailwind.config.js +++ b/web/tailwind-themes/tailwind.config.js @@ -370,5 +370,12 @@ module.exports = { plugin(({ addVariant }) => { addVariant("focus-within-nonactive", "&:focus-within:not(:active)"); }), + plugin(({ addUtilities }) => { + addUtilities({ + ".break-anywhere": { + "overflow-wrap": "anywhere", + }, + }); + }), ], }; diff --git a/web/tests/e2e/chat/chat_message_rendering.spec.ts b/web/tests/e2e/chat/chat_message_rendering.spec.ts index 018011725cc..99229ca81c8 100644 --- a/web/tests/e2e/chat/chat_message_rendering.spec.ts +++ b/web/tests/e2e/chat/chat_message_rendering.spec.ts @@ -6,6 +6,9 @@ import { expectElementScreenshot } from "@tests/e2e/utils/visualRegression"; const SHORT_USER_MESSAGE = "What is Onyx?"; +const LONG_WORD_USER_MESSAGE = + "Please look into this issue: __________________________________________ and also this token: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and this URL: https://example.com/a/very/long/path/that/keeps/going/and/going/and/going/without/any/breaks/whatsoever/to/test/overflow"; + const LONG_USER_MESSAGE = `I've been evaluating several enterprise search and AI platforms for our organization, and I have a number of detailed questions about Onyx that I'd like to understand before we make a decision. First, can you explain how Onyx handles document indexing across multiple data sources? We currently use Confluence, Google Drive, Slack, and GitHub, and we need to ensure that all of these can be indexed simultaneously without performance degradation. @@ -369,6 +372,36 @@ for (const theme of THEMES) { ); }); + test("user message with very long words wraps without overflowing", async ({ + page, + }) => { + await openChat(page); + await mockChatEndpoint(page, SHORT_AI_RESPONSE); + + await sendMessage(page, LONG_WORD_USER_MESSAGE); + + const userMessage = page.locator("#onyx-human-message").first(); + await expect(userMessage).toContainText("__________"); + + await screenshotChatContainer( + page, + `chat-long-word-user-message-${theme}` + ); + + // Assert the message bubble does not overflow horizontally. + const overflows = await userMessage.evaluate((el) => { + const bubble = el.querySelector( + ".whitespace-break-spaces" + ); + if (!bubble) + throw new Error( + "Expected human message bubble (.whitespace-break-spaces) to exist" + ); + return bubble.scrollWidth > bubble.offsetWidth; + }); + expect(overflows).toBe(false); + }); + test("long user message with long AI response renders correctly", async ({ page, }) => { From 6cfd49439a8637ddab60c29c5f36b911d493926c Mon Sep 17 00:00:00 2001 From: Danelegend <43459662+Danelegend@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:49:58 -0800 Subject: [PATCH 031/267] chore: Bump code interpreter to 0.3.1 (#8937) --- deployment/helm/charts/onyx/Chart.lock | 6 +++--- deployment/helm/charts/onyx/Chart.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deployment/helm/charts/onyx/Chart.lock b/deployment/helm/charts/onyx/Chart.lock index 7ca59d8f8e9..979ac2e3b88 100644 --- a/deployment/helm/charts/onyx/Chart.lock +++ b/deployment/helm/charts/onyx/Chart.lock @@ -19,6 +19,6 @@ dependencies: version: 5.4.0 - name: code-interpreter repository: https://onyx-dot-app.github.io/python-sandbox/ - version: 0.3.0 -digest: sha256:cf8f01906d46034962c6ce894770621ee183ac761e6942951118aeb48540eddd -generated: "2026-02-24T10:59:38.78318-08:00" + version: 0.3.1 +digest: sha256:4965b6ea3674c37163832a2192cd3bc8004f2228729fca170af0b9f457e8f987 +generated: "2026-03-02T15:29:39.632344-08:00" diff --git a/deployment/helm/charts/onyx/Chart.yaml b/deployment/helm/charts/onyx/Chart.yaml index 1c6ddeb5325..8e3459577a6 100644 --- a/deployment/helm/charts/onyx/Chart.yaml +++ b/deployment/helm/charts/onyx/Chart.yaml @@ -45,6 +45,6 @@ dependencies: repository: https://charts.min.io/ condition: minio.enabled - name: code-interpreter - version: 0.3.0 + version: 0.3.1 repository: https://onyx-dot-app.github.io/python-sandbox/ condition: codeInterpreter.enabled From 4fc802e19d81e8459776810a0a9d974cc8806089 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:55:34 +0000 Subject: [PATCH 032/267] chore(deps): bump pypdf from 6.7.4 to 6.7.5 (#8932) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jamison Lahman --- backend/requirements/default.txt | 2 +- pyproject.toml | 2 +- uv.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/requirements/default.txt b/backend/requirements/default.txt index efdbad05411..59a94f1d976 100644 --- a/backend/requirements/default.txt +++ b/backend/requirements/default.txt @@ -809,7 +809,7 @@ pypandoc-binary==1.16.2 # via onyx pyparsing==3.2.5 # via httplib2 -pypdf==6.7.4 +pypdf==6.7.5 # via # onyx # unstructured-client diff --git a/pyproject.toml b/pyproject.toml index f0cfd09feac..5b3d8710e58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ backend = [ "python-gitlab==5.6.0", "python-pptx==0.6.23", "pypandoc_binary==1.16.2", - "pypdf==6.7.4", + "pypdf==6.7.5", "pytest-mock==3.12.0", "pytest-playwright==0.7.0", "python-docx==1.1.2", diff --git a/uv.lock b/uv.lock index ad93c9ebc18..d843f03b9ae 100644 --- a/uv.lock +++ b/uv.lock @@ -4678,7 +4678,7 @@ requires-dist = [ { name = "pygithub", marker = "extra == 'backend'", specifier = "==2.5.0" }, { name = "pympler", marker = "extra == 'backend'", specifier = "==1.1" }, { name = "pypandoc-binary", marker = "extra == 'backend'", specifier = "==1.16.2" }, - { name = "pypdf", marker = "extra == 'backend'", specifier = "==6.7.4" }, + { name = "pypdf", marker = "extra == 'backend'", specifier = "==6.7.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.5" }, { name = "pytest-alembic", marker = "extra == 'dev'", specifier = "==0.12.1" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, @@ -5925,11 +5925,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.7.4" +version = "6.7.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/dc/f52deef12797ad58b88e4663f097a343f53b9361338aef6573f135ac302f/pypdf-6.7.4.tar.gz", hash = "sha256:9edd1cd47938bb35ec87795f61225fd58a07cfaf0c5699018ae1a47d6f8ab0e3", size = 5304821, upload-time = "2026-02-27T10:44:39.395Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/be/cded021305f5c81b47265b8c5292b99388615a4391c21ff00fd538d34a56/pypdf-6.7.4-py3-none-any.whl", hash = "sha256:527d6da23274a6c70a9cb59d1986d93946ba8e36a6bc17f3f7cce86331492dda", size = 331496, upload-time = "2026-02-27T10:44:37.527Z" }, + { url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" }, ] [[package]] From a84f8238ecc93216de6af093e47c326e61832c7a Mon Sep 17 00:00:00 2001 From: Jamison Lahman Date: Mon, 2 Mar 2026 15:56:08 -0800 Subject: [PATCH 033/267] chore(fe): space between Manage All connectors button (#8938) --- .../admin/ChatPreferencesPage.tsx | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/web/src/refresh-pages/admin/ChatPreferencesPage.tsx b/web/src/refresh-pages/admin/ChatPreferencesPage.tsx index 131c33ccd13..a0a8ae8343f 100644 --- a/web/src/refresh-pages/admin/ChatPreferencesPage.tsx +++ b/web/src/refresh-pages/admin/ChatPreferencesPage.tsx @@ -472,7 +472,7 @@ function ChatPreferencesForm() {
@@ -480,22 +480,29 @@ function ChatPreferencesForm() { ) : ( <> - {uniqueSources.slice(0, 3).map((source) => { - const meta = getSourceMetadata(source); - return ( - - - - ); - })} +
+ {uniqueSources.slice(0, 3).map((source) => { + const meta = getSourceMetadata(source); + return ( + + + + ); + })} +