mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-27 20:55:45 +00:00
Compare commits
2 Commits
default_py
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bb8f16d6c | ||
|
|
21aa89badc |
@@ -18,14 +18,6 @@ inputs:
|
||||
description: "Optional NIGHTLY_LLM_API_BASE"
|
||||
required: false
|
||||
default: ""
|
||||
api-version:
|
||||
description: "Optional NIGHTLY_LLM_API_VERSION"
|
||||
required: false
|
||||
default: ""
|
||||
deployment-name:
|
||||
description: "Optional NIGHTLY_LLM_DEPLOYMENT_NAME"
|
||||
required: false
|
||||
default: ""
|
||||
custom-config-json:
|
||||
description: "Optional NIGHTLY_LLM_CUSTOM_CONFIG_JSON"
|
||||
required: false
|
||||
@@ -92,8 +84,6 @@ runs:
|
||||
NIGHTLY_LLM_PROVIDER: ${{ inputs.provider }}
|
||||
NIGHTLY_LLM_API_KEY: ${{ inputs.provider-api-key }}
|
||||
NIGHTLY_LLM_API_BASE: ${{ inputs.api-base }}
|
||||
NIGHTLY_LLM_API_VERSION: ${{ inputs.api-version }}
|
||||
NIGHTLY_LLM_DEPLOYMENT_NAME: ${{ inputs.deployment-name }}
|
||||
NIGHTLY_LLM_CUSTOM_CONFIG_JSON: ${{ inputs.custom-config-json }}
|
||||
NIGHTLY_LLM_STRICT: ${{ inputs.strict }}
|
||||
RUNS_ON_ECR_CACHE: ${{ inputs.runs-on-ecr-cache }}
|
||||
@@ -122,8 +112,6 @@ runs:
|
||||
-e NIGHTLY_LLM_MODELS="${MODELS}" \
|
||||
-e NIGHTLY_LLM_API_KEY="${NIGHTLY_LLM_API_KEY}" \
|
||||
-e NIGHTLY_LLM_API_BASE="${NIGHTLY_LLM_API_BASE}" \
|
||||
-e NIGHTLY_LLM_API_VERSION="${NIGHTLY_LLM_API_VERSION}" \
|
||||
-e NIGHTLY_LLM_DEPLOYMENT_NAME="${NIGHTLY_LLM_DEPLOYMENT_NAME}" \
|
||||
-e NIGHTLY_LLM_CUSTOM_CONFIG_JSON="${NIGHTLY_LLM_CUSTOM_CONFIG_JSON}" \
|
||||
-e NIGHTLY_LLM_STRICT="${NIGHTLY_LLM_STRICT}" \
|
||||
${RUNS_ON_ECR_CACHE}:nightly-llm-it-${RUN_ID} \
|
||||
|
||||
@@ -20,19 +20,12 @@ jobs:
|
||||
anthropic_models: ${{ vars.NIGHTLY_LLM_ANTHROPIC_MODELS }}
|
||||
bedrock_models: ${{ vars.NIGHTLY_LLM_BEDROCK_MODELS }}
|
||||
vertex_ai_models: ${{ vars.NIGHTLY_LLM_VERTEX_AI_MODELS }}
|
||||
azure_models: ${{ vars.NIGHTLY_LLM_AZURE_MODELS }}
|
||||
azure_api_base: ${{ vars.NIGHTLY_LLM_AZURE_API_BASE }}
|
||||
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 }}
|
||||
|
||||
|
||||
@@ -23,26 +23,6 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
azure_models:
|
||||
description: "Comma-separated models for azure"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
ollama_models:
|
||||
description: "Comma-separated models for ollama_chat"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
openrouter_models:
|
||||
description: "Comma-separated models for openrouter"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
azure_api_base:
|
||||
description: "API base for azure provider"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
strict:
|
||||
description: "Default NIGHTLY_LLM_STRICT passed to tests"
|
||||
required: false
|
||||
@@ -57,12 +37,6 @@ on:
|
||||
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:
|
||||
@@ -172,57 +146,21 @@ jobs:
|
||||
models: ${{ inputs.openai_models }}
|
||||
api_key_secret: openai_api_key
|
||||
custom_config_secret: ""
|
||||
api_base: ""
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: true
|
||||
- provider: anthropic
|
||||
models: ${{ inputs.anthropic_models }}
|
||||
api_key_secret: anthropic_api_key
|
||||
custom_config_secret: ""
|
||||
api_base: ""
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: true
|
||||
- provider: bedrock
|
||||
models: ${{ inputs.bedrock_models }}
|
||||
api_key_secret: bedrock_api_key
|
||||
custom_config_secret: ""
|
||||
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_base: ""
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: false
|
||||
- provider: azure
|
||||
models: ${{ inputs.azure_models }}
|
||||
api_key_secret: azure_api_key
|
||||
custom_config_secret: ""
|
||||
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_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_base: "https://openrouter.ai/api/v1"
|
||||
api_version: ""
|
||||
deployment_name: ""
|
||||
required: false
|
||||
runs-on:
|
||||
- runs-on
|
||||
@@ -245,9 +183,6 @@ jobs:
|
||||
models: ${{ matrix.models }}
|
||||
provider-api-key: ${{ matrix.api_key_secret && secrets[matrix.api_key_secret] || '' }}
|
||||
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] || '' }}
|
||||
runs-on-ecr-cache: ${{ env.RUNS_ON_ECR_CACHE }}
|
||||
run-id: ${{ github.run_id }}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""add python tool on default
|
||||
|
||||
Revision ID: 57122d037335
|
||||
Revises: c0c937d5c9e5
|
||||
Create Date: 2026-02-27 10:10:40.124925
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "57122d037335"
|
||||
down_revision = "c0c937d5c9e5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PYTHON_TOOL_NAME = "python"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Look up the PythonTool id
|
||||
result = conn.execute(
|
||||
sa.text("SELECT id FROM tool WHERE name = :name"),
|
||||
{"name": PYTHON_TOOL_NAME},
|
||||
).fetchone()
|
||||
|
||||
if not result:
|
||||
return
|
||||
|
||||
tool_id = result[0]
|
||||
|
||||
# Attach to the default persona (id=0) if not already attached
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO persona__tool (persona_id, tool_id)
|
||||
VALUES (0, :tool_id)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"tool_id": tool_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
result = conn.execute(
|
||||
sa.text("SELECT id FROM tool WHERE name = :name"),
|
||||
{"name": PYTHON_TOOL_NAME},
|
||||
).fetchone()
|
||||
|
||||
if not result:
|
||||
return
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM persona__tool
|
||||
WHERE persona_id = 0 AND tool_id = :tool_id
|
||||
"""
|
||||
),
|
||||
{"tool_id": result[0]},
|
||||
)
|
||||
@@ -124,3 +124,26 @@ def send_message(
|
||||
"X-Accel-Buffering": "no", # Disable nginx buffering
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/cancel-message", tags=PUBLIC_API_TAGS)
|
||||
def cancel_message(
|
||||
session_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation. This follows the ACP protocol specification
|
||||
for cancellation.
|
||||
|
||||
Returns:
|
||||
{"cancelled": true} if cancel was sent successfully
|
||||
{"cancelled": false} if no active operation to cancel
|
||||
"""
|
||||
session_manager = SessionManager(db_session)
|
||||
cancelled = session_manager.cancel_message(session_id, user.id)
|
||||
|
||||
return {"cancelled": cancelled}
|
||||
|
||||
@@ -491,6 +491,27 @@ class SandboxManager(ABC):
|
||||
nextjs_port: The port the Next.js server should be listening on
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def cancel_message(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
) -> bool:
|
||||
"""Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation. This is a non-blocking operation that
|
||||
signals the agent to stop but does not wait for confirmation.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID whose operation should be cancelled
|
||||
|
||||
Returns:
|
||||
True if cancel was sent, False if no active session/client found
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Singleton instance cache for the factory
|
||||
_sandbox_manager_instance: SandboxManager | None = None
|
||||
|
||||
@@ -358,6 +358,15 @@ class KubernetesSandboxManager(SandboxManager):
|
||||
self._agent_instructions_template_path = build_dir / "AGENTS.template.md"
|
||||
self._skills_path = Path(__file__).parent / "docker" / "skills"
|
||||
|
||||
# Track active ephemeral ACP clients for cancellation support.
|
||||
# Only populated during an active send_message() call — entries are
|
||||
# added at the start of send_message() and removed in its finally block.
|
||||
# Keyed by (sandbox_id, session_id) tuple.
|
||||
# Lock guards dict access and client lifecycle between send_message()
|
||||
# (streaming thread) and cancel_message() (cancel request thread).
|
||||
self._active_acp_clients: dict[tuple[UUID, UUID], ACPExecClient] = {}
|
||||
self._active_acp_clients_lock = threading.Lock()
|
||||
|
||||
logger.info(
|
||||
f"KubernetesSandboxManager initialized: "
|
||||
f"namespace={self._namespace}, image={self._image}"
|
||||
@@ -1405,8 +1414,8 @@ echo "Session workspace setup complete"
|
||||
) -> None:
|
||||
"""Clean up a session workspace (on session delete).
|
||||
|
||||
Removes the ACP session mapping and executes kubectl exec to remove
|
||||
the session directory. The shared ACP client persists for other sessions.
|
||||
Executes kubectl exec to remove the session directory. ACP clients are
|
||||
ephemeral (created per message), so there's nothing to stop here.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
@@ -1889,6 +1898,12 @@ echo "Session config regeneration complete"
|
||||
# Create an ephemeral ACP client for this message
|
||||
acp_client = self._create_ephemeral_acp_client(sandbox_id, session_path)
|
||||
|
||||
# Register as the active client for this session so cancel_message()
|
||||
# can find and cancel it from another thread.
|
||||
client_key = (sandbox_id, session_id)
|
||||
with self._active_acp_clients_lock:
|
||||
self._active_acp_clients[client_key] = acp_client
|
||||
|
||||
try:
|
||||
# Resume (or create) the ACP session from opencode's on-disk storage
|
||||
acp_session_id = acp_client.resume_or_create_session(cwd=session_path)
|
||||
@@ -1972,8 +1987,9 @@ echo "Session config regeneration complete"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Always stop the ephemeral ACP client to kill the opencode process.
|
||||
# This ensures no stale processes linger in the sandbox container.
|
||||
# Always deregister and stop the ephemeral ACP client.
|
||||
with self._active_acp_clients_lock:
|
||||
self._active_acp_clients.pop(client_key, None)
|
||||
try:
|
||||
acp_client.stop()
|
||||
except Exception as e:
|
||||
@@ -2714,3 +2730,43 @@ fi
|
||||
except ApiException as e:
|
||||
logger.warning(f"Failed to get upload stats: {e}")
|
||||
return 0, 0
|
||||
|
||||
def cancel_message(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
) -> bool:
|
||||
"""Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID whose operation should be cancelled
|
||||
|
||||
Returns:
|
||||
True if cancel was sent, False if no active session/client found
|
||||
"""
|
||||
client_key = (sandbox_id, session_id)
|
||||
with self._active_acp_clients_lock:
|
||||
exec_client = self._active_acp_clients.get(client_key)
|
||||
|
||||
if exec_client is None or not exec_client.is_running:
|
||||
logger.debug(
|
||||
f"No active ACP client for sandbox {sandbox_id}, session {session_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
exec_client.cancel()
|
||||
logger.info(
|
||||
f"Sent cancel notification for sandbox {sandbox_id}, session {session_id}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cancel operation for sandbox {sandbox_id}, "
|
||||
f"session {session_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -1047,23 +1047,35 @@ class LocalSandboxManager(SandboxManager):
|
||||
if not self._is_path_allowed(session_path, target_path):
|
||||
raise ValueError("Path traversal not allowed")
|
||||
|
||||
# If directory doesn't exist, return empty list (no files yet)
|
||||
if not target_path.exists():
|
||||
return []
|
||||
|
||||
if not target_path.is_dir():
|
||||
raise ValueError(f"Not a directory: {path}")
|
||||
|
||||
entries = []
|
||||
for item in target_path.iterdir():
|
||||
stat = item.stat()
|
||||
is_file = item.is_file()
|
||||
mime_type = mimetypes.guess_type(str(item))[0] if is_file else None
|
||||
entries.append(
|
||||
FilesystemEntry(
|
||||
name=item.name,
|
||||
path=str(item.relative_to(session_path)),
|
||||
is_directory=item.is_dir(),
|
||||
size=stat.st_size if is_file else None,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
)
|
||||
try:
|
||||
for item in target_path.iterdir():
|
||||
try:
|
||||
stat = item.stat()
|
||||
is_file = item.is_file()
|
||||
mime_type = mimetypes.guess_type(str(item))[0] if is_file else None
|
||||
entries.append(
|
||||
FilesystemEntry(
|
||||
name=item.name,
|
||||
path=str(item.relative_to(session_path)),
|
||||
is_directory=item.is_dir(),
|
||||
size=stat.st_size if is_file else None,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
)
|
||||
except (FileNotFoundError, OSError):
|
||||
# Skip files that were deleted during iteration (race condition)
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
# Directory was deleted during iteration
|
||||
return []
|
||||
|
||||
return sorted(entries, key=lambda e: (not e.is_directory, e.name.lower()))
|
||||
|
||||
@@ -1411,3 +1423,42 @@ class LocalSandboxManager(SandboxManager):
|
||||
f"sync_files called for local sandbox {sandbox_id}{source_info} - no-op"
|
||||
)
|
||||
return True
|
||||
|
||||
def cancel_message(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
) -> bool:
|
||||
"""Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID whose operation should be cancelled
|
||||
|
||||
Returns:
|
||||
True if cancel was sent, False if no active session/client found
|
||||
"""
|
||||
client_key = (sandbox_id, session_id)
|
||||
client = self._acp_clients.get(client_key)
|
||||
|
||||
if client is None or not client.is_running:
|
||||
logger.debug(
|
||||
f"No active ACP client for sandbox {sandbox_id}, session {session_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
client.cancel()
|
||||
logger.info(
|
||||
f"Sent cancel notification for sandbox {sandbox_id}, session {session_id}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cancel operation for sandbox {sandbox_id}, "
|
||||
f"session {session_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -1550,6 +1550,46 @@ class SessionManager:
|
||||
return "error"
|
||||
return "unknown"
|
||||
|
||||
def cancel_message(
|
||||
self,
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
) -> bool:
|
||||
"""Cancel the current message/prompt operation for a session.
|
||||
|
||||
Sends a session/cancel notification to the ACP agent to stop the
|
||||
currently running operation. This follows the ACP protocol:
|
||||
https://agentclientprotocol.com
|
||||
|
||||
Args:
|
||||
session_id: The session UUID
|
||||
user_id: The user ID (for ownership verification)
|
||||
|
||||
Returns:
|
||||
True if cancel was sent, False if session/sandbox not found or cancel failed
|
||||
"""
|
||||
# Verify session ownership
|
||||
session = get_build_session(session_id, user_id, self._db_session)
|
||||
if session is None:
|
||||
logger.warning(f"Cancel request for non-existent session {session_id}")
|
||||
return False
|
||||
|
||||
# Get the user's sandbox
|
||||
sandbox = get_sandbox_by_user_id(self._db_session, user_id)
|
||||
if sandbox is None:
|
||||
logger.warning(f"Cancel request but no sandbox for user {user_id}")
|
||||
return False
|
||||
|
||||
# Send cancel to the sandbox manager
|
||||
result = self._sandbox_manager.cancel_message(sandbox.id, session_id)
|
||||
|
||||
if result:
|
||||
logger.info(f"Cancelled message operation for session {session_id}")
|
||||
else:
|
||||
logger.debug(f"No active operation to cancel for session {session_id}")
|
||||
|
||||
return result
|
||||
|
||||
# =========================================================================
|
||||
# Artifact Operations
|
||||
# =========================================================================
|
||||
|
||||
@@ -11,7 +11,6 @@ SQLAlchemy connection pool metrics are registered separately via
|
||||
"""
|
||||
|
||||
from prometheus_fastapi_instrumentator import Instrumentator
|
||||
from prometheus_fastapi_instrumentator.metrics import default as default_metrics
|
||||
from sqlalchemy.exc import TimeoutError as SATimeoutError
|
||||
from starlette.applications import Starlette
|
||||
|
||||
@@ -61,14 +60,6 @@ def setup_prometheus_metrics(app: Starlette) -> None:
|
||||
excluded_handlers=_EXCLUDED_HANDLERS,
|
||||
)
|
||||
|
||||
# Explicitly create the default metrics (http_requests_total,
|
||||
# http_request_duration_seconds, etc.) and add them first. The library
|
||||
# skips creating defaults when ANY custom instrumentations are registered
|
||||
# via .add(), so we must include them ourselves.
|
||||
default_callback = default_metrics(latency_lowr_buckets=_LATENCY_BUCKETS)
|
||||
if default_callback:
|
||||
instrumentator.add(default_callback)
|
||||
|
||||
instrumentator.add(slow_request_callback)
|
||||
instrumentator.add(per_tenant_request_callback)
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ _ENV_PROVIDER = "NIGHTLY_LLM_PROVIDER"
|
||||
_ENV_MODELS = "NIGHTLY_LLM_MODELS"
|
||||
_ENV_API_KEY = "NIGHTLY_LLM_API_KEY"
|
||||
_ENV_API_BASE = "NIGHTLY_LLM_API_BASE"
|
||||
_ENV_API_VERSION = "NIGHTLY_LLM_API_VERSION"
|
||||
_ENV_DEPLOYMENT_NAME = "NIGHTLY_LLM_DEPLOYMENT_NAME"
|
||||
_ENV_CUSTOM_CONFIG_JSON = "NIGHTLY_LLM_CUSTOM_CONFIG_JSON"
|
||||
_ENV_STRICT = "NIGHTLY_LLM_STRICT"
|
||||
|
||||
@@ -36,8 +34,6 @@ class NightlyProviderConfig(BaseModel):
|
||||
model_names: list[str]
|
||||
api_key: str | None
|
||||
api_base: str | None
|
||||
api_version: str | None
|
||||
deployment_name: str | None
|
||||
custom_config: dict[str, str] | None
|
||||
strict: bool
|
||||
|
||||
@@ -70,8 +66,6 @@ def _load_provider_config() -> NightlyProviderConfig:
|
||||
model_names = _parse_models_env(_ENV_MODELS)
|
||||
api_key = os.environ.get(_ENV_API_KEY) or None
|
||||
api_base = os.environ.get(_ENV_API_BASE) or None
|
||||
api_version = os.environ.get(_ENV_API_VERSION) or None
|
||||
deployment_name = os.environ.get(_ENV_DEPLOYMENT_NAME) or None
|
||||
strict = _env_true(_ENV_STRICT, default=False)
|
||||
|
||||
custom_config: dict[str, str] | None = None
|
||||
@@ -90,8 +84,6 @@ def _load_provider_config() -> NightlyProviderConfig:
|
||||
model_names=model_names,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
api_version=api_version,
|
||||
deployment_name=deployment_name,
|
||||
custom_config=custom_config,
|
||||
strict=strict,
|
||||
)
|
||||
@@ -132,22 +124,6 @@ def _validate_provider_config(config: NightlyProviderConfig) -> None:
|
||||
message=(f"{_ENV_API_BASE} is required for provider '{config.provider}'"),
|
||||
)
|
||||
|
||||
if config.provider == "azure":
|
||||
if not config.api_base:
|
||||
_skip_or_fail(
|
||||
strict=config.strict,
|
||||
message=(
|
||||
f"{_ENV_API_BASE} is required for provider '{config.provider}'"
|
||||
),
|
||||
)
|
||||
if not config.api_version:
|
||||
_skip_or_fail(
|
||||
strict=config.strict,
|
||||
message=(
|
||||
f"{_ENV_API_VERSION} is required for provider '{config.provider}'"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _assert_integration_mode_enabled() -> None:
|
||||
assert (
|
||||
@@ -186,8 +162,6 @@ def _create_provider_payload(
|
||||
model_name: str,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
api_version: str | None,
|
||||
deployment_name: str | None,
|
||||
custom_config: dict[str, str] | None,
|
||||
) -> dict:
|
||||
return {
|
||||
@@ -195,8 +169,6 @@ def _create_provider_payload(
|
||||
"provider": provider,
|
||||
"api_key": api_key,
|
||||
"api_base": api_base,
|
||||
"api_version": api_version,
|
||||
"deployment_name": deployment_name,
|
||||
"custom_config": custom_config,
|
||||
"default_model_name": model_name,
|
||||
"is_public": True,
|
||||
@@ -298,8 +270,6 @@ def _create_and_test_provider_for_model(
|
||||
model_name=model_name,
|
||||
api_key=config.api_key,
|
||||
api_base=resolved_api_base,
|
||||
api_version=config.api_version,
|
||||
deployment_name=config.deployment_name,
|
||||
custom_config=config.custom_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -72,9 +72,6 @@ def test_cold_startup_default_assistant() -> None:
|
||||
assert (
|
||||
"read_file" in tool_names
|
||||
), "Default assistant should have FileReaderTool attached"
|
||||
assert (
|
||||
"python" in tool_names
|
||||
), "Default assistant should have PythonTool attached"
|
||||
|
||||
# Also verify by display names for clarity
|
||||
assert (
|
||||
@@ -89,11 +86,8 @@ def test_cold_startup_default_assistant() -> None:
|
||||
assert (
|
||||
"File Reader" in tool_display_names
|
||||
), "Default assistant should have File Reader tool"
|
||||
assert (
|
||||
"Code Interpreter" in tool_display_names
|
||||
), "Default assistant should have Code Interpreter tool"
|
||||
|
||||
# Should have exactly 6 tools
|
||||
# Should have exactly 5 tools
|
||||
assert (
|
||||
len(tool_associations) == 6
|
||||
), f"Default assistant should have exactly 6 tools attached, got {len(tool_associations)}"
|
||||
len(tool_associations) == 5
|
||||
), f"Default assistant should have exactly 5 tools attached, got {len(tool_associations)}"
|
||||
|
||||
@@ -82,7 +82,7 @@ def test_setup_attaches_instrumentator_to_app() -> None:
|
||||
inprogress_labels=True,
|
||||
excluded_handlers=["/health", "/metrics", "/openapi.json"],
|
||||
)
|
||||
assert mock_instance.add.call_count == 3
|
||||
assert mock_instance.add.call_count == 2
|
||||
mock_instance.instrument.assert_called_once_with(
|
||||
app,
|
||||
latency_lowr_buckets=(
|
||||
|
||||
@@ -163,16 +163,3 @@ Add clear comments:
|
||||
- Any TODOs you add in the code must be accompanied by either the name/username
|
||||
of the owner of that TODO, or an issue number for an issue referencing that
|
||||
piece of work.
|
||||
- Avoid module-level logic that runs on import, which leads to import-time side
|
||||
effects. Essentially every piece of meaningful logic should exist within some
|
||||
function that has to be explicitly invoked. Acceptable exceptions to this may
|
||||
include loading environment variables or setting up loggers.
|
||||
- If you find yourself needing something like this, you may want that logic to
|
||||
exist in a file dedicated for manual execution (contains `if __name__ ==
|
||||
"__main__":`) which should not be imported by anything else.
|
||||
- Related to the above, do not conflate Python scripts you intend to run from
|
||||
the command line (contains `if __name__ == "__main__":`) with modules you
|
||||
intend to import from elsewhere. If for some unlikely reason they have to be
|
||||
the same file, any logic specific to executing the file (including imports)
|
||||
should be contained in the `if __name__ == "__main__":` block.
|
||||
- Generally these executable files exist in `backend/scripts/`.
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Main() {
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgMcp}
|
||||
title="MCP Actions"
|
||||
description="Connect MCP (Model Context Protocol) servers to add custom actions and tools for your agents."
|
||||
description="Connect MCP (Model Context Protocol) servers to add custom actions and tools for your assistants."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Main() {
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgActions}
|
||||
title="OpenAPI Actions"
|
||||
description="Connect OpenAPI servers to add custom actions and tools for your agents."
|
||||
description="Connect OpenAPI servers to add custom actions and tools for your assistants."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
|
||||
@@ -170,7 +170,7 @@ export function PersonasTable({
|
||||
{deleteModalOpen && personaToDelete && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgAlertCircle}
|
||||
title="Delete Agent"
|
||||
title="Delete Assistant"
|
||||
onClose={closeDeleteModal}
|
||||
submit={<Button onClick={handleDeletePersona}>Delete</Button>}
|
||||
>
|
||||
@@ -183,15 +183,15 @@ export function PersonasTable({
|
||||
const isDefault = personaToToggleDefault.is_default_persona;
|
||||
|
||||
const title = isDefault
|
||||
? "Remove Featured Agent"
|
||||
: "Set Featured Agent";
|
||||
? "Remove Featured Assistant"
|
||||
: "Set Featured Assistant";
|
||||
const buttonText = isDefault ? "Remove Feature" : "Set as Featured";
|
||||
const text = isDefault
|
||||
? `Are you sure you want to remove the featured status of ${personaToToggleDefault.name}?`
|
||||
: `Are you sure you want to set the featured status of ${personaToToggleDefault.name}?`;
|
||||
const additionalText = isDefault
|
||||
? `Removing "${personaToToggleDefault.name}" as a featured agent will not affect its visibility or accessibility.`
|
||||
: `Setting "${personaToToggleDefault.name}" as a featured agent will make it public and visible to all users. This action cannot be undone.`;
|
||||
? `Removing "${personaToToggleDefault.name}" as a featured assistant will not affect its visibility or accessibility.`
|
||||
: `Setting "${personaToToggleDefault.name}" as a featured assistant will make it public and visible to all users. This action cannot be undone.`;
|
||||
|
||||
return (
|
||||
<ConfirmationModalLayout
|
||||
@@ -217,7 +217,7 @@ export function PersonasTable({
|
||||
"Name",
|
||||
"Description",
|
||||
"Type",
|
||||
"Featured Agent",
|
||||
"Featured Assistant",
|
||||
"Is Visible",
|
||||
"Delete",
|
||||
]}
|
||||
|
||||
@@ -47,8 +47,8 @@ function MainContent({
|
||||
return (
|
||||
<div>
|
||||
<Text className="mb-2">
|
||||
Agents are a way to build custom search/question-answering experiences
|
||||
for different use cases.
|
||||
Assistants are a way to build custom search/question-answering
|
||||
experiences for different use cases.
|
||||
</Text>
|
||||
<Text className="mt-2">They allow you to customize:</Text>
|
||||
<div className="text-sm">
|
||||
@@ -63,21 +63,21 @@ function MainContent({
|
||||
<div>
|
||||
<Separator />
|
||||
|
||||
<Title>Create an Agent</Title>
|
||||
<Title>Create an Assistant</Title>
|
||||
<CreateButton href="/app/agents/create?admin=true">
|
||||
New Agent
|
||||
New Assistant
|
||||
</CreateButton>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Title>Existing Agents</Title>
|
||||
<Title>Existing Assistants</Title>
|
||||
{totalItems > 0 ? (
|
||||
<>
|
||||
<SubLabel>
|
||||
Agents will be displayed as options on the Chat / Search
|
||||
interfaces in the order they are displayed below. Agents marked as
|
||||
hidden will not be displayed. Editable agents are shown at the
|
||||
top.
|
||||
Assistants will be displayed as options on the Chat / Search
|
||||
interfaces in the order they are displayed below. Assistants
|
||||
marked as hidden will not be displayed. Editable assistants are
|
||||
shown at the top.
|
||||
</SubLabel>
|
||||
<PersonasTable
|
||||
personas={customPersonas}
|
||||
@@ -96,21 +96,21 @@ function MainContent({
|
||||
) : (
|
||||
<div className="mt-6 p-8 border border-border rounded-lg bg-background-weak text-center">
|
||||
<Text className="text-lg font-medium mb-2">
|
||||
No custom agents yet
|
||||
No custom assistants yet
|
||||
</Text>
|
||||
<Text className="text-subtle mb-3">
|
||||
Create your first agent to:
|
||||
Create your first assistant to:
|
||||
</Text>
|
||||
<ul className="text-subtle text-sm list-disc text-left inline-block mb-3">
|
||||
<li>Build department-specific knowledge bases</li>
|
||||
<li>Create specialized research agents</li>
|
||||
<li>Create specialized research assistants</li>
|
||||
<li>Set up compliance and policy advisors</li>
|
||||
</ul>
|
||||
<Text className="text-subtle text-sm mb-4">
|
||||
...and so much more!
|
||||
</Text>
|
||||
<CreateButton href="/app/agents/create?admin=true">
|
||||
Create Your First Agent
|
||||
Create Your First Assistant
|
||||
</CreateButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -128,13 +128,13 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageTitle icon={SvgOnyxOctagon} title="Agents" />
|
||||
<AdminPageTitle icon={SvgOnyxOctagon} title="Assistants" />
|
||||
|
||||
{isLoading && <ThreeDotsLoader />}
|
||||
|
||||
{error && (
|
||||
<ErrorCallout
|
||||
errorTitle="Failed to load agents"
|
||||
errorTitle="Failed to load assistants"
|
||||
errorMsg={
|
||||
error?.info?.message ||
|
||||
error?.info?.detail ||
|
||||
|
||||
@@ -156,7 +156,7 @@ export const SlackChannelConfigCreationForm = ({
|
||||
is: "assistant",
|
||||
then: (schema) =>
|
||||
schema.required(
|
||||
"An agent is required when using the 'Agent' knowledge source"
|
||||
"A persona is required when using the'Assistant' knowledge source"
|
||||
),
|
||||
}),
|
||||
standard_answer_categories: Yup.array(),
|
||||
|
||||
@@ -224,14 +224,14 @@ export function SlackChannelConfigFormFields({
|
||||
<RadioGroupItemField
|
||||
value="assistant"
|
||||
id="assistant"
|
||||
label="Search Agent"
|
||||
label="Search Assistant"
|
||||
sublabel="Control both the documents and the prompt to use for answering questions"
|
||||
/>
|
||||
<RadioGroupItemField
|
||||
value="non_search_assistant"
|
||||
id="non_search_assistant"
|
||||
label="Non-Search Agent"
|
||||
sublabel="Chat with an agent that does not use documents"
|
||||
label="Non-Search Assistant"
|
||||
sublabel="Chat with an assistant that does not use documents"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
@@ -327,15 +327,15 @@ export function SlackChannelConfigFormFields({
|
||||
<div className="mt-4">
|
||||
<SubLabel>
|
||||
<>
|
||||
Select the search-enabled agent OnyxBot will use while answering
|
||||
questions in Slack.
|
||||
Select the search-enabled assistant OnyxBot will use while
|
||||
answering questions in Slack.
|
||||
{syncEnabledAssistants.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-sm text-text-dark/80">
|
||||
Note: Some of your agents have auto-synced connectors in
|
||||
their document sets. You cannot select these agents as
|
||||
they will not be able to answer questions in Slack.{" "}
|
||||
Note: Some of your assistants have auto-synced connectors
|
||||
in their document sets. You cannot select these assistants
|
||||
as they will not be able to answer questions in Slack.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -349,7 +349,7 @@ export function SlackChannelConfigFormFields({
|
||||
{viewSyncEnabledAssistants
|
||||
? "Hide un-selectable "
|
||||
: "View all "}
|
||||
agents
|
||||
assistants
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
@@ -367,7 +367,7 @@ export function SlackChannelConfigFormFields({
|
||||
{viewSyncEnabledAssistants && syncEnabledAssistants.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-text-dark/80">
|
||||
Un-selectable agents:
|
||||
Un-selectable assistants:
|
||||
</p>
|
||||
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
|
||||
{syncEnabledAssistants.map(
|
||||
@@ -394,15 +394,15 @@ export function SlackChannelConfigFormFields({
|
||||
<div className="mt-4">
|
||||
<SubLabel>
|
||||
<>
|
||||
Select the non-search agent OnyxBot will use while answering
|
||||
Select the non-search assistant OnyxBot will use while answering
|
||||
questions in Slack.
|
||||
{syncEnabledAssistants.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-sm text-text-dark/80">
|
||||
Note: Some of your agents have auto-synced connectors in
|
||||
their document sets. You cannot select these agents as
|
||||
they will not be able to answer questions in Slack.{" "}
|
||||
Note: Some of your assistants have auto-synced connectors
|
||||
in their document sets. You cannot select these assistants
|
||||
as they will not be able to answer questions in Slack.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -416,7 +416,7 @@ export function SlackChannelConfigFormFields({
|
||||
{viewSyncEnabledAssistants
|
||||
? "Hide un-selectable "
|
||||
: "View all "}
|
||||
agents
|
||||
assistants
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
@@ -524,7 +524,7 @@ export function SlackChannelConfigFormFields({
|
||||
name="is_ephemeral"
|
||||
label="Respond to user in a private (ephemeral) message"
|
||||
tooltip="If set, OnyxBot will respond only to the user in a private (ephemeral) message. If you also
|
||||
chose 'Search' Agent above, selecting this option will make documents that are private to the user
|
||||
chose 'Search' Assistant above, selecting this option will make documents that are private to the user
|
||||
available for their queries."
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import CodeInterpreterPage from "@/refresh-pages/admin/CodeInterpreterPage";
|
||||
|
||||
export default function Page() {
|
||||
return <CodeInterpreterPage />;
|
||||
}
|
||||
@@ -39,10 +39,10 @@ export function AdvancedOptions({
|
||||
agents={agents}
|
||||
isLoading={agentsLoading}
|
||||
error={agentsError}
|
||||
label="Agent Whitelist"
|
||||
subtext="Restrict this provider to specific agents."
|
||||
label="Assistant Whitelist"
|
||||
subtext="Restrict this provider to specific assistants."
|
||||
disabled={formikProps.values.is_public}
|
||||
disabledMessage="This LLM Provider is public and available to all agents."
|
||||
disabledMessage="This LLM Provider is public and available to all assistants."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -299,11 +299,11 @@ export default function Page({ params }: Props) {
|
||||
});
|
||||
refreshGuild();
|
||||
toast.success(
|
||||
personaId ? "Default agent updated" : "Default agent cleared"
|
||||
personaId ? "Default assistant updated" : "Default assistant cleared"
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to update agent"
|
||||
err instanceof Error ? err.message : "Failed to update assistant"
|
||||
);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
@@ -355,7 +355,7 @@ export default function Page({ params }: Props) {
|
||||
<InputSelect.Trigger placeholder="Select agent" />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="default">
|
||||
Default Agent
|
||||
Default Assistant
|
||||
</InputSelect.Item>
|
||||
{personas.map((persona) => (
|
||||
<InputSelect.Item
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import Logo from "@/refresh-components/Logo";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import TextChunk from "@/app/craft/components/TextChunk";
|
||||
import ThinkingCard from "@/app/craft/components/ThinkingCard";
|
||||
import ToolCallPill from "@/app/craft/components/ToolCallPill";
|
||||
@@ -68,6 +69,8 @@ interface BuildMessageListProps {
|
||||
messages: BuildMessage[];
|
||||
streamItems: StreamItem[];
|
||||
isStreaming?: boolean;
|
||||
/** Whether the user cancelled the current generation */
|
||||
userCancelled?: boolean;
|
||||
/** Whether auto-scroll is enabled (user is at bottom) */
|
||||
autoScrollEnabled?: boolean;
|
||||
/** Ref to the end marker div for scroll detection */
|
||||
@@ -85,6 +88,7 @@ export default function BuildMessageList({
|
||||
messages,
|
||||
streamItems,
|
||||
isStreaming = false,
|
||||
userCancelled = false,
|
||||
autoScrollEnabled = true,
|
||||
messagesEndRef: externalMessagesEndRef,
|
||||
}: BuildMessageListProps) {
|
||||
@@ -104,8 +108,11 @@ export default function BuildMessageList({
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastMessageIsUser = lastMessage?.type === "user";
|
||||
// Show streaming area if we have stream items OR if we're waiting for a response to the latest user message
|
||||
// Also show if user cancelled (to display the cancellation message)
|
||||
const showStreamingArea =
|
||||
hasStreamItems || (isStreaming && lastMessageIsUser);
|
||||
hasStreamItems ||
|
||||
(isStreaming && lastMessageIsUser) ||
|
||||
(userCancelled && lastMessageIsUser);
|
||||
|
||||
// Check for active tools (for "Working..." state)
|
||||
const hasActiveTools = streamItems.some(
|
||||
@@ -206,16 +213,28 @@ export default function BuildMessageList({
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
||||
{!hasStreamItems ? (
|
||||
// Loading state - no content yet, show blinking dot like main chat
|
||||
<BlinkingDot />
|
||||
// Loading state or cancelled - show blinking dot or cancelled message
|
||||
userCancelled ? (
|
||||
<Text as="p" secondaryBody text04>
|
||||
User has stopped generation
|
||||
</Text>
|
||||
) : (
|
||||
<BlinkingDot />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{/* Render stream items in FIFO order */}
|
||||
{renderStreamItems(streamItems, true)}
|
||||
|
||||
{/* Streaming indicator when actively streaming text */}
|
||||
{isStreaming && hasStreamItems && !hasActiveTools && (
|
||||
<BlinkingDot />
|
||||
{/* Show cancelled message or streaming indicator */}
|
||||
{userCancelled ? (
|
||||
<Text as="p" secondaryBody text04>
|
||||
User has stopped generation
|
||||
</Text>
|
||||
) : (
|
||||
isStreaming &&
|
||||
hasStreamItems &&
|
||||
!hasActiveTools && <BlinkingDot />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useSessionId,
|
||||
useHasSession,
|
||||
useIsRunning,
|
||||
useIsStreaming,
|
||||
useOutputPanelOpen,
|
||||
useToggleOutputPanel,
|
||||
useBuildSessionStore,
|
||||
@@ -67,6 +68,7 @@ export default function BuildChatPanel({
|
||||
const sessionId = useSessionId();
|
||||
const hasSession = useHasSession();
|
||||
const isRunning = useIsRunning();
|
||||
const isStreaming = useIsStreaming();
|
||||
const { setLeftSidebarFolded, leftSidebarFolded } = useBuildContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const toggleOutputPanel = useToggleOutputPanel();
|
||||
@@ -112,13 +114,16 @@ export default function BuildChatPanel({
|
||||
const nameBuildSession = useBuildSessionStore(
|
||||
(state) => state.nameBuildSession
|
||||
);
|
||||
const { streamMessage } = useBuildStreaming();
|
||||
const { streamMessage, abortStream } = useBuildStreaming();
|
||||
const isPreProvisioning = useIsPreProvisioning();
|
||||
const isPreProvisioningFailed = useIsPreProvisioningFailed();
|
||||
const preProvisionedSessionId = usePreProvisionedSessionId();
|
||||
|
||||
// Disable input when pre-provisioning is in progress or failed (waiting for retry)
|
||||
const sandboxNotReady = isPreProvisioning || isPreProvisioningFailed;
|
||||
// Disable input when pre-provisioning is in progress, failed, or sandbox is restoring
|
||||
const sandboxNotReady =
|
||||
isPreProvisioning ||
|
||||
isPreProvisioningFailed ||
|
||||
session?.sandbox?.status === "restoring";
|
||||
const { currentMessageFiles, hasUploadingFiles, setActiveSession } =
|
||||
useUploadFilesContext();
|
||||
const followupSuggestions = useFollowupSuggestions();
|
||||
@@ -431,6 +436,7 @@ export default function BuildChatPanel({
|
||||
messages={session?.messages ?? []}
|
||||
streamItems={session?.streamItems ?? []}
|
||||
isStreaming={isRunning}
|
||||
userCancelled={session?.status === "cancelled"}
|
||||
autoScrollEnabled={isAtBottom}
|
||||
/>
|
||||
)}
|
||||
@@ -483,7 +489,10 @@ export default function BuildChatPanel({
|
||||
<InputBar
|
||||
ref={inputBarRef}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={abortStream}
|
||||
isRunning={isRunning}
|
||||
isStreaming={isStreaming}
|
||||
sandboxInitializing={sandboxNotReady}
|
||||
placeholder="Continue the conversation..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
SvgPaperclip,
|
||||
SvgOrganization,
|
||||
SvgAlertCircle,
|
||||
SvgStop,
|
||||
} from "@opal/icons";
|
||||
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
@@ -52,7 +53,11 @@ export interface InputBarProps {
|
||||
files: BuildFile[],
|
||||
demoDataEnabled: boolean
|
||||
) => void;
|
||||
/** Callback to stop the current generation. If provided and isStreaming is true, shows a stop button. */
|
||||
onStop?: () => void;
|
||||
isRunning: boolean;
|
||||
/** True only when the LLM is actively streaming. Controls whether the stop button is shown. */
|
||||
isStreaming?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
/** When true, shows spinner on send button with "Initializing sandbox..." tooltip */
|
||||
@@ -153,7 +158,9 @@ const InputBar = memo(
|
||||
(
|
||||
{
|
||||
onSubmit,
|
||||
onStop,
|
||||
isRunning,
|
||||
isStreaming = false,
|
||||
disabled = false,
|
||||
placeholder = "Describe your task...",
|
||||
sandboxInitializing = false,
|
||||
@@ -405,18 +412,26 @@ const InputBar = memo(
|
||||
|
||||
{/* Bottom right controls */}
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{/* Submit button */}
|
||||
<IconButton
|
||||
icon={sandboxInitializing ? SvgLoader : SvgArrowUp}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
tooltip={
|
||||
sandboxInitializing ? "Initializing sandbox..." : "Send"
|
||||
}
|
||||
iconClassName={
|
||||
sandboxInitializing ? "animate-spin" : undefined
|
||||
}
|
||||
/>
|
||||
{/* Submit button - shows Stop when running, Send otherwise */}
|
||||
{isStreaming && onStop ? (
|
||||
<IconButton
|
||||
icon={SvgStop}
|
||||
onClick={() => onStop()}
|
||||
tooltip="Stop generation"
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={sandboxInitializing ? SvgLoader : SvgArrowUp}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
tooltip={
|
||||
sandboxInitializing ? "Initializing sandbox..." : "Send"
|
||||
}
|
||||
iconClassName={
|
||||
sandboxInitializing ? "animate-spin" : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2068,6 +2068,15 @@ export const useIsRunning = () =>
|
||||
return session?.status === "running" || session?.status === "creating";
|
||||
});
|
||||
|
||||
/** True only when the LLM is actively streaming a response. */
|
||||
export const useIsStreaming = () =>
|
||||
useBuildSessionStore((state) => {
|
||||
const { currentSessionId, sessions } = state;
|
||||
if (!currentSessionId) return false;
|
||||
const session = sessions.get(currentSessionId);
|
||||
return session?.status === "running";
|
||||
});
|
||||
|
||||
export const useMessages = () =>
|
||||
useBuildSessionStore((state) => {
|
||||
const { currentSessionId, sessions } = state;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
fetchSession,
|
||||
generateFollowupSuggestions,
|
||||
RateLimitError,
|
||||
cancelMessage,
|
||||
} from "@/app/craft/services/apiServices";
|
||||
|
||||
import { useBuildSessionStore } from "@/app/craft/hooks/useBuildSessionStore";
|
||||
@@ -40,9 +41,7 @@ export function useBuildStreaming() {
|
||||
const setAbortController = useBuildSessionStore(
|
||||
(state) => state.setAbortController
|
||||
);
|
||||
const abortCurrentSession = useBuildSessionStore(
|
||||
(state) => state.abortCurrentSession
|
||||
);
|
||||
const abortSession = useBuildSessionStore((state) => state.abortSession);
|
||||
const updateSessionData = useBuildSessionStore(
|
||||
(state) => state.updateSessionData
|
||||
);
|
||||
@@ -449,11 +448,45 @@ export function useBuildStreaming() {
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Abort the current streaming operation.
|
||||
* This both:
|
||||
* 1. Aborts the HTTP request (via AbortController)
|
||||
* 2. Sends a cancel notification to the ACP agent (via backend API)
|
||||
* 3. Updates session status to "cancelled" to update UI state
|
||||
*/
|
||||
const abortStream = useCallback(
|
||||
async (sessionId?: string) => {
|
||||
const targetSessionId =
|
||||
sessionId ?? useBuildSessionStore.getState().currentSessionId;
|
||||
|
||||
if (!targetSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First, send cancel to the backend to stop the agent
|
||||
// This sends session/cancel notification per ACP protocol
|
||||
try {
|
||||
await cancelMessage(targetSessionId);
|
||||
} catch (err) {
|
||||
console.error("[Streaming] Failed to cancel agent operation:", err);
|
||||
}
|
||||
|
||||
// Update status first so the UI transitions atomically (stop button
|
||||
// disappears and cancellation message appears before the stream closes)
|
||||
updateSessionData(targetSessionId, { status: "cancelled" });
|
||||
|
||||
// Abort the HTTP request to stop receiving events
|
||||
abortSession(targetSessionId);
|
||||
},
|
||||
[abortSession, updateSessionData]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
streamMessage,
|
||||
abortStream: abortCurrentSession,
|
||||
abortStream,
|
||||
}),
|
||||
[streamMessage, abortCurrentSession]
|
||||
[streamMessage, abortStream]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,6 +358,30 @@ export async function sendMessageStream(
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current message/prompt operation for a session.
|
||||
* Sends a session/cancel notification to the ACP agent to stop
|
||||
* the currently running operation.
|
||||
*
|
||||
* This follows the ACP (Agent Client Protocol) specification for cancellation.
|
||||
*
|
||||
* @returns true if cancel was sent, false if no active operation
|
||||
*/
|
||||
export async function cancelMessage(sessionId: string): Promise<boolean> {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}/cancel-message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to cancel message: ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data.cancelled ?? false;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Artifacts API
|
||||
// =============================================================================
|
||||
|
||||
@@ -125,7 +125,8 @@ export type SessionStatus =
|
||||
| "creating"
|
||||
| "running"
|
||||
| "active"
|
||||
| "failed";
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface Session {
|
||||
id: string | null;
|
||||
|
||||
@@ -427,7 +427,7 @@ export const GroupDisplay = ({
|
||||
|
||||
<Separator />
|
||||
|
||||
<h2 className="text-xl font-bold mt-8 mb-2">Agents</h2>
|
||||
<h2 className="text-xl font-bold mt-8 mb-2">Assistants</h2>
|
||||
|
||||
<div>
|
||||
{userGroup.document_sets.length > 0 ? (
|
||||
@@ -445,7 +445,7 @@ export const GroupDisplay = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Text>No Agents in this group...</Text>
|
||||
<Text>No Assistants in this group...</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -152,14 +152,14 @@ export function PersonaMessagesChart({
|
||||
} else if (selectedPersonaId === undefined) {
|
||||
content = (
|
||||
<div className="h-80 text-text-500 flex flex-col">
|
||||
<p className="m-auto">Select an agent to view analytics</p>
|
||||
<p className="m-auto">Select an assistant to view analytics</p>
|
||||
</div>
|
||||
);
|
||||
} else if (!personaMessagesData?.length) {
|
||||
content = (
|
||||
<div className="h-80 text-text-500 flex flex-col">
|
||||
<p className="m-auto">
|
||||
No data found for selected agent in the specified time range
|
||||
No data found for selected assistant in the specified time range
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -178,9 +178,11 @@ export function PersonaMessagesChart({
|
||||
|
||||
return (
|
||||
<CardSection className="mt-8">
|
||||
<Title>Agent Analytics</Title>
|
||||
<Title>Assistant Analytics</Title>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text>Messages and unique users per day for the selected agent</Text>
|
||||
<Text>
|
||||
Messages and unique users per day for the selected assistant
|
||||
</Text>
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={selectedPersonaId?.toString() ?? ""}
|
||||
@@ -189,14 +191,14 @@ export function PersonaMessagesChart({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex w-full max-w-xs">
|
||||
<SelectValue placeholder="Select an agent to display" />
|
||||
<SelectValue placeholder="Select an assistant to display" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<div className="flex items-center px-2 pb-2 sticky top-0 bg-background border-b">
|
||||
<Search className="h-4 w-4 mr-2 shrink-0 opacity-50" />
|
||||
<input
|
||||
className="flex h-8 w-full rounded-sm bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Search agents..."
|
||||
placeholder="Search assistants..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AssistantStats({ assistantId }: { assistantId: number }) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<p className="text-base font-normal text-2xl">Agent Analytics</p>
|
||||
<p className="text-base font-normal text-2xl">Assistant Analytics</p>
|
||||
<AdminDateRangeSelector
|
||||
value={dateRange}
|
||||
onValueChange={setDateRange}
|
||||
|
||||
@@ -12,17 +12,17 @@ export default function NoAssistantModal() {
|
||||
return (
|
||||
<Modal open>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header icon={SvgUser} title="No Agent Available" />
|
||||
<Modal.Header icon={SvgUser} title="No Assistant Available" />
|
||||
<Modal.Body>
|
||||
<Text as="p">
|
||||
You currently have no agent configured. To use this feature, you
|
||||
You currently have no assistant configured. To use this feature, you
|
||||
need to take action.
|
||||
</Text>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<Text as="p">
|
||||
As an administrator, you can create a new agent by visiting the
|
||||
admin panel.
|
||||
As an administrator, you can create a new assistant by visiting
|
||||
the admin panel.
|
||||
</Text>
|
||||
<Button className="w-full" href="/admin/assistants">
|
||||
Go to Admin Panel
|
||||
@@ -30,7 +30,8 @@ export default function NoAssistantModal() {
|
||||
</>
|
||||
) : (
|
||||
<Text as="p">
|
||||
Please contact your administrator to configure an agent for you.
|
||||
Please contact your administrator to configure an assistant for
|
||||
you.
|
||||
</Text>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
|
||||
const HEALTH_ENDPOINT = "/api/admin/code-interpreter/health";
|
||||
const STATUS_ENDPOINT = "/api/admin/code-interpreter";
|
||||
|
||||
interface CodeInterpreterHealth {
|
||||
healthy: boolean;
|
||||
}
|
||||
|
||||
interface CodeInterpreterStatus {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default function useCodeInterpreter() {
|
||||
const {
|
||||
data: healthData,
|
||||
error: healthError,
|
||||
isLoading: isHealthLoading,
|
||||
mutate: refetchHealth,
|
||||
} = useSWR<CodeInterpreterHealth>(HEALTH_ENDPOINT, errorHandlingFetcher, {
|
||||
refreshInterval: 30000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: statusData,
|
||||
error: statusError,
|
||||
isLoading: isStatusLoading,
|
||||
mutate: refetchStatus,
|
||||
} = useSWR<CodeInterpreterStatus>(STATUS_ENDPOINT, errorHandlingFetcher);
|
||||
|
||||
function refetch() {
|
||||
refetchHealth();
|
||||
refetchStatus();
|
||||
}
|
||||
|
||||
return {
|
||||
isHealthy: healthData?.healthy ?? false,
|
||||
isEnabled: statusData?.enabled ?? false,
|
||||
isLoading: isHealthLoading || isStatusLoading,
|
||||
error: healthError || statusError,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
const UPDATE_ENDPOINT = "/api/admin/code-interpreter";
|
||||
|
||||
interface CodeInterpreterUpdateRequest {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function updateCodeInterpreter(
|
||||
request: CodeInterpreterUpdateRequest
|
||||
): Promise<Response> {
|
||||
return fetch(UPDATE_ENDPOINT, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
@@ -179,7 +179,7 @@ export default function ActionLineItem({
|
||||
)}
|
||||
|
||||
{isSearchToolAndNotInProject && (
|
||||
<Button
|
||||
<IconButton
|
||||
icon={
|
||||
isSearchToolWithNoConnectors ? SvgSettings : SvgChevronRight
|
||||
}
|
||||
@@ -188,8 +188,11 @@ export default function ActionLineItem({
|
||||
router.push("/admin/add-connector");
|
||||
else onSourceManagementOpen?.();
|
||||
})}
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
internal
|
||||
className={cn(
|
||||
isSearchToolWithNoConnectors &&
|
||||
"invisible group-hover/LineItem:visible"
|
||||
)}
|
||||
tooltip={
|
||||
isSearchToolWithNoConnectors
|
||||
? "Add Connectors"
|
||||
|
||||
@@ -425,7 +425,7 @@ export default function AgentsNavigationPage() {
|
||||
>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgOnyxOctagon}
|
||||
title="Agents"
|
||||
title="Agents & Assistants"
|
||||
description="Customize AI behavior and knowledge for you and your team's use cases."
|
||||
rightChildren={
|
||||
<Button
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Card, type CardProps } from "@/refresh-components/cards";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgCheckCircle,
|
||||
SvgRefreshCw,
|
||||
SvgTerminal,
|
||||
SvgUnplug,
|
||||
SvgXOctagon,
|
||||
} from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import useCodeInterpreter from "@/hooks/useCodeInterpreter";
|
||||
import { updateCodeInterpreter } from "@/lib/admin/code-interpreter/svc";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
|
||||
interface CodeInterpreterCardProps {
|
||||
variant?: CardProps["variant"];
|
||||
title: string;
|
||||
middleText?: string;
|
||||
strikethrough?: boolean;
|
||||
rightContent: React.ReactNode;
|
||||
}
|
||||
|
||||
function CodeInterpreterCard({
|
||||
variant,
|
||||
title,
|
||||
middleText,
|
||||
strikethrough,
|
||||
rightContent,
|
||||
}: CodeInterpreterCardProps) {
|
||||
return (
|
||||
// TODO (@raunakab): Allow Content to accept strikethrough and middleText
|
||||
<Card variant={variant} padding={0.5}>
|
||||
<ContentAction
|
||||
icon={SvgTerminal}
|
||||
title={middleText ? `${title} ${middleText}` : title}
|
||||
description="Built-in Python runtime"
|
||||
variant="section"
|
||||
sizePreset="main-ui"
|
||||
rightChildren={rightContent}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckingStatus() {
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
gap={0.25}
|
||||
padding={0.5}
|
||||
>
|
||||
<Text mainUiAction text03>
|
||||
Checking...
|
||||
</Text>
|
||||
<SimpleLoader />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
healthy: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ConnectionStatus({ healthy, isLoading }: ConnectionStatusProps) {
|
||||
if (isLoading) {
|
||||
return <CheckingStatus />;
|
||||
}
|
||||
|
||||
const label = healthy ? "Connected" : "Connection Lost";
|
||||
const Icon = healthy ? SvgCheckCircle : SvgXOctagon;
|
||||
const iconColor = healthy ? "text-status-success-05" : "text-status-error-05";
|
||||
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
gap={0.25}
|
||||
padding={0.5}
|
||||
>
|
||||
<Text mainUiAction text03>
|
||||
{label}
|
||||
</Text>
|
||||
<Icon size={16} className={iconColor} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onDisconnect: () => void;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ActionButtons({
|
||||
onDisconnect,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: ActionButtonsProps) {
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="end"
|
||||
alignItems="center"
|
||||
gap={0.25}
|
||||
padding={0.25}
|
||||
>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgUnplug}
|
||||
onClick={onDisconnect}
|
||||
tooltip="Disconnect"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgRefreshCw}
|
||||
onClick={onRefresh}
|
||||
tooltip="Refresh"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CodeInterpreterPage() {
|
||||
const { isHealthy, isEnabled, isLoading, refetch } = useCodeInterpreter();
|
||||
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
|
||||
async function handleToggle(enabled: boolean) {
|
||||
const action = enabled ? "reconnect" : "disconnect";
|
||||
setIsReconnecting(enabled);
|
||||
try {
|
||||
const response = await updateCodeInterpreter({ enabled });
|
||||
if (!response.ok) {
|
||||
toast.error(`Failed to ${action} Code Interpreter`);
|
||||
return;
|
||||
}
|
||||
setShowDisconnectModal(false);
|
||||
refetch();
|
||||
} finally {
|
||||
setIsReconnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgTerminal}
|
||||
title="Code Interpreter"
|
||||
description="Safe and sandboxed Python runtime available to your LLM. See docs for more details."
|
||||
separator
|
||||
/>
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{isEnabled || isLoading ? (
|
||||
<CodeInterpreterCard
|
||||
title="Code Interpreter"
|
||||
variant={isHealthy ? "primary" : "secondary"}
|
||||
strikethrough={!isHealthy}
|
||||
rightContent={
|
||||
<Section
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="end"
|
||||
gap={0}
|
||||
padding={0}
|
||||
>
|
||||
<ConnectionStatus healthy={isHealthy} isLoading={isLoading} />
|
||||
<ActionButtons
|
||||
onDisconnect={() => setShowDisconnectModal(true)}
|
||||
onRefresh={refetch}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Section>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CodeInterpreterCard
|
||||
variant="secondary"
|
||||
title="Code Interpreter"
|
||||
middleText="(Disconnected)"
|
||||
strikethrough={true}
|
||||
rightContent={
|
||||
<Section flexDirection="row" alignItems="center" padding={0.5}>
|
||||
{isReconnecting ? (
|
||||
<CheckingStatus />
|
||||
) : (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
rightIcon={SvgArrowExchange}
|
||||
onClick={() => handleToggle(true)}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</Section>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
|
||||
{showDisconnectModal && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgUnplug}
|
||||
title="Disconnect Code Interpreter"
|
||||
onClose={() => setShowDisconnectModal(false)}
|
||||
submit={
|
||||
<Button variant="danger" onClick={() => handleToggle(false)}>
|
||||
Disconnect
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
All running sessions connected to{" "}
|
||||
<Text as="span" mainContentEmphasis text03>
|
||||
Code Interpreter
|
||||
</Text>{" "}
|
||||
will stop working. Note that this will not remove any data from your
|
||||
runtime. You can reconnect to this runtime later if needed.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export default function NewTenantModal({
|
||||
: `Your request to join ${tenantInfo.number_of_users} other users of ${APP_DOMAIN} has been approved.`;
|
||||
|
||||
const description = isInvite
|
||||
? `By accepting this invitation, you will join the existing ${APP_DOMAIN} team and lose access to your current team. Note: you will lose access to your current agents, prompts, chats, and connected sources.`
|
||||
? `By accepting this invitation, you will join the existing ${APP_DOMAIN} team and lose access to your current team. Note: you will lose access to your current assistants, prompts, chats, and connected sources.`
|
||||
: `To finish joining your team, please reauthenticate with ${user?.email}.`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
SvgPaintBrush,
|
||||
SvgDiscordMono,
|
||||
SvgWallet,
|
||||
SvgTerminal,
|
||||
} from "@opal/icons";
|
||||
import SvgMcp from "@opal/icons/mcp";
|
||||
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
|
||||
@@ -92,7 +91,7 @@ const custom_assistants_items = (
|
||||
) => {
|
||||
const items = [
|
||||
{
|
||||
name: "Agents",
|
||||
name: "Assistants",
|
||||
icon: SvgOnyxOctagon,
|
||||
link: "/admin/assistants",
|
||||
},
|
||||
@@ -166,7 +165,7 @@ const collections = (
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Custom Agents",
|
||||
name: "Custom Assistants",
|
||||
items: custom_assistants_items(isCurator, enableEnterprise),
|
||||
},
|
||||
...(isCurator && enableEnterprise
|
||||
@@ -208,11 +207,6 @@ const collections = (
|
||||
icon: SvgImage,
|
||||
link: "/admin/configuration/image-generation",
|
||||
},
|
||||
{
|
||||
name: "Code Interpreter",
|
||||
icon: SvgTerminal,
|
||||
link: "/admin/configuration/code-interpreter",
|
||||
},
|
||||
...(!enableCloud && vectorDbEnabled
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -29,12 +29,12 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
pageTitle: "Add Connector",
|
||||
},
|
||||
{
|
||||
name: "Custom Agents - Agents",
|
||||
name: "Custom Assistants - Assistants",
|
||||
path: "assistants",
|
||||
pageTitle: "Agents",
|
||||
pageTitle: "Assistants",
|
||||
options: {
|
||||
paragraphText:
|
||||
"Agents are a way to build custom search/question-answering experiences for different use cases.",
|
||||
"Assistants are a way to build custom search/question-answering experiences for different use cases.",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -52,7 +52,7 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Agents - Slack Bots",
|
||||
name: "Custom Assistants - Slack Bots",
|
||||
path: "bots",
|
||||
pageTitle: "Slack Bots",
|
||||
options: {
|
||||
@@ -61,7 +61,7 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Agents - Standard Answers",
|
||||
name: "Custom Assistants - Standard Answers",
|
||||
path: "standard-answer",
|
||||
pageTitle: "Standard Answers",
|
||||
},
|
||||
@@ -101,12 +101,12 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
pageTitle: "Search Settings",
|
||||
},
|
||||
{
|
||||
name: "Custom Agents - MCP Actions",
|
||||
name: "Custom Assistants - MCP Actions",
|
||||
path: "actions/mcp",
|
||||
pageTitle: "MCP Actions",
|
||||
},
|
||||
{
|
||||
name: "Custom Agents - OpenAPI Actions",
|
||||
name: "Custom Assistants - OpenAPI Actions",
|
||||
path: "actions/open-api",
|
||||
pageTitle: "OpenAPI Actions",
|
||||
},
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { loginAs } from "@tests/e2e/utils/auth";
|
||||
|
||||
const CODE_INTERPRETER_URL = "/admin/configuration/code-interpreter";
|
||||
const API_STATUS_URL = "**/api/admin/code-interpreter";
|
||||
const API_HEALTH_URL = "**/api/admin/code-interpreter/health";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Intercept the status (GET /) and health (GET /health) endpoints with the
|
||||
* given values so the page renders deterministically.
|
||||
*
|
||||
* Also handles PUT requests — by default they succeed (200). Pass
|
||||
* `putStatus` to simulate failures.
|
||||
*/
|
||||
async function mockCodeInterpreterApi(
|
||||
page: Page,
|
||||
opts: { enabled: boolean; healthy: boolean; putStatus?: number }
|
||||
) {
|
||||
const putStatus = opts.putStatus ?? 200;
|
||||
|
||||
await page.route(API_HEALTH_URL, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ healthy: opts.healthy }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(API_STATUS_URL, async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
await route.fulfill({
|
||||
status: putStatus,
|
||||
contentType: "application/json",
|
||||
body:
|
||||
putStatus >= 400
|
||||
? JSON.stringify({ detail: "Server Error" })
|
||||
: JSON.stringify(null),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ enabled: opts.enabled }),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The disconnect icon button is an icon-only opal Button whose tooltip text
|
||||
* is not exposed as an accessible name. Locate it by finding the first
|
||||
* icon-only button (no label span) inside the card area.
|
||||
*/
|
||||
function getDisconnectIconButton(page: Page) {
|
||||
return page
|
||||
.locator("button:has(.opal-button):not(:has(.opal-button-label))")
|
||||
.first();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Code Interpreter Admin Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await loginAs(page, "admin");
|
||||
});
|
||||
|
||||
test("page loads with header and description", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
|
||||
/^Code Interpreter/,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
await expect(page.getByText("Built-in Python runtime")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows Connected status when enabled and healthy", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("shows Connection Lost when enabled but unhealthy", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: false });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connection Lost")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows Reconnect button when disabled", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: false, healthy: false });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("(Disconnected)")).toBeVisible();
|
||||
});
|
||||
|
||||
test("disconnect flow opens modal and sends PUT request", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the disconnect icon button
|
||||
await getDisconnectIconButton(page).click();
|
||||
|
||||
// Modal should appear
|
||||
await expect(page.getByText("Disconnect Code Interpreter")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("All running sessions connected to")
|
||||
).toBeVisible();
|
||||
|
||||
// Click the danger Disconnect button in the modal
|
||||
const modal = page.getByRole("dialog");
|
||||
await modal.getByRole("button", { name: "Disconnect" }).click();
|
||||
|
||||
// Modal should close after successful disconnect
|
||||
await expect(page.getByText("Disconnect Code Interpreter")).not.toBeVisible(
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnect modal can be closed without disconnecting", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open modal
|
||||
await getDisconnectIconButton(page).click();
|
||||
await expect(page.getByText("Disconnect Code Interpreter")).toBeVisible();
|
||||
|
||||
// Close modal via Cancel button
|
||||
const modal = page.getByRole("dialog");
|
||||
await modal.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
// Modal should be gone, page still shows Connected
|
||||
await expect(
|
||||
page.getByText("Disconnect Code Interpreter")
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByText("Connected")).toBeVisible();
|
||||
});
|
||||
|
||||
test("reconnect flow sends PUT with enabled=true", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, { enabled: false, healthy: false });
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Intercept the PUT and verify the payload
|
||||
const putPromise = page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes("/api/admin/code-interpreter") &&
|
||||
req.method() === "PUT"
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Reconnect" }).click();
|
||||
|
||||
const putReq = await putPromise;
|
||||
expect(putReq.postDataJSON()).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
test("shows Checking... while reconnect is in progress", async ({ page }) => {
|
||||
// Use a single route handler that delays PUT responses
|
||||
await page.route(API_HEALTH_URL, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ healthy: false }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(API_STATUS_URL, async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(null),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Reconnect" }).click();
|
||||
|
||||
// Should show Checking... while the request is in flight
|
||||
await expect(page.getByText("Checking...")).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test("shows error toast when disconnect fails", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, {
|
||||
enabled: true,
|
||||
healthy: true,
|
||||
putStatus: 500,
|
||||
});
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open modal and click disconnect
|
||||
await getDisconnectIconButton(page).click();
|
||||
const modal = page.getByRole("dialog");
|
||||
await modal.getByRole("button", { name: "Disconnect" }).click();
|
||||
|
||||
// Error toast should appear
|
||||
await expect(
|
||||
page.getByText("Failed to disconnect Code Interpreter")
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("shows error toast when reconnect fails", async ({ page }) => {
|
||||
await mockCodeInterpreterApi(page, {
|
||||
enabled: false,
|
||||
healthy: false,
|
||||
putStatus: 500,
|
||||
});
|
||||
await page.goto(CODE_INTERPRETER_URL);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Reconnect" }).click();
|
||||
|
||||
// Error toast should appear
|
||||
await expect(
|
||||
page.getByText("Failed to reconnect Code Interpreter")
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Reconnect button should reappear (not stuck in Checking...)
|
||||
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ test.skip("User changes password and logs in with new password", async ({
|
||||
|
||||
// Verify successful login
|
||||
await expect(page).toHaveURL("http://localhost:3000/app");
|
||||
await expect(page.getByText("Explore Agents")).toBeVisible();
|
||||
await expect(page.getByText("Explore Assistants")).toBeVisible();
|
||||
});
|
||||
|
||||
test.use({ storageState: "admin2_auth.json" });
|
||||
@@ -115,5 +115,5 @@ test.skip("Admin resets own password and logs in with new password", async ({
|
||||
|
||||
// Verify successful login
|
||||
await expect(page).toHaveURL("http://localhost:3000/app");
|
||||
await expect(page.getByText("Explore Agents")).toBeVisible();
|
||||
await expect(page.getByText("Explore Assistants")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
|
||||
|
||||
// Tool-related test selectors now imported from shared utils
|
||||
|
||||
test.describe("Default Agent Tests", () => {
|
||||
test.describe("Default Assistant Tests", () => {
|
||||
let imageGenConfigId: string | null = null;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
@@ -69,7 +69,7 @@ test.describe("Default Agent Tests", () => {
|
||||
});
|
||||
|
||||
test.describe("Greeting Message Display", () => {
|
||||
test("should display greeting message when opening new chat with default agent", async ({
|
||||
test("should display greeting message when opening new chat with default assistant", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Look for greeting message - should be one from the predefined list
|
||||
@@ -95,21 +95,23 @@ test.describe("Default Agent Tests", () => {
|
||||
expect(GREETING_MESSAGES).toContain(greetingAfterReload?.trim());
|
||||
});
|
||||
|
||||
test("greeting should only appear for default agent", async ({ page }) => {
|
||||
// First verify greeting appears for default agent
|
||||
test("greeting should only appear for default assistant", async ({
|
||||
page,
|
||||
}) => {
|
||||
// First verify greeting appears for default assistant
|
||||
const greetingElement = await page.waitForSelector(
|
||||
'[data-testid="onyx-logo"]',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
expect(greetingElement).toBeTruthy();
|
||||
|
||||
// Create a custom agent to test non-default behavior
|
||||
// Create a custom assistant to test non-default behavior
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.locator('input[name="name"]').fill("Custom Test Agent");
|
||||
await page.locator('input[name="name"]').fill("Custom Test Assistant");
|
||||
await page
|
||||
.locator('textarea[name="description"]')
|
||||
.fill("Test Description");
|
||||
@@ -118,17 +120,17 @@ test.describe("Default Agent Tests", () => {
|
||||
.fill("Test Instructions");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
// Wait for agent to be created and selected
|
||||
await verifyAssistantIsChosen(page, "Custom Test Agent");
|
||||
// Wait for assistant to be created and selected
|
||||
await verifyAssistantIsChosen(page, "Custom Test Assistant");
|
||||
|
||||
// Greeting should NOT appear for custom agent
|
||||
// Greeting should NOT appear for custom assistant
|
||||
const customGreeting = await page.$('[data-testid="onyx-logo"]');
|
||||
expect(customGreeting).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Default Agent Branding", () => {
|
||||
test("should display Onyx logo for default agent", async ({ page }) => {
|
||||
test.describe("Default Assistant Branding", () => {
|
||||
test("should display Onyx logo for default assistant", async ({ page }) => {
|
||||
// Look for Onyx logo
|
||||
const logoElement = await page.waitForSelector(
|
||||
'[data-testid="onyx-logo"]',
|
||||
@@ -136,23 +138,23 @@ test.describe("Default Agent Tests", () => {
|
||||
);
|
||||
expect(logoElement).toBeTruthy();
|
||||
|
||||
// Should NOT show agent name for default agent
|
||||
// Should NOT show assistant name for default assistant
|
||||
const assistantNameElement = await page.$(
|
||||
'[data-testid="assistant-name-display"]'
|
||||
);
|
||||
expect(assistantNameElement).toBeNull();
|
||||
});
|
||||
|
||||
test("custom agents should show name and icon instead of logo", async ({
|
||||
test("custom assistants should show name and icon instead of logo", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a custom agent
|
||||
// Create a custom assistant
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.locator('input[name="name"]').fill("Custom Agent");
|
||||
await page.locator('input[name="name"]').fill("Custom Assistant");
|
||||
await page
|
||||
.locator('textarea[name="description"]')
|
||||
.fill("Test Description");
|
||||
@@ -161,16 +163,16 @@ test.describe("Default Agent Tests", () => {
|
||||
.fill("Test Instructions");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
// Wait for agent to be created and selected
|
||||
await verifyAssistantIsChosen(page, "Custom Agent");
|
||||
// Wait for assistant to be created and selected
|
||||
await verifyAssistantIsChosen(page, "Custom Assistant");
|
||||
|
||||
// Should show agent name and icon, not Onyx logo
|
||||
// Should show assistant name and icon, not Onyx logo
|
||||
const assistantNameElement = await page.waitForSelector(
|
||||
'[data-testid="assistant-name-display"]',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
const nameText = await assistantNameElement.textContent();
|
||||
expect(nameText).toContain("Custom Agent");
|
||||
expect(nameText).toContain("Custom Assistant");
|
||||
|
||||
// Onyx logo should NOT be shown
|
||||
const logoElement = await page.$('[data-testid="onyx-logo"]');
|
||||
@@ -179,8 +181,10 @@ test.describe("Default Agent Tests", () => {
|
||||
});
|
||||
|
||||
test.describe("Starter Messages", () => {
|
||||
test("default agent should NOT have starter messages", async ({ page }) => {
|
||||
// Check that starter messages container does not exist for default agent
|
||||
test("default assistant should NOT have starter messages", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Check that starter messages container does not exist for default assistant
|
||||
const starterMessagesContainer = await page.$(
|
||||
'[data-testid="starter-messages"]'
|
||||
);
|
||||
@@ -191,14 +195,18 @@ test.describe("Default Agent Tests", () => {
|
||||
expect(starterButtons.length).toBe(0);
|
||||
});
|
||||
|
||||
test("custom agents should display starter messages", async ({ page }) => {
|
||||
// Create a custom agent with starter messages
|
||||
test("custom assistants should display starter messages", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a custom assistant with starter messages
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.locator('input[name="name"]').fill("Test Agent with Starters");
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.fill("Test Assistant with Starters");
|
||||
await page
|
||||
.locator('textarea[name="description"]')
|
||||
.fill("Test Description");
|
||||
@@ -211,9 +219,9 @@ 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 verifyAssistantIsChosen(page, "Test Assistant with Starters");
|
||||
|
||||
// Starter messages container might exist but be empty for custom agents
|
||||
// Starter messages container might exist but be empty for custom assistants
|
||||
const starterMessagesContainer = await page.$(
|
||||
'[data-testid="starter-messages"]'
|
||||
);
|
||||
@@ -222,22 +230,24 @@ test.describe("Default Agent Tests", () => {
|
||||
const starterButtons = await page.$$(
|
||||
'[data-testid^="starter-message-"]'
|
||||
);
|
||||
// Custom agent without configured starter messages should have none
|
||||
// Custom assistant without configured starter messages should have none
|
||||
expect(starterButtons.length).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Agent Selection", () => {
|
||||
test("default agent should be selected for new chats", async ({ page }) => {
|
||||
// Verify the input placeholder indicates default agent (Onyx)
|
||||
test.describe("Assistant Selection", () => {
|
||||
test("default assistant should be selected for new chats", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Verify the input placeholder indicates default assistant (Onyx)
|
||||
await verifyDefaultAssistantIsChosen(page);
|
||||
});
|
||||
|
||||
test("default agent should NOT appear in agent selector", async ({
|
||||
test("default assistant should NOT appear in assistant selector", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Open agent selector
|
||||
// Open assistant selector
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
|
||||
// Wait for modal or assistant list to appear
|
||||
@@ -246,13 +256,13 @@ test.describe("Default Agent Tests", () => {
|
||||
.getByLabel("AgentsPage/new-agent-button")
|
||||
.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
// Look for default agent by name - it should NOT be there
|
||||
// Look for default assistant by name - it should NOT be there
|
||||
const assistantElements = await page.$$('[data-testid^="assistant-"]');
|
||||
const assistantTexts = await Promise.all(
|
||||
assistantElements.map((el) => el.textContent())
|
||||
);
|
||||
|
||||
// Check that the default agent is not in the list
|
||||
// Check that "Assistant" (the default assistant name) is not in the list
|
||||
const hasDefaultAssistant = assistantTexts.some(
|
||||
(text) =>
|
||||
text?.includes("Assistant") &&
|
||||
@@ -265,16 +275,16 @@ test.describe("Default Agent Tests", () => {
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("should be able to switch from default to custom agent", async ({
|
||||
test("should be able to switch from default to custom assistant", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create a custom agent
|
||||
// Create a custom assistant
|
||||
await page.getByTestId("AppSidebar/more-agents").click();
|
||||
await page.getByLabel("AgentsPage/new-agent-button").click();
|
||||
await page
|
||||
.locator('input[name="name"]')
|
||||
.waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.locator('input[name="name"]').fill("Switch Test Agent");
|
||||
await page.locator('input[name="name"]').fill("Switch Test Assistant");
|
||||
await page
|
||||
.locator('textarea[name="description"]')
|
||||
.fill("Test Description");
|
||||
@@ -283,13 +293,13 @@ test.describe("Default Agent Tests", () => {
|
||||
.fill("Test Instructions");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
// Verify switched to custom agent
|
||||
await verifyAssistantIsChosen(page, "Switch Test Agent");
|
||||
// Verify switched to custom assistant
|
||||
await verifyAssistantIsChosen(page, "Switch Test Assistant");
|
||||
|
||||
// Start new chat to go back to default
|
||||
await startNewChat(page);
|
||||
|
||||
// Should be back to default agent
|
||||
// Should be back to default assistant
|
||||
await verifyDefaultAssistantIsChosen(page);
|
||||
});
|
||||
});
|
||||
@@ -369,7 +379,7 @@ test.describe("Default Agent Tests", () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Enable the tools in default agent config via API
|
||||
// Enable the tools in default assistant config via API
|
||||
// Get current tools to find their IDs
|
||||
const toolsListResp = await page.request.get(
|
||||
"http://localhost:3000/api/tool"
|
||||
@@ -532,7 +542,7 @@ test.describe("Default Agent Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("End-to-End Default Agent Flow", () => {
|
||||
test.describe("End-to-End Default Assistant Flow", () => {
|
||||
let imageGenConfigId: string | null = null;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
@@ -574,7 +584,7 @@ test.describe("End-to-End Default Agent Flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("complete user journey with default agent", async ({ page }) => {
|
||||
test("complete user journey with default assistant", async ({ page }) => {
|
||||
// Clear cookies and log in as a random user
|
||||
await page.context().clearCookies();
|
||||
await loginAsRandomUser(page);
|
||||
@@ -601,7 +611,7 @@ test.describe("End-to-End Default Agent Flow", () => {
|
||||
// Start a new chat
|
||||
await startNewChat(page);
|
||||
|
||||
// Verify we're back to default agent with greeting
|
||||
// Verify we're back to default assistant with greeting
|
||||
await expect(page.locator('[data-testid="onyx-logo"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user