Compare commits

..

2 Commits

Author SHA1 Message Date
rohoswagger
5bb8f16d6c fix(craft): show grayed-out send button instead of stop during sandbox restore
During sandbox restoration, the session status is "creating" which
caused useIsRunning to return true, incorrectly showing the stop button.
Added useIsStreaming hook (only true for status "running") to control
the stop button, and included sandbox restoring state in sandboxNotReady.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:42:00 -08:00
rohoswagger
21aa89badc feat(craft): stop generation 2026-02-26 15:03:34 -08:00
45 changed files with 485 additions and 960 deletions

View File

@@ -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} \

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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]},
)

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
# =========================================================================

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)}"

View File

@@ -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=(

View File

@@ -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/`.

View File

@@ -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>

View File

@@ -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>

View File

@@ -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",
]}

View File

@@ -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 ||

View File

@@ -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(),

View File

@@ -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."
/>

View File

@@ -1,7 +0,0 @@
"use client";
import CodeInterpreterPage from "@/refresh-pages/admin/CodeInterpreterPage";
export default function Page() {
return <CodeInterpreterPage />;
}

View File

@@ -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>
</>

View File

@@ -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

View File

@@ -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 />
)}
</>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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]
);
}

View File

@@ -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
// =============================================================================

View File

@@ -125,7 +125,8 @@ export type SessionStatus =
| "creating"
| "running"
| "active"
| "failed";
| "failed"
| "cancelled";
export interface Session {
id: string | null;

View File

@@ -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>

View File

@@ -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()}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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),
});
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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 (

View File

@@ -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
? [
{

View File

@@ -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",
},

View File

@@ -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,
});
});
});

View File

@@ -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();
});

View File

@@ -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();
});
});