Compare commits

..

1 Commits

Author SHA1 Message Date
TyperBody 9fe80eaf64 newplatfrom 2026-09-09 23:37:51 +08:00
53 changed files with 1373 additions and 3613 deletions
+4 -35
View File
@@ -2,11 +2,6 @@ name: Build and Publish to PyPI
on:
workflow_dispatch:
inputs:
source_ref:
description: 'Existing release tag to publish (for example v4.10.11)'
required: true
type: string
release:
types: [published]
@@ -16,39 +11,13 @@ jobs:
permissions:
contents: read
id-token: write # Required for trusted publishing to PyPI
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ inputs.source_ref || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Validate release source and version
env:
RELEASE_TAG: ${{ inputs.source_ref || github.event.release.tag_name }}
run: |
python3 - <<'PY'
import os
import re
import subprocess
import tomllib
from pathlib import Path
tag = os.environ['RELEASE_TAG']
if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag):
raise SystemExit('source_ref must be an existing release tag: vX.Y.Z')
def revision(ref):
return subprocess.check_output(['git', 'rev-parse', '--verify', ref], text=True).strip()
if revision('HEAD') != revision(f'refs/tags/{tag}^{{}}'):
raise SystemExit('Checked-out commit does not match the release tag')
version = tomllib.loads(Path('pyproject.toml').read_text())['project']['version']
if version != tag[1:]:
raise SystemExit(f'Package version {version} does not match tag {tag}')
print(f'Validated {tag} at {revision("HEAD")} (package {version})')
PY
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -57,9 +26,9 @@ jobs:
- name: Build frontend
run: |
cd web
# Match the archive/Docker npm path; npm ci rejects older tags' stale npm lockfiles.
npm install --include=optional
npm run build
npm install -g pnpm
pnpm install
pnpm build
mkdir -p ../src/langbot/web/dist
cp -r dist ../src/langbot/web/
-6
View File
@@ -10,16 +10,12 @@ on:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
- 'tests/integration/persistence/**'
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
- '.github/workflows/test-migrations.yml'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
- 'tests/integration/persistence/**'
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
- '.github/workflows/test-migrations.yml'
jobs:
test-migrations-sqlite:
@@ -84,8 +80,6 @@ jobs:
run: >-
uv run pytest
tests/integration/persistence/test_migrations_postgres.py
tests/integration/persistence/test_monitoring_postgres.py
tests/unit_tests/api/service/test_monitoring_sessions.py::test_postgres_upgrade_rls_and_concurrent_bot_counts
tests/integration/persistence/test_pgvector_postgres.py
tests/integration/persistence/test_release_migration_postgres.py
tests/integration/persistence/test_plugin_identity_migration.py
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.11"
version = "4.10.10"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.8",
"langbot-plugin==0.5.7",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -5,7 +5,6 @@ import quart
from ...authz import Permission
from ...context import RequestContext
from ...service.monitoring_traffic import get_traffic_series
from .. import group
@@ -378,14 +377,6 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(
data={
'traffic': await get_traffic_series(
self.ap,
request_context,
bot_ids=bot_ids or None,
pipeline_ids=pipeline_ids or None,
start_time=start_time,
end_time=end_time,
),
'overview': overview,
'messages': messages,
'llmCalls': llm_calls,
@@ -414,7 +405,6 @@ class MonitoringRouterGroup(group.RouterGroup):
session_id,
start_time=start_time,
end_time=end_time,
bot_id=quart.request.args.get('botId'),
)
# Always return success with the analysis data
+19 -64
View File
@@ -29,19 +29,6 @@ _DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4
_HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100
def _normalize_user_id(value: str | int | None) -> str | None:
"""Convert numeric platform IDs before binding a VARCHAR with asyncpg.
Opaque string IDs (including whitespace and leading zeros) and missing
IDs must remain unchanged. Do not silently stringify unsupported objects.
"""
if value is None or isinstance(value, str):
return value
if isinstance(value, int) and not isinstance(value, bool):
return str(value)
raise TypeError('user_id must be a string, integer, or None')
def _workspace_transaction(method):
"""Run an explicit service entrypoint in one Workspace transaction."""
@@ -294,21 +281,19 @@ class MonitoringService:
for _batch_number in range(max_batches):
async def delete_batch() -> tuple[int, int]:
key_columns = list(model_cls.__table__.primary_key.columns)
select_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(*key_columns)
sqlalchemy.select(pk_column)
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
.limit(batch_size)
)
pk_values = [tuple(row) for row in select_result.all()]
pk_values = list(select_result.scalars().all())
if not pk_values:
return 0, 0
delete_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(model_cls).where(
model_cls.workspace_uuid == workspace_uuid,
sqlalchemy.tuple_(*key_columns).in_(pk_values),
ts_column < cutoff,
pk_column.in_(pk_values),
)
)
return len(pk_values), int(delete_result.rowcount or 0)
@@ -430,7 +415,7 @@ class MonitoringService:
status: str = 'success',
level: str = 'info',
platform: str | None = None,
user_id: str | int | None = None,
user_id: str | None = None,
user_name: str | None = None,
runner_name: str | None = None,
variables: str | None = None,
@@ -452,7 +437,7 @@ class MonitoringService:
'status': status,
'level': level,
'platform': platform,
'user_id': _normalize_user_id(user_id),
'user_id': user_id,
'user_name': user_name,
'runner_name': runner_name,
'variables': variables,
@@ -625,7 +610,7 @@ class MonitoringService:
pipeline_id: str,
pipeline_name: str,
platform: str | None = None,
user_id: str | int | None = None,
user_id: str | None = None,
user_name: str | None = None,
) -> None:
"""Record a new session"""
@@ -637,29 +622,17 @@ class MonitoringService:
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'message_count': 1,
'message_count': 0,
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'is_active': True,
'platform': platform,
'user_id': _normalize_user_id(user_id),
'user_id': user_id,
'user_name': user_name,
}
model = persistence_monitoring.MonitoringSession
dialect = self.ap.persistence_mgr.get_db_engine().dialect.name
insert = postgresql_dialect.insert if dialect == 'postgresql' else sqlite_dialect.insert
statement = insert(model).values(session_data)
await self.ap.persistence_mgr.execute_async(
statement.on_conflict_do_update(
index_elements=['workspace_uuid', 'bot_id', 'session_id'],
set_={
'message_count': model.message_count + 1,
'last_activity': statement.excluded.last_activity,
'pipeline_id': statement.excluded.pipeline_id,
'pipeline_name': statement.excluded.pipeline_name,
},
)
sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)
)
@_workspace_transaction
@@ -669,7 +642,6 @@ class MonitoringService:
session_id: str,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
bot_id: str | None = None,
) -> bool:
"""Update session last activity time and increment message count.
@@ -679,9 +651,6 @@ class MonitoringService:
True if session was found and updated, False if session doesn't exist.
"""
workspace_uuid = self._require_write_context(context)
bot_id = bot_id if bot_id is not None else context.bot_uuid
if not bot_id:
raise ValueError('Session activity requires a bot_id')
update_values = {
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
@@ -698,7 +667,6 @@ class MonitoringService:
.where(
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringSession.session_id == session_id,
persistence_monitoring.MonitoringSession.bot_id == bot_id,
)
.values(update_values)
)
@@ -801,13 +769,13 @@ class MonitoringService:
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time:
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
# Total messages
message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
@@ -1304,9 +1272,9 @@ class MonitoringService:
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
if user_query and user_query.strip():
user_pattern = f'%{user_query.strip()}%'
conditions.append(
@@ -1408,7 +1376,6 @@ class MonitoringService:
session_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
bot_id: str | None = None,
) -> dict:
"""Get bounded session details with full statistics computed in SQL."""
workspace_uuid = require_workspace_uuid(context)
@@ -1418,13 +1385,8 @@ class MonitoringService:
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringSession.session_id == session_id,
)
if bot_id is not None:
session_query = session_query.where(persistence_monitoring.MonitoringSession.bot_id == bot_id)
session_result = await self.ap.persistence_mgr.execute_async(session_query.limit(2))
session_rows = session_result.all()
if len(session_rows) > 1:
return {'session_id': session_id, 'found': False, 'ambiguous': True}
session_row = session_rows[0] if session_rows else None
session_result = await self.ap.persistence_mgr.execute_async(session_query)
session_row = session_result.first()
if not session_row:
return {
@@ -1433,7 +1395,6 @@ class MonitoringService:
}
session = session_row[0] if isinstance(session_row, tuple) else session_row
bot_id = session.bot_id
message_stats_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(
@@ -1461,7 +1422,6 @@ class MonitoringService:
).where(
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringMessage.session_id == session_id,
persistence_monitoring.MonitoringMessage.bot_id == bot_id,
)
)
message_stats = message_stats_result.one()
@@ -1500,7 +1460,6 @@ class MonitoringService:
).where(
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringLLMCall.session_id == session_id,
persistence_monitoring.MonitoringLLMCall.bot_id == bot_id,
)
)
llm_stats = llm_stats_result.one()
@@ -1527,14 +1486,12 @@ class MonitoringService:
).where(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
)
)
tool_stats = tool_stats_result.one()
tool_conditions = [
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
]
if start_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
@@ -1563,7 +1520,6 @@ class MonitoringService:
.where(
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringError.session_id == session_id,
persistence_monitoring.MonitoringError.bot_id == bot_id,
)
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
.limit(detail_limit + 1)
@@ -2048,9 +2004,9 @@ class MonitoringService:
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
persistence_monitoring.MonitoringSession.last_activity.desc()
@@ -2084,7 +2040,6 @@ class MonitoringService:
# ========== Feedback Methods ==========
@_workspace_transaction
async def record_feedback(
self,
context: ExecutionContext,
@@ -2099,7 +2054,7 @@ class MonitoringService:
session_id: str | None = None,
message_id: str | None = None,
stream_id: str | None = None,
user_id: str | int | None = None,
user_id: str | None = None,
platform: str | None = None,
) -> str | None:
"""Record user feedback (like/dislike) from AI Bot conversation.
@@ -2155,7 +2110,7 @@ class MonitoringService:
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': _normalize_user_id(user_id),
'user_id': user_id,
'platform': platform,
}
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
@@ -1,83 +0,0 @@
"""Bounded traffic aggregation, independent of record-list pagination."""
from __future__ import annotations
import datetime
import typing
import sqlalchemy
from ....entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
from .tenant import TenantContext, require_workspace_uuid
if typing.TYPE_CHECKING:
from ....core.app import Application
MAX_TRAFFIC_POINTS = 1000
async def get_traffic_series(
ap: Application,
context: TenantContext,
*,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Count all matching records in UTC buckets, returning at most 1000 points."""
workspace_uuid = require_workspace_uuid(context)
bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day'
step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1)
postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql'
points: dict[datetime.datetime, dict[str, int]] = {}
truncated = False
for model, field in ((MonitoringMessage, 'messages'), (MonitoringLLMCall, 'llm_calls')):
timestamp = model.timestamp
if postgres:
time_bucket = sqlalchemy.func.date_trunc(bucket, timestamp)
else:
pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00'
time_bucket = sqlalchemy.func.strftime(pattern, timestamp)
conditions = [model.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(model.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(model.pipeline_id.in_(pipeline_ids))
if start_time is not None:
conditions.append(timestamp >= start_time)
if end_time is not None:
conditions.append(timestamp <= end_time)
statement = (
sqlalchemy.select(time_bucket.label('bucket'), sqlalchemy.func.count(model.id).label('count'))
.where(*conditions)
.group_by(time_bucket)
.order_by(time_bucket)
.limit(MAX_TRAFFIC_POINTS + 1)
)
result = await ap.persistence_mgr.execute_async(statement)
rows = result.all()
truncated = truncated or len(rows) > MAX_TRAFFIC_POINTS
for timestamp_value, count in rows[:MAX_TRAFFIC_POINTS]:
key = (
datetime.datetime.fromisoformat(timestamp_value)
if isinstance(timestamp_value, str)
else timestamp_value
)
points.setdefault(key, {'messages': 0, 'llm_calls': 0})[field] = int(count)
def floor(value: datetime.datetime) -> datetime.datetime:
return value.replace(minute=0, second=0, microsecond=0, **({'hour': 0} if bucket == 'day' else {}))
first = floor(start_time) if start_time is not None else min(points, default=None)
last = floor(end_time) if end_time is not None else max(points, default=None)
series = []
if first is not None and last is not None:
cursor = first
while cursor <= last and len(series) < MAX_TRAFFIC_POINTS:
series.append(
{'timestamp': cursor.isoformat() + 'Z', **points.get(cursor, {'messages': 0, 'llm_calls': 0})}
)
cursor += step
truncated = truncated or cursor <= last
return {'bucket': bucket, 'points': series, 'truncated': truncated}
@@ -111,8 +111,8 @@ class MonitoringSession(Base):
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
primary_key=True,
)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, index=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -1,104 +0,0 @@
"""Scope monitoring sessions by bot without changing runtime session IDs.
Revision ID: 0023_bot_scoped_sessions
Revises: 0022_codex_credentials
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql, sqlite
revision = '0023_bot_scoped_sessions'
down_revision = '0022_codex_credentials'
branch_labels = None
depends_on = None
_TABLE = 'monitoring_sessions'
_KEY = ['workspace_uuid', 'bot_id', 'session_id']
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if _TABLE not in inspector.get_table_names():
return
pk = inspector.get_pk_constraint(_TABLE)
if pk['constrained_columns'] == _KEY:
return
# PostgreSQL alters in place, retaining indexes, grants, policies and RLS.
# SQLite batch reflection retains all existing indexes and foreign keys.
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
batch.create_primary_key(f'pk_{_TABLE}', _KEY)
metadata = sa.MetaData()
sessions = sa.Table(_TABLE, metadata, autoload_with=conn)
messages = sa.Table('monitoring_messages', metadata, autoload_with=conn)
m = messages.c
collisions = (
sa.select(m.workspace_uuid, m.session_id)
.group_by(m.workspace_uuid, m.session_id)
.having(sa.func.count(sa.distinct(m.bot_id)) > 1)
.subquery()
)
partition = [m.workspace_uuid, m.bot_id, m.session_id]
# Repair only demonstrable collisions. Retention may have removed earlier
# evidence; these summaries describe surviving messages, never invented text.
ranked = (
sa.select(
*[m[name] for name in _KEY],
m.bot_name,
m.pipeline_id,
m.pipeline_name,
m.platform,
m.user_id,
m.user_name,
sa.func.sum(sa.case((sa.or_(m.role == 'user', m.role.is_(None)), 1), else_=0))
.over(partition_by=partition)
.label('message_count'),
sa.func.min(m.timestamp).over(partition_by=partition).label('start_time'),
sa.func.max(m.timestamp).over(partition_by=partition).label('last_activity'),
sa.func.row_number().over(partition_by=partition, order_by=[m.timestamp.desc(), m.id.desc()]).label('rank'),
)
.join(
collisions,
sa.and_(m.workspace_uuid == collisions.c.workspace_uuid, m.session_id == collisions.c.session_id),
)
.subquery()
)
columns = _KEY + [
'bot_name',
'pipeline_id',
'pipeline_name',
'platform',
'user_id',
'user_name',
'message_count',
'start_time',
'last_activity',
'is_active',
]
select = sa.select(*[ranked.c[name] for name in columns[:-1]], sa.literal(True)).where(ranked.c.rank == 1)
insert = postgresql.insert if conn.dialect.name == 'postgresql' else sqlite.insert
statement = insert(sessions).from_select(columns, select)
conn.execute(
statement.on_conflict_do_update(
index_elements=_KEY,
set_={name: statement.excluded[name] for name in columns if name not in _KEY and name != 'is_active'},
)
)
def downgrade() -> None:
conn = op.get_bind()
if _TABLE not in sa.inspect(conn).get_table_names():
return
collisions = conn.execute(
sa.text('SELECT 1 FROM monitoring_sessions GROUP BY workspace_uuid, session_id HAVING COUNT(*) > 1 LIMIT 1')
).first()
if collisions:
raise RuntimeError('Cannot downgrade bot-scoped sessions without losing colliding bot records')
pk = sa.inspect(conn).get_pk_constraint(_TABLE)
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
batch.create_primary_key(f'pk_{_TABLE}', ['workspace_uuid', 'session_id'])
@@ -207,8 +207,6 @@ _SYNC_PROXY_CAPABILITY: contextvars.ContextVar[_ScopedSessionGuardState | None]
_ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
'coalesce': sqlalchemy.sql.functions.coalesce,
'count': sqlalchemy.sql.functions.count,
'min': sqlalchemy.sql.functions.min,
'max': sqlalchemy.sql.functions.max,
'now': sqlalchemy.sql.functions.now,
'sum': sqlalchemy.sql.functions.sum,
}
@@ -79,7 +79,6 @@ class MonitoringHelper:
session_updated = await ap.monitoring_service.update_session_activity(
get_query_execution_context(query),
session_id,
bot_id=bot_id,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+587
View File
@@ -0,0 +1,587 @@
"""ESPL V3 adapter — WebSocket server for E-SP-Line2's Adapter Gateway (接入器).
LangBot acts as a server-mode WebSocket endpoint. E-SP-Line2's adapter (接入器)
in **client mode** connects to this endpoint (or a reverse proxy forwards it)
and exchanges e-commerce messages:
* **Inbound** — E-SP-Line2 broadcasts ``message.received`` envelopes to every
connected adapter client. This adapter converts each envelope into a LangBot
``FriendMessage`` / ``GroupMessage`` event (the ``conversation_id`` maps to
the LangBot launcher/session id) and fires it into the normal pipeline.
* **Outbound** — every ``reply_message`` / ``reply_message_chunk`` the pipeline
emits is converted into an ESPL v3 outbound ``message`` frame
(``command_type: send_text``) and sent back over the WebSocket that carries
the matching conversation.
Design notes:
* Listens on ``ws://<host>:<port>/ws`` (default ``ws://127.0.0.1:8000/ws``).
In E-SP-Line2 create a **client-mode** 接入器 with ``ws_url`` pointing here.
* Supports multiple simultaneous E-SP-Line2 connections. Each connection is
identified by its ``adapter_id`` (from the ``key``/path) so outbound replies
route back to the correct connection.
* Heartbeats: responds to ``ping`` frames with ``pong``; the E-SP-Line2
gateway also sends server pings that we answer automatically via the
websockets library.
* The ``conversation_id`` from the inbound envelope is used as the LangBot
launcher id so each e-commerce conversation maps 1:1 to an isolated LangBot
session. Replies are routed back to the same ``conversation_id``.
* ``instance_id`` is captured from the inbound envelope and stashed on the
event's ``source_platform_object``.
See docs/user-guide/adapter-gateway.md in the E-SP-Line2 repo for the full
ESPL v3 protocol reference.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import typing
import uuid
from datetime import datetime
import pydantic
import websockets
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
logger = logging.getLogger(__name__)
# Default listen host / port (E-SP-Line2 client-mode 接入器 connects here).
_DEFAULT_HOST = '127.0.0.1'
_DEFAULT_PORT = 8000
# Default heartbeat ping interval (seconds).
_DEFAULT_HEARTBEAT_INTERVAL = 30
# Max inbound frame size (1MB, matches E-SP-Line2 gateway).
_MAX_MESSAGE_SIZE = 1 * 1024 * 1024
class _EsplConnection:
"""A single connected E-SP-Line2 adapter gateway client.
Holds the WebSocket plus the routing info needed to reply.
"""
def __init__(self, ws, adapter_id: str = ''):
self.ws = ws
self.adapter_id = adapter_id
self.send_lock = asyncio.Lock()
async def send_frame(self, frame: dict) -> None:
async with self.send_lock:
await self.ws.send(json.dumps(frame, ensure_ascii=False))
class EsplAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""ESPL V3 WebSocket server adapter (LangBot is the server)."""
bot_uuid: str = pydantic.Field(default='', exclude=True)
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = pydantic.Field(default_factory=dict, exclude=True)
# WebSocket server state (excluded from pydantic serialization).
server: typing.Any = pydantic.Field(default=None, exclude=True)
running: bool = pydantic.Field(default=False, exclude=True)
connections: dict[str, '_EsplConnection'] = pydantic.Field(default_factory=dict, exclude=True)
inbound_tasks: set[asyncio.Task] = pydantic.Field(default_factory=set, exclude=True)
heartbeat_task: asyncio.Task | None = pydantic.Field(default=None, exclude=True)
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
super().__init__(config=config, logger=logger, **kwargs)
self.bot_account_id = 'espl'
self.listeners = {}
self.server = None
self.running = False
self.connections = {}
self.inbound_tasks = set()
self.heartbeat_task = None
# -- framework hooks ------------------------------------------------------
def set_bot_uuid(self, bot_uuid: str) -> None:
"""Called by the bot manager so the adapter knows its own bot uuid."""
object.__setattr__(self, 'bot_uuid', bot_uuid)
def get_launcher_id(self, event: platform_events.MessageEvent) -> str:
"""Map an inbound event to a LangBot launcher id.
We use the e-commerce ``conversation_id`` (stashed on the sender id at
inbound time) so each conversation maps 1:1 to an isolated LangBot
session.
"""
if isinstance(event, platform_events.GroupMessage):
return str(event.sender.group.id)
return str(event.sender.id)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners[event_type] = func
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners.pop(event_type, None)
async def is_muted(self, group_id: int) -> bool:
return False
async def is_stream_output_supported(self) -> bool:
return False
# -- server lifecycle -----------------------------------------------------
async def run_async(self):
"""Start the WebSocket server and serve forever."""
host = str(self.config.get('host', _DEFAULT_HOST))
port = int(self.config.get('port', _DEFAULT_PORT))
self.running = True
self.server = await websockets.serve(
self._handle_connection,
host,
port,
ping_interval=None, # we manage heartbeats ourselves
max_size=_MAX_MESSAGE_SIZE,
)
await self.logger.info(f'ESPL adapter listening on ws://{host}:{port}/ws')
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
try:
# Serve forever; run_async is expected to stay alive.
while self.running:
await asyncio.sleep(3600)
except asyncio.CancelledError:
raise
finally:
if self.server is not None:
self.server.close()
await self.server.wait_closed()
self.server = None
async def kill(self) -> bool:
"""Stop the server and close all connections."""
self.running = False
if self.heartbeat_task is not None and not self.heartbeat_task.done():
self.heartbeat_task.cancel()
self.heartbeat_task = None
for task in list(self.inbound_tasks):
if not task.done():
task.cancel()
self.inbound_tasks.clear()
for conn in list(self.connections.values()):
try:
await conn.ws.close()
except Exception:
pass
self.connections.clear()
return True
# -- connection handler ---------------------------------------------------
async def _handle_connection(self, ws):
"""Handle a new WebSocket connection from an E-SP-Line2 gateway client.
The E-SP-Line2 client-mode adapter connects with ``?key=<KEY>`` in the
query string. If the adapter has been configured with a non-empty
``key``, this method **rejects** connections that do not present a
matching key (close code 1008 — policy violation).
Note: websockets >= 14 removed the ``path`` / ``query_string``
attributes from the connection object. The request path (including
the query string) is available via ``ws.request.path``.
"""
# In websockets >= 14 the request path (with query string) lives on
# ``ws.request.path`` (e.g. ``/ws?key=abc``). Fall back to the legacy
# ``ws.path`` / ``ws.query_string`` attributes for older versions.
request = getattr(ws, 'request', None)
if request is not None:
raw_path = str(getattr(request, 'path', '') or '')
else:
raw_path = str(getattr(ws, 'path', '') or '')
path, _, query = raw_path.partition('?')
# ── Key authentication ──────────────────────────────────────────
expected_key = str(self.config.get('key') or '')
provided_key = self._extract_key(query)
if expected_key:
if not provided_key:
await self.logger.warning(
f'ESPL adapter key missing; closing connection from {raw_path}'
)
await ws.close(1008, 'Unauthorized: key missing')
return
if provided_key != expected_key:
await self.logger.warning(
f'ESPL adapter key mismatch; closing connection from {raw_path}'
)
await ws.close(1008, 'Unauthorized: invalid key')
return
# ── Identify the connection for routing ─────────────────────────
adapter_id = self._extract_adapter_id(path, query)
conn = _EsplConnection(ws, adapter_id=adapter_id)
conn_key = adapter_id or ('conn_' + uuid.uuid4().hex)
self.connections[conn_key] = conn
await self.logger.info(
f'ESPL adapter client connected: adapter_id={adapter_id or "(client-mode, no adapter-id in path)"} '
f'path={raw_path}'
)
# ── Send the connected handshake ────────────────────────────────
try:
await conn.send_frame(
{
'type': 'connected',
'id': uuid.uuid4().hex,
'timestamp': int(time.time() * 1000),
'adapter_id': adapter_id or '',
'gateway_version': 'v3',
'session_id': conn_key,
'adapter_name': self.config.get('name', 'ESPL'),
'platform': self.config.get('platform', ''),
}
)
except Exception as e:
await self.logger.warning(f'ESPL adapter handshake failed: {e}')
self.connections.pop(conn_key, None)
return
# ── Read loop ───────────────────────────────────────────────────
try:
async for raw in ws:
try:
frame = json.loads(raw)
except (json.JSONDecodeError, ValueError):
await self.logger.warning(f'ESPL adapter received non-JSON frame: {raw[:200]}')
continue
await self._handle_frame(conn, frame)
except websockets.exceptions.ConnectionClosed as e:
await self.logger.info(f'ESPL adapter client disconnected: {e.code} {e.reason}')
except asyncio.CancelledError:
raise
except Exception as e:
await self.logger.warning(f'ESPL adapter connection error: {e}')
finally:
self.connections.pop(conn_key, None)
@staticmethod
def _extract_adapter_id(path: str, query: str) -> str:
"""Extract the adapter id from the connection path.
E-SP-Line2 client mode may connect to /ws/adapter-gateway/<id>?key=...
or a custom path /custom?key=... The adapter id is extracted from the
path segment, NOT from the key query parameter.
"""
path_part = path.split('?', 1)[0]
if '/ws/adapter-gateway/' in path_part:
maybe_id = path_part.rsplit('/', 1)[-1]
if maybe_id and maybe_id not in ('ws', 'adapter-gateway'):
return maybe_id
# No adapter id in the path; return empty string (anonymous connection).
return ''
@staticmethod
def _extract_key(query: str) -> str:
"""Extract the ``key`` query parameter from the WebSocket query string.
E-SP-Line2 client-mode adapter passes the access key as
``?key=<KEY>`` in the WebSocket URL (see ``client_connector.go``
line 172-177).
"""
for pair in query.split('&'):
if '=' in pair:
k, v = pair.split('=', 1)
if k == 'key':
return v
return ''
async def _handle_frame(self, conn: _EsplConnection, frame: dict) -> None:
"""Handle a single inbound frame from an E-SP-Line2 gateway client."""
msg_type = frame.get('type', '')
if msg_type == 'ping':
await conn.send_frame({'type': 'pong', 'timestamp': int(time.time() * 1000)})
return
if msg_type == 'pong':
return
if msg_type == 'ack':
return
if msg_type == 'error':
await self.logger.warning(f'ESPL adapter gateway error: {frame.get("code")} {frame.get("message")}')
return
# Inbound message envelope (message.received).
if frame.get('event_type') == 'message.received':
await self._handle_inbound_message(conn, frame)
return
await self.logger.debug(f'ESPL adapter unhandled frame type: {msg_type}')
def _start_inbound_task(self, coro) -> asyncio.Task | None:
self.inbound_tasks = {task for task in self.inbound_tasks if not task.done()}
task = asyncio.create_task(coro)
self.inbound_tasks.add(task)
def task_done(done_task: asyncio.Task) -> None:
self.inbound_tasks.discard(done_task)
if not done_task.cancelled():
done_task.exception()
task.add_done_callback(task_done)
return task
async def _handle_inbound_message(self, conn: _EsplConnection, envelope: dict) -> None:
"""Convert a message.received envelope into a LangBot event and fire it."""
payload = envelope.get('payload') or {}
if not isinstance(payload, dict):
await self.logger.warning('ESPL adapter inbound payload is not an object')
return
conversation_id = str(payload.get('conversation_id') or '')
sender_id = str(payload.get('sender_id') or '')
sender_name = str(payload.get('sender_name') or 'User')
message_content = str(payload.get('message_content') or '')
instance_id = str(payload.get('instance') or payload.get('instance_id') or '')
platform = str(payload.get('platform_id') or envelope.get('platform') or '')
if not conversation_id:
await self.logger.warning('ESPL adapter inbound message missing conversation_id')
return
chain = self._build_message_chain(payload.get('message_chain'), message_content)
# Stash routing context (instance_id, conversation_id, conn_key) on the
# event so outbound replies route back to the correct connection.
source_platform_object = {
'instance_id': instance_id,
'conversation_id': conversation_id,
'platform': platform,
'sender_id': sender_id,
'_conn': conn,
}
session_type = str(payload.get('session_type') or 'person')
if session_type == 'group':
group = platform_entities.Group(
id=conversation_id,
name=str(payload.get('group_name') or conversation_id),
permission=platform_entities.Permission.Member,
)
sender = platform_entities.GroupMember(
id=sender_id or conversation_id,
member_name=sender_name,
group=group,
permission=platform_entities.Permission.Member,
)
event = platform_events.GroupMessage(
sender=sender,
message_chain=chain,
time=datetime.now().timestamp(),
source_platform_object=source_platform_object,
)
else:
sender = platform_entities.Friend(
id=conversation_id,
nickname=sender_name,
remark=sender_name,
)
event = platform_events.FriendMessage(
sender=sender,
message_chain=chain,
time=datetime.now().timestamp(),
source_platform_object=source_platform_object,
)
listener = self.listeners.get(type(event))
if listener is None:
await self.logger.warning(f'ESPL adapter no listener for {type(event).__name__}')
return
await self.logger.info(
f'ESPL adapter inbound: conversation={conversation_id} sender={sender_name} '
f'content={message_content[:100]}'
)
self._start_inbound_task(listener(event, self))
def _build_message_chain(
self,
message_chain: typing.Any,
fallback_text: str,
) -> platform_message.MessageChain:
"""Convert an ESPL message_chain into a LangBot MessageChain."""
components: list[platform_message.MessageComponent] = []
if isinstance(message_chain, list):
for elem in message_chain:
if not isinstance(elem, dict):
continue
elem_type = elem.get('type', '')
content = elem.get('content')
if elem_type == 'text':
text = ''
if isinstance(content, dict):
text = str(content.get('text', ''))
elif isinstance(content, str):
text = content
else:
text = str(elem.get('text', ''))
if text:
components.append(platform_message.Plain(text=text))
elif elem_type == 'image':
url = ''
if isinstance(content, dict):
url = str(content.get('url', ''))
elif isinstance(content, str):
url = content
else:
url = str(elem.get('url', ''))
if url:
components.append(platform_message.Image(url=url))
elif elem_type in ('item', 'product', 'goods'):
# E-commerce product card (e.g. 闲鱼 itemInfo).
# Render as a plain-text description so the product info
# (title/price) is not dropped downstream.
title = ''
price = ''
if isinstance(content, dict):
title = str(content.get('title') or '')
price = str(content.get('price') or '')
elif isinstance(content, str):
title = content
else:
title = str(elem.get('title') or '')
price = str(elem.get('price') or '')
product_text = title
if price:
product_text = f'{title} [价格: {price}]' if title else f'价格: {price}'
if product_text:
components.append(platform_message.Plain(text=product_text))
if not components and fallback_text:
components.append(platform_message.Plain(text=fallback_text))
return platform_message.MessageChain(components)
# -- outbound -------------------------------------------------------------
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain) -> dict:
"""Proactively push a message to a conversation (target_id == conversation_id)."""
return await self._emit_outbound(target_id, message)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> dict:
return await self._emit_outbound_from_event(message_source, message)
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
) -> dict:
# ESPL v3 has no streaming; send the whole chunk as a final message.
return await self._emit_outbound_from_event(message_source, message)
async def _emit_outbound_from_event(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
) -> dict:
"""Send a reply, routing back to the connection captured at inbound."""
source = getattr(message_source, 'source_platform_object', None) or {}
conn = source.get('_conn')
conversation_id = str(source.get('conversation_id') or '')
instance_id = str(source.get('instance_id') or '')
sender_id = str(source.get('sender_id') or '')
if not conversation_id:
conversation_id = str(self.get_launcher_id(message_source))
return await self._emit_outbound(
conversation_id,
message,
instance_id=instance_id,
sender_id=sender_id,
conn=conn,
)
async def _emit_outbound(
self,
conversation_id: str,
message: platform_message.MessageChain,
instance_id: str = '',
sender_id: str = '',
conn: _EsplConnection | None = None,
) -> dict:
"""Build and send an ESPL v3 outbound message frame."""
if conn is None:
# Try to find a connection for this conversation by scanning.
if not self.connections:
await self.logger.warning('ESPL adapter no connections; dropping outbound message')
return {}
conn = next(iter(self.connections.values()))
# Convert the LangBot message chain to ESPL chain elements.
chain = []
for component in message:
if isinstance(component, platform_message.Plain):
chain.append({'type': 'text', 'content': {'text': component.text}})
elif isinstance(component, platform_message.Image):
chain.append({'type': 'image', 'content': {'url': component.url or ''}})
frame = {
'type': 'message',
'id': 'out_' + uuid.uuid4().hex,
'timestamp': int(time.time() * 1000),
'payload': {
'instance_id': instance_id,
'command_type': 'send_text',
'conversation_id': conversation_id,
'target_id': sender_id or conversation_id,
'sender_id': sender_id,
'message_chain': chain,
},
}
try:
await conn.send_frame(frame)
except Exception as e:
await self.logger.error(f'ESPL adapter failed to send outbound: {e}')
return {}
await self.logger.info(f'ESPL adapter outbound: conversation={conversation_id} chain={chain}')
return frame
# -- heartbeat ------------------------------------------------------------
async def _heartbeat_loop(self) -> None:
"""Periodically ping all connected clients to keep connections alive."""
interval = int(self.config.get('heartbeat_interval', _DEFAULT_HEARTBEAT_INTERVAL))
while self.running:
await asyncio.sleep(interval)
for conn in list(self.connections.values()):
try:
await conn.send_frame({'type': 'ping', 'timestamp': int(time.time() * 1000)})
except Exception as e:
await self.logger.warning(f'ESPL adapter heartbeat to client failed: {e}')
@@ -0,0 +1,83 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: espl
label:
en_US: ESPL V3
zh_Hans: ESPL V3
zh_Hant: ESPL V3
ja_JP: ESPL V3
description:
en_US: "LangBot acts as a WebSocket server. E-SP-Line2 creates a client-mode adapter (接入器) pointing its ws_url to this endpoint. Receives e-commerce messages (Taobao / Xianyu) as inbound events and sends AI replies back to the platform."
zh_Hans: "LangBot 作为 WebSocket 服务端。在 E-SP-Line2 中创建客户端模式接入器,将 ws_url 指向本端点即可接入。接收电商平台(淘宝/闲鱼)消息作为入站事件,并将 AI 回复发回平台。"
zh_Hant: "LangBot 作為 WebSocket 服務端。在 E-SP-Line2 中建立用戶端模式接入器,將 ws_url 指向本端點即可接入。接收電商平台(淘寶/閒魚)訊息作為入站事件,並將 AI 回覆發回平台。"
ja_JP: "LangBot が WebSocket サーバーとして動作します。E-SP-Line2 でクライアントモードのアダプター(接入器)を作成し、ws_url をこのエンドポイントに向けます。EC プラットフォーム(Taobao / Xianyu)のメッセージをインバウンドイベントとして受信し、AI 返信をプラットフォームに送り返します。"
icon: espl.png
spec:
categories:
- global
help_links:
zh: https://docs.langbot.app/zh/platforms/espl
en: https://docs.langbot.app/en/platforms/espl
ja: https://docs.langbot.app/ja/platforms/espl
config:
- name: host
label:
en_US: Listen Host
zh_Hans: 监听主机
zh_Hant: 監聽主機
ja_JP: リッスンホスト
description:
en_US: "Host to bind the WebSocket server. Set 0.0.0.0 when E-SP-Line2 is on another machine."
zh_Hans: "WebSocket 服务端绑定的主机。E-SP-Line2 在其他机器时设为 0.0.0.0。"
zh_Hant: "WebSocket 服務端綁定的主機。E-SP-Line2 在其他機器時設為 0.0.0.0。"
ja_JP: "WebSocket サーバーをバインドするホスト。E-SP-Line2 が別マシンの場合は 0.0.0.0 を設定します。"
type: string
required: true
default: "127.0.0.1"
- name: port
label:
en_US: Listen Port
zh_Hans: 监听端口
zh_Hant: 監聽連接埠
ja_JP: リッスンポート
description:
en_US: "Port to bind the WebSocket server. E-SP-Line2 client-mode adapter connects to ws://<host>:<port>/ws."
zh_Hans: "WebSocket 服务端绑定的端口。E-SP-Line2 客户端模式接入器连接 ws://<host>:<port>/ws。"
zh_Hant: "WebSocket 服務端綁定的連接埠。E-SP-Line2 用戶端模式接入器連接 ws://<host>:<port>/ws。"
ja_JP: "WebSocket サーバーをバインドするポート。E-SP-Line2 クライアントモードアダプターは ws://<host>:<port>/ws に接続します。"
type: integer
required: false
default: 8000
- name: key
label:
en_US: Access Key
zh_Hans: 访问密钥
zh_Hant: 訪問密鑰
ja_JP: アクセスキー
description:
en_US: "Access key for authentication. E-SP-Line2 client-mode adapter passes this key as ?key=<KEY> in the WebSocket URL. Leave empty to disable key validation (not recommended)."
zh_Hans: "访问密钥用于认证。E-SP-Line2 客户端模式接入器在 WebSocket URL 中携带 ?key=<KEY> 传递此密钥。留空表示不验证密钥(不推荐)。"
zh_Hant: "訪問密鑰用於認證。E-SP-Line2 用戶端模式接入器在 WebSocket URL 中攜帶 ?key=<KEY> 傳遞此密鑰。留空表示不驗證密鑰(不推薦)。"
ja_JP: "認証用のアクセスキー。E-SP-Line2 クライアントモードアダプターは WebSocket URL に ?key=<KEY> としてこのキーを渡します。空の場合はキー検証を無効にします(非推奨)。"
type: string
required: false
default: ""
- name: heartbeat_interval
label:
en_US: Heartbeat Interval (seconds)
zh_Hans: 心跳间隔(秒)
zh_Hant: 心跳間隔(秒)
ja_JP: ハートビート間隔(秒)
description:
en_US: "How often to ping connected clients to keep the connection alive."
zh_Hans: "发送 ping 帧保持连接的间隔。"
zh_Hant: "發送 ping 幀保持連線的間隔。"
ja_JP: "接続を維持するためにクライアントに ping を送信する間隔。"
type: integer
required: false
default: 30
execution:
python:
path: ./espl.py
attr: EsplAdapter
+6 -8
View File
@@ -48,7 +48,6 @@ from ..utils import constants
_DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024
_HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024
_UNSET_INSTALLATION_SCOPE = object()
def _binary_storage_value_limit(ap: Any) -> int:
@@ -480,6 +479,7 @@ class RuntimeConnectionHandler(handler.Handler):
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
contextvars.ContextVar(
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
default=None,
)
)
self._installation_bindings: dict[
@@ -1631,15 +1631,13 @@ class RuntimeConnectionHandler(handler.Handler):
) -> InstallationBinding | ActionContext | None:
if action_context is not None:
return super().resolve_outbound_action_context(action_context)
# An explicit scope targets the nested call, not its inbound caller.
# None deliberately clears the context for runtime-scoped actions.
scoped_context = self._outbound_installation_context.get(_UNSET_INSTALLATION_SCOPE)
if scoped_context is not _UNSET_INSTALLATION_SCOPE:
return typing.cast(InstallationBinding | None, scoped_context)
return self.current_action_context
inbound_context = self.current_action_context
if inbound_context is not None:
return inbound_context
return self._outbound_installation_context.get()
def require_outbound_installation_context(self) -> InstallationBinding:
binding = self._outbound_installation_context.get(None)
binding = self._outbound_installation_context.get()
if not isinstance(binding, InstallationBinding):
raise ValueError('Host plugin action requires an InstallationBinding scope')
return binding
+12 -35
View File
@@ -143,41 +143,18 @@ stages:
operator: eq
value: false
disabled_tooltip:
en_US: "Sandbox is unavailable. Enable Box and check its connection before changing the scope."
zh_Hans: "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。"
zh_Hant: "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。"
ja_JP: "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。"
vi_VN: "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi."
th_TH: "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต"
es_ES: "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance."
ru_RU: "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область."
disabled_tooltip_overrides:
- when:
field: __system.box_scope_forced_global
operator: eq
value: true
tooltip:
en_US: "A global sandbox is enforced; the scope cannot be changed."
zh_Hans: "已强制使用全局沙箱,无法修改作用域。"
zh_Hant: "已強制使用全域沙箱,無法修改作用域。"
ja_JP: "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。"
vi_VN: "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un sandbox global; no se puede cambiar el alcance."
ru_RU: "Принудительно используется глобальная песочница; изменить область нельзя."
- when:
field: __system.box_scope_forced
operator: eq
value: true
tooltip:
en_US: "A fixed sandbox scope is enforced; the scope cannot be changed."
zh_Hans: "已强制使用固定沙箱作用域,无法修改作用域。"
zh_Hant: "已強制使用固定沙箱作用域,無法修改作用域。"
ja_JP: "固定のサンドボックススコープが強制されているため、スコープを変更できません。"
vi_VN: "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance."
ru_RU: "Принудительно задана фиксированная область песочницы; изменить её нельзя."
en_US: >-
Sandbox scope can't be changed: either the Box sandbox is disabled
or unavailable (enable it in config.yaml with box.enabled = true and
ensure the runtime is reachable), or this deployment pins all
pipelines to a fixed scope.
zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。"
zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。"
ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。"
vi_VN: "Không thể thay đổi phạm vi sandboxBox sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi."
th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว"
es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único."
ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров."
type: select
required: false
default: "{launcher_type}_{launcher_id}"
+4 -11
View File
@@ -9,7 +9,7 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock, patch
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -280,20 +280,13 @@ class TestMonitoringAllDataEndpoint:
@pytest.mark.asyncio
async def test_get_all_data_success(self, quart_test_client):
"""GET /api/v1/monitoring/data returns all data."""
traffic = {'series': [], 'truncated': False}
with patch(
'langbot.pkg.api.http.controller.groups.monitoring.get_traffic_series',
new=AsyncMock(return_value=traffic),
) as get_traffic:
response = await quart_test_client.get(
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
)
get_traffic.assert_awaited_once()
response = await quart_test_client.get(
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert 'overview' in data['data']
assert data['data']['traffic'] == traffic
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -193,22 +193,6 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
sa.Column('message_id', sa.String(255), nullable=True),
)
# Include historical monitoring columns consumed by later migrations.
for table_name in ('monitoring_messages', 'monitoring_sessions'):
table = monitoring_tables[table_name]
for name, value in (('bot_name', 'bot'), ('pipeline_id', 'pipeline-1'), ('pipeline_name', 'pipeline')):
table.append_column(sa.Column(name, sa.String(255), nullable=False, default=value))
for name in ('platform', 'user_id', 'user_name'):
table.append_column(sa.Column(name, sa.String(255)))
if table_name == 'monitoring_messages':
table.append_column(sa.Column('bot_id', sa.String(255), nullable=False, default='bot-1'))
table.append_column(sa.Column('role', sa.String(50)))
else:
table.append_column(sa.Column('message_count', sa.Integer, nullable=False, default=1))
table.append_column(
sa.Column('start_time', sa.DateTime, nullable=False, default=datetime.datetime(2026, 1, 1))
)
now = datetime.datetime(2026, 1, 1)
async with engine.begin() as conn:
await conn.run_sync(metadata.create_all)
@@ -17,7 +17,6 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence import mgr as persistence_mgr # noqa: F401 -- register all ORM tables
from langbot.pkg.persistence.alembic_runner import (
run_alembic_downgrade,
run_alembic_upgrade,
@@ -109,6 +108,7 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0022_codex_credentials'
@pytest.mark.asyncio
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
@@ -119,7 +119,7 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials'
@pytest.mark.asyncio
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
@@ -280,15 +280,6 @@ class TestSQLiteMigrationUpgrade:
class TestSQLiteMigrationFreshDatabase:
"""Tests for fresh database workflow."""
@pytest.mark.asyncio
async def test_bot_scoped_sessions_skips_absent_table(self, sqlite_engine):
"""A partial schema needs no session key migration in either direction."""
await run_alembic_stamp(sqlite_engine, '0022_codex_credentials')
await run_alembic_upgrade(sqlite_engine, '0023_bot_scoped_sessions')
assert await get_alembic_current(sqlite_engine) == '0023_bot_scoped_sessions'
await run_alembic_downgrade(sqlite_engine, '0022_codex_credentials')
assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials'
@pytest.mark.asyncio
async def test_fresh_db_upgrade_from_scratch(self, tmp_path):
"""
@@ -1,354 +0,0 @@
"""Monitoring regressions through asyncpg, Cloud UoW guards, and migrated RLS.
TEST_POSTGRES_URL must identify a disposable PostgreSQL/pgvector test server
with permission to create databases and roles. Each run owns a fresh database;
no existing tables are dropped. Without that URL these tests are skipped.
"""
from __future__ import annotations
import logging
import os
import uuid
from types import SimpleNamespace
import pytest
import pytest_asyncio
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.api.http.service.monitoring import MonitoringService
from langbot.pkg.entity.persistence import monitoring as models
from langbot.pkg.entity.persistence.workspace import Workspace
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.persistence.tenant_uow import TenantScopeRequiredError
from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio(loop_scope='module')]
WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
RESOURCE = dict(bot_id='same-bot', bot_name='Bot', pipeline_id='same-pipeline', pipeline_name='Pipeline')
MONITORING_TABLES = tuple(
table for table in models.MonitoringMessage.metadata.sorted_tables if table.name.startswith('monitoring_')
)
def _context(workspace_uuid):
return ExecutionContext(
instance_uuid='monitoring-postgres-test',
workspace_uuid=workspace_uuid,
placement_generation=1,
bot_uuid=RESOURCE['bot_id'],
pipeline_uuid=RESOURCE['pipeline_id'],
)
def _application(url):
return SimpleNamespace(
instance_config=SimpleNamespace(
data={
'database': {
'use': 'postgresql',
'postgresql': {
'host': url.host,
'port': url.port,
'user': url.username,
'password': url.password,
'database': url.database,
},
}
}
),
logger=logging.getLogger('monitoring-postgres-test'),
)
@pytest_asyncio.fixture(scope='module', loop_scope='module')
async def cloud_database():
url = os.environ.get('TEST_POSTGRES_URL')
if not url:
pytest.skip('TEST_POSTGRES_URL not set')
admin_url = sa.engine.make_url(url)
admin = create_async_engine(admin_url, isolation_level='AUTOCOMMIT')
suffix = uuid.uuid4().hex[:12]
database_name = f'lb_monitoring_{suffix}'
runtime_role = f'lb_monitoring_{suffix}'
password = f'Test{uuid.uuid4().hex}'
database_created = role_created = False
release_manager = runtime_manager = None
quote = admin.dialect.identifier_preparer.quote
from langbot.pkg.persistence import mgr as mgr_module
from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager
from langbot.pkg.utils import constants
with pytest.MonkeyPatch.context() as patch:
patch.setattr(mgr_module.database, 'preregistered_managers', [PostgreSQLDatabaseManager])
patch.setattr(constants, 'instance_id', 'monitoring-postgres-test')
try:
async with admin.connect() as conn:
await conn.execute(sa.text(f'CREATE DATABASE {quote(database_name)}'))
database_created = True
await conn.execute(
sa.text(f"CREATE ROLE {quote(runtime_role)} LOGIN NOSUPERUSER NOBYPASSRLS PASSWORD '{password}'")
)
role_created = True
release_app = _application(admin_url.set(database=database_name))
release_manager = PersistenceManager(release_app, mode=PersistenceMode.RELEASE_MIGRATION)
release_app.persistence_mgr = release_manager
await release_manager.initialize()
async with release_manager.get_db_engine().begin() as conn:
for workspace in (WORKSPACE_A, WORKSPACE_B):
await conn.execute(
sa.insert(Workspace).values(
uuid=workspace,
instance_uuid='monitoring-postgres-test',
name=workspace,
slug=workspace,
source='cloud_projection',
)
)
tables = release_manager._runtime_business_table_names()
quoted_tables = ', '.join(f'public.{quote(name)}' for name in tables)
await conn.execute(
sa.text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(runtime_role)}')
)
await conn.execute(sa.text(f'GRANT USAGE ON SCHEMA public TO {quote(runtime_role)}'))
await conn.execute(
sa.text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(runtime_role)}')
)
await conn.execute(sa.text(f'GRANT SELECT ON public.alembic_version TO {quote(runtime_role)}'))
sequences = await release_manager._runtime_business_sequence_names(conn, tables)
if sequences:
names = ', '.join(f'public.{quote(name)}' for name in sequences)
await conn.execute(sa.text(f'GRANT USAGE, SELECT ON SEQUENCE {names} TO {quote(runtime_role)}'))
runtime_app = _application(admin_url.set(database=database_name, username=runtime_role, password=password))
runtime_manager = PersistenceManager(runtime_app, mode=PersistenceMode.CLOUD_RUNTIME)
runtime_app.persistence_mgr = runtime_manager
await runtime_manager.initialize()
runtime_app.monitoring_service = MonitoringService(runtime_app)
yield runtime_app, release_manager.get_db_engine()
finally:
if runtime_manager is not None:
await runtime_manager.shutdown()
if release_manager is not None:
await release_manager.shutdown()
async with admin.connect() as conn:
if database_created:
await conn.execute(sa.text(f'DROP DATABASE {quote(database_name)} WITH (FORCE)'))
if role_created:
await conn.execute(sa.text(f'DROP ROLE {quote(runtime_role)}'))
await admin.dispose()
@pytest_asyncio.fixture(loop_scope='module')
async def service(cloud_database):
application, admin = cloud_database
async with admin.begin() as conn:
for table in MONITORING_TABLES:
await conn.execute(sa.delete(table))
application.instance_config.data.pop('monitoring', None)
return application.monitoring_service
async def _read(service, method, context, *args, **kwargs):
# HTTP auth binds a tenant scope; exercise that same guard for service reads.
async with service.ap.persistence_mgr.tenant_scope(context.workspace_uuid):
return await getattr(service, method)(context, *args, **kwargs)
def _query(context, sender_id):
return SimpleNamespace(
_execution_context=context,
launcher_type='person',
launcher_id='same-user',
sender_id=sender_id,
message_chain=SimpleNamespace(model_dump=lambda: [{'type': 'Plain', 'text': 'hello'}]),
resp_message_chain=[SimpleNamespace(model_dump=lambda: [{'type': 'Plain', 'text': 'reply'}])],
message_event=SimpleNamespace(sender=SimpleNamespace(nickname='Alice')),
variables={'public': 'value', '_private': 'hidden'},
)
@pytest.mark.parametrize('user_id', [123456789, -100123456789, 0, None, '', '00123', ' opaque用户 '])
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
async def test_optional_user_ids_round_trip_through_asyncpg(service, user_id, record_type):
context = _context(WORKSPACE_A)
expected = str(user_id) if isinstance(user_id, int) else user_id
if record_type == 'message':
record_id = await service.record_message(
context,
**RESOURCE,
message_content='hello',
session_id='same-session',
user_id=user_id,
)
details = await _read(service, 'get_message_details', context, record_id)
assert details['message']['user_id'] == expected
elif record_type == 'session':
await service.record_session_start(context, **RESOURCE, session_id='same-session', user_id=user_id)
rows, total = await _read(service, 'get_sessions', context)
assert total == 1
assert rows[0]['user_id'] == expected
else:
await service.record_feedback(context, feedback_id='same-feedback', feedback_type=1, user_id=user_id)
rows, total = await _read(service, 'get_feedback_list', context)
assert total == 1
assert rows[0]['user_id'] == expected
@pytest.mark.parametrize('user_id', [123456789, -100123456789])
async def test_query_lifecycle_persists_messages_session_and_llm_link(service, user_id, caplog):
context = _context(WORKSPACE_A)
query = _query(context, user_id)
message_id = await MonitoringHelper.record_query_start(service.ap, query, **RESOURCE)
assert message_id, caplog.text
await MonitoringHelper.record_llm_call(
service.ap,
query,
**RESOURCE,
model_name='model',
input_tokens=3,
output_tokens=5,
duration_ms=25,
message_id=message_id,
)
await MonitoringHelper.record_query_success(service.ap, message_id, query)
await MonitoringHelper.record_query_response(service.ap, query, **RESOURCE)
rows, total = await _read(service, 'get_messages', context)
assert total == 2
assert {row['role'] for row in rows} == {'user', 'assistant'}
assert {row['user_id'] for row in rows} == {str(user_id)}
details = await _read(service, 'get_message_details', context, message_id)
assert details['message']['status'] == 'success'
assert details['message']['variables'] == '{"public": "value"}'
assert details['llm_calls'][0]['message_id'] == message_id
assert details['llm_stats']['total_tokens'] == 8
sessions, total = await _read(service, 'get_sessions', context)
assert total == 1
assert sessions[0]['session_id'] == 'person_same-user'
assert sessions[0]['user_id'] == str(user_id)
assert not [record for record in caplog.records if record.levelno >= logging.ERROR]
@pytest.mark.parametrize('user_id', [123, -123])
async def test_query_error_persists_error_message_and_linked_log(service, user_id, caplog):
context = _context(WORKSPACE_A)
message_id = await MonitoringHelper.record_query_error(
service.ap,
_query(context, user_id),
**RESOURCE,
error=ValueError('failed query'),
)
assert message_id, caplog.text
details = await _read(service, 'get_message_details', context, message_id)
assert details['message']['user_id'] == str(user_id)
assert details['message']['status'] == 'error'
assert details['errors'][0]['message_id'] == message_id
assert details['errors'][0]['error_type'] == 'ValueError'
@pytest.mark.parametrize('user_id', [True, 1.5, b'123', ['123']])
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id, record_type):
context = _context(WORKSPACE_A)
with pytest.raises(TypeError, match='user_id must be a string, integer, or None'):
if record_type == 'message':
await service.record_message(
context,
**RESOURCE,
message_content='hello',
session_id='session',
user_id=user_id,
)
elif record_type == 'session':
await service.record_session_start(context, **RESOURCE, session_id='session', user_id=user_id)
else:
await service.record_feedback(context, feedback_id='feedback', feedback_type=1, user_id=user_id)
async with service.ap.persistence_mgr.tenant_scope(WORKSPACE_A):
for model in (models.MonitoringMessage, models.MonitoringSession, models.MonitoringFeedback):
count = await service.ap.persistence_mgr.execute_async(sa.select(sa.func.count()).select_from(model))
assert count.scalar_one() == 0
async def test_session_analysis_aggregates_under_cloud_sql_guard(service):
context = _context(WORKSPACE_A)
await service.record_session_start(context, **RESOURCE, session_id='same-session')
await service.record_message(context, **RESOURCE, session_id='same-session', message_content='hello')
result = await _read(service, 'get_session_analysis', context, 'same-session')
assert result['found'] is True
assert result['message_stats'] == {'total': 1, 'success': 1, 'error': 0, 'pending': 0}
assert result['llm_stats']['total_calls'] == 0
assert result['tool_stats']['total_calls'] == 0
assert result['session_duration_seconds'] == 0
async def test_rls_is_enforced_without_application_workspace_predicates(service, cloud_database):
_, admin = cloud_database
for workspace in (WORKSPACE_A, WORKSPACE_B):
await service.record_message(
_context(workspace), **RESOURCE, session_id='same-session', message_content=workspace
)
async with admin.connect() as conn:
states = (
await conn.execute(
sa.text(
'SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class '
"WHERE relname LIKE 'monitoring_%' AND relkind = 'r'"
)
)
).all()
assert len(states) == len(MONITORING_TABLES)
assert all(enabled and forced for _, enabled, forced in states)
engine = service.ap.persistence_mgr.get_db_engine()
async with engine.connect() as conn:
role = (
await conn.execute(sa.text('SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user'))
).one()
assert role == (False, False)
assert (await conn.execute(sa.select(models.MonitoringMessage.id))).all() == []
for workspace in (WORKSPACE_A, WORKSPACE_B):
async with service.ap.persistence_mgr.tenant_uow(workspace):
rows = (
await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.workspace_uuid))
).all()
assert rows == [(workspace,)]
with pytest.raises(TenantScopeRequiredError):
await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.id))
async def test_traffic_series_aggregates_all_rows_under_cloud_rls(service):
import datetime
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
context = _context(WORKSPACE_A)
for workspace, count in ((WORKSPACE_A, 61), (WORKSPACE_B, 2)):
async with service.ap.persistence_mgr.tenant_scope(workspace):
await service.ap.persistence_mgr.execute_async(
sa.insert(models.MonitoringMessage).values(
[
dict(
workspace_uuid=workspace,
id=f'{workspace}-m-{i}',
**RESOURCE,
session_id='shared',
message_content='test',
status='success',
level='info',
timestamp=datetime.datetime(2026, 9, 11, 1, 30),
)
for i in range(count)
]
)
)
async with service.ap.persistence_mgr.tenant_uow(WORKSPACE_A):
result = await get_traffic_series(
service.ap,
context,
bot_ids=[RESOURCE['bot_id']],
start_time=datetime.datetime(2026, 9, 11),
end_time=datetime.datetime(2026, 9, 12),
)
assert result['truncated'] is False
assert sum(point['messages'] for point in result['points']) == 61
@@ -142,7 +142,7 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
assert pk_columns == {
'binary_storages': ('workspace_uuid', 'unique_key'),
'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'),
'monitoring_sessions': ('workspace_uuid', 'bot_id', 'session_id'),
'monitoring_sessions': ('workspace_uuid', 'session_id'),
}
pipeline_run_foreign_keys = await _inspect(
@@ -237,10 +237,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
await conn.execute(
sa.text(
'INSERT INTO monitoring_sessions '
'(workspace_uuid, session_id, bot_id, bot_name, pipeline_id, pipeline_name, '
'start_time, last_activity, message_count, is_active) '
"VALUES (:workspace_uuid, 'session-1', 'bot-2', 'bot', 'pipeline-2', 'pipeline', "
'CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, 1)'
'(workspace_uuid, session_id, bot_id, last_activity, is_active) '
"VALUES (:workspace_uuid, 'session-1', 'bot-2', CURRENT_TIMESTAMP, 1)"
),
{'workspace_uuid': second_workspace_uuid},
)
@@ -1,307 +0,0 @@
"""Real Core/SDK protocol regression tests; no subprocesses or external services.
Run against the intended local SDK (``uv run --no-sync`` after local install).
The in-memory transport carries JSON strings through Handler.run on both sides;
send_file, envelope validation, base64 decoding and transfer storage are real.
Only Core's database/object-storage services, parser dispatch/provider and host
sandbox prerequisite probing are doubles. Worker launch/registration is
represented by its already-registered state.
"""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
from langbot_plugin.runtime.context import RuntimeContext
from langbot_plugin.runtime.io.connection import Connection
from langbot_plugin.entities.io.errors import ActionCallError, ConnectionClosedError
from langbot_plugin.runtime.io.handler import FILE_CHUNK_LENGTH, Handler
from langbot_plugin.runtime.io.handlers.control import ControlConnectionHandler
from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler
from langbot_plugin.runtime.plugin.mgr import PluginManager
from langbot_plugin.runtime.security import PLUGIN_FILE_STORAGE_DIR_ENV
pytestmark = pytest.mark.asyncio
PAYLOAD = bytes(range(256)) * 161 + b'\x00original RAG file\xff'
BINDING = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=3,
artifact_digest='a' * 64,
)
LEGACY = ActionContext(**BINDING.model_dump(exclude={'runtime_revision', 'artifact_digest'}))
class QueueConnection(Connection):
"""Only the byte transport is replaced, not the request/response machinery."""
def __init__(self):
self.incoming = asyncio.Queue()
self.sent = []
self.peer = None
async def send(self, message: str) -> None:
assert isinstance(message, str)
self.sent.append(json.loads(message))
await self.peer.incoming.put(message)
async def receive(self) -> str:
message = await self.incoming.get()
if message is None:
raise ConnectionClosedError('test transport closed')
return message
async def close(self) -> None:
await self.incoming.put(None)
await self.peer.incoming.put(None)
def connection_pair():
left, right = QueueConnection(), QueueConnection()
left.peer, right.peer = right, left
return left, right
@asynccontextmanager
async def protocol_stack(tmp_path, monkeypatch, profile='oss_dev', binding=LEGACY):
monkeypatch.chdir(tmp_path)
stored = tmp_path / 'original.bin'
stored.write_bytes(PAYLOAD)
storage_calls = []
async def get_file_stream(execution_context, storage_path):
storage_calls.append((execution_context, storage_path))
assert execution_context.workspace_uuid == BINDING.workspace_uuid
assert storage_path == 'knowledge/original.bin'
return stored.read_bytes()
async def get_execution_binding(workspace_uuid, expected_generation):
assert workspace_uuid == BINDING.workspace_uuid
assert expected_generation == BINDING.placement_generation
return BINDING
setting = SimpleNamespace(
plugin_author='tester',
plugin_name='engine',
installation_uuid=BINDING.installation_uuid,
runtime_revision=BINDING.runtime_revision,
artifact_digest=BINDING.artifact_digest,
)
app = SimpleNamespace(
deployment=SimpleNamespace(mode='oss' if profile == 'oss_dev' else 'cloud'),
logger=logging.getLogger(__name__),
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(first=lambda: setting))),
workspace_service=SimpleNamespace(get_execution_binding=get_execution_binding),
rag_runtime_service=SimpleNamespace(get_file_stream=get_file_stream),
)
core_conn, control_conn = connection_pair()
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'core-transfer'))
core = RuntimeConnectionHandler(core_conn, AsyncMock(return_value=False), app)
core.register_installation_binding(BINDING, plugin_author='tester', plugin_name='engine')
runtime = RuntimeContext()
runtime.plugin_mgr = PluginManager(runtime)
# No worker is launched: omit only host nsjail/cgroup prerequisite probing.
monkeypatch.setattr(runtime.plugin_mgr.worker_launcher, 'configure', lambda policy, profile: None)
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'runtime-transfer'))
control = ControlConnectionHandler(control_conn, runtime)
runtime.activate_control_handler(control)
bridge_conn, plugin_conn = connection_pair()
bridge = PluginConnectionHandler(bridge_conn, runtime, file_storage_dir=str(tmp_path / 'bridge-transfer'))
plugin = Handler(plugin_conn, file_storage_dir=str(tmp_path / 'plugin-transfer'))
# Trusted state left by registration, not plugin-supplied action data.
bridge.bind_action_context(binding)
runtime.plugin_mgr.plugin_handlers.append(bridge)
runtime.plugin_mgr.plugins.append(SimpleNamespace(_runtime_plugin_handler=bridge))
handlers = [core, control, bridge, plugin]
tasks = [asyncio.create_task(handler.run()) for handler in handlers]
try:
await asyncio.wait_for(
core.set_runtime_config(
runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='test-runtime'),
worker_policy=PluginWorkerPolicy(
max_cpus=1,
max_memory_mb=128,
max_pids=32,
max_open_files=64,
max_file_size_mb=8,
require_hard_limits=False,
),
runtime_profile=profile,
cloud_service_url=None,
),
5,
)
if isinstance(binding, InstallationBinding):
runtime.activate_installation_binding(binding)
else:
runtime.bind_workspace(binding)
yield SimpleNamespace(
core=core,
control=control,
runtime=runtime,
bridge=bridge,
plugin=plugin,
core_conn=core_conn,
control_conn=control_conn,
bridge_conn=bridge_conn,
plugin_conn=plugin_conn,
app=app,
storage_calls=storage_calls,
)
finally:
for handler in handlers:
await handler.close()
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 5)
def assert_chunks(connection, binding, payload=PAYLOAD):
chunks = [message for message in connection.sent if message.get('action') == CommonAction.FILE_CHUNK.value]
expected = (len(payload) + FILE_CHUNK_LENGTH - 1) // FILE_CHUNK_LENGTH
assert expected > 1
assert len(chunks) == expected
assert [chunk['data']['chunk_index'] for chunk in chunks] == list(range(expected))
assert {chunk['data']['chunk_amount'] for chunk in chunks} == {expected}
assert all(chunk['context'] == binding.model_dump() for chunk in chunks)
assert len({chunk['data']['file_key'] for chunk in chunks}) == 1
return chunks[0]['data']['file_key']
@pytest.mark.parametrize(
'profile,binding',
[('oss_dev', LEGACY), ('oss_dev', BINDING), ('shared', BINDING)],
ids=['legacy-oss', 'managed-oss', 'managed-shared'],
)
async def test_knowledge_file_roundtrip_reaches_plugin_original_bytes(tmp_path, monkeypatch, profile, binding):
async with protocol_stack(tmp_path, monkeypatch, profile, binding) as stack:
# Legacy plugin API sends no authority; Runtime supplies its trusted binding.
result = await asyncio.wait_for(
stack.plugin.call_action(
PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM,
{'storage_path': 'knowledge/original.bin'},
),
5,
)
assert await stack.plugin.read_local_file(result['file_key']) == PAYLOAD
assert len(stack.storage_calls) == 1
core_key = assert_chunks(stack.core_conn, binding)
plugin_key = assert_chunks(stack.bridge_conn, binding)
assert result['file_key'] == plugin_key != core_key
assert not (Path(stack.control.file_storage_dir) / core_key).exists()
assert not stack.control._owned_transfer_files
callbacks = [
message
for message in stack.control_conn.sent
if message.get('action') == PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM.value
]
assert len(callbacks) == 1
assert callbacks[0]['context'] == binding.model_dump()
assert callbacks[0]['data'] == {'storage_path': 'knowledge/original.bin'}
async def test_shared_control_rejects_legacy_chunks_before_storage(tmp_path, monkeypatch):
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
with stack.core.installation_scope(LEGACY):
with pytest.raises(ActionCallError, match='InstallationBinding|Legacy FILE_CHUNK'):
await asyncio.wait_for(stack.core.send_file(PAYLOAD, ''), 5)
assert not list(Path(stack.control.file_storage_dir).iterdir())
assert not stack.control._owned_transfer_files
async def test_candidate_artifact_pretransfer_does_not_require_active_installation(tmp_path, monkeypatch):
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
candidate = BINDING.model_copy(
update={'installation_uuid': 'candidate-installation', 'runtime_revision': 1, 'artifact_digest': 'c' * 64}
)
assert not stack.runtime.is_current_installation_binding(candidate)
with stack.core.installation_scope(candidate):
key = await asyncio.wait_for(stack.core.send_file(PAYLOAD, 'lbp'), 5)
assert_chunks(stack.core_conn, candidate)
assert await stack.control.read_local_file(key) == PAYLOAD
assert not stack.runtime.is_current_installation_binding(candidate)
async def test_nested_parser_target_owns_file_and_action_envelopes(tmp_path, monkeypatch):
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
target = BINDING.model_copy(
update={
'installation_uuid': 'parser-installation',
'runtime_revision': 2,
'artifact_digest': 'b' * 64,
}
)
stack.runtime.activate_installation_binding(target)
parser_calls = []
restored = []
async def parse_document(author, name, context_data, file_bytes):
parser_calls.append((stack.control.current_action_context, author, name, context_data, file_bytes))
return {'documents': [{'text': 'parsed'}]}
stack.runtime.plugin_mgr.parse_document = parse_document
class ParserConnector:
async def require_workspace_context(self, context):
assert context.workspace_uuid == BINDING.workspace_uuid
async def call_parser(self, plugin_name, context_data, file_bytes):
assert plugin_name == 'tester/parser'
assert stack.core.current_action_context == BINDING
with stack.core.installation_scope(target):
result = await stack.core.parse_document('tester', 'parser', context_data, file_bytes)
restored.append(stack.core.resolve_outbound_action_context(None))
return result
stack.app.plugin_connector = ParserConnector()
result = await asyncio.wait_for(
stack.plugin.call_action(
PluginToRuntimeAction.INVOKE_PARSER,
{
'plugin_author': 'tester',
'plugin_name': 'parser',
'storage_path': 'knowledge/original.bin',
'filename': 'original.bin',
},
),
5,
)
assert result == {'documents': [{'text': 'parsed'}]}
key = assert_chunks(stack.core_conn, target)
parse_requests = [
message
for message in stack.core_conn.sent
if message.get('action') == LangBotToRuntimeAction.PARSE_DOCUMENT.value
]
assert len(parse_requests) == 1
assert parse_requests[0]['context'] == target.model_dump()
assert parse_requests[0]['data']['context']['file_key'] == key
assert parser_calls == [
(
target,
'tester',
'parser',
{
'mime_type': 'application/octet-stream',
'filename': 'original.bin',
'metadata': {},
},
PAYLOAD,
)
]
assert restored == [BINDING]
assert stack.core.current_action_context is None
assert stack.core.resolve_outbound_action_context(None) is None
assert not (Path(stack.control.file_storage_dir) / key).exists()
@@ -1,19 +0,0 @@
"""Identifier normalization must not rely on SQLite's permissive codecs."""
import pytest
from langbot.pkg.api.http.service import monitoring
@pytest.mark.parametrize(
('value', 'expected'),
[(None, None), ('', ''), ('00123', '00123'), (' 用户 ', ' 用户 '), (123, '123'), (-123, '-123'), (0, '0')],
)
def test_normalize_user_id_preserves_opaque_strings(value, expected):
assert monitoring._normalize_user_id(value) == expected
@pytest.mark.parametrize('value', [True, False, 1.5, b'123', ['123'], {'id': 123}])
def test_normalize_user_id_rejects_unsupported_types(value):
with pytest.raises(TypeError, match='user_id must be a string, integer, or None'):
monitoring._normalize_user_id(value)
@@ -1,220 +0,0 @@
"""Bot-scoped session regressions exercised against real SQL databases."""
import datetime as dt
import logging
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.api.http.service.monitoring import MonitoringService
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence import monitoring as models
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper
from tests.integration.persistence.test_monitoring_postgres import cloud_database # noqa: F401
pytestmark = pytest.mark.asyncio
@pytest.mark.asyncio(loop_scope='module')
async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811
import asyncio
import importlib
from alembic.migration import MigrationContext
from alembic.operations import Operations
from tests.integration.persistence.test_monitoring_postgres import WORKSPACE_A, _context, _read
ap, admin = cloud_database
service = ap.monitoring_service
ctx = _context(WORKSPACE_A)
await service.record_session_start(ctx, session_id='person_42', **resource('a'))
for bot in ['a', 'b']:
await service.record_message(ctx, session_id='person_42', message_content=bot, **resource(bot))
async with admin.begin() as conn:
def migrate(connection):
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
with Operations.context(MigrationContext.configure(connection)):
migration.downgrade()
migration.upgrade()
rls = connection.execute(
sa.text("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname='monitoring_sessions'")
).one()
assert tuple(rls) == (True, True)
assert (
connection.execute(
sa.text("SELECT count(*) FROM pg_policies WHERE tablename='monitoring_sessions'")
).scalar_one()
== 1
)
await conn.run_sync(migrate)
rows, total = await _read(service, 'get_sessions', ctx)
assert total == 2
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
await asyncio.gather(*[service.record_session_start(ctx, session_id='race', **resource('a')) for _ in range(10)])
result = await _read(service, 'get_session_analysis', ctx, 'race', bot_id='a')
assert result['session']['message_count'] == 10
assert not (await _read(service, 'get_session_analysis', ctx, 'person_42'))['found']
assert (await _read(service, 'get_session_analysis', ctx, 'person_42', bot_id='b'))['message_stats']['total'] == 1
async def test_migration_reconstructs_collisions_and_preserves_indexes(service):
import importlib
from alembic.migration import MigrationContext
from alembic.operations import Operations
engine = service.ap.persistence_mgr.get_db_engine()
async with engine.begin() as conn:
def upgrade(connection):
table = models.MonitoringSession.__table__
table.drop(connection)
metadata = sa.MetaData()
legacy = table.to_metadata(metadata)
legacy.primary_key._columns.remove(legacy.c.bot_id)
legacy.c.bot_id.primary_key = False
# Resolve the unchanged Workspace FK in copied metadata.
Base.metadata.tables['workspaces'].to_metadata(metadata)
legacy.create(connection)
now = dt.datetime(2026, 1, 1)
connection.execute(
sa.insert(legacy).values(
workspace_uuid='workspace',
session_id='person_42',
**resource('a'),
message_count=99,
start_time=now,
last_activity=now,
is_active=True,
)
)
for bot in ['a', 'b']:
connection.execute(
sa.insert(models.MonitoringMessage).values(
id=bot,
workspace_uuid='workspace',
timestamp=now,
**resource(bot),
session_id='person_42',
message_content=bot,
role='user',
status='success',
level='info',
)
)
indexes = {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
with Operations.context(MigrationContext.configure(connection)):
migration.upgrade()
migration.upgrade() # Fresh/already-upgraded schema is safe.
assert sa.inspect(connection).get_pk_constraint('monitoring_sessions')['constrained_columns'] == [
'workspace_uuid',
'bot_id',
'session_id',
]
assert indexes <= {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
await conn.run_sync(upgrade)
rows, total = await service.get_sessions(context())
assert total == 2
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
def context(bot=None):
return ExecutionContext(instance_uuid='test', workspace_uuid='workspace', placement_generation=1, bot_uuid=bot)
def resource(bot):
return dict(bot_id=bot, bot_name=bot, pipeline_id=bot, pipeline_name=bot)
@pytest.fixture
async def service():
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
class Persistence:
serialize_model = PersistenceManager.serialize_model
def get_db_engine(self):
return engine
async def execute_async(self, stmt):
async with engine.begin() as conn:
return await conn.execute(stmt)
ap = SimpleNamespace(persistence_mgr=Persistence(), logger=logging.getLogger(__name__))
ap.monitoring_service = MonitoringService(ap)
yield ap.monitoring_service
await engine.dispose()
async def test_helper_first_message_count_and_two_bot_isolation(service):
for bot in ['a', 'b', 'a']:
query = SimpleNamespace(
_execution_context=context(bot),
launcher_type='person',
launcher_id=42,
sender_id=42,
message_chain=SimpleNamespace(model_dump=lambda: []),
)
assert await MonitoringHelper.record_query_start(service.ap, query, **resource(bot))
rows, total = await service.get_sessions(context())
assert total == 2
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 2, 'b': 1}
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
assert {r['session_id'] for r in rows} == {'person_42'}
async def test_analysis_fails_closed_and_scopes_statistics(service):
for bot in ['a', 'b']:
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
await service.record_message(context(bot), session_id='person_42', message_content=bot, **resource(bot))
assert (await service.get_session_analysis(context(), 'person_42'))['found'] is False
result = await service.get_session_analysis(context(), 'person_42', bot_id='b')
assert result['message_stats']['total'] == 1
assert result['session']['bot_id'] == 'b'
async def test_activity_requires_bot_and_upsert_counts_racing_first_queries(service):
for _ in range(2):
await service.record_session_start(context('a'), session_id='person_42', **resource('a'))
with pytest.raises(ValueError, match='bot'):
await service.update_session_activity(context(), 'person_42')
assert await service.update_session_activity(context('a'), 'person_42')
assert not await service.update_session_activity(context('b'), 'person_42')
rows, _ = await service.get_sessions(context())
assert rows[0]['message_count'] == 3
async def test_old_active_sessions_are_listed_exported_and_not_cleaned(service):
for bot in ['a', 'b']:
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
old = dt.datetime(2000, 1, 1)
await service.ap.persistence_mgr.execute_async(sa.update(models.MonitoringSession).values(start_time=old))
await service.ap.persistence_mgr.execute_async(
sa.update(models.MonitoringSession).where(models.MonitoringSession.bot_id == 'a').values(last_activity=old)
)
since = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None) - dt.timedelta(days=1)
rows, total = await service.get_sessions(context(), start_time=since)
assert total == 1 and rows[0]['bot_id'] == 'b'
assert len(await service.export_sessions(context(), start_time=since)) == 1
count = await service._delete_expired_in_batches(
context(),
models.MonitoringSession,
models.MonitoringSession.last_activity,
models.MonitoringSession.session_id,
since,
1,
2,
)
assert count == 1
rows, total = await service.get_sessions(context())
assert total == 1 and rows[0]['bot_id'] == 'b'
@@ -1,125 +0,0 @@
from __future__ import annotations
import datetime
from types import SimpleNamespace
import pytest
import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
from langbot.pkg.entity.persistence.workspace import Workspace
pytestmark = pytest.mark.asyncio
A = '00000000-0000-0000-0000-00000000000a'
B = '00000000-0000-0000-0000-00000000000b'
START = datetime.datetime(2026, 1, 1)
@pytest.fixture
async def traffic_app():
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(
sqlalchemy.insert(Workspace),
[
{'uuid': wid, 'instance_uuid': 'instance', 'name': wid, 'slug': wid, 'source': 'cloud_projection'}
for wid in (A, B)
],
)
for wid, bot, count in [(A, 'bot-a', 60), (A, 'bot-b', 7), (B, 'bot-a', 9)]:
common = {
'workspace_uuid': wid,
'timestamp': START,
'bot_id': bot,
'bot_name': bot,
'pipeline_id': 'pipeline',
'pipeline_name': 'Pipeline',
'session_id': 'person_42',
'status': 'success',
}
await connection.execute(
sqlalchemy.insert(MonitoringMessage),
[
dict(common, id=f'{wid}-{bot}-{i}', message_content='test fixture', level='info', role='user')
for i in range(count)
],
)
await connection.execute(
sqlalchemy.insert(MonitoringLLMCall),
[
dict(
common,
id=f'{wid}-{bot}-{i}',
model_name='fixture-model',
input_tokens=1,
output_tokens=1,
total_tokens=2,
duration=1,
)
for i in range(count)
],
)
class Persistence:
def get_db_engine(self):
return engine
async def execute_async(self, statement):
async with engine.connect() as connection:
return await connection.execute(statement)
yield SimpleNamespace(persistence_mgr=Persistence())
await engine.dispose()
async def test_traffic_counts_all_rows_not_just_latest_page(traffic_app):
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1)
result = await get_traffic_series(
traffic_app, context, bot_ids=['bot-a'], start_time=START, end_time=START + datetime.timedelta(hours=2)
)
assert result['bucket'] == 'hour'
assert result['truncated'] is False
assert sum(point['messages'] for point in result['points']) == 60
assert sum(point['llm_calls'] for point in result['points']) == 60
assert len(result['points']) == 3
assert result['points'][1]['messages'] == result['points'][1]['llm_calls'] == 0
assert result['points'][0]['timestamp'] == '2026-01-01T00:00:00Z'
async def test_traffic_workspace_pipeline_and_empty_filters(traffic_app):
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
context = ExecutionContext(instance_uuid='instance', workspace_uuid=B, placement_generation=1)
kwargs = dict(start_time=START, end_time=START + datetime.timedelta(hours=2))
result = await get_traffic_series(traffic_app, context, **kwargs)
assert sum(point['messages'] for point in result['points']) == 9
empty = await get_traffic_series(traffic_app, context, pipeline_ids=['missing'], **kwargs)
assert sum(point['messages'] for point in empty['points']) == 0
assert sum(point['llm_calls'] for point in empty['points']) == 0
async def test_traffic_bounds_large_ranges_and_marks_truncation(traffic_app):
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1)
result = await get_traffic_series(
traffic_app, context, start_time=START, end_time=START + datetime.timedelta(days=5000)
)
assert result['bucket'] == 'day'
assert result['truncated'] is True
assert len(result['points']) == 1000
async def test_traffic_fails_closed_without_workspace(traffic_app):
from langbot.pkg.api.http.authz import WorkspaceRequiredError
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
with pytest.raises(WorkspaceRequiredError):
await get_traffic_series(traffic_app, None)
@@ -958,8 +958,6 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
[
sa.select(sa.literal('set_config(')),
sa.select(sa.func.count()),
sa.select(sa.func.min(sa.column('timestamp'))),
sa.select(sa.func.max(sa.column('timestamp'))),
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
sa.select(
sa.func.now(),
@@ -1,193 +0,0 @@
"""Exercise nested installation routing through real Core/SDK wire envelopes."""
from __future__ import annotations
import asyncio
import base64
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
from langbot_plugin.entities.io.req import ActionRequest
from langbot_plugin.entities.io.resp import ActionResponse
from langbot_plugin.runtime.io import handler as sdk_handler
from langbot.pkg.plugin.connector import PluginRuntimeConnector
from tests.unit_tests.plugin.test_handler_tenancy import RecordingConnection, make_handler, workspace_context
class ReplyingConnection(RecordingConnection):
"""Replace only the transport, retaining serialization and response routing."""
async def send(self, message: str) -> None:
await super().send(message)
request = json.loads(message)
if 'action' in request:
response = ActionResponse.success({'elements': []})
response.seq_id = request['seq_id']
await self.handler._route_response(response.seq_id, response.model_dump())
@property
def requests(self):
return [request for message in self.sent if 'action' in (request := json.loads(message))]
@pytest.fixture
def bridge(monkeypatch):
runtime_handler, app, binding_a = make_handler()
connection = ReplyingConnection()
connection.handler = runtime_handler
runtime_handler.conn = connection
monkeypatch.setattr(sdk_handler, 'FILE_CHUNK_LENGTH', 4)
binding_b = binding_a.model_copy(
update={
'installation_uuid': '00000000-0000-4000-8000-000000000002',
'runtime_revision': 2,
'artifact_digest': 'b' * 64,
}
)
return runtime_handler, app, connection, binding_a, binding_b
@pytest.mark.asyncio
@pytest.mark.parametrize('mode', ['managed', 'legacy'])
async def test_nested_invoke_parser_uses_target_for_every_chunk_and_parse(bridge, mode):
runtime_handler, app, connection, binding_a, binding_b = bridge
app.instance_config = SimpleNamespace(data={'plugin': {'enable': True}})
app.deployment.mode = 'cloud' if mode == 'managed' else 'oss'
connector = PluginRuntimeConnector(app, AsyncMock())
connector.handler = runtime_handler
app.plugin_connector = connector
execution_context = runtime_handler._execution_context(binding_a)
setting_b = SimpleNamespace(
installation_uuid=binding_b.installation_uuid,
runtime_revision=binding_b.runtime_revision,
artifact_digest=binding_b.artifact_digest,
install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if mode == 'managed' else {},
)
connector._setting_for_plugin = AsyncMock(return_value=(execution_context, setting_b))
connector.require_workspace_context = AsyncMock(return_value=execution_context)
file_bytes = b'parser document'
app.rag_runtime_service = SimpleNamespace(get_file_stream=AsyncMock(return_value=file_bytes))
inbound_context = binding_a
if mode == 'legacy':
inbound_context = workspace_context().for_installation(binding_a.installation_uuid)
setting_a = SimpleNamespace(
plugin_author='author-a',
plugin_name='plugin-a',
installation_uuid=binding_a.installation_uuid,
runtime_revision=binding_a.runtime_revision,
artifact_digest=binding_a.artifact_digest,
)
app.persistence_mgr.execute_async.return_value = SimpleNamespace(first=lambda: setting_a)
expected = binding_b if mode == 'managed' else connector._legacy_oss_bridge_binding(execution_context)
request = ActionRequest.make_request(
101,
PluginToRuntimeAction.INVOKE_PARSER.value,
{'plugin_author': 'author-b', 'plugin_name': 'parser-b', 'storage_path': 'file-a'},
inbound_context,
)
await runtime_handler._handle_action(request.model_dump())
response = json.loads(connection.sent[-1])
assert response['code'] == 0, response
chunks = connection.requests[:-1]
parse = connection.requests[-1]
assert len(chunks) == 4
assert all(chunk['action'] == CommonAction.FILE_CHUNK.value for chunk in chunks)
assert parse['action'] == LangBotToRuntimeAction.PARSE_DOCUMENT.value
assert all(request['context'] == expected.model_dump() for request in connection.requests)
assert b''.join(base64.b64decode(chunk['data']['chunk_base64']) for chunk in chunks) == file_bytes
assert {chunk['data']['file_key'] for chunk in chunks} == {parse['data']['context']['file_key']}
connector._setting_for_plugin.assert_awaited_once_with('author-b', 'parser-b', require_enabled=True)
assert runtime_handler.current_action_context is None
assert runtime_handler.resolve_outbound_action_context(None) is None
@pytest.mark.asyncio
async def test_explicit_argument_overrides_scope_and_inbound_falls_back(bridge):
runtime_handler, _, connection, binding_a, binding_b = bridge
token = runtime_handler._current_action_context.set(binding_a)
try:
with runtime_handler.installation_scope(binding_b):
await runtime_handler.call_action(
LangBotToRuntimeAction.LIST_PARSERS, {}, action_context=binding_a.model_dump()
)
await runtime_handler.list_parsers()
finally:
runtime_handler._current_action_context.reset(token)
assert [request['context'] for request in connection.requests] == [binding_a.model_dump()] * 2
assert runtime_handler.resolve_outbound_action_context(None) is None
@pytest.mark.asyncio
async def test_explicit_none_scope_clears_inbound_and_restores_outer_scope(bridge):
runtime_handler, _, connection, binding_a, binding_b = bridge
token = runtime_handler._current_action_context.set(binding_a)
try:
with runtime_handler.installation_scope(binding_b):
await runtime_handler.ping()
await runtime_handler.list_parsers()
await runtime_handler.list_parsers()
finally:
runtime_handler._current_action_context.reset(token)
assert [request.get('context') for request in connection.requests] == [
None,
binding_b.model_dump(),
binding_a.model_dump(),
]
@pytest.mark.asyncio
@pytest.mark.parametrize('failure', [RuntimeError, asyncio.CancelledError])
async def test_scope_restores_after_exception_or_cancellation(bridge, failure):
runtime_handler, _, connection, binding_a, binding_b = bridge
with runtime_handler.installation_scope(binding_a):
with pytest.raises(failure):
with runtime_handler.installation_scope(binding_b):
await runtime_handler.list_parsers()
raise failure()
await runtime_handler.list_parsers()
await runtime_handler.list_parsers()
assert [request.get('context') for request in connection.requests] == [
binding_b.model_dump(),
binding_a.model_dump(),
None,
]
@pytest.mark.asyncio
async def test_concurrent_nested_scopes_do_not_leak_on_task_cancellation(bridge):
runtime_handler, _, connection, binding_a, binding_b = bridge
entered = asyncio.Event()
release = asyncio.Event()
async def cancelled_invocation():
with runtime_handler.installation_scope(binding_b):
await runtime_handler.list_parsers()
entered.set()
await release.wait()
token = runtime_handler._current_action_context.set(binding_a)
task = asyncio.create_task(cancelled_invocation())
try:
await asyncio.wait_for(entered.wait(), timeout=2)
with runtime_handler.installation_scope(None):
await runtime_handler.list_parsers()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await runtime_handler.list_parsers()
finally:
runtime_handler._current_action_context.reset(token)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert [request.get('context') for request in connection.requests] == [
binding_b.model_dump(),
None,
binding_a.model_dump(),
]
assert runtime_handler.resolve_outbound_action_context(None) is None
Generated
+5 -5
View File
@@ -2008,7 +2008,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.10.11"
version = "4.10.10"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2129,7 +2129,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.8" },
{ name = "langbot-plugin", specifier = "==0.5.7" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2196,7 +2196,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.8"
version = "0.5.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2217,9 +2217,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/ab/8d8bd6b8355c5b30b4aab2b5322fd28d8f36158f36d6b4ee33f4df4bc861/langbot_plugin-0.5.8.tar.gz", hash = "sha256:46fbdf948f4a2d110607738ab35633c9ab22a30784edce3a4e684cd19bab84ff", size = 487972, upload-time = "2026-09-11T09:27:58.304Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/13/4939205e2f7922ec09113e390e35f9355ce6d93e1b380a4b3c49441130f5/langbot_plugin-0.5.8-py3-none-any.whl", hash = "sha256:4fbbcfa55f1dcb9af8392b48de8b7877ea79c880dfd268d651404702614d182e", size = 311552, upload-time = "2026-09-11T09:27:57.082Z" },
{ url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" },
]
[[package]]
@@ -157,9 +157,6 @@ const BotSessionMonitor = forwardRef<
const [messagePage, setMessagePage] = useState(0);
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [sessionError, setSessionError] = useState(false);
const [messageError, setMessageError] = useState(false);
const [analysisError, setAnalysisError] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
const [feedbackMap, setFeedbackMap] = useState<
Record<string, SessionFeedback>
@@ -239,8 +236,6 @@ const BotSessionMonitor = forwardRef<
const loadSessions = useCallback(async () => {
const requestId = ++sessionRequestIdRef.current;
setLoadingSessions(true);
setSessionError(false);
setSessions([]);
try {
const response = await httpClient.getBotSessions(botId, {
limit: SESSION_PAGE_SIZE,
@@ -259,7 +254,6 @@ const BotSessionMonitor = forwardRef<
} catch (error) {
if (requestId === sessionRequestIdRef.current) {
console.error('Failed to load sessions:', error);
setSessionError(true);
}
} finally {
if (requestId === sessionRequestIdRef.current) {
@@ -280,18 +274,12 @@ const BotSessionMonitor = forwardRef<
async (sessionId: string, page: number) => {
const requestId = ++messageRequestIdRef.current;
setLoadingMessages(true);
setMessageError(false);
setAnalysisError(false);
setMessages([]);
setToolCalls([]);
setFeedbackMap({});
setExpandedToolCallIds({});
try {
const messagesRes = await httpClient.getSessionMessages(
sessionId,
MESSAGE_PAGE_SIZE,
page * MESSAGE_PAGE_SIZE,
botId,
);
if (requestId !== messageRequestIdRef.current) return;
const sorted = (messagesRes.messages ?? []).sort(
@@ -302,19 +290,22 @@ const BotSessionMonitor = forwardRef<
setMessageTotal(messagesRes.total ?? 0);
try {
const analysisRes = await httpClient.getSessionAnalysis<{
const analysisParams = new URLSearchParams();
if (sorted.length > 0) {
analysisParams.set('startTime', sorted[0].timestamp);
analysisParams.set('endTime', sorted[sorted.length - 1].timestamp);
}
const analysisRes = await httpClient.get<{
tool_calls?: SessionToolCall[];
}>(sessionId, botId, {
startTime: sorted[0]?.timestamp,
endTime: sorted[sorted.length - 1]?.timestamp,
});
}>(
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`,
);
if (requestId !== messageRequestIdRef.current) return;
setToolCalls(analysisRes?.tool_calls ?? []);
} catch (analysisError) {
if (requestId !== messageRequestIdRef.current) return;
console.error('Failed to load session tool calls:', analysisError);
setToolCalls([]);
setAnalysisError(true);
}
// Collect user message IDs for feedback matching
@@ -346,7 +337,6 @@ const BotSessionMonitor = forwardRef<
} catch (error) {
if (requestId === messageRequestIdRef.current) {
console.error('Failed to load session messages:', error);
setMessageError(true);
}
} finally {
if (requestId === messageRequestIdRef.current) {
@@ -359,9 +349,6 @@ const BotSessionMonitor = forwardRef<
useEffect(() => {
loadSessions();
return () => {
sessionRequestIdRef.current += 1;
};
}, [loadSessions]);
useEffect(() => {
@@ -375,17 +362,12 @@ const BotSessionMonitor = forwardRef<
} else {
messageRequestIdRef.current += 1;
setLoadingMessages(false);
setMessageError(false);
setAnalysisError(false);
setMessages([]);
setMessageTotal(0);
setToolCalls([]);
setExpandedToolCallIds({});
setFeedbackMap({});
}
return () => {
messageRequestIdRef.current += 1;
};
}, [selectedSessionId, messagePage, loadMessages]);
useEffect(() => {
@@ -746,20 +728,6 @@ const BotSessionMonitor = forwardRef<
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t('bots.sessionMonitor.loading')}
</div>
) : sessionError ? (
<div
role="alert"
className="p-3 space-y-2 text-sm text-destructive"
>
<p>{t('monitoring.loadError')}</p>
<button
type="button"
onClick={loadSessions}
className="rounded border px-2 py-1 text-foreground"
>
{t('common.retry')}
</button>
</div>
) : sessions.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noSessions')}
@@ -930,46 +898,10 @@ const BotSessionMonitor = forwardRef<
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
>
<div className="space-y-4">
{analysisError && !loadingMessages && (
<div
role="alert"
className="text-sm text-destructive space-y-2"
>
<p>
{t('monitoring.toolCalls.title')}:{' '}
{t('monitoring.loadError')}
</p>
<button
type="button"
onClick={() =>
loadMessages(selectedSessionId, messagePage)
}
className="rounded border px-2 py-1 text-foreground"
>
{t('common.retry')}
</button>
</div>
)}
{loadingMessages ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')}
</div>
) : messageError ? (
<div
role="alert"
className="text-sm text-destructive space-y-2"
>
<p>{t('monitoring.loadError')}</p>
<button
type="button"
onClick={() =>
loadMessages(selectedSessionId, messagePage)
}
className="rounded border px-2 py-1 text-foreground"
>
{t('common.retry')}
</button>
</div>
) : timelineItems.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')}
@@ -46,10 +46,30 @@ import {
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import {
resolveDisabledState,
resolveShowIfValue,
} from './DynamicFormConditions';
/**
* Resolve the value referenced by a `show_if.field` string.
*
* Fields prefixed with `__system.` are looked up in the caller-supplied
* `systemContext` dictionary (e.g. `__system.is_wizard` `systemContext.is_wizard`).
* All other field names are resolved from the live form values first, then
* fall back to `externalDependentValues`.
*/
function resolveShowIfValue(
field: string,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
const key = field.slice(SYSTEM_FIELD_PREFIX.length);
return systemContext?.[key];
}
if (watchedValues[field] !== undefined) {
return watchedValues[field];
}
return externalDependentValues?.[field];
}
type DynamicFormValueSpec = Pick<
IDynamicFormItemSchema,
@@ -655,19 +675,40 @@ export default function DynamicFormComponent({
}
}
// Keep locked fields visible and resolve only the applicable reason.
const { isDisabledByCondition, disabledTooltip: tooltip } =
resolveDisabledState(
config,
// ``disable_if`` mirrors ``show_if``'s evaluator but instead of
// hiding the field, leaves it visible and inert. Use it when the
// operator needs to see that the field exists yet cannot edit it
// under the current runtime state (e.g. sandbox-bound fields when
// Box is disabled).
let isDisabledByCondition = false;
if (config.disable_if) {
const dependValue = resolveShowIfValue(
config.disable_if.field,
watchedValues as Record<string, unknown>,
externalDependentValues,
systemContext,
);
const cond = config.disable_if;
if (cond.operator === 'eq' && dependValue === cond.value) {
isDisabledByCondition = true;
} else if (cond.operator === 'neq' && dependValue !== cond.value) {
isDisabledByCondition = true;
} else if (
cond.operator === 'in' &&
Array.isArray(cond.value) &&
cond.value.includes(dependValue)
) {
isDisabledByCondition = true;
}
}
// All fields are disabled when editing (creation_settings are
// immutable) or when ``disable_if`` matches.
const isFieldDisabled = !!isEditing || isDisabledByCondition;
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
const disabledTooltip =
isDisabledByCondition && config.disabled_tooltip
? extractI18nObject(config.disabled_tooltip)
: '';
const renderDisabledTooltipIcon = () =>
disabledTooltip ? (
<DisabledTooltipIcon text={disabledTooltip} />
@@ -1,71 +0,0 @@
import {
SYSTEM_FIELD_PREFIX,
type IDynamicFormItemSchema,
type IShowIfCondition,
} from '@/app/infra/entities/form/dynamic';
/** System references use caller context; other fields prefer live form values. */
export function resolveShowIfValue(
field: string,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
return systemContext?.[field.slice(SYSTEM_FIELD_PREFIX.length)];
}
if (watchedValues[field] !== undefined) {
return watchedValues[field];
}
return externalDependentValues?.[field];
}
export function matchesFormCondition(
condition: IShowIfCondition,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): boolean {
const value = resolveShowIfValue(
condition.field,
watchedValues,
externalDependentValues,
systemContext,
);
switch (condition.operator) {
case 'eq':
return value === condition.value;
case 'neq':
return value !== condition.value;
case 'in':
return Array.isArray(condition.value) && condition.value.includes(value);
default:
return false;
}
}
export function resolveDisabledState(
config: Pick<
IDynamicFormItemSchema,
'disable_if' | 'disabled_tooltip' | 'disabled_tooltip_overrides'
>,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
) {
const matches = (condition: IShowIfCondition) =>
matchesFormCondition(
condition,
watchedValues,
externalDependentValues,
systemContext,
);
const isDisabledByCondition =
!!config.disable_if && matches(config.disable_if);
const disabledTooltip = isDisabledByCondition
? (config.disabled_tooltip_overrides?.find((override) =>
matches(override.when),
)?.tooltip ?? config.disabled_tooltip)
: undefined;
return { isDisabledByCondition, disabledTooltip };
}
@@ -4,18 +4,24 @@ import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
import MetricCard from './MetricCard';
import SystemStatusCard from './SystemStatusCards';
import TrafficChart from './TrafficChart';
import { OverviewMetrics, MonitoringData } from '../../types/monitoring';
import {
OverviewMetrics,
MonitoringMessage,
LLMCall,
} from '../../types/monitoring';
interface OverviewCardsProps {
metrics: OverviewMetrics | null;
traffic?: MonitoringData['traffic'];
messages?: MonitoringMessage[];
llmCalls?: LLMCall[];
loading?: boolean;
refreshKey?: number;
}
export default function OverviewCards({
metrics,
traffic,
messages = [],
llmCalls = [],
loading,
refreshKey,
}: OverviewCardsProps) {
@@ -94,7 +100,7 @@ export default function OverviewCards({
</div>
{/* Traffic Chart */}
<TrafficChart traffic={traffic} loading={loading} />
<TrafficChart messages={messages} llmCalls={llmCalls} loading={loading} />
</div>
);
}
@@ -11,33 +11,119 @@ import {
ResponsiveContainer,
Legend,
} from 'recharts';
import { MonitoringData } from '../../types/monitoring';
import { MonitoringMessage, LLMCall } from '../../types/monitoring';
interface TrafficChartProps {
traffic?: MonitoringData['traffic'];
messages: MonitoringMessage[];
llmCalls: LLMCall[];
loading?: boolean;
}
export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
interface ChartDataPoint {
time: string;
timestamp: number;
messages: number;
llmCalls: number;
}
export default function TrafficChart({
messages,
llmCalls,
loading,
}: TrafficChartProps) {
const { t } = useTranslation();
const chartData = useMemo(
() =>
(traffic?.points ?? []).map((point) => ({
...point,
time: point.timestamp.toLocaleString(
[],
traffic?.bucket === 'day'
? { month: 'short', day: 'numeric' }
: {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
),
})),
[traffic],
);
const chartData = useMemo(() => {
const safeMessages = Array.isArray(messages) ? messages : [];
const safeLlmCalls = Array.isArray(llmCalls) ? llmCalls : [];
if (!safeMessages.length && !safeLlmCalls.length) {
return [];
}
// Combine all timestamps and find the range
const allTimestamps = [
...safeMessages.map((m) => m.timestamp.getTime()),
...safeLlmCalls.map((c) => c.timestamp.getTime()),
];
if (allTimestamps.length === 0) return [];
const minTime = Math.min(...allTimestamps);
const maxTime = Math.max(...allTimestamps);
const timeRange = maxTime - minTime;
// Determine bucket size based on time range
let bucketSize: number;
let formatTime: (date: Date) => string;
if (timeRange <= 60 * 60 * 1000) {
// <= 1 hour: 5-minute buckets
bucketSize = 5 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 6 * 60 * 60 * 1000) {
// <= 6 hours: 15-minute buckets
bucketSize = 15 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 24 * 60 * 60 * 1000) {
// <= 24 hours: 1-hour buckets
bucketSize = 60 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 7 * 24 * 60 * 60 * 1000) {
// <= 7 days: 4-hour buckets
bucketSize = 4 * 60 * 60 * 1000;
formatTime = (date) =>
`${date.toLocaleDateString([], {
month: 'short',
day: 'numeric',
})} ${date.toLocaleTimeString([], { hour: '2-digit' })}`;
} else {
// > 7 days: 1-day buckets
bucketSize = 24 * 60 * 60 * 1000;
formatTime = (date) =>
date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
// Create buckets
const buckets: Map<number, ChartDataPoint> = new Map();
const startBucket = Math.floor(minTime / bucketSize) * bucketSize;
const endBucket = Math.ceil(maxTime / bucketSize) * bucketSize;
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {
buckets.set(bucket, {
time: formatTime(new Date(bucket)),
timestamp: bucket,
messages: 0,
llmCalls: 0,
});
}
// Count messages per bucket
safeMessages.forEach((msg) => {
const bucket =
Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
if (point) {
point.messages++;
}
});
// Count LLM calls per bucket
safeLlmCalls.forEach((call) => {
const bucket =
Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
if (point) {
point.llmCalls++;
}
});
return Array.from(buckets.values()).sort(
(a, b) => a.timestamp - b.timestamp,
);
}, [messages, llmCalls]);
if (loading) {
return (
@@ -64,13 +150,7 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
</h3>
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
<BarChart3 className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t(
traffic
? 'monitoring.trafficChart.noData'
: 'monitoring.trafficChart.unavailable',
)}
</div>
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
</div>
</div>
);
@@ -81,11 +161,6 @@ export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
<h3 className="text-base font-semibold text-foreground mb-6">
{t('monitoring.trafficChart.title')}
</h3>
{traffic?.truncated && (
<p role="status" className="text-sm text-muted-foreground mb-3">
{t('monitoring.trafficChart.truncated')}
</p>
)}
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
FilterState,
MonitoringData,
@@ -6,8 +6,7 @@ import {
LLMCall,
EmbeddingCall,
} from '../types/monitoring';
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
import { backendClient } from '@/app/infra/http';
import { parseUTCTimestamp } from '../utils/dateUtils';
/**
@@ -17,10 +16,6 @@ export function useMonitoringData(filterState: FilterState) {
const [data, setData] = useState<MonitoringData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
const requestIdRef = useRef(0);
const scope = JSON.stringify([workspaceUuid, filterState]);
const [requestScope, setRequestScope] = useState<string | null>(null);
// Memoize filter parameters to prevent unnecessary re-renders
const selectedBotsStr = useMemo(
@@ -77,12 +72,6 @@ export function useMonitoringData(filterState: FilterState) {
// Fetch data based on filters
const fetchData = useCallback(async () => {
const requestId = ++requestIdRef.current;
const isCurrent = () =>
requestId === requestIdRef.current &&
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
setRequestScope(scope);
setData(null);
setLoading(true);
setError(null);
@@ -102,7 +91,6 @@ export function useMonitoringData(filterState: FilterState) {
endTime,
limit: 50,
});
if (!isCurrent()) return;
const overview = response?.overview ?? {
total_messages: 0,
@@ -139,17 +127,6 @@ export function useMonitoringData(filterState: FilterState) {
// Transform the response to match MonitoringData interface
const transformedData: MonitoringData = {
traffic: response.traffic
? {
bucket: response.traffic.bucket,
truncated: response.traffic.truncated,
points: response.traffic.points.map((point) => ({
timestamp: parseUTCTimestamp(point.timestamp),
messages: point.messages,
llmCalls: point.llm_calls,
})),
}
: undefined,
overview: {
totalMessages: overview.total_messages,
llmCalls: overview.llm_calls,
@@ -419,33 +396,22 @@ export function useMonitoringData(filterState: FilterState) {
setData(transformedData);
} catch (err) {
if (!isCurrent()) return;
setError(err as Error);
console.error('Failed to fetch monitoring data:', err);
} finally {
if (isCurrent()) setLoading(false);
setLoading(false);
}
}, [
getTimeRange,
filterState.selectedBots,
filterState.selectedPipelines,
scope,
workspaceUuid,
]);
}, [getTimeRange, filterState.selectedBots, filterState.selectedPipelines]);
// Fetch data when filter state changes
useEffect(() => {
fetchData();
return () => {
requestIdRef.current += 1;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedBotsStr,
selectedPipelinesStr,
filterState.timeRange,
customDateRangeStr,
workspaceUuid,
]);
// Manual refetch function
@@ -454,9 +420,9 @@ export function useMonitoringData(filterState: FilterState) {
};
return {
data: requestScope === scope ? data : null,
loading: requestScope !== scope || loading,
error: requestScope === scope ? error : null,
data,
loading,
error,
refetch,
};
}
File diff suppressed because it is too large Load Diff
@@ -217,11 +217,6 @@ export interface FeedbackStats {
}
export interface MonitoringData {
traffic?: {
bucket: 'hour' | 'day';
points: Array<{ timestamp: Date; messages: number; llmCalls: number }>;
truncated: boolean;
};
overview: OverviewMetrics;
messages: MonitoringMessage[];
llmCalls: LLMCall[];
@@ -155,18 +155,17 @@ function findTurnBySessionTime(
sessionTurns: Map<string, ConversationTurn[]>,
sessionId: string | undefined,
timestamp: Date,
botId: string,
): ConversationTurn | undefined {
if (!sessionId) {
return undefined;
}
const turns = sessionTurns.get(JSON.stringify([botId, sessionId]));
const turns = sessionTurns.get(sessionId);
if (!turns?.length) {
return undefined;
}
let nearest: ConversationTurn | undefined;
let nearest = turns[0];
const targetTime = timestamp.getTime();
for (const turn of turns) {
@@ -204,16 +203,15 @@ export function buildConversationTurns(
for (const message of visibleMessages) {
const role = normalizeRole(message, activityMessageIds);
const sessionKey = JSON.stringify([message.botId, message.sessionId]);
const previousTurn = lastTurnBySession.get(sessionKey);
const previousTurn = lastTurnBySession.get(message.sessionId);
const shouldStartTurn = role === 'user' || !previousTurn;
const turn = shouldStartTurn ? createTurn(message) : previousTurn;
if (shouldStartTurn) {
const turns = sessionTurns.get(sessionKey) ?? [];
const turns = sessionTurns.get(message.sessionId) ?? [];
turns.push(turn);
sessionTurns.set(sessionKey, turns);
lastTurnBySession.set(sessionKey, turn);
sessionTurns.set(message.sessionId, turns);
lastTurnBySession.set(message.sessionId, turn);
}
addMessageToTurn(turn, message, role);
@@ -223,14 +221,9 @@ export function buildConversationTurns(
const allTurns = Array.from(sessionTurns.values()).flat();
for (const call of llmCalls) {
const turn = call.messageId
? messageIdToTurn.get(call.messageId)
: findTurnBySessionTime(
sessionTurns,
call.sessionId,
call.timestamp,
call.botId,
);
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
if (!turn) {
continue;
@@ -250,14 +243,9 @@ export function buildConversationTurns(
}
for (const call of toolCalls) {
const turn = call.messageId
? messageIdToTurn.get(call.messageId)
: findTurnBySessionTime(
sessionTurns,
call.sessionId,
call.timestamp,
call.botId,
);
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
if (!turn) {
continue;
@@ -274,14 +262,9 @@ export function buildConversationTurns(
}
for (const error of errors) {
const turn = error.messageId
? messageIdToTurn.get(error.messageId)
: findTurnBySessionTime(
sessionTurns,
error.sessionId,
error.timestamp,
error.botId,
);
const turn =
(error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp);
if (!turn) {
continue;
@@ -1,14 +0,0 @@
/** Unavailability takes priority over the deployment's scope restriction. */
export function getBoxScopeContext(
boxAvailable: boolean,
forcedTemplate?: string,
) {
forcedTemplate = forcedTemplate?.trim();
return {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !forcedTemplate,
// Only expose forced-scope reasons when the sandbox is available.
box_scope_forced: boxAvailable && !!forcedTemplate,
box_scope_forced_global: boxAvailable && forcedTemplate === '{global}',
};
}
@@ -8,7 +8,6 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { getBoxScopeContext } from './BoxScopeContext';
import { systemInfo } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form';
@@ -426,12 +425,13 @@ export default function PipelineFormComponent({
// 2. the deployment pins all pipelines to a fixed scope via
// ``system.limitation.force_box_session_id_template`` (SaaS).
const forcedBoxTemplate =
systemInfo.limitation?.force_box_session_id_template?.trim() || '';
systemInfo.limitation?.force_box_session_id_template || '';
const boxScopeForced = !!forcedBoxTemplate;
const isLocalAgentStage = formName === 'ai' && stage.name === 'local-agent';
const stageSystemContext = isLocalAgentStage
? {
...getBoxScopeContext(boxAvailable, forcedBoxTemplate),
box_available: boxAvailable,
box_scope_editable: boxAvailable && !boxScopeForced,
pipeline_id: pipelineId,
}
: undefined;
@@ -39,13 +39,6 @@ export interface IDynamicFormItemSchema {
disable_if?: IShowIfCondition;
/** Tooltip shown next to the field label when ``disable_if`` is active. */
disabled_tooltip?: I18nObject;
/** Optional overrides evaluated in order when ``disable_if`` matches.
* The first matching ``when`` wins; otherwise use ``disabled_tooltip``.
* Conditions use the same operators and value lookup as ``disable_if``. */
disabled_tooltip_overrides?: {
when: IShowIfCondition;
tooltip: I18nObject;
}[];
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
scopes?: string[];
-20
View File
@@ -563,24 +563,10 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
}
public getSessionAnalysis<T>(
sessionId: string,
botId: string,
options: { startTime?: string; endTime?: string } = {},
): Promise<T> {
const queryParams = new URLSearchParams({ botId });
if (options.startTime) queryParams.set('startTime', options.startTime);
if (options.endTime) queryParams.set('endTime', options.endTime);
return this.get(
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${queryParams.toString()}`,
);
}
public getSessionMessages(
sessionId: string,
limit: number = 200,
offset: number = 0,
botId?: string,
): Promise<{
messages: Array<{
id: string;
@@ -604,7 +590,6 @@ export class BackendClient extends BaseHttpClient {
}> {
const queryParams = new URLSearchParams();
queryParams.append('sessionId', sessionId);
if (botId) queryParams.append('botId', botId);
queryParams.append('limit', limit.toString());
queryParams.append('offset', offset.toString());
return this.get(`/api/v1/monitoring/messages?${queryParams.toString()}`);
@@ -1511,11 +1496,6 @@ export class BackendClient extends BaseHttpClient {
endTime?: string;
limit?: number;
}): Promise<{
traffic?: {
bucket: 'hour' | 'day';
points: Array<{ timestamp: string; messages: number; llm_calls: number }>;
truncated: boolean;
};
overview: {
total_messages: number;
llm_calls: number;
-9
View File
@@ -1644,16 +1644,7 @@ const enUS = {
queryVariables: {
title: 'Query Variables',
},
loadError: 'Failed to load monitoring data',
partialMessages:
'Showing {{shown}} of {{total}} messages. Conversation traces may be incomplete.',
partialModelCalls: 'Showing {{shown}} of {{total}} model calls.',
partialToolCalls:
'Showing {{shown}} of {{total}} tool calls. Conversation traces may be incomplete.',
partialErrors: 'Showing {{shown}} of {{total}} errors.',
trafficChart: {
unavailable: 'Traffic aggregation unavailable',
truncated: 'Traffic range truncated. Choose a shorter time range.',
title: 'Traffic Overview',
messages: 'Messages',
llmCalls: 'LLM Calls',
-10
View File
@@ -1602,17 +1602,7 @@ const esES = {
queryVariables: {
title: 'Variables de consulta',
},
loadError: 'No se pudieron cargar los datos de monitoreo',
partialMessages:
'Se muestran {{shown}} de {{total}} mensajes. Las trazas de conversación pueden estar incompletas.',
partialModelCalls: 'Se muestran {{shown}} de {{total}} llamadas al modelo.',
partialToolCalls:
'Se muestran {{shown}} de {{total}} llamadas a herramientas. Las trazas de conversación pueden estar incompletas.',
partialErrors: 'Se muestran {{shown}} de {{total}} errores.',
trafficChart: {
unavailable: 'Agregación de tráfico no disponible',
truncated:
'Rango de tráfico truncado. Selecciona un intervalo más corto.',
title: 'Resumen de tráfico',
messages: 'Mensajes',
llmCalls: 'Llamadas LLM',
-10
View File
@@ -1653,17 +1653,7 @@ const jaJP = {
queryVariables: {
title: 'クエリ変数',
},
loadError: 'モニタリングデータを読み込めませんでした',
partialMessages:
'全 {{total}} 件中 {{shown}} 件のメッセージを表示。会話トレースは不完全な場合があります。',
partialModelCalls: '全 {{total}} 件中 {{shown}} 件のモデル呼び出しを表示。',
partialToolCalls:
'全 {{total}} 件中 {{shown}} 件のツール呼び出しを表示。会話トレースは不完全な場合があります。',
partialErrors: '全 {{total}} 件中 {{shown}} 件のエラーを表示。',
trafficChart: {
unavailable: 'トラフィック集計を利用できません',
truncated:
'トラフィック範囲が切り詰められています。短い期間を選択してください。',
title: 'トラフィック概要',
messages: 'メッセージ',
llmCalls: 'LLM呼び出し',
-9
View File
@@ -1574,16 +1574,7 @@ const ruRU = {
queryVariables: {
title: 'Переменные запроса',
},
loadError: 'Не удалось загрузить данные мониторинга',
partialMessages:
'Показано {{shown}} из {{total}} сообщений. Трассировки диалогов могут быть неполными.',
partialModelCalls: 'Показано {{shown}} из {{total}} вызовов модели.',
partialToolCalls:
'Показано {{shown}} из {{total}} вызовов инструментов. Трассировки диалогов могут быть неполными.',
partialErrors: 'Показано {{shown}} из {{total}} ошибок.',
trafficChart: {
unavailable: 'Агрегированные данные трафика недоступны',
truncated: 'Диапазон трафика обрезан. Выберите более короткий период.',
title: 'Обзор трафика',
messages: 'Сообщения',
llmCalls: 'Вызовы LLM',
-10
View File
@@ -1543,17 +1543,7 @@ const thTH = {
queryVariables: {
title: 'ตัวแปรคำค้นหา',
},
loadError: 'โหลดข้อมูลการตรวจสอบไม่สำเร็จ',
partialMessages:
'แสดง {{shown}} จาก {{total}} ข้อความ ประวัติการสนทนาอาจไม่ครบถ้วน',
partialModelCalls: 'แสดง {{shown}} จาก {{total}} การเรียกโมเดล',
partialToolCalls:
'แสดง {{shown}} จาก {{total}} การเรียกเครื่องมือ ประวัติการสนทนาอาจไม่ครบถ้วน',
partialErrors: 'แสดง {{shown}} จาก {{total}} ข้อผิดพลาด',
trafficChart: {
unavailable: 'ไม่มีข้อมูลสรุปปริมาณการใช้งาน',
truncated:
'ช่วงข้อมูลปริมาณการใช้งานถูกตัดทอน โปรดเลือกช่วงเวลาที่สั้นลง',
title: 'ภาพรวมปริมาณการใช้งาน',
messages: 'ข้อความ',
llmCalls: 'การเรียก LLM',
-10
View File
@@ -1567,17 +1567,7 @@ const viVN = {
queryVariables: {
title: 'Biến truy vấn',
},
loadError: 'Không thể tải dữ liệu giám sát',
partialMessages:
'Hiển thị {{shown}} trên {{total}} tin nhắn. Dấu vết hội thoại có thể không đầy đủ.',
partialModelCalls: 'Hiển thị {{shown}} trên {{total}} lượt gọi mô hình.',
partialToolCalls:
'Hiển thị {{shown}} trên {{total}} lượt gọi công cụ. Dấu vết hội thoại có thể không đầy đủ.',
partialErrors: 'Hiển thị {{shown}} trên {{total}} lỗi.',
trafficChart: {
unavailable: 'Không có dữ liệu tổng hợp lưu lượng',
truncated:
'Phạm vi lưu lượng bị cắt ngắn. Hãy chọn khoảng thời gian ngắn hơn.',
title: 'Tổng quan lưu lượng',
messages: 'Tin nhắn',
llmCalls: 'Cuộc gọi LLM',
-9
View File
@@ -1572,16 +1572,7 @@ const zhHans = {
queryVariables: {
title: '查询变量',
},
loadError: '监控数据加载失败',
partialMessages:
'显示 {{total}} 条消息中的 {{shown}} 条,对话轨迹可能不完整。',
partialModelCalls: '显示 {{total}} 次模型调用中的 {{shown}} 次。',
partialToolCalls:
'显示 {{total}} 次工具调用中的 {{shown}} 次,对话轨迹可能不完整。',
partialErrors: '显示 {{total}} 条错误中的 {{shown}} 条。',
trafficChart: {
unavailable: '流量聚合数据不可用',
truncated: '流量时间范围已截断,请选择更短的时间范围。',
title: '流量概览',
messages: '消息数',
llmCalls: 'LLM调用',
-9
View File
@@ -1495,16 +1495,7 @@ const zhHant = {
queryVariables: {
title: '查詢變數',
},
loadError: '監控資料載入失敗',
partialMessages:
'顯示 {{total}} 則訊息中的 {{shown}} 則,對話軌跡可能不完整。',
partialModelCalls: '顯示 {{total}} 次模型呼叫中的 {{shown}} 次。',
partialToolCalls:
'顯示 {{total}} 次工具呼叫中的 {{shown}} 次,對話軌跡可能不完整。',
partialErrors: '顯示 {{total}} 筆錯誤中的 {{shown}} 筆。',
trafficChart: {
unavailable: '流量彙總資料無法使用',
truncated: '流量時間範圍已截斷,請選擇較短的時間範圍。',
title: '流量概覽',
messages: '訊息',
llmCalls: 'LLM呼叫',
@@ -66,381 +66,7 @@ function toolCall(
};
}
test.describe('bot session request recovery', () => {
for (const failure of [
'initial list',
'list page',
'session switch',
'message page',
'analysis',
]) {
test(`${failure} failure is visible and retry recovers`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let failing = true;
await page.route('**/api/v1/monitoring/**', async (route) => {
const url = new URL(route.request().url());
const offset = Number(url.searchParams.get('offset') || 0);
const second = url.searchParams.get('sessionId') === 'person-second';
const list = url.pathname.endsWith('/sessions');
const message = url.pathname.endsWith('/messages');
const analysis = url.pathname.endsWith('/analysis');
if (!list && !message && !analysis) return route.fallback();
const fail =
failing &&
((list && failure === 'initial list') ||
(list && failure === 'list page' && offset > 0) ||
(message && failure === 'session switch' && second) ||
(message && failure === 'message page' && offset > 0) ||
(analysis && failure === 'analysis'));
if (fail)
return route.fulfill({
status: 500,
json: { code: 500, message: 'fixture failure' },
});
const data = list
? {
sessions: [sessionId, 'person-second'].map((id, i) => ({
session_id: id,
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_count: 51,
start_time: at(0),
last_activity: at(4),
is_active: true,
user_name: offset ? `Page two ${i}` : `Recovery user ${i}`,
})),
total: 21,
}
: message
? {
messages: [
sessionMessage(
'recovery-message',
'user',
0,
second
? 'Second session message'
: offset
? 'Second page message'
: 'Successful message',
),
],
total: 51,
}
: {
tool_calls: [
toolCall('recovery-tool', 1, 'recovered_tool', 40),
],
};
return route.fulfill({ json: { code: 0, data } });
});
await page.goto(`/home/bots?id=${botId}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
if (failure === 'list page') {
await page.getByRole('button', { name: 'Next', exact: true }).click();
} else if (failure !== 'initial list') {
await page.getByRole('button', { name: /Recovery user 0/ }).click();
if (failure !== 'analysis') {
await expect(
page.getByText('Successful message', { exact: true }),
).toBeVisible();
if (failure === 'session switch')
await page.getByRole('button', { name: /Recovery user 1/ }).click();
else
await page
.getByRole('button', { name: 'Next', exact: true })
.last()
.click();
}
}
await expect(page.getByRole('alert')).toBeVisible();
await expect(
page.getByText('No sessions found', { exact: true }),
).toHaveCount(0);
if (failure === 'analysis') {
await expect(page.getByRole('alert')).toContainText(/Tool/i);
await expect(
page.getByText('Successful message', { exact: true }),
).toBeVisible();
} else {
await expect(
page.getByText('Successful message', { exact: true }),
).toHaveCount(0);
}
if (failure === 'list page')
await expect(
page.getByRole('button', { name: /Recovery user 0/ }),
).toHaveCount(0);
failing = false;
await page
.getByRole('alert')
.getByRole('button', { name: 'Retry', exact: true })
.click();
await expect(page.getByRole('alert')).toHaveCount(0);
if (failure === 'initial list' || failure === 'list page') {
await expect(
page.getByRole('button', {
name: failure === 'list page' ? /Page two 0/ : /Recovery user 0/,
}),
).toBeVisible();
} else {
await expect(
page.getByText(
failure === 'session switch'
? 'Second session message'
: failure === 'message page'
? 'Second page message'
: 'Successful message',
{ exact: true },
),
).toBeVisible();
await expect(
page.getByText('recovered_tool', { exact: true }),
).toBeVisible();
}
});
}
});
test.describe('bot session request races', () => {
for (const kind of ['messages', 'analysis', 'sessions']) {
for (const status of [200, 500]) {
test(`ignores stale ${kind} ${status} after switching`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let held = false;
let released = false;
await page.route('**/api/v1/monitoring/**', async (route) => {
const url = new URL(route.request().url());
const list = url.pathname.endsWith('/sessions');
const message = url.pathname.endsWith('/messages');
const analysis = url.pathname.endsWith('/analysis');
if (!list && !message && !analysis) return route.fallback();
const old =
kind === 'sessions'
? url.searchParams.get('userQuery') === 'old'
: message
? url.searchParams.get('sessionId') === sessionId
: url.pathname.includes(sessionId);
const isHeld = old && url.pathname.endsWith(`/${kind}`);
if (isHeld) {
held = true;
await gate;
if (status === 500) {
await route.fulfill({ status: 500, json: { code: 500 } });
released = true;
return;
}
}
const data = list
? {
sessions: [sessionId, 'person-new'].map((id, i) => ({
session_id: id,
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_count: 1,
start_time: at(0),
last_activity: at(4),
is_active: true,
user_name: isHeld ? 'Stale list' : `Race user ${i}`,
})),
total: 2,
}
: message
? {
messages: [
sessionMessage(
'race-message',
'user',
0,
old ? 'Old message' : 'Current message',
),
],
total: 1,
}
: {
tool_calls: [
toolCall(
'race-tool',
1,
old ? 'old_tool' : 'current_tool',
40,
),
],
};
await route.fulfill({ json: { code: 0, data } });
if (isHeld) released = true;
});
await page.goto(`/home/bots?id=${botId}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
if (kind === 'sessions') {
await page
.getByRole('textbox', { name: 'User ID or name' })
.fill('old');
await page
.getByRole('textbox', { name: 'User ID or name' })
.press('Enter');
} else await page.getByRole('button', { name: /Race user 0/ }).click();
await expect.poll(() => held).toBe(true);
if (kind === 'sessions') {
await page
.getByRole('textbox', { name: 'User ID or name' })
.fill('new');
await page
.getByRole('textbox', { name: 'User ID or name' })
.press('Enter');
await expect(
page.getByRole('button', { name: /Race user 0/ }),
).toBeVisible();
} else {
await page.getByRole('button', { name: /Race user 1/ }).click();
await expect(
page.getByText('Current message', { exact: true }),
).toBeVisible();
}
release();
await expect.poll(() => released).toBe(true);
// Allow the released HTTP response and React's queued update to settle.
await page.waitForTimeout(200);
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByText('Stale list', { exact: true })).toHaveCount(
0,
);
if (kind !== 'sessions') {
await expect(
page.getByText('Current message', { exact: true }),
).toBeVisible();
await expect(
page.getByText('current_tool', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Old message', { exact: true }),
).toHaveCount(0);
await expect(page.getByText('old_tool', { exact: true })).toHaveCount(
0,
);
}
});
}
}
});
test.describe('bot session monitor tool timeline', () => {
test('isolates messages and analysis for two bots sharing a raw session id', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const requests: Array<{ bot: string; path: string }> = [];
await page.route('**/api/v1/monitoring/**', async (route) => {
const url = new URL(route.request().url());
const selectedBot = url.searchParams.get('botId');
if (
!url.pathname.endsWith('/sessions') &&
!url.pathname.endsWith('/messages') &&
!url.pathname.endsWith('/analysis')
) {
return route.fallback();
}
expect(['bot-shared-a', 'bot-shared-b']).toContain(selectedBot);
expect(route.request().headers().authorization).toBe(
'Bearer playwright-token',
);
expect(route.request().headers()['x-workspace-id']).toBe(
'workspace-playwright',
);
requests.push({ bot: selectedBot!, path: url.pathname });
const shared = {
session_id: sessionId,
bot_id: selectedBot,
bot_name: selectedBot,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_count: 1,
start_time: at(0),
last_activity: at(4),
is_active: true,
platform: 'person',
user_id: 'shared-user',
user_name: 'Shared User',
};
const data = url.pathname.endsWith('/sessions')
? { sessions: [shared], total: 1 }
: url.pathname.endsWith('/messages')
? {
messages: [
{
...sessionMessage(
'shared-message',
'user',
0,
`Message for ${selectedBot}`,
),
bot_id: selectedBot,
},
],
total: 1,
}
: {
session_id: sessionId,
found: true,
tool_calls: [
{
...toolCall('shared-tool', 1, `tool_${selectedBot}`, 40),
bot_id: selectedBot,
},
],
};
if (url.pathname.endsWith('/messages'))
expect(url.searchParams.get('sessionId')).toBe(sessionId);
if (url.pathname.endsWith('/analysis'))
expect(decodeURIComponent(url.pathname)).toContain(
`/sessions/${sessionId}/analysis`,
);
await route.fulfill({ json: { code: 0, data } });
});
for (const selectedBot of ['bot-shared-a', 'bot-shared-b']) {
await page.goto(`/home/bots?id=${selectedBot}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
await page.getByRole('button', { name: /Shared User/ }).click();
await expect(
page.getByText(`Message for ${selectedBot}`, { exact: true }),
).toBeVisible();
await expect(
page.getByText(`tool_${selectedBot}`, { exact: true }),
).toBeVisible();
const otherBot =
selectedBot === 'bot-shared-a' ? 'bot-shared-b' : 'bot-shared-a';
await expect(
page.getByText(`Message for ${otherBot}`, { exact: true }),
).toHaveCount(0);
await expect(
page.getByText(`tool_${otherBot}`, { exact: true }),
).toHaveCount(0);
expect(
requests.some(
(request) =>
request.bot === selectedBot && request.path.endsWith('/messages'),
),
).toBe(true);
expect(
requests.some(
(request) =>
request.bot === selectedBot && request.path.endsWith('/analysis'),
),
).toBe(true);
}
});
test('renders tool calls as left-side agent events interleaved with messages', async ({
page,
}) => {
@@ -491,41 +117,11 @@ test.describe('bot session monitor tool timeline', () => {
},
});
const monitoringRequests: import('@playwright/test').Request[] = [];
page.on('request', (request) => {
if (request.url().includes('/api/v1/monitoring/'))
monitoringRequests.push(request);
});
await page.goto(`/home/bots?id=${botId}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
await page.getByRole('button', { name: /Timeline User/ }).click();
await expect(page.getByText('Need a timeline check')).toBeVisible();
await expect
.poll(() =>
monitoringRequests.some((request) =>
request.url().includes('/analysis?'),
),
)
.toBe(true);
for (const request of monitoringRequests.filter((request) =>
/\/messages\?|\/analysis\?/.test(request.url()),
)) {
const url = new URL(request.url());
expect(url.searchParams.get('botId')).toBe(botId);
if (url.pathname.endsWith('/analysis')) {
expect(url.searchParams.get('startTime')).toBe(at(0));
expect(url.searchParams.get('endTime')).toBe(at(4));
}
expect(request.headers().authorization).toBe('Bearer playwright-token');
expect(request.headers()['x-workspace-id']).toBe('workspace-playwright');
if (url.pathname.endsWith('/messages'))
expect(url.searchParams.get('sessionId')).toBe(sessionId);
else
expect(decodeURIComponent(url.pathname)).toContain(
`/sessions/${sessionId}/analysis`,
);
}
await expect(
page.getByText('repo_file_read', { exact: true }),
).toBeVisible();
+1 -192
View File
@@ -1,4 +1,4 @@
import { expect, test, Route } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
@@ -271,198 +271,7 @@ function rawMonitoringData() {
};
}
async function respond(route: Route, label: string) {
const data = rawMonitoringData();
data.messages = [rawMessage(message(label, 'user', 10, label))];
await route.fulfill({ json: { code: 0, data } });
}
test.describe('monitoring request contracts', () => {
test('shows failures instead of empty success and retries with auth and Workspace headers', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let failing = true;
await page.route('**/api/v1/monitoring/data?*', async (route) => {
expect(route.request().headers().authorization).toBe(
'Bearer playwright-token',
);
expect(route.request().headers()['x-workspace-id']).toBe(
'workspace-playwright',
);
if (failing)
await route.fulfill({
status: 500,
json: { code: 500, msg: 'fixture database unavailable' },
});
else await respond(route, 'Recovered monitoring');
});
await page.goto('/home/monitoring');
await expect(page.getByRole('alert')).toContainText(
'Failed to load monitoring data',
);
await expect(page.getByText('No message records')).toHaveCount(0);
failing = false;
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(
page.getByText('Recovered monitoring', { exact: true }),
).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
});
test('latest filter request wins over delayed data and delayed failures', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const pending: Route[] = [];
await page.route('**/api/v1/monitoring/data?*', (route) => {
pending.push(route);
});
await page.goto('/home/monitoring');
await expect.poll(() => pending.length).toBe(2);
await page.getByRole('combobox').last().click();
await page.getByRole('option', { name: /Last 7 days/i }).click();
await expect.poll(() => pending.length).toBe(3);
await respond(pending[2], 'Latest filter data');
await expect(
page.getByText('Latest filter data', { exact: true }),
).toBeVisible();
await respond(pending[0], 'Obsolete filter data');
await respond(pending[1], 'Obsolete filter data');
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await expect(
page.getByText('Latest filter data', { exact: true }),
).toBeVisible();
await page
.getByRole('button', { name: 'Refresh Data', exact: true })
.click();
await expect.poll(() => pending.length).toBe(4);
await expect(
page.getByText('Obsolete filter data', { exact: true }),
).toHaveCount(0);
await page.getByRole('combobox').last().click();
await page.getByRole('option', { name: /Last 24 hours/i }).click();
await expect.poll(() => pending.length).toBe(5);
await respond(pending[4], 'Current result');
await expect(
page.getByText('Current result', { exact: true }),
).toBeVisible();
await pending[3].fulfill({
status: 500,
json: { code: 500, msg: 'old failure' },
});
await expect(
page.getByText('Current result', { exact: true }),
).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
});
test('uses aggregate traffic rather than the sparse record page and discloses truncation', async ({
page,
}) => {
const data = rawMonitoringData();
data.totalCount.messages = 125;
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: {
...data,
traffic: {
bucket: 'hour',
truncated: true,
points: [
{ timestamp: time(0).toISOString(), messages: 125, llm_calls: 77 },
{ timestamp: time(1).toISOString(), messages: 0, llm_calls: 0 },
],
},
},
});
await page.goto('/home/monitoring');
await expect(
page.getByText(
'Showing 7 of 125 messages. Conversation traces may be incomplete.',
),
).toBeVisible();
await expect(
page.getByText('Traffic range truncated. Choose a shorter time range.'),
).toBeVisible();
const chart = page.locator('.recharts-wrapper');
await expect(chart).toHaveCount(1);
await chart
.locator(':scope > .recharts-surface')
.hover({ position: { x: 70, y: 100 } });
await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText(
'125',
);
await expect(chart.locator('.recharts-tooltip-wrapper')).toContainText(
'77',
);
});
test('does not invent traffic totals when aggregation is unavailable', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: rawMonitoringData(),
});
await page.goto('/home/monitoring');
await expect(
page.getByText('Traffic aggregation unavailable'),
).toBeVisible();
await expect(page.locator('.recharts-wrapper')).toHaveCount(0);
});
});
test.describe('monitoring conversation turn grouping', () => {
test('does not reassign explicitly linked activity outside the visible page', () => {
const turns = buildConversationTurns(
[message('visible', 'user', 10, 'Visible turn')],
[llmCall('older-call', 11, 'off-page', 10, 5, 40)],
[errorLog('older-error', 11, 'off-page')],
[toolCall('older-tool', 11, 'off-page', 'search', 40)],
);
expect(turns[0].llmCalls).toEqual([]);
expect(turns[0].toolCalls).toEqual([]);
expect(turns[0].errors).toEqual([]);
});
test('does not assign unlinked activity before the first visible turn', () => {
const turns = buildConversationTurns(
[message('visible', 'user', 10, 'Visible turn')],
[llmCall('older-call', 1, undefined, 10, 5, 40)],
[{ ...errorLog('older-error', 1, ''), messageId: undefined }],
[toolCall('older-tool', 1, undefined, 'search', 40)],
);
expect(turns[0].llmCalls).toEqual([]);
expect(turns[0].toolCalls).toEqual([]);
expect(turns[0].errors).toEqual([]);
});
test('isolates same-session messages and activity by bot identity', () => {
const first = message('first', 'user', 1, 'Bot one');
const other = {
...message('other', 'user', 2, 'Bot two'),
botId: 'other-bot',
};
const reply = message('reply', 'assistant', 3, 'Bot one reply');
const turns = buildConversationTurns(
[first, other, reply],
[llmCall('call', 3, undefined, 10, 5, 40)],
[errorLog('error', 3, first.id)],
[toolCall('tool', 3, undefined, 'search', 40)],
);
const own = turns.find((turn) => turn.id === first.id)!;
expect(own.assistantMessages.map((item) => item.id)).toEqual(['reply']);
expect(own.llmCalls.map((item) => item.id)).toEqual(['call']);
expect(own.toolCalls.map((item) => item.id)).toEqual(['tool']);
expect(turns.find((turn) => turn.id === other.id)?.totalTokens).toBe(0);
});
test('keeps a single user message as one observable turn', () => {
const userOnly = message(
'single-user-only',
-232
View File
@@ -1,232 +0,0 @@
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// UI fixtures only: real app/components, intercepted APIs, no production Box.
// Load the shipped metadata rather than reproducing its tooltip conditions.
const requireFromTest = createRequire(__filename);
const { load } = createRequire(requireFromTest.resolve('eslint'))(
'js-yaml',
) as {
load: (source: string) => unknown;
};
const aiMetadata = load(
readFileSync(
resolve(
__dirname,
'../../../src/langbot/templates/metadata/pipeline/ai.yaml',
),
'utf8',
),
);
const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const forcedHint = '已强制使用全局沙箱,无法修改作用域。';
interface BoxState {
enabled: boolean;
available: boolean;
}
async function openPipeline(page: Page, box: BoxState, forced = '') {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_language: 'zh-Hans' },
});
await page.route('**/api/v1/system/info', (route) =>
route.fulfill({
json: {
code: 0,
data: {
debug: false,
version: 'sandbox-scope-ui-fixture',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
allow_modify_login_info: true,
disable_models_service: false,
limitation: {
max_bots: -1,
max_pipelines: -1,
max_extensions: -1,
force_box_session_id_template: forced,
},
outbound_ips: [],
wizard_status: 'completed',
wizard_progress: null,
},
},
}),
);
await page.route('**/api/v1/box/status', (route) =>
route.fulfill({
json: {
code: 0,
data: {
...box,
profile: 'UI fixture only',
recent_error_count: 0,
active_sessions: 0,
managed_processes: 0,
session_ttl_sec: 3600,
backend: { name: 'ui-fixture', available: box.available },
},
},
}),
);
await page.route(/\/api\/v1\/tools(?:\?.*)?$/, (route) =>
route.fulfill({ json: { code: 0, data: { tools: [] } } }),
);
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({ json: { code: 0, data: { configs: [aiMetadata] } } }),
);
await page.route('**/api/v1/pipelines/sandbox-scope-fixture', (route) =>
route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'sandbox-scope-fixture',
name: 'Sandbox scope — UI fixture only',
description: '',
emoji: '⚙️',
is_default: false,
config: {
ai: {
runner: { runner: 'local-agent' },
'local-agent': {
'box-session-id-template': '{launcher_type}_{launcher_id}',
},
},
trigger: {},
safety: {},
output: {},
},
},
},
},
}),
);
await page.goto('/home/pipelines?id=sandbox-scope-fixture');
await page.getByRole('button', { name: 'AI 能力', exact: true }).click();
// DynamicForm gates this control through its wrapper's pointer-events,
// and its label targets that wrapper rather than the nested select.
const scope = page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('沙箱作用域', { exact: true }) })
.getByRole('combobox');
await expect(scope).toBeVisible();
return scope;
}
async function expectWarning(page: Page, hint: string) {
const warning = page.getByRole('button', { name: hint, exact: true });
await expect(warning).toBeVisible();
await warning.hover();
await expect(page.getByRole('tooltip')).toHaveText(hint);
}
async function expectNoWarning(page: Page) {
await expect(page.getByRole('button', { name: unavailableHint })).toHaveCount(
0,
);
await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(0);
await expect(page.getByRole('tooltip')).toHaveCount(0);
}
test.describe('sandbox scope disabled reason (UI fixtures only)', () => {
for (const scenario of [
{ name: 'Box disabled', enabled: false, available: false, forced: '' },
{ name: 'Box disconnected', enabled: true, available: false, forced: '' },
{
name: 'unavailable Box takes precedence over forced global',
enabled: true,
available: false,
forced: '{global}',
},
]) {
test(scenario.name, async ({ page }) => {
const scope = await openPipeline(page, scenario, scenario.forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(
0,
);
});
}
for (const forced of ['{global}', ' {global} ']) {
test(`available Box with forced global explains the deployment restriction (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openPipeline(
page,
{ enabled: true, available: true },
forced,
);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expect(scope).toHaveText('全局(所有人共享)');
await expectWarning(page, forcedHint);
await expect(
page.getByRole('button', { name: unavailableHint }),
).toHaveCount(0);
});
}
for (const forced of ['', ' ']) {
test(`available and unforced Box is editable without a disabled warning (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openPipeline(
page,
{ enabled: true, available: true },
forced,
);
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expect(scope).toHaveText('每个会话(推荐)');
await expectNoWarning(page);
await scope.click();
await page
.getByRole('option', { name: '全局(所有人共享)', exact: true })
.click();
await expect(scope).toHaveText('全局(所有人共享)');
await expectNoWarning(page);
});
}
for (const forced of ['', '{global}']) {
test(`Box status polls update the warning without remounting (${forced || 'unforced'})`, async ({
page,
}) => {
await page.clock.install();
const box = { enabled: true, available: false };
const scope = await openPipeline(page, box, forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await page.mouse.move(0, 0);
const recovered = page.waitForResponse('**/api/v1/box/status');
box.available = true;
await page.clock.fastForward(31_000);
await recovered;
if (forced) {
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, forcedHint);
} else {
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expectNoWarning(page);
}
await page.mouse.move(0, 0);
const disconnected = page.waitForResponse('**/api/v1/box/status');
box.available = false;
await page.clock.fastForward(31_000);
await disconnected;
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
});
}
});
@@ -1,252 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import test from 'node:test';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const { load } = createRequire(require.resolve('eslint'))('js-yaml');
const metadata = load(
fs.readFileSync(
new URL(
'../../../src/langbot/templates/metadata/pipeline/ai.yaml',
import.meta.url,
),
'utf8',
),
);
const scope = metadata.stages
.find((stage) => stage.name === 'local-agent')
.config.find((item) => item.name === 'box-session-id-template');
const unavailable = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const globalForced = '已强制使用全局沙箱,无法修改作用域。';
const customForced = '已强制使用固定沙箱作用域,无法修改作用域。';
function loadSource(relativePath) {
const filename = new URL(`../../src/${relativePath}`, import.meta.url);
assert.ok(fs.existsSync(filename), `Missing policy module: ${relativePath}`);
const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loaded = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
(name) => {
if (name === '@/app/infra/entities/form/dynamic')
return loadSource('app/infra/entities/form/dynamic.ts');
throw new Error(`Unexpected runtime import: ${name}`);
},
loaded,
loaded.exports,
);
return loaded.exports;
}
function policies() {
return {
...loadSource('app/home/components/dynamic-form/DynamicFormConditions.ts'),
...loadSource(
'app/home/pipelines/components/pipeline-form/BoxScopeContext.ts',
),
};
}
function scopeState(available, forcedTemplate) {
const { getBoxScopeContext, resolveDisabledState } = policies();
return resolveDisabledState(
scope,
{},
undefined,
getBoxScopeContext(available, forcedTemplate),
);
}
test('sandbox default tooltip explains only unavailability', () => {
assert.equal(scope.disabled_tooltip.zh_Hans, unavailable);
});
for (const [name, available, template, expected] of [
['Box disabled', false, '', unavailable],
['Box disconnected', false, undefined, unavailable],
[
'unavailable takes precedence over forced global',
false,
'{global}',
unavailable,
],
[
'unavailable takes precedence over forced custom',
false,
'{pipeline_id}',
unavailable,
],
['available forced global', true, '{global}', globalForced],
['available padded forced global', true, ' {global} ', globalForced],
['available whitespace-only editable', true, ' ', undefined],
['available forced custom', true, '{pipeline_id}', customForced],
['available forced literal', true, 'tenant-sandbox', customForced],
['available editable', true, '', undefined],
['available without limitation', true, undefined, undefined],
]) {
test(name, () => {
const state = scopeState(available, template);
assert.equal(state.isDisabledByCondition, expected !== undefined);
assert.equal(state.disabledTooltip?.zh_Hans, expected);
});
}
test('reason follows availability and forced-scope transitions without mutating metadata', () => {
const snapshot = structuredClone(scope);
for (const [available, template, expected] of [
[false, '{global}', unavailable],
[true, '{global}', globalForced],
[true, '{pipeline_id}', customForced],
[true, '', undefined],
[false, '', unavailable],
[true, '', undefined],
]) {
assert.equal(
scopeState(available, template).disabledTooltip?.zh_Hans,
expected,
);
}
assert.deepEqual(scope, snapshot);
});
test('all sandbox reason variants preserve the eight metadata locales', () => {
const locales = [
'en_US',
'zh_Hans',
'zh_Hant',
'ja_JP',
'vi_VN',
'th_TH',
'es_ES',
'ru_RU',
].sort();
assert.equal(scope.disabled_tooltip_overrides?.length, 2);
const messages = [
scope.disabled_tooltip,
...scope.disabled_tooltip_overrides.map((entry) => entry.tooltip),
];
for (const message of messages) {
assert.deepEqual(Object.keys(message).sort(), locales);
for (const locale of locales) assert.ok(message[locale].trim(), locale);
}
for (const locale of locales) {
assert.equal(
new Set(messages.map((message) => message[locale])).size,
3,
locale,
);
assert.equal(
scopeState(false, '{global}').disabledTooltip[locale],
messages[0][locale],
);
assert.equal(
scopeState(true, '{global}').disabledTooltip[locale],
messages[1][locale],
);
assert.equal(
scopeState(true, '{pipeline_id}').disabledTooltip[locale],
messages[2][locale],
);
}
});
test('ordinary static disabled tooltip remains compatible', () => {
const { resolveDisabledState } = policies();
const tooltip = { en_US: 'Read only' };
const config = {
disable_if: { field: 'locked', operator: 'eq', value: true },
disabled_tooltip: tooltip,
};
assert.deepEqual(resolveDisabledState(config, { locked: true }), {
isDisabledByCondition: true,
disabledTooltip: tooltip,
});
assert.deepEqual(resolveDisabledState(config, { locked: false }), {
isDisabledByCondition: false,
disabledTooltip: undefined,
});
assert.equal(
resolveDisabledState({ disabled_tooltip: tooltip }, {}).disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState({ disable_if: config.disable_if }, { locked: true })
.disabledTooltip,
undefined,
);
});
test('conditional overrides reuse eq, neq, in and live/external/system resolution', () => {
const { matchesFormCondition, resolveDisabledState } = policies();
const watched = { mode: 'live', empty: null, '__system.locked': false };
const external = { mode: 'external', fallback: 3, empty: 'external' };
const system = { locked: true };
for (const [condition, expected] of [
[{ field: 'mode', operator: 'eq', value: 'live' }, true],
[{ field: 'mode', operator: 'eq', value: 'external' }, false],
[{ field: 'fallback', operator: 'neq', value: 4 }, true],
[{ field: 'fallback', operator: 'in', value: [2, 3] }, true],
[{ field: 'fallback', operator: 'in', value: '3' }, false],
[{ field: 'fallback', operator: 'eq', value: '3' }, false],
[{ field: 'empty', operator: 'eq', value: null }, true],
[{ field: '__system.locked', operator: 'eq', value: true }, true],
[{ field: 'absent', operator: 'eq', value: true }, false],
])
assert.equal(
matchesFormCondition(condition, watched, external, system),
expected,
);
const config = {
disable_if: { field: '__system.locked', operator: 'eq', value: true },
disabled_tooltip: { en_US: 'Default' },
disabled_tooltip_overrides: [
{
when: { field: 'mode', operator: 'eq', value: 'external' },
tooltip: { en_US: 'Wrong' },
},
{
when: { field: 'fallback', operator: 'in', value: [3] },
tooltip: { en_US: 'First match' },
},
{
when: { field: 'mode', operator: 'neq', value: 'external' },
tooltip: { en_US: 'Later match' },
},
],
};
assert.equal(
resolveDisabledState(config, watched, external, system).disabledTooltip
.en_US,
'First match',
);
assert.equal(
resolveDisabledState(config, {}, {}, system).disabledTooltip.en_US,
'Later match',
);
assert.equal(
resolveDisabledState(config, watched, external, { locked: false })
.disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState(
{ ...config, disabled_tooltip_overrides: [] },
watched,
external,
system,
).disabledTooltip.en_US,
'Default',
);
const unmatched = {
...config,
disabled_tooltip_overrides: [config.disabled_tooltip_overrides[0]],
};
assert.equal(
resolveDisabledState(unmatched, watched, external, system).disabledTooltip
.en_US,
'Default',
);
});
@@ -134,22 +134,6 @@ test('session tool calls are bounded to the visible message page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(monitor, 'startTime: sorted[0]?.timestamp', 'analysis page start');
includes(
monitor,
'endTime: sorted[sorted.length - 1]?.timestamp',
'analysis page end',
);
includes(monitor, 'sessionId, botId, {', 'bot-scoped analysis');
const client = read('src/app/infra/http/BackendClient.ts');
includes(
client,
"queryParams.set('startTime', options.startTime)",
'analysis start query',
);
includes(
client,
"queryParams.set('endTime', options.endTime)",
'analysis end query',
);
includes(monitor, "analysisParams.set('startTime'", 'analysis page start');
includes(monitor, "analysisParams.set('endTime'", 'analysis page end');
});