diff --git a/AGENTS.md b/AGENTS.md
index bc016a26e..96f8c6903 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -88,6 +88,7 @@ Config keys to verify in `data/config.yaml` / `src/langbot/templates/config.yaml
- New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`. LangBot 4.x does not support upgrading 3.x databases.
- New platform behavior belongs in platform adapters only for platform translation; pipeline/business logic belongs in `pkg/pipeline/` or services.
- User-facing strings must support i18n; follow the backend language rules below for server-generated content.
+- Conversation payload columns, including new ones, must use `MediaReferenceText` and persist image references only. See the [media persistence contract](ARCHITECTURE.md#persistence-and-configuration).
- Code comments and docstrings must be English.
- Keep compatibility and security in mind; LangBot is used in both self-hosted/community and toB deployments.
- Commit message format: `(): `.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9ad2a5a9b..f99f926a8 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -267,12 +267,15 @@ Persistence is centered on `pkg/persistence/mgr.py`.
- SQLite is the default database; PostgreSQL is supported.
- Models live under `pkg/entity/persistence/`.
+- `pkg/persistence/media.py::MediaReferenceText` validates bound conversation payloads on ORM/Core writes while preserving the existing TEXT schema and string API. It rejects recognized structured inline images: retained images go through `MediaCache.externalize_chain_dump()` before serialization, while metadata-only snapshots and transcripts use `redact_inline_images()`. Reads and comparisons preserve historical values for incremental cleanup. Runtime/adapter messages, arbitrary diagnostic text, and opaque strings are outside this contract.
- Timezone-less SQL `DateTime` columns store UTC-naive values on both backends. Normalize aware values with `pkg/persistence/datetime_utils.py::as_naive_utc` at the bind boundary, including deadlines, leases, event times, and query/retention cutoffs; do not merely strip an offset. Existing naive rows represent UTC. Restore UTC awareness for application comparisons and epoch serialization. Runner ledger/event/transcript stores follow this existing schema contract without a data migration or changes to journal/tenant authorization.
- Fresh schemas are created from current metadata, then Alembic migrations run to head. LangBot 4.x does not upgrade 3.x databases.
- New schema changes use Alembic under `pkg/persistence/alembic/versions/`; there is no legacy migration chain in 4.x.
Configuration starts from `src/langbot/templates/config.yaml` and is generated into `data/config.yaml` on first run. Most long-lived managers read from `ap.instance_config.data`.
+Storage maintenance applies `storage.media_cache.retention_days` to both CAS files and historical inline images. Inline image expiry uses the owning record's creation time and preserves text, URLs, and record identity. It does not backfill CAS. `pkg/storage/inline_media_cleanup.py` processes one payload row at a time, with a per-table limit of 100 indexed candidates or 8 MiB of JSON characters per maintenance pass (one oversized row may exceed the latter limit). Run events lack a Workspace column, so their candidate cursor also advances past foreign or missing records; payloads are loaded only after checking Workspace ownership. Durable Workspace metadata cursors and timestamp/identity indexes keep completed history out of subsequent scans; newly written history must retain its actual creation timestamp. Updates and cursor advancement commit together. Invalid historical JSON is preserved. SQLite pages become reusable after payload removal; the database file is not automatically vacuumed by this task.
+
## Frontend
The frontend lives in `web/` and is a Vite SPA using React Router 7, shadcn/ui, Tailwind CSS, and pnpm. It is not Next.js, despite some historical filenames.
diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md
index ae73d104c..ec47b8bb1 100644
--- a/skills/skills/langbot-mcp-ops/SKILL.md
+++ b/skills/skills/langbot-mcp-ops/SKILL.md
@@ -203,6 +203,9 @@ message or event identifier outside the current list page. The detail exposes
`inputs`, `outputs` (generated content), `deliveries` (recorded platform sends),
`events`, `llm_calls`, `tool_calls`, `errors`, `related`, and `conversation` in
`pages`. Follow each section's `has_more` and `next_offset` independently.
+Start with `section=overview` to load only inputs, outputs, deliveries and errors.
+Request `conversation`, `related`, calls and events separately when needed;
+omitting `section` retains the complete trace response for existing consumers.
Conversation history supplies context; historical messages without explicit
links must not be asserted to belong to the selected execution. Events without
a processor run use `source=event`. All lookups remain Workspace-scoped.
diff --git a/src/langbot/pkg/agent/runner/platform_tools.py b/src/langbot/pkg/agent/runner/platform_tools.py
index b74ab8041..b744f865b 100644
--- a/src/langbot/pkg/agent/runner/platform_tools.py
+++ b/src/langbot/pkg/agent/runner/platform_tools.py
@@ -794,7 +794,9 @@ async def execute_platform_tool(
bot_name=str(getattr(getattr(bot, 'bot_entity', None), 'name', bot_id)),
pipeline_id=str(authorization.get('processor_id') or ''),
pipeline_name=str(authorization.get('processor_id') or authorization.get('runner_id') or ''),
- message_content=json.dumps(content, ensure_ascii=False),
+ message_content=content
+ if isinstance(content, (list, dict))
+ else json.dumps(content, ensure_ascii=False),
session_id=str(authorization.get('conversation_id') or ''),
role='assistant',
status='error' if delivery_error else 'success',
diff --git a/src/langbot/pkg/agent/runner/run_journal.py b/src/langbot/pkg/agent/runner/run_journal.py
index 3a0217f89..c2ea15df8 100644
--- a/src/langbot/pkg/agent/runner/run_journal.py
+++ b/src/langbot/pkg/agent/runner/run_journal.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import typing
from ...core import app
+from ...storage.media import MediaCache
from .descriptor import RunnerDescriptor
from .errors import RunnerProtocolError
from .host_models import AgentBinding, AgentEventEnvelope
@@ -44,10 +45,10 @@ class AgentRunJournal:
if not item:
return value
item_type = item.get('type')
- if item_type == 'image_base64' and item.get('image_base64'):
- item['image_base64'] = None
+ if MediaCache.has_inline_images(item):
+ item = MediaCache.redact_inline_images(item)
item['content_redacted'] = True
- elif item_type == 'file_base64' and item.get('file_base64'):
+ if item_type == 'file_base64' and item.get('file_base64'):
item['file_base64'] = None
item['content_redacted'] = True
return item
@@ -77,6 +78,8 @@ class AgentRunJournal:
return [cls._sanitize_event_data(item) for item in value]
if not isinstance(value, dict):
return value
+ if value.get('type') in ('Image', 'image', 'image_url', 'image_base64'):
+ value = cls._sanitize_content_item(value)
result = {}
for key, item in value.items():
if key in ('image_base64', 'file_base64', 'base64') and item:
@@ -157,11 +160,16 @@ class AgentRunJournal:
usage = result_dict.get('usage')
if hasattr(usage, 'model_dump'):
usage = usage.model_dump(mode='json')
+ data = result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}
+ if MediaCache.has_inline_images(data):
+ data = await self.ap.storage_mgr.media_cache.externalize_chain_dump(data)
+ if MediaCache.has_inline_images(metadata):
+ metadata = MediaCache.redact_inline_images(metadata)
return await self._get_run_ledger_store().append_event(
run_id=run_id,
sequence=sequence,
event_type=str(result_dict.get('type') or 'unknown'),
- data=result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {},
+ data=data,
usage=usage if isinstance(usage, dict) else None,
source=source,
metadata=metadata,
diff --git a/src/langbot/pkg/agent/runner/run_ledger_store.py b/src/langbot/pkg/agent/runner/run_ledger_store.py
index 2a8b9c75b..06ec1d969 100644
--- a/src/langbot/pkg/agent/runner/run_ledger_store.py
+++ b/src/langbot/pkg/agent/runner/run_ledger_store.py
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...persistence.datetime_utils import as_naive_utc
+from ...storage.media import MediaCache
from ...utils.inflight import inflight_hub
from ...utils.execution_lease import host_execution_lease
@@ -384,7 +385,7 @@ class RunLedgerStore:
if run is None or run.workspace_id != workspace_id or run.status in TERMINAL_STATUSES:
raise ValueError('Skill activation requires an active run in the current Workspace')
original = run.metadata_json
- metadata = _json_loads(original, {})
+ metadata = MediaCache.redact_inline_images(_json_loads(original, {}))
bindings = metadata.setdefault('activated_skills', {})
if name in bindings:
return str(bindings[name])
@@ -439,7 +440,8 @@ class RunLedgerStore:
if cost is not None:
run.cost_json = _json_dumps(cost)
if metadata is not None:
- existing_metadata = _json_loads(run.metadata_json, {})
+ # Discard legacy image bytes before merging; new metadata still uses the write contract.
+ existing_metadata = MediaCache.redact_inline_images(_json_loads(run.metadata_json, {}))
if isinstance(existing_metadata, dict):
existing_metadata.update(metadata)
run.metadata_json = _json_dumps(existing_metadata)
diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py
index 5cbdf56c6..d6feeb2bc 100644
--- a/src/langbot/pkg/api/http/controller/groups/monitoring.py
+++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py
@@ -129,7 +129,7 @@ class MonitoringRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def get_execution_detail(source: str, execution_id: str, request_context: RequestContext) -> str:
- """Full trace of a single execution (agent run or pipeline query)."""
+ """Read a full trace, its initial overview, or one paginated section."""
try:
detail = await self.ap.monitoring_service.get_execution_detail(
request_context,
diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py
index 4fe3cc642..86485efa7 100644
--- a/src/langbot/pkg/api/http/service/maintenance.py
+++ b/src/langbot/pkg/api/http/service/maintenance.py
@@ -114,6 +114,10 @@ class MaintenanceService:
}
if media_cache is not None and is_singleton:
result['media_files'] = media_cleanup.get('expired_deleted', 0) + media_cleanup.get('size_deleted', 0)
+ if media_cache is not None:
+ from ....storage.inline_media_cleanup import cleanup_inline_media
+
+ result['inline_media_records'] = await cleanup_inline_media(self.ap, context, media_retention_days)
return result
async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]:
diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py
index 0ca23d7e1..0480a7d1b 100644
--- a/src/langbot/pkg/api/http/service/monitoring.py
+++ b/src/langbot/pkg/api/http/service/monitoring.py
@@ -15,6 +15,7 @@ from ....entity.persistence import monitoring as persistence_monitoring
from ....entity.persistence import agent_run as persistence_agent_run
from ....entity.persistence import agent_interaction as persistence_agent_interaction
from ....entity.persistence.event_log import EventLog
+from ....storage.media import MediaCache
from ..authz import WorkspaceRequiredError
from ..context import ExecutionContext
from .tenant import TenantContext, require_workspace_uuid
@@ -504,30 +505,31 @@ class MonitoringService(ExecutionDetailsMixin):
def _sanitize_message_content(self, content: str) -> str:
"""Strip raw base64 data to protect database storage from unbounded bloating."""
- if not content or len(content) < 10000 or (';base64,' not in content and 'data:image/' not in content):
+ if not content or ('base64' not in content and 'data:image/' not in content):
return content
try:
data = json.loads(content)
-
- def _strip_node(node):
- if isinstance(node, list):
- return [_strip_node(x) for x in node]
- if isinstance(node, dict):
- res = dict(node)
- if res.get('type') == 'Image' and res.get('base64'):
- res['base64'] = None
- for k, v in list(res.items()):
- if isinstance(v, (list, dict)):
- res[k] = _strip_node(v)
- return res
- return node
-
- return json.dumps(_strip_node(data), ensure_ascii=False)
+ return json.dumps(MediaCache.redact_inline_images(data), ensure_ascii=False)
except Exception:
return re.sub(
r'data:image/[a-zA-Z0-9.+_-]+;base64,[\sA-Za-z0-9+/=]{1000,}', '[base64 image omitted]', content
)
+ async def _externalize_message_content(self, content: str | list | dict) -> str:
+ """Externalize structured images before allocating their JSON representation."""
+ if isinstance(content, str):
+ if not content or ('base64' not in content and 'data:image/' not in content):
+ return content
+ try:
+ payload = json.loads(content)
+ except (TypeError, ValueError):
+ return self._sanitize_message_content(content)
+ else:
+ payload = content
+ if MediaCache.has_inline_images(payload):
+ payload = await self.ap.storage_mgr.media_cache.externalize_chain_dump(payload)
+ return json.dumps(MediaCache.redact_inline_images(payload), ensure_ascii=False)
+
@_workspace_transaction
async def record_message(
self,
@@ -536,7 +538,7 @@ class MonitoringService(ExecutionDetailsMixin):
bot_name: str,
pipeline_id: str,
pipeline_name: str,
- message_content: str,
+ message_content: str | list | dict,
session_id: str,
status: str = 'success',
level: str = 'info',
@@ -558,7 +560,7 @@ class MonitoringService(ExecutionDetailsMixin):
event_id = event_id or (trace_state.event_id if trace_state else None)
run_id = run_id or trace.current_run() or None
message_id = str(uuid.uuid4())
- message_content = self._sanitize_message_content(message_content)
+ message_content = await self._externalize_message_content(message_content)
from ....utils.execution_lease import host_execution_lease
message_data = {
@@ -578,7 +580,7 @@ class MonitoringService(ExecutionDetailsMixin):
'user_id': _normalize_user_id(user_id),
'user_name': user_name,
'runner_name': runner_name,
- 'variables': variables,
+ 'variables': self._sanitize_message_content(variables) if variables is not None else None,
'role': role,
'event_id': event_id,
'run_id': run_id,
@@ -886,7 +888,7 @@ class MonitoringService(ExecutionDetailsMixin):
if level is not None:
update_values['level'] = level
if variables is not None:
- update_values['variables'] = variables
+ update_values['variables'] = self._sanitize_message_content(variables)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_monitoring.MonitoringMessage)
@@ -1291,15 +1293,16 @@ class MonitoringService(ExecutionDetailsMixin):
'input_preview': _message_preview(json.dumps(metadata.get('input') or {}, ensure_ascii=False), 200),
}
- def _serialize_pipeline_execution(self, row) -> dict:
+ def _serialize_pipeline_execution(self, row, *, summary_only=False) -> dict:
created_at_ms = _epoch_ms(row.timestamp)
+ preview = '' if summary_only else _message_preview(row.message_content)
return {
'source': 'pipeline',
'id': row.id,
'event_id': row.event_id,
'status': row.status,
'status_group': _status_group(row.status, 'pipeline'),
- 'title': _message_preview(row.message_content),
+ 'title': row.pipeline_name if summary_only else preview,
'target_kind': 'pipeline',
'target_id': row.pipeline_id,
'target_name': row.pipeline_name,
@@ -1319,7 +1322,7 @@ class MonitoringService(ExecutionDetailsMixin):
'queue_name': None,
'debug': row.bot_id == 'websocket-proxy-bot',
'has_error': row.status == 'error',
- 'input_preview': _message_preview(row.message_content),
+ 'input_preview': preview,
'platform': row.platform,
'user_id': row.user_id,
'user_name': row.user_name,
@@ -1533,6 +1536,23 @@ class MonitoringService(ExecutionDetailsMixin):
'real': total - debug_count,
}
+ @staticmethod
+ def _inflight_pipeline_condition(*, pending_only: bool = False):
+ """Historical pending records without a live Host lease are not tasks."""
+ Message = persistence_monitoring.MonitoringMessage
+ live_lease = sqlalchemy.and_(
+ Message.execution_owner_id.is_not(None),
+ Message.execution_lease_expires_at > datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
+ )
+ # Active-only queries must expose the lease range to the composite index.
+ # The status OR otherwise reads historical payload pages to check role.
+ if pending_only:
+ return live_lease
+ return sqlalchemy.or_(
+ Message.status != 'pending',
+ live_lease,
+ )
+
async def get_executions(
self,
context: TenantContext,
@@ -1548,6 +1568,7 @@ class MonitoringService(ExecutionDetailsMixin):
limit: int = 50,
offset: int = 0,
include_summary: bool = True,
+ for_inflight: bool = False,
) -> dict:
"""List executions from both object types (agent runs and pipeline queries)."""
workspace_uuid = require_workspace_uuid(context)
@@ -1579,15 +1600,18 @@ class MonitoringService(ExecutionDetailsMixin):
).where(*conditions)
)
if source in {'all', 'pipeline'} and (not agent_ids or pipeline_ids):
+ pipeline_statuses = self._raw_pipeline_statuses(statuses)
conditions = self._pipeline_execution_conditions(
workspace_uuid,
bot_ids=bot_ids,
pipeline_ids=pipeline_ids,
- statuses=self._raw_pipeline_statuses(statuses),
+ statuses=pipeline_statuses,
start_time=start_time,
end_time=end_time,
debug=debug,
)
+ if for_inflight:
+ conditions.append(self._inflight_pipeline_condition(pending_only=pipeline_statuses == ['pending']))
branches.append(
sqlalchemy.select(
sqlalchemy.literal('pipeline').label('source'),
@@ -1595,7 +1619,7 @@ class MonitoringService(ExecutionDetailsMixin):
Message.timestamp.label('time'),
).where(*conditions)
)
- if source in {'all', 'event'} and mode != 'debug' and not pipeline_ids and not agent_ids:
+ if source in {'all', 'event'} and mode != 'debug' and not pipeline_ids and not agent_ids and not for_inflight:
conditions = self._unhandled_event_conditions(workspace_uuid, bot_ids, start_time, end_time)
if statuses:
conditions.append(self._execution_json_text(EventLog.metadata_json, 'status').in_(statuses))
@@ -1634,20 +1658,23 @@ class MonitoringService(ExecutionDetailsMixin):
workspace_column = model.workspace_uuid if kind == 'pipeline' else model.workspace_id
records = await self._execution_rows(
workspace_uuid,
- sqlalchemy.select(model).where(workspace_column == workspace_uuid, identity_column.in_(ids)),
+ self._execution_statement(model, summary_only=for_inflight).where(
+ workspace_column == workspace_uuid, identity_column.in_(ids)
+ ),
+ summary_only=for_inflight,
)
names = await self._agent_names(workspace_uuid, records) if kind == 'agent' else {}
for record in records:
item = (
self._serialize_agent_execution(record, names)
if kind == 'agent'
- else self._serialize_pipeline_execution(record)
+ else self._serialize_pipeline_execution(record, summary_only=for_inflight)
if kind == 'pipeline'
else self._serialize_event_execution(record)
)
by_key[(kind, item['id'])] = item
page = [by_key[(ref.source, ref.id)] for ref in refs if (ref.source, ref.id) in by_key]
- await self.enrich_execution_rows(workspace_uuid, page)
+ await self.enrich_execution_rows(workspace_uuid, page, summary_only=for_inflight)
if not include_summary:
return {'items': page, 'total': total, 'has_more': total > offset + len(page)}
return {
@@ -1692,7 +1719,9 @@ class MonitoringService(ExecutionDetailsMixin):
from ....utils.inflight import inflight_hub
workspace = require_workspace_uuid(context)
- active = await self.get_executions(context, statuses=['running', 'queued'], limit=100, include_summary=False)
+ active = await self.get_executions(
+ context, statuses=['running', 'queued'], limit=100, include_summary=False, for_inflight=True
+ )
# Execution timestamps are stored as UTC without a timezone. asyncpg
# rejects aware datetimes for these columns, even though SQLite accepts them.
recent_start = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
@@ -1703,6 +1732,7 @@ class MonitoringService(ExecutionDetailsMixin):
start_time=recent_start,
limit=100,
include_summary=False,
+ for_inflight=True,
)
items = {(r['source'], r['id']): r for r in recent['items'] + active['items'] if r['source'] != 'event'}
# Keep the actual terminal state of older runs that just left the active set.
@@ -1722,28 +1752,56 @@ class MonitoringService(ExecutionDetailsMixin):
]:
missing = [r['id'] for r in (previous or [])[:100] if r['source'] == kind and (kind, r['id']) not in items]
if missing:
- records = await self._execution_rows(
- workspace, sqlalchemy.select(model).where(scope == workspace, column.in_(missing))
+ statement = self._execution_statement(model, summary_only=True).where(
+ scope == workspace, column.in_(missing)
)
+ if kind == 'pipeline':
+ statement = statement.where(self._inflight_pipeline_condition())
+ records = await self._execution_rows(workspace, statement, summary_only=True)
names = await self._agent_names(workspace, records) if kind == 'agent' else {}
for record in records:
row = (
self._serialize_agent_execution(record, names)
if kind == 'agent'
- else self._serialize_pipeline_execution(record)
+ else self._serialize_pipeline_execution(record, summary_only=True)
)
items[(kind, row['id'])] = row
rows = list(items.values())
+ snapshot = {
+ 'items': rows,
+ 'active_total': active['total'],
+ 'truncated': active['has_more'] or recent['has_more'],
+ }
+ if not rows:
+ inflight_hub.track(workspace, [])
+ return snapshot
Run = persistence_agent_run.AgentRun
Message = persistence_monitoring.MonitoringMessage
agent_ids = [r['id'] for r in rows if r['source'] == 'agent']
pipeline_ids = [r['id'] for r in rows if r['source'] == 'pipeline']
- linked = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(Run.run_id, Message.id.label('message_id'))
- .outerjoin(Message, sqlalchemy.and_(Message.workspace_uuid == workspace, Message.run_id == Run.run_id))
- .where(
- Run.workspace_id == workspace, sqlalchemy.or_(Run.run_id.in_(agent_ids), Message.id.in_(pipeline_ids))
+ links = []
+ if agent_ids:
+ links.append(
+ sqlalchemy.select(Run.run_id, sqlalchemy.literal(None).label('message_id')).where(
+ Run.workspace_id == workspace, Run.run_id.in_(agent_ids)
+ )
)
+ if pipeline_ids:
+ # Start from the selected message IDs. A cross-table OR after a
+ # LEFT JOIN makes SQLite scan the attachment-heavy message ledger
+ # whenever the snapshot contains even one Pipeline execution.
+ selected_messages = sqlalchemy.union_all(
+ *[sqlalchemy.select(sqlalchemy.literal(message_id).label('message_id')) for message_id in pipeline_ids]
+ ).subquery('selected_messages')
+ links.append(
+ sqlalchemy.select(Run.run_id, Message.id.label('message_id'))
+ .select_from(selected_messages)
+ .join(Message, Message.id == selected_messages.c.message_id)
+ .join(Run, sqlalchemy.and_(Run.workspace_id == workspace, Run.run_id == Message.run_id))
+ .where(Message.workspace_uuid == workspace)
+ )
+ linked = await self.ap.persistence_mgr.execute_async(
+ links[0] if len(links) == 1 else sqlalchemy.union_all(*links)
)
identities = {
r.run_id: ('pipeline', r.message_id) if r.message_id in pipeline_ids else ('agent', r.run_id)
@@ -1766,7 +1824,7 @@ class MonitoringService(ExecutionDetailsMixin):
row = items.get(identities[run_id])
if row is not None:
row['progress_event'] = event_type
- return {'items': rows, 'active_total': active['total'], 'truncated': active['has_more'] or recent['has_more']}
+ return snapshot
async def get_token_statistics(
self,
diff --git a/src/langbot/pkg/api/http/service/monitoring_execution_details.py b/src/langbot/pkg/api/http/service/monitoring_execution_details.py
index 467a8026f..9c65f3f2c 100644
--- a/src/langbot/pkg/api/http/service/monitoring_execution_details.py
+++ b/src/langbot/pkg/api/http/service/monitoring_execution_details.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import datetime
import json
+from types import SimpleNamespace
from langbot_plugin.api.entities.builtin.platform.events import event_summary
import sqlalchemy as sa
@@ -99,7 +100,7 @@ def transcript_item(row):
class ExecutionDetailsMixin:
- def _session_message_source(self, workspace, bot_ids):
+ def _session_message_source(self, workspace, bot_ids, *, summary_only=False):
"""Combine delivered messages with ingress missing from the legacy ledger."""
message = models.MonitoringMessage
conversation = sa.func.coalesce(
@@ -147,7 +148,11 @@ class ExecutionDetailsMixin:
'event_id': EventLog.event_id,
'run_id': EventLog.run_id,
}
- columns = list(message.__table__.columns)
+ columns = [
+ column
+ for column in message.__table__.columns
+ if not summary_only or column.name not in {'message_content', 'variables'}
+ ]
stored = sa.select(*columns).where(message.workspace_uuid == workspace, message.bot_id.in_(bot_ids))
incoming = sa.select(
*[values.get(column.name, sa.literal(None)).label(column.name) for column in columns]
@@ -183,7 +188,9 @@ class ExecutionDetailsMixin:
self, workspace, bot_ids, start_time, end_time, user_query, is_active, limit, offset
):
"""Read session summaries across processor types without mutating history."""
- messages = self._session_message_source(workspace, bot_ids)
+ # Window functions sort and retain their input rows. Carrying historical
+ # inline media through that sort can consume gigabytes for a summary.
+ messages = self._session_message_source(workspace, bot_ids, summary_only=True)
key = [messages.c.workspace_uuid, messages.c.bot_id, messages.c.session_id]
ranked = (
sa.select(
@@ -354,8 +361,44 @@ class ExecutionDetailsMixin:
async with AsyncSession(self.ap.persistence_mgr.get_db_engine()) as session:
return await session.execute(statement)
- async def _execution_rows(self, workspace, statement):
- return list((await self._execution_query(workspace, statement)).scalars().all())
+ async def _execution_rows(self, workspace, statement, *, summary_only=False):
+ result = await self._execution_query(workspace, statement)
+ if not summary_only:
+ return list(result.scalars().all())
+ records = []
+ for row in result.mappings():
+ fields = dict(row)
+ metadata = {
+ key.removeprefix('display_metadata_'): fields.pop(key)
+ for key in row
+ if key.startswith('display_metadata_')
+ }
+ if metadata:
+ fields['metadata_json'] = json.dumps(metadata, ensure_ascii=False)
+ records.append(SimpleNamespace(**fields))
+ return records
+
+ def _execution_statement(self, model, *, summary_only=False):
+ """Keep periodic snapshots independent of message and attachment sizes."""
+ if not summary_only:
+ return sa.select(model)
+ columns = [
+ column
+ for column in model.__table__.columns
+ if column.name not in {'message_content', 'variables', 'input_json', 'authorization_json', 'metadata_json'}
+ ]
+ if model is models.MonitoringMessage:
+ return sa.select(*columns)
+ keys = (
+ ('processor_id', 'processor_type', 'event_type', 'agent_name', 'target_name', 'bot_name')
+ if model is AgentRun
+ else ('platform', 'bot_name')
+ )
+ # Metadata also contains complete inputs. Project only the small display
+ # fields in SQL, rather than deserializing an attachment-bearing object.
+ for key in keys:
+ columns.append(self._execution_json_text(model.metadata_json, key).label(f'display_metadata_{key}'))
+ return sa.select(*columns)
async def _execution_page(self, workspace, statement, convert, offset, limit):
rows = await self._execution_rows(workspace, statement.offset(offset).limit(limit + 1))
@@ -365,6 +408,35 @@ class ExecutionDetailsMixin:
'next_offset': offset + min(len(rows), limit),
}
+ async def _conversation_message_page(self, workspace, statement, offset, limit):
+ """Select a page from the covering index before loading its payloads."""
+ message = models.MonitoringMessage
+ identities = list(
+ (
+ await self._execution_query(
+ workspace, statement.with_only_columns(message.id).offset(offset).limit(limit + 1)
+ )
+ ).scalars()
+ )
+ selected = identities[:limit]
+ records = {}
+ if selected:
+ # Equality joins keep SQLite on the primary key even for multiple IDs.
+ page = sa.union_all(*[sa.select(sa.literal(identity).label('id')) for identity in selected]).subquery()
+ rows = await self._execution_rows(
+ workspace,
+ sa.select(message)
+ .select_from(page)
+ .join(message, message.id == page.c.id)
+ .where(message.workspace_uuid == workspace),
+ )
+ records = {row.id: row for row in rows}
+ return {
+ 'items': [message_item(records[identity]) for identity in selected if identity in records],
+ 'has_more': len(identities) > limit,
+ 'next_offset': offset + len(selected),
+ }
+
def _unhandled_event_conditions(self, workspace, bot_ids=None, start_time=None, end_time=None):
# Absorbed steering events belong to an existing run, too.
conditions = [
@@ -445,14 +517,21 @@ class ExecutionDetailsMixin:
or None,
}
- async def enrich_execution_rows(self, workspace, items):
+ async def enrich_execution_rows(self, workspace, items, *, summary_only=False):
pipeline_items = {item['id']: item for item in items if item.get('source') == 'pipeline'}
if pipeline_items:
# Pipeline rows own the UI identity; their linked Runner ledger owns
# timing and usage. Read them in one batch for both list and detail.
linked = await self._execution_query(
workspace,
- sa.select(models.MonitoringMessage.id, AgentRun)
+ sa.select(
+ models.MonitoringMessage.id,
+ AgentRun.started_at,
+ AgentRun.finished_at,
+ AgentRun.usage_json,
+ AgentRun.cost_json,
+ )
+ .select_from(models.MonitoringMessage)
.join(
AgentRun,
sa.and_(
@@ -465,10 +544,15 @@ class ExecutionDetailsMixin:
models.MonitoringMessage.id.in_(pipeline_items),
),
)
- for message_id, run in linked.all():
- metrics = self._serialize_agent_execution(run)
- pipeline_items[message_id].update(
- {key: metrics[key] for key in ('started_at_ms', 'finished_at_ms', 'duration_ms', 'usage', 'cost')}
+ for run in linked.all():
+ pipeline_items[run.id].update(
+ started_at_ms=epoch(run.started_at),
+ finished_at_ms=epoch(run.finished_at),
+ duration_ms=int(round((run.finished_at - run.started_at).total_seconds() * 1000))
+ if run.started_at and run.finished_at
+ else None,
+ usage=self._deserialize_json(run.usage_json) or None,
+ cost=self._deserialize_json(run.cost_json) or None,
)
calls = models.MonitoringLLMCall
@@ -498,10 +582,11 @@ class ExecutionDetailsMixin:
return
events = await self._execution_rows(
workspace,
- sa.select(EventLog).where(
+ self._execution_statement(EventLog, summary_only=summary_only).where(
EventLog.workspace_id == workspace,
EventLog.event_id.in_(ids),
),
+ summary_only=summary_only,
)
by_id = {row.event_id: row for row in events}
for item in items:
@@ -510,7 +595,9 @@ class ExecutionDetailsMixin:
meta = parsed(event.metadata_json, {})
item.update(
event_type=event.event_type,
- input_preview=event_preview(parsed(event.input_json), event.input_summary, event.event_type),
+ input_preview=event_preview(
+ None if summary_only else parsed(event.input_json), event.input_summary, event.event_type
+ ),
user_id=event.actor_id,
user_name=event.actor_name,
platform=meta.get('platform') or event.source,
@@ -532,8 +619,13 @@ class ExecutionDetailsMixin:
'tool_calls',
'errors',
}
- if section is not None and section not in sections:
+ if section is not None and section not in sections | {'overview'}:
raise ValueError('Unknown execution section')
+ requested = (
+ sections
+ if section is None
+ else ({'inputs', 'outputs', 'deliveries', 'errors'} if section == 'overview' else {section})
+ )
run = message = event = None
if source in {'agent', 'auto'}:
@@ -700,8 +792,12 @@ class ExecutionDetailsMixin:
pages = {}
for name, (query, convert) in statements.items():
- if section is None or section == name:
- pages[name] = await self._execution_page(workspace, query, convert, offset, limit)
+ if name in requested:
+ pages[name] = (
+ await self._conversation_message_page(workspace, query, offset, limit)
+ if name == 'conversation' and message
+ else await self._execution_page(workspace, query, convert, offset, limit)
+ )
if run:
meta = parsed(run.metadata_json, {})
for item in pages.get('inputs', {}).get('items', []):
@@ -709,17 +805,12 @@ class ExecutionDetailsMixin:
# Do not apply the root snapshot to later steering events.
if item['id'] == run.event_id and not item.get('metadata', {}).get('input_event'):
item['content'] = event_content(item['content'], meta.get('input_event'))
- if message and (section is None or section == 'inputs'):
+ if message and 'inputs' in requested:
# The original rich message is authoritative even when the event
# log is absent or only retained a compact event summary.
if offset == 0:
pages['inputs']['items'].insert(0, message_item(message))
- if (
- run
- and not pages.get('inputs', {}).get('items')
- and offset == 0
- and (section is None or section == 'inputs')
- ):
+ if run and not pages.get('inputs', {}).get('items') and offset == 0 and 'inputs' in requested:
meta = parsed(run.metadata_json, {})
content = event_content(meta.get('input'), meta.get('input_event'))
if content:
@@ -727,7 +818,7 @@ class ExecutionDetailsMixin:
{'id': run.event_id or run.run_id, 'content': content, 'timestamp_ms': epoch(run.created_at)}
]
- if section is None or section == 'related':
+ if 'related' in requested:
# Union of ledger executions and legacy pipeline roots. SQL paging
# avoids silently dropping siblings on highly fanned-out events.
related_runs = sa.select(
diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py
index d7ad628d6..527bd00d8 100644
--- a/src/langbot/pkg/api/mcp/server.py
+++ b/src/langbot/pkg/api/mcp/server.py
@@ -313,7 +313,7 @@ class LangBotMCPServer:
)
@mcp.tool(
- description='Read execution inputs, generated outputs, delivery records, calls, errors, related executions and conversation history. Each section is independently paginated; follow pages[section].next_offset when has_more is true.'
+ description='Read execution inputs, generated outputs, delivery records, calls, errors, related executions and conversation history. Use section=overview for inputs, outputs, deliveries and errors without loading history; request other sections on demand. Omit section for the full trace. Each section is independently paginated; follow pages[section].next_offset when has_more is true.'
)
async def get_monitoring_execution_detail(
source: typing.Literal['auto', 'agent', 'pipeline', 'event'],
diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py
index 97dce0da4..4b4d499ab 100644
--- a/src/langbot/pkg/core/app.py
+++ b/src/langbot/pkg/core/app.py
@@ -545,7 +545,7 @@ class Application:
if total_deleted > 0:
self.logger.info(
f'Storage maintenance for Workspace {context.workspace_uuid}: '
- f'deleted expired files: {deleted}'
+ f'cleaned expired storage data: {deleted}'
)
except asyncio.CancelledError:
raise
diff --git a/src/langbot/pkg/entity/persistence/agent_run.py b/src/langbot/pkg/entity/persistence/agent_run.py
index 7d5dc0a8c..05d5c2b00 100644
--- a/src/langbot/pkg/entity/persistence/agent_run.py
+++ b/src/langbot/pkg/entity/persistence/agent_run.py
@@ -6,6 +6,7 @@ import datetime
import sqlalchemy
+from ...persistence.media import MediaReferenceText
from .base import Base
@@ -107,15 +108,17 @@ class AgentRun(Base):
authorization_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Run-scoped authorization snapshot JSON."""
- metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Additional metadata JSON."""
__table_args__ = (
+ sqlalchemy.Index('ix_agent_run_media_ttl', 'workspace_id', 'created_at', 'id'),
sqlalchemy.Index(
'ix_agent_run_scope_status', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'status'
),
sqlalchemy.Index('ix_agent_run_runner_status', 'runner_id', 'status'),
sqlalchemy.Index('ix_agent_run_queue_claim', 'queue_name', 'status', 'priority', 'id'),
+ sqlalchemy.Index('ix_agent_run_workspace_status_lease', 'workspace_id', 'status', 'execution_lease_expires_at'),
)
@@ -183,7 +186,7 @@ class AgentRunEvent(Base):
type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True)
"""Result event type."""
- data_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ data_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Result event payload JSON."""
usage_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
@@ -195,10 +198,11 @@ class AgentRunEvent(Base):
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
"""Source that appended the event."""
- metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Additional metadata JSON."""
__table_args__ = (
sqlalchemy.UniqueConstraint('run_id', 'sequence', name='uq_agent_run_event_run_sequence'),
sqlalchemy.Index('ix_agent_run_event_run_sequence', 'run_id', 'sequence'),
+ sqlalchemy.Index('ix_agent_run_event_media_ttl', 'created_at', 'id', 'run_id'),
)
diff --git a/src/langbot/pkg/entity/persistence/event_log.py b/src/langbot/pkg/entity/persistence/event_log.py
index bd730b03c..8132d7ce9 100644
--- a/src/langbot/pkg/entity/persistence/event_log.py
+++ b/src/langbot/pkg/entity/persistence/event_log.py
@@ -6,6 +6,7 @@ import sqlalchemy
import datetime
from .base import Base
+from ...persistence.media import MediaReferenceText
class EventLog(Base):
@@ -66,7 +67,7 @@ class EventLog(Base):
input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Brief summary of input (truncated text, max 1000 chars)."""
- input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ input_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Full input JSON if reasonably sized (AgentInput as JSON string)."""
# Raw event reference
@@ -82,5 +83,7 @@ class EventLog(Base):
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
"""When this record was created."""
- metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Additional metadata as JSON string."""
+
+ __table_args__ = (sqlalchemy.Index('ix_event_log_media_ttl', 'workspace_id', 'created_at', 'id'),)
diff --git a/src/langbot/pkg/entity/persistence/monitoring.py b/src/langbot/pkg/entity/persistence/monitoring.py
index ecf032986..358c1791e 100644
--- a/src/langbot/pkg/entity/persistence/monitoring.py
+++ b/src/langbot/pkg/entity/persistence/monitoring.py
@@ -1,5 +1,6 @@
import sqlalchemy
+from ...persistence.media import MediaReferenceText
from .base import Base
@@ -19,7 +20,7 @@ class MonitoringMessage(Base):
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)
- message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
+ message_content = sqlalchemy.Column(MediaReferenceText, nullable=False)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
execution_owner_id = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True)
execution_lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True)
@@ -31,7 +32,7 @@ class MonitoringMessage(Base):
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
- variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
+ variables = sqlalchemy.Column(MediaReferenceText, nullable=True) # Query variables as JSON string
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
@@ -39,7 +40,17 @@ class MonitoringMessage(Base):
__table_args__ = (
sqlalchemy.Index('ix_monitoring_messages_workspace_timestamp', 'workspace_uuid', 'timestamp'),
+ sqlalchemy.Index('ix_monitoring_messages_media_ttl', 'workspace_uuid', 'timestamp', 'id'),
sqlalchemy.Index('ix_monitoring_messages_workspace_session', 'workspace_uuid', 'session_id'),
+ sqlalchemy.Index(
+ 'ix_monitoring_messages_conversation_page', 'workspace_uuid', 'bot_id', 'session_id', 'timestamp', 'id'
+ ),
+ sqlalchemy.Index(
+ 'ix_monitoring_messages_workspace_status_lease',
+ 'workspace_uuid',
+ 'status',
+ 'execution_lease_expires_at',
+ ),
)
diff --git a/src/langbot/pkg/entity/persistence/transcript.py b/src/langbot/pkg/entity/persistence/transcript.py
index f975ab245..d61be2e3b 100644
--- a/src/langbot/pkg/entity/persistence/transcript.py
+++ b/src/langbot/pkg/entity/persistence/transcript.py
@@ -6,6 +6,7 @@ import sqlalchemy
import datetime
from .base import Base
+from ...persistence.media import MediaReferenceText
class Transcript(Base):
@@ -48,11 +49,11 @@ class Transcript(Base):
content = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
"""Text content summary (may be truncated for large messages, max 4000 chars)."""
- content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ content_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Full structured content as JSON string (Message model dump)."""
# Attachment references
- attachment_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ attachment_refs_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Attachment references as JSON string."""
# Sequence for cursor-based pagination
@@ -69,11 +70,12 @@ class Transcript(Base):
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
"""When this item was created."""
- metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
"""Additional metadata as JSON string (sender_id, platform, etc.)."""
# Indexes
__table_args__ = (
+ sqlalchemy.Index('ix_transcript_media_ttl', 'workspace_id', 'created_at', 'id'),
sqlalchemy.Index('ix_transcript_conversation_seq', 'conversation_id', 'seq'),
sqlalchemy.Index('ix_transcript_conversation_created', 'conversation_id', 'created_at'),
sqlalchemy.Index('ix_transcript_scope_seq', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'),
diff --git a/src/langbot/pkg/persistence/alembic/versions/0039_execution_lease_indexes.py b/src/langbot/pkg/persistence/alembic/versions/0039_execution_lease_indexes.py
new file mode 100644
index 000000000..eba466381
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0039_execution_lease_indexes.py
@@ -0,0 +1,47 @@
+"""Index execution leases, conversation paging, and historical inline image expiry."""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = '0039_execution_lease_indexes'
+down_revision = '0038_llm_call_details'
+branch_labels = None
+depends_on = None
+
+INDEXES = (
+ ('agent_run', 'ix_agent_run_workspace_status_lease', ['workspace_id', 'status', 'execution_lease_expires_at']),
+ (
+ 'monitoring_messages',
+ 'ix_monitoring_messages_workspace_status_lease',
+ ['workspace_uuid', 'status', 'execution_lease_expires_at'],
+ ),
+ (
+ 'monitoring_messages',
+ 'ix_monitoring_messages_conversation_page',
+ ['workspace_uuid', 'bot_id', 'session_id', 'timestamp', 'id'],
+ ),
+ ('monitoring_messages', 'ix_monitoring_messages_media_ttl', ['workspace_uuid', 'timestamp', 'id']),
+ ('event_log', 'ix_event_log_media_ttl', ['workspace_id', 'created_at', 'id']),
+ ('agent_run', 'ix_agent_run_media_ttl', ['workspace_id', 'created_at', 'id']),
+ ('agent_run_event', 'ix_agent_run_event_media_ttl', ['created_at', 'id', 'run_id']),
+ ('transcript', 'ix_transcript_media_ttl', ['workspace_id', 'created_at', 'id']),
+)
+
+
+def upgrade():
+ inspector = sa.inspect(op.get_bind())
+ for table, index, columns in INDEXES:
+ if not inspector.has_table(table):
+ continue
+ # Fresh installations create these indexes from ORM metadata before migrations.
+ if index not in {item['name'] for item in inspector.get_indexes(table)}:
+ op.create_index(index, table, columns)
+
+
+def downgrade():
+ inspector = sa.inspect(op.get_bind())
+ for table, index, _ in reversed(INDEXES):
+ if not inspector.has_table(table):
+ continue
+ if index in {item['name'] for item in inspector.get_indexes(table)}:
+ op.drop_index(index, table_name=table)
diff --git a/src/langbot/pkg/persistence/media.py b/src/langbot/pkg/persistence/media.py
new file mode 100644
index 000000000..337c63834
--- /dev/null
+++ b/src/langbot/pkg/persistence/media.py
@@ -0,0 +1,46 @@
+"""The storage contract for conversation payloads containing image references."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from sqlalchemy import Text
+from sqlalchemy.engine import Dialect
+from sqlalchemy.sql.operators import OperatorType
+from sqlalchemy.types import TypeDecorator
+
+
+class MediaReferenceText(TypeDecorator[str]):
+ """Keep the existing TEXT format while rejecting structured inline images.
+
+ Writers must externalize retained images through MediaCache or redact
+ metadata-only images before binding a value. Both ORM and Core writes use
+ this contract; reads preserve historical values for incremental cleanup.
+ Plain text remains valid for fields such as monitoring message content.
+ """
+
+ impl = Text
+ cache_ok = True
+
+ def process_bind_param(self, value: str | None, dialect: Dialect) -> str | None:
+ if value is None:
+ return None
+ try:
+ payload = json.loads(value)
+ except json.JSONDecodeError:
+ return value
+
+ # Model imports must not initialize the media cache's runtime dependencies.
+ from ..storage.media import MediaCache
+
+ if MediaCache.has_inline_images(payload):
+ raise ValueError(
+ 'Inline images cannot be persisted; use MediaCache.externalize_chain_dump() '
+ 'for retained content or MediaCache.redact_inline_images() for metadata'
+ )
+ return value
+
+ def coerce_compared_value(self, op: OperatorType | None, value: Any) -> Text:
+ # Legacy payloads must remain usable in searches and compare-and-swap updates.
+ return self.impl
diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py
index 396b20103..c55b7a252 100644
--- a/src/langbot/pkg/persistence/tenant_uow.py
+++ b/src/langbot/pkg/persistence/tenant_uow.py
@@ -19,6 +19,8 @@ from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLO
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
from sqlalchemy.dialects.sqlite.dml import OnConflictDoUpdate as SQLiteOnConflictDoUpdate
+from .media import MediaReferenceText
+
TENANT_SETTING = 'langbot.workspace_uuid'
ACCOUNT_SETTING = 'langbot.account_uuid'
@@ -292,7 +294,7 @@ def _validate_scoped_sql_type(
if type(sql_type) in {Vector, HALFVEC}:
return
- if not type(sql_type).__module__.startswith('sqlalchemy.'):
+ if type(sql_type) is not MediaReferenceText and not type(sql_type).__module__.startswith('sqlalchemy.'):
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
for identifier_attribute in ('name', 'schema', 'collation'):
@@ -486,7 +488,7 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
monitoring_metadata = (
type(element.type) is sqlalchemy.JSON
and isinstance(source, sqlalchemy.Column)
- and type(source.type) is sqlalchemy.Text
+ and type(source.type) in {sqlalchemy.Text, MediaReferenceText}
and getattr(getattr(source, 'table', None), 'name', None) in {'event_log', 'agent_run'}
and (
source.name == 'metadata_json' or (source.table.name == 'event_log' and source.name == 'input_json')
diff --git a/src/langbot/pkg/pipeline/monitoring_helper.py b/src/langbot/pkg/pipeline/monitoring_helper.py
index 4efb21498..25b58ec8b 100644
--- a/src/langbot/pkg/pipeline/monitoring_helper.py
+++ b/src/langbot/pkg/pipeline/monitoring_helper.py
@@ -143,12 +143,9 @@ class MonitoringHelper:
sender_name = query.message_event.sender.member_name
# Try to record message
- # Use JSON serialization to preserve message chain structure (including image URLs, etc.)
+ # Let persistence externalize images before serializing the message chain.
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
- chain_dump = query.message_chain.model_dump()
- if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
- chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
- message_content = json.dumps(chain_dump, ensure_ascii=False)
+ message_content = query.message_chain.model_dump()
else:
message_content = str(query)
@@ -257,10 +254,10 @@ class MonitoringHelper:
# Extract response content from resp_message_chain
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
- # Serialize the last response message chain
+ # Preserve the structure until persistence has externalized images.
last_resp = query.resp_message_chain[-1]
if hasattr(last_resp, 'model_dump'):
- message_content = json.dumps(last_resp.model_dump(), ensure_ascii=False)
+ message_content = last_resp.model_dump()
else:
message_content = str(last_resp)
elif hasattr(query, 'resp_messages') and query.resp_messages:
@@ -268,10 +265,7 @@ class MonitoringHelper:
if hasattr(last_resp, 'get_content_platform_message_chain'):
chain = last_resp.get_content_platform_message_chain()
if hasattr(chain, 'model_dump'):
- chain_dump = chain.model_dump()
- if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
- chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
- message_content = json.dumps(chain_dump, ensure_ascii=False)
+ message_content = chain.model_dump()
else:
message_content = str(chain)
else:
diff --git a/src/langbot/pkg/pipeline/resprule/resprule.py b/src/langbot/pkg/pipeline/resprule/resprule.py
index 6e7fc369e..3e8c16a96 100644
--- a/src/langbot/pkg/pipeline/resprule/resprule.py
+++ b/src/langbot/pkg/pipeline/resprule/resprule.py
@@ -37,8 +37,13 @@ class GroupRespondRuleCheckStage(stage.PipelineStage):
if query.launcher_type.value != 'group': # 只处理群消息
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
- # 通过路由规则明确指定的流水线,跳过群响应规则检查
- if query.variables and query.variables.get('_routed_by_rule', False):
+ # Interaction submissions and routed platform form callbacks resume the
+ # original run without another mention or prefix. Ordinary routing only
+ # selects a pipeline and must still obey its group response rules.
+ if query.variables and (
+ query.variables.get('_interaction_submission')
+ or (query.variables.get('_routed_by_rule') and query.variables.get('_dify_form_action'))
+ ):
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
rules = query.pipeline_config['trigger']['group-respond-rules']
diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py
index ae0ecdc4e..ac425a9c4 100644
--- a/src/langbot/pkg/platform/botmgr.py
+++ b/src/langbot/pkg/platform/botmgr.py
@@ -1356,10 +1356,7 @@ class RuntimeBot:
"""Record a discarded message in the monitoring system."""
try:
if hasattr(message_chain, 'model_dump'):
- chain_dump = message_chain.model_dump()
- if hasattr(self.ap, 'storage_mgr') and hasattr(self.ap.storage_mgr, 'media_cache'):
- chain_dump = await self.ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
- message_content = json.dumps(chain_dump, ensure_ascii=False)
+ message_content = message_chain.model_dump()
else:
message_content = str(message_chain)
diff --git a/src/langbot/pkg/plugin/runner_actions.py b/src/langbot/pkg/plugin/runner_actions.py
index bca1a6c07..659ac2f4c 100644
--- a/src/langbot/pkg/plugin/runner_actions.py
+++ b/src/langbot/pkg/plugin/runner_actions.py
@@ -469,12 +469,16 @@ def register(h):
if payload_error:
return handler.ActionResponse.error(message=payload_error)
- event = await store.append_event(
+ from ..agent.runner.run_journal import AgentRunJournal
+
+ event = await AgentRunJournal(h.ap).append_run_result(
run_id=str(target_run_id),
sequence=sequence,
- event_type=str(event_type),
- data=event_payload,
- usage=usage if isinstance(usage, dict) else None,
+ result_dict={
+ 'type': str(event_type),
+ 'data': event_payload,
+ 'usage': usage if isinstance(usage, dict) else None,
+ },
source=str(data.get('source') or result.get('source') or 'runner'),
metadata=metadata,
)
diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
index 300fb7551..6f1de0e86 100644
--- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
+++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
@@ -836,6 +836,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
req_messages = []
for m in messages:
msg_dict = m.dict(exclude_none=True)
+ provider_fields = msg_dict.get('provider_specific_fields')
+ if isinstance(provider_fields, dict):
+ # Stream completion metadata belongs to the response, never to
+ # replayed chat messages. Keep provider signatures for LiteLLM.
+ provider_fields.pop('finish_reason', None)
+ if not provider_fields:
+ msg_dict.pop('provider_specific_fields', None)
content = msg_dict.get('content')
if msg_dict.get('role') == 'assistant' and reasoning_family:
@@ -908,6 +915,17 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return req_messages
+ @staticmethod
+ def _adapt_messages_for_provider(messages: list[dict], chat_provider: str | None) -> None:
+ """Adapt serialized request messages in place for the chat transport."""
+ if chat_provider == 'groq':
+ # Groq rejects LiteLLM's metadata envelope on messages and tool calls.
+ for message in messages:
+ message.pop('provider_specific_fields', None)
+ for tool_call in message.get('tool_calls') or []:
+ tool_call.pop('provider_specific_fields', None)
+ tool_call.get('function', {}).pop('provider_specific_fields', None)
+
_THINK_PATTERNS: tuple[str, ...] = (
r'^\s*(?:(?!).)*?\s*',
r'^\s*(?:(?!CRETIRE_REASONING_BEGINk).)*?CRETIRE_REASONING_ENDk\s*',
@@ -1324,6 +1342,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
# OpenRouter shares requester metadata with OpenAI-compatible non-chat
# operations, but chat needs its own adapter to preserve cache markers.
chat_provider = 'openrouter' if self._uses_openrouter_protocol() else self._get_custom_llm_provider()
+ self._adapt_messages_for_provider(req_messages, chat_provider)
model_name = self._build_litellm_model_name(model.model_entity.name, chat_provider)
api_key = model.provider.token_mgr.get_token()
diff --git a/src/langbot/pkg/storage/inline_media_cleanup.py b/src/langbot/pkg/storage/inline_media_cleanup.py
new file mode 100644
index 000000000..3293fcca4
--- /dev/null
+++ b/src/langbot/pkg/storage/inline_media_cleanup.py
@@ -0,0 +1,147 @@
+"""Incrementally expire historical inline images without rewriting record history."""
+
+from __future__ import annotations
+
+import asyncio
+import datetime
+import json
+from typing import TYPE_CHECKING
+
+import sqlalchemy as sa
+
+from ..api.http.authz import WorkspaceRequiredError
+from ..api.http.context import ExecutionContext
+from ..entity.persistence.agent_run import AgentRun, AgentRunEvent
+from ..entity.persistence.event_log import EventLog
+from ..entity.persistence.metadata import WorkspaceMetadata
+from ..entity.persistence.monitoring import MonitoringMessage
+from ..entity.persistence.transcript import Transcript
+from ..persistence.datetime_utils import as_naive_utc
+from .media import MediaCache
+
+if TYPE_CHECKING:
+ from ..core.app import Application
+
+
+ROWS_PER_TABLE = 100
+CHARACTERS_PER_TABLE = 8 * 1024 * 1024
+CURSOR_PREFIX = 'inline_media_ttl_v1:'
+
+
+def _redact_columns(values: dict) -> dict:
+ updates = {}
+ for name, raw in values.items():
+ if not raw or ('base64' not in raw and 'data:image/' not in raw):
+ continue
+ try:
+ payload = json.loads(raw)
+ except (ValueError, TypeError, RecursionError):
+ # Preserve malformed historical text rather than deleting an entire record.
+ continue
+ if MediaCache.has_inline_images(payload):
+ updates[name] = json.dumps(MediaCache.redact_inline_images(payload), ensure_ascii=False)
+ return updates
+
+
+async def cleanup_inline_media(ap: Application, context: ExecutionContext, retention_days: int) -> int:
+ """Use the media TTL for immutable history, with durable per-Workspace cursors.
+
+ Bound each table by both candidate count and serialized payload characters. One
+ oversized row is processed on its own so it cannot permanently block the cursor.
+ """
+ if not isinstance(context, ExecutionContext) or not context.workspace_uuid:
+ raise WorkspaceRequiredError('Inline media cleanup requires an ExecutionContext')
+ if retention_days < 1:
+ raise ValueError('retention_days must be >= 1')
+ workspace = context.workspace_uuid
+ cutoff = as_naive_utc(datetime.datetime.now(datetime.timezone.utc)) - datetime.timedelta(days=retention_days)
+ sources = (
+ (
+ MonitoringMessage,
+ MonitoringMessage.timestamp,
+ MonitoringMessage.workspace_uuid == workspace,
+ ('message_content', 'variables'),
+ ),
+ (EventLog, EventLog.created_at, EventLog.workspace_id == workspace, ('input_json', 'metadata_json')),
+ (AgentRun, AgentRun.created_at, AgentRun.workspace_id == workspace, ('metadata_json',)),
+ (
+ AgentRunEvent,
+ AgentRunEvent.created_at,
+ sa.exists(
+ sa.select(AgentRun.id).where(
+ AgentRun.run_id == AgentRunEvent.run_id, AgentRun.workspace_id == workspace
+ )
+ ),
+ ('data_json', 'metadata_json'),
+ ),
+ (
+ Transcript,
+ Transcript.created_at,
+ Transcript.workspace_id == workspace,
+ ('content_json', 'attachment_refs_json', 'metadata_json'),
+ ),
+ )
+ manager = ap.persistence_mgr
+ cleaned = 0
+ for model, timestamp, scope, fields in sources:
+ consumed = 0
+ cursor_key = CURSOR_PREFIX + model.__tablename__
+ cursor_filter = (WorkspaceMetadata.workspace_uuid == workspace, WorkspaceMetadata.key == cursor_key)
+ for _ in range(ROWS_PER_TABLE):
+ async with manager.tenant_uow(workspace):
+ cursor_result = await manager.execute_async(
+ sa.select(WorkspaceMetadata.value).where(*cursor_filter).with_for_update()
+ )
+ cursor_raw = cursor_result.scalar_one_or_none()
+ # Events have no Workspace column. Limit indexed candidates before
+ # checking ownership so other Workspaces cannot cause unbounded scans.
+ query = sa.select(model.id, timestamp).where(timestamp < cutoff)
+ if model is not AgentRunEvent:
+ query = query.where(scope)
+ if cursor_raw is not None:
+ after_time, after_id = json.loads(cursor_raw)
+ query = query.where(
+ sa.tuple_(timestamp, model.id)
+ > sa.tuple_(datetime.datetime.fromisoformat(after_time), after_id)
+ )
+ result = await manager.execute_async(query.order_by(timestamp, model.id).limit(1))
+ row = result.first()
+ if row is None:
+ break
+ payload = (
+ await manager.execute_async(
+ sa.select(*(getattr(model, field) for field in fields)).where(model.id == row[0], scope)
+ )
+ ).first()
+ # Missing and foreign candidates still consume budget and advance the
+ # cursor. Their payloads are never returned to the cleanup worker.
+ values = dict(zip(fields, payload)) if payload is not None else {}
+ consumed += sum(len(value) for value in values.values() if value)
+ updates = await asyncio.to_thread(_redact_columns, values) if values else {}
+ if updates:
+ if model is AgentRun:
+ # Media expiry must not appear as new execution activity.
+ updates['updated_at'] = AgentRun.updated_at
+ changed = await manager.execute_async(
+ sa.update(model)
+ .where(
+ scope,
+ model.id == row[0],
+ *(getattr(model, name) == values[name] for name in fields if name in updates),
+ )
+ .values(**updates)
+ )
+ if not changed.rowcount:
+ # A concurrent writer changed this payload; retry it next time.
+ break
+ cleaned += 1
+ cursor = json.dumps([row[1].isoformat(), row[0]])
+ if cursor_raw is None:
+ await manager.execute_async(
+ sa.insert(WorkspaceMetadata).values(workspace_uuid=workspace, key=cursor_key, value=cursor)
+ )
+ else:
+ await manager.execute_async(sa.update(WorkspaceMetadata).where(*cursor_filter).values(value=cursor))
+ if consumed >= CHARACTERS_PER_TABLE:
+ break
+ return cleaned
diff --git a/src/langbot/pkg/storage/media.py b/src/langbot/pkg/storage/media.py
index 8f5537fb3..4a615abc1 100644
--- a/src/langbot/pkg/storage/media.py
+++ b/src/langbot/pkg/storage/media.py
@@ -11,6 +11,8 @@ import xxhash
from pathlib import Path
from typing import TYPE_CHECKING, Any
+from ..utils.image import DEFAULT_BASE64_MEDIA_LIMIT
+
if TYPE_CHECKING:
from ...core import app
from . import mgr as storage_mgr
@@ -49,14 +51,80 @@ class MediaCache:
if split_index != -1:
mime_type = data_url[5:split_index]
b64_data = data_url[split_index + 8 :]
- return base64.b64decode(b64_data), mime_type
- # Try raw base64 if sufficiently long
- if len(data_url) > 20 and not data_url.startswith(('http://', 'https://', '/')):
- return base64.b64decode(data_url), 'application/octet-stream'
+ return MediaCache._decode_base64(b64_data), mime_type
+ if not data_url.startswith(('http://', 'https://', '/')):
+ return MediaCache._decode_base64(data_url), 'application/octet-stream'
except Exception:
return None
return None
+ @staticmethod
+ def _decode_base64(value: str) -> bytes:
+ """Bound decoding before allocating a binary payload for persistence."""
+ if len(value) > 4 * ((DEFAULT_BASE64_MEDIA_LIMIT + 2) // 3):
+ raise ValueError('Inline image exceeds the media size limit')
+ decoded = base64.b64decode(value, validate=True)
+ if len(decoded) > DEFAULT_BASE64_MEDIA_LIMIT:
+ raise ValueError('Inline image exceeds the media size limit')
+ return decoded
+
+ @staticmethod
+ def _inline_image_payload(node: dict) -> str | None:
+ """Recognize platform and provider image payloads without fetching URLs."""
+ node_type = node.get('type')
+ if node_type == 'Image':
+ payload = node.get('base64')
+ url = node.get('url')
+ elif node_type in ('image', 'image_url', 'image_base64'):
+ payload = node.get('image_base64')
+ url = node.get('image_url')
+ if isinstance(url, dict):
+ url = url.get('url')
+ else:
+ return None
+ if isinstance(payload, str) and payload:
+ return payload
+ if isinstance(url, str) and url.startswith('data:image/'):
+ return url
+ return None
+
+ @classmethod
+ def has_inline_images(cls, value: Any) -> bool:
+ if isinstance(value, list):
+ return any(cls.has_inline_images(item) for item in value)
+ if isinstance(value, dict):
+ return cls._inline_image_payload(value) is not None or any(
+ cls.has_inline_images(item) for item in value.values() if isinstance(item, (list, dict))
+ )
+ return False
+
+ @staticmethod
+ def _clear_inline_image(node: dict) -> None:
+ field = 'base64' if node.get('type') == 'Image' else 'image_base64'
+ if node.get(field):
+ node[field] = None
+ url_field = 'url' if node.get('type') == 'Image' else 'image_url'
+ url = node.get(url_field)
+ if isinstance(url, str) and url.startswith('data:image/'):
+ node[url_field] = None
+ elif isinstance(url, dict) and str(url.get('url', '')).startswith('data:image/'):
+ node[url_field] = {**url, 'url': None}
+
+ @classmethod
+ def redact_inline_images(cls, value: Any) -> Any:
+ """Keep metadata-only records binary-free, including short raw base64."""
+ if isinstance(value, list):
+ return [cls.redact_inline_images(item) for item in value]
+ if isinstance(value, dict):
+ node = dict(value)
+ if cls._inline_image_payload(node) is not None:
+ cls._clear_inline_image(node)
+ return {
+ key: cls.redact_inline_images(item) if isinstance(item, (list, dict)) else item
+ for key, item in node.items()
+ }
+ return value
+
@staticmethod
def guess_extension(mime_type: str | None, default: str = '.jpg') -> str:
"""Guess appropriate file extension from MIME type."""
@@ -201,29 +269,33 @@ class MediaCache:
return [await self.externalize_chain_dump(item) for item in chain_dump]
if isinstance(chain_dump, dict):
node = copy.copy(chain_dump)
- node_type = node.get('type')
- if node_type == 'Image':
- b64 = node.get('base64')
- if b64 and isinstance(b64, str):
- try:
- parsed = self.parse_data_url(b64)
- if parsed is not None:
- raw_bytes, mime_type = parsed
- hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type)
- filename = os.path.basename(storage_key)
- current_url = node.get('url') or ''
- if current_url and not current_url.startswith('data:'):
- node['original_url'] = current_url
- node['url'] = f'/api/v1/files/media/{filename}'
- node['base64'] = None
- node['hash'] = hash_str
- node['storage_key'] = storage_key
- node['size'] = size
- node['mime_type'] = mime_type
- except Exception as e:
- if hasattr(self.ap, 'logger') and self.ap.logger:
- self.ap.logger.warning(f'Failed to externalize image to media cache: {e}')
- node['base64'] = None
+ payload = self._inline_image_payload(node)
+ if payload is not None:
+ try:
+ parsed = await asyncio.to_thread(self.parse_data_url, payload)
+ if parsed is None:
+ raise ValueError('Invalid inline image payload')
+ raw_bytes, mime_type = parsed
+ hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type)
+ url = f'/api/v1/files/media/{os.path.basename(storage_key)}'
+ if node.get('type') == 'Image':
+ current_url = node.get('url') or ''
+ if current_url and not current_url.startswith('data:'):
+ node['original_url'] = current_url
+ node['url'] = url
+ else:
+ current_url = node.get('image_url')
+ if isinstance(current_url, dict):
+ current_url = current_url.get('url')
+ if current_url and not current_url.startswith('data:'):
+ node['original_url'] = current_url
+ image_url = node.get('image_url')
+ node['image_url'] = {**image_url, 'url': url} if isinstance(image_url, dict) else {'url': url}
+ node.update(hash=hash_str, storage_key=storage_key, size=size, mime_type=mime_type)
+ except Exception as e:
+ if hasattr(self.ap, 'logger') and self.ap.logger:
+ self.ap.logger.warning(f'Failed to externalize image to media cache: {e}')
+ self._clear_inline_image(node)
for k, v in list(node.items()):
if isinstance(v, (list, dict)):
node[k] = await self.externalize_chain_dump(v)
diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml
index de5092370..97650b8d8 100644
--- a/src/langbot/templates/config.yaml
+++ b/src/langbot/templates/config.yaml
@@ -224,7 +224,8 @@ storage:
max_object_read_bytes: 10485760
# Media content cache (images & attachments externalized from monitoring and pipelines)
media_cache:
- # Retention period in days for cached media (defaults to 30 days)
+ # Retention for CAS files and historical inline images (defaults to 30 days).
+ # Inline images expire by record creation time; message text is retained.
retention_days: 30
# Maximum disk storage for cached media in MB (0 means unlimited, defaults to 0)
max_size_mb: 0
diff --git a/tests/integration/persistence/resource_migration_support.py b/tests/integration/persistence/resource_migration_support.py
index c82742acf..47bac85fe 100644
--- a/tests/integration/persistence/resource_migration_support.py
+++ b/tests/integration/persistence/resource_migration_support.py
@@ -202,6 +202,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
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('status', sa.String(50), nullable=False, default='success'))
table.append_column(sa.Column('role', sa.String(50)))
else:
table.append_column(sa.Column('message_count', sa.Integer, nullable=False, default=1))
diff --git a/tests/integration/persistence/test_media_reference_postgres.py b/tests/integration/persistence/test_media_reference_postgres.py
new file mode 100644
index 000000000..a9bea397c
--- /dev/null
+++ b/tests/integration/persistence/test_media_reference_postgres.py
@@ -0,0 +1,190 @@
+"""Media writes against real asyncpg and existing TEXT columns.
+
+Run with TEST_POSTGRES_URL pointing to a disposable PostgreSQL database.
+Each case creates and removes its own schema.
+"""
+
+import importlib
+import json
+import os
+import uuid
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import sqlalchemy as sa
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+
+from langbot.pkg.agent.runner.run_journal import AgentRunJournal
+from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore
+from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.persistence.tenant_uow import _validate_scoped_statement_call
+from langbot.pkg.storage.media import MediaCache
+
+pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
+
+
+def _published_schema(connection):
+ with Operations.context(MigrationContext.configure(connection)):
+ for revision in (
+ '58846a8d7a81_add_event_log_and_transcript_tables',
+ '8d3a1f2c4b6e_add_agent_run_ledger',
+ '0036_execution_host_leases',
+ ):
+ importlib.import_module(f'langbot.pkg.persistence.alembic.versions.{revision}').upgrade()
+
+
+@pytest.fixture(params=['metadata', 'published_migrations'])
+async def pg_engine(request):
+ url = os.environ.get('TEST_POSTGRES_URL')
+ if not url:
+ pytest.skip('TEST_POSTGRES_URL is required (disposable PostgreSQL)')
+ namespace = f'media_contract_{uuid.uuid4().hex}'
+ admin = create_async_engine(url)
+ engine = None
+ try:
+ async with admin.begin() as conn:
+ await conn.execute(sa.text(f'CREATE SCHEMA {namespace}'))
+ engine = create_async_engine(url, connect_args={'server_settings': {'search_path': namespace}})
+ async with engine.begin() as conn:
+ if request.param == 'metadata':
+ await conn.run_sync(
+ lambda sync: Base.metadata.create_all(sync, tables=[AgentRun.__table__, AgentRunEvent.__table__])
+ )
+ else:
+ await conn.run_sync(_published_schema)
+ yield engine
+ finally:
+ if engine is not None:
+ await engine.dispose()
+ async with admin.begin() as conn:
+ await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS {namespace} CASCADE'))
+ await admin.dispose()
+
+
+@pytest.fixture
+async def store(pg_engine):
+ store = RunLedgerStore(pg_engine)
+ await store.create_run(
+ run_id='run', event_id='event', binding_id=None, runner_id='runner', workspace_id='ws', metadata={'attempt': 0}
+ )
+ return store
+
+
+@pytest.mark.parametrize('writer', ['orm', 'insert', 'update'])
+@pytest.mark.parametrize(
+ 'image',
+ [
+ {'type': 'Image', 'base64': 'eA=='},
+ {'type': 'image_base64', 'image_base64': 'eA=='},
+ {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}},
+ ],
+)
+async def test_unprocessed_images_roll_back(pg_engine, store, writer, image):
+ content = json.dumps({'nested': [image]})
+ with pytest.raises(sa.exc.StatementError, match='Inline images cannot be persisted'):
+ if writer == 'orm':
+ async with AsyncSession(pg_engine) as session:
+ run = (await session.execute(sa.select(AgentRun).where(AgentRun.run_id == 'run'))).scalar_one()
+ run.status = 'completed'
+ run.metadata_json = content
+ await session.commit()
+ else:
+ statement = (
+ sa.insert(AgentRun).values(run_id='new', runner_id='runner', status='completed', metadata_json=content)
+ if writer == 'insert'
+ else sa.update(AgentRun)
+ .where(AgentRun.run_id == 'run')
+ .values(status='completed', metadata_json=content)
+ )
+ async with pg_engine.begin() as conn:
+ await conn.execute(statement)
+ run = await store.get_run('run')
+ assert run['status'] == 'running'
+ assert run['metadata'] == {'attempt': 0}
+ assert await store.get_run('new') is None
+
+
+@pytest.mark.parametrize('operation', ['pin', 'finalize', 'status_only'])
+async def test_legacy_metadata_updates_preserve_the_write_contract(pg_engine, store, operation):
+ legacy = {'image': {'type': 'Image', 'base64': 'eA=='}, 'attempt': 0}
+ original = json.dumps(legacy)
+ async with pg_engine.begin() as conn:
+ # Seed the pre-contract format while keeping current model mappings.
+ await conn.execute(
+ sa.update(AgentRun)
+ .where(AgentRun.run_id == 'run')
+ .values(metadata_json=sa.bindparam('legacy', original, type_=sa.Text()))
+ )
+ assert (
+ await conn.execute(sa.select(AgentRun.run_id).where(AgentRun.metadata_json == original))
+ ).scalar_one() == 'run'
+
+ with pytest.raises(sa.exc.StatementError, match='Inline images cannot be persisted'):
+ await store.finalize_run(
+ run_id='run', status='completed', metadata={'new': {'type': 'Image', 'base64': 'eQ=='}}
+ )
+ assert (await store.get_run('run'))['status'] == 'running'
+ assert (await store.get_run('run'))['metadata'] == legacy
+
+ if operation == 'pin':
+ assert await store.pin_skill_revision('run', 'ws', 'demo', 'v1') == 'v1'
+ assert (await store.get_run('run'))['metadata'] == {
+ 'image': {'type': 'Image', 'base64': None},
+ 'attempt': 0,
+ 'activated_skills': {'demo': 'v1'},
+ }
+ else:
+ metadata = {'note': 'done'} if operation == 'finalize' else None
+ run = await store.finalize_run(run_id='run', status='completed', metadata=metadata)
+ assert run['status'] == 'completed'
+ assert run['metadata'] == (
+ {'image': {'type': 'Image', 'base64': None}, 'attempt': 0, 'note': 'done'}
+ if metadata is not None
+ else legacy
+ )
+
+
+async def test_journal_retains_references_and_redacts_metadata(pg_engine, store):
+ provider = SimpleNamespace(exists=AsyncMock(return_value=False), save=AsyncMock())
+ storage = SimpleNamespace(storage_provider=provider)
+ app = SimpleNamespace(storage_mgr=storage, persistence_mgr=SimpleNamespace(get_db_engine=lambda: pg_engine))
+ storage.media_cache = MediaCache(app, storage)
+ image = {'type': 'Image', 'base64': 'eA=='}
+ event = await AgentRunJournal(app).append_run_result(
+ run_id='run',
+ sequence=1,
+ result_dict={'type': 'tool.call.completed', 'data': {'image': image}},
+ metadata={'preview': image},
+ )
+ assert event['data']['image']['base64'] is None
+ assert event['data']['image']['url'].startswith('/api/v1/files/media/')
+ assert event['metadata'] == {'preview': {'type': 'Image', 'base64': None}}
+ provider.save.assert_awaited_once()
+ async with pg_engine.connect() as conn:
+ row = (await conn.execute(sa.select(AgentRunEvent.data_json, AgentRunEvent.metadata_json))).one()
+ assert json.loads(row.data_json) == event['data']
+ assert json.loads(row.metadata_json) == event['metadata']
+
+
+async def test_text_schema_and_monitoring_json_casts(pg_engine, store):
+ statement = sa.select(sa.cast(AgentRun.metadata_json, sa.JSON)['attempt'].as_integer())
+ _validate_scoped_statement_call((statement,), {})
+ async with pg_engine.connect() as conn:
+ assert (await conn.execute(statement)).scalar_one() == 0
+ columns = (
+ (
+ await conn.execute(
+ sa.text(
+ 'SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() '
+ "AND table_name IN ('agent_run', 'agent_run_event') AND column_name IN ('data_json', 'metadata_json')"
+ )
+ )
+ )
+ .scalars()
+ .all()
+ )
+ assert columns == ['text'] * 3
diff --git a/tests/unit_tests/agent/test_run_journal.py b/tests/unit_tests/agent/test_run_journal.py
index 1ae134a01..c229318ca 100644
--- a/tests/unit_tests/agent/test_run_journal.py
+++ b/tests/unit_tests/agent/test_run_journal.py
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock
import pytest
from langbot.pkg.agent.runner.run_journal import AgentRunJournal
+from langbot.pkg.storage.media import MediaCache
@pytest.mark.asyncio
@@ -68,3 +69,21 @@ def test_event_snapshot_preserves_custom_values_and_redacts_nested_binary():
assert saved['custom']['contents'][0]['image_base64'] is None
assert saved['attachments'][0]['content'] is None
assert data['custom']['contents'][0]['image_base64'] == 'secret'
+
+
+@pytest.mark.parametrize(
+ 'image',
+ [
+ {'type': 'Image', 'base64': 'eA=='},
+ {'type': 'image_base64', 'image_base64': 'eA=='},
+ {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}},
+ ],
+)
+def test_metadata_and_transcript_redaction_cover_all_image_encodings(image):
+ content = AgentRunJournal._sanitize_content_item(image)
+ event = AgentRunJournal._sanitize_event_data({'nested': [image]})
+ assert not MediaCache.has_inline_images(content)
+ assert not MediaCache.has_inline_images(event)
+ assert content['content_redacted'] is True
+ assert event['nested'][0]['content_redacted'] is True
+ assert MediaCache.has_inline_images(image)
diff --git a/tests/unit_tests/agent/test_run_ledger_api_auth.py b/tests/unit_tests/agent/test_run_ledger_api_auth.py
index e75f67739..62d390326 100644
--- a/tests/unit_tests/agent/test_run_ledger_api_auth.py
+++ b/tests/unit_tests/agent/test_run_ledger_api_auth.py
@@ -3,8 +3,9 @@
from __future__ import annotations
import datetime
+import json
from types import SimpleNamespace
-from unittest.mock import MagicMock
+from unittest.mock import AsyncMock, MagicMock
import pytest
import sqlalchemy
@@ -17,6 +18,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.entity.persistence import agent_run as agent_run_model
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
+from langbot.pkg.storage.media import MediaCache
from langbot_plugin.api.entities.builtin.runner.run_ledger import (
AgentRun,
AgentRunEvent,
@@ -1064,6 +1066,50 @@ async def test_run_append_result_basic_path(session_registry, db_engine):
assert event.usage == {'output_tokens': 1}
+@pytest.mark.asyncio
+async def test_run_append_result_uses_cas_and_redacts_metadata(session_registry, db_engine):
+ await _register_session(session_registry, available_apis={'run_append_result': True})
+ await _create_run(db_engine)
+ handler = _handler(db_engine)
+ objects = {}
+
+ async def save(key, data):
+ objects[key] = data
+
+ storage = SimpleNamespace(
+ storage_provider=SimpleNamespace(exists=AsyncMock(return_value=False), save=AsyncMock(side_effect=save))
+ )
+ storage.media_cache = MediaCache(handler.ap, storage)
+ handler.ap.storage_mgr = storage
+ image = {'type': 'Image', 'base64': 'eA=='}
+ result = await handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value](
+ {
+ 'run_id': 'run_1',
+ 'caller_plugin_identity': 'test/runner',
+ 'sequence': 2,
+ 'event_type': 'tool.call.completed',
+ 'data': {'tool_call_id': 'call_1', 'tool_name': 'image', 'result': {'images': [image]}},
+ 'metadata': {'preview': image},
+ }
+ )
+ assert result.code == 0
+ event = AgentRunEvent.model_validate(result.data)
+ saved = event.data['result']['images'][0]
+ assert saved['base64'] is None
+ assert saved['url'].startswith('/api/v1/files/media/')
+ assert objects == {saved['storage_key']: b'x'}
+ async with db_engine.connect() as conn:
+ metadata = (
+ await conn.execute(
+ sqlalchemy.select(agent_run_model.AgentRunEvent.metadata_json).where(
+ agent_run_model.AgentRunEvent.sequence == 2
+ )
+ )
+ ).scalar_one()
+ assert json.loads(metadata)['preview']['base64'] is None
+ assert image['base64'] == 'eA=='
+
+
@pytest.mark.asyncio
async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine, ledger_snapshot):
await _register_session(session_registry, available_apis={'run_append_result': True})
diff --git a/tests/unit_tests/agent/test_run_ledger_store.py b/tests/unit_tests/agent/test_run_ledger_store.py
index 22c97baea..2195f86f7 100644
--- a/tests/unit_tests/agent/test_run_ledger_store.py
+++ b/tests/unit_tests/agent/test_run_ledger_store.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import datetime
+import json
import pytest
import sqlalchemy
@@ -36,6 +37,81 @@ def store(db_engine):
return RunLedgerStore(db_engine)
+@pytest.fixture
+async def legacy_run(store, db_engine):
+ await store.create_run(
+ run_id='legacy',
+ event_id='event',
+ binding_id='binding',
+ runner_id='runner',
+ workspace_id='ws',
+ )
+ metadata = {
+ 'input': [
+ {'type': 'Image', 'base64': 'eA=='},
+ {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA==', 'detail': 'low'}},
+ {'type': 'image_url', 'image_url': {'url': 'https://example.com/image.png'}},
+ {'type': 'text', 'text': 'keep this'},
+ ],
+ 'attempt': 0,
+ 'activated_skills': {'existing': 'v1'},
+ }
+ async with db_engine.begin() as conn:
+ # Explicit TEXT binding simulates data persisted before the media contract.
+ await conn.execute(
+ sqlalchemy.update(AgentRun)
+ .where(AgentRun.run_id == 'legacy')
+ .values(
+ metadata_json=sqlalchemy.bindparam('legacy_metadata', json.dumps(metadata), type_=sqlalchemy.Text())
+ )
+ )
+ return metadata
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('operation', ['pin', 'finalize'])
+async def test_metadata_update_redacts_legacy_images(store, legacy_run, operation):
+ if operation == 'pin':
+ assert await store.pin_skill_revision('legacy', 'ws', 'demo', 'v2') == 'v2'
+ else:
+ await store.finalize_run(run_id='legacy', status='completed', metadata={'note': 'done'})
+
+ run = await store.get_run('legacy')
+ expected = {
+ **legacy_run,
+ 'input': [
+ {'type': 'Image', 'base64': None},
+ {'type': 'image_url', 'image_url': {'url': None, 'detail': 'low'}},
+ *legacy_run['input'][2:],
+ ],
+ }
+ if operation == 'pin':
+ expected['activated_skills'] = {'existing': 'v1', 'demo': 'v2'}
+ assert run['status'] == 'running'
+ else:
+ expected['note'] = 'done'
+ assert run['status'] == 'completed'
+ assert run['metadata'] == expected
+
+
+@pytest.mark.asyncio
+async def test_finalize_without_metadata_preserves_legacy_snapshot(store, legacy_run):
+ run = await store.finalize_run(run_id='legacy', status='completed')
+ assert run['status'] == 'completed'
+ assert run['metadata'] == legacy_run
+
+
+@pytest.mark.asyncio
+async def test_metadata_update_still_rejects_new_inline_images(store, legacy_run):
+ with pytest.raises(sqlalchemy.exc.StatementError, match='Inline images cannot be persisted'):
+ await store.finalize_run(
+ run_id='legacy', status='completed', metadata={'new_image': {'type': 'Image', 'base64': 'eQ=='}}
+ )
+ run = await store.get_run('legacy')
+ assert run['status'] == 'running'
+ assert run['metadata'] == legacy_run
+
+
@pytest.mark.asyncio
async def test_create_queued_run_claim_renew_release(store):
run = await store.create_run(
diff --git a/tests/unit_tests/api/service/test_maintenance_service.py b/tests/unit_tests/api/service/test_maintenance_service.py
index c43ecbff5..77d6dbe90 100644
--- a/tests/unit_tests/api/service/test_maintenance_service.py
+++ b/tests/unit_tests/api/service/test_maintenance_service.py
@@ -294,7 +294,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
assert 'database' in result
assert result['cleanup_policy'] == {'uploaded_file_retention_days': 7, 'log_retention_days': 3}
assert {
- section['key']: (section['path'], section['size_bytes'], section['file_count'])
+ section['key']: (Path(section['path']).as_posix(), section['size_bytes'], section['file_count'])
for section in result['sections']
} == {
'database': ('data/langbot.db', 1000, 5),
@@ -426,9 +426,8 @@ class TestMaintenanceServiceGetStorageAnalysis:
result = await service.get_storage_analysis(TEST_CONTEXT)
assert result['total_size_bytes'] == 125
- assert (
- next(section for section in result['sections'] if section['key'] == 'skills')['path']
- == '/core/skills/tenants/test'
+ assert next(section for section in result['sections'] if section['key'] == 'skills')['path'] == str(
+ Path('/core/skills/tenants/test')
)
assert result['processes'][1]['directories'][0]['key'] == 'artifacts'
assert result['processes'][2]['directories'][1]['key'] == 'mcp'
diff --git a/tests/unit_tests/api/service/test_monitoring_executions.py b/tests/unit_tests/api/service/test_monitoring_executions.py
index 63fbd32b5..39587f245 100644
--- a/tests/unit_tests/api/service/test_monitoring_executions.py
+++ b/tests/unit_tests/api/service/test_monitoring_executions.py
@@ -423,6 +423,99 @@ async def test_execution_detail_for_agent_and_pipeline(engine, service):
await service.get_execution_detail(WORKSPACE, 'pipeline', 'missing-message')
+@pytest.mark.asyncio
+async def test_detail_overview_does_not_query_collapsed_sections(engine, service, monkeypatch):
+ await _seed(engine)
+ page = service._execution_page
+ converters = []
+
+ async def capture(workspace, statement, convert, offset, limit):
+ converters.append(convert.__name__)
+ return await page(workspace, statement, convert, offset, limit)
+
+ async def reject_history(*args, **kwargs):
+ pytest.fail('Collapsed conversation history must not be loaded')
+
+ monkeypatch.setattr(service, '_execution_page', capture)
+ monkeypatch.setattr(service, '_conversation_message_page', reject_history)
+ detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user', section='overview')
+
+ assert set(detail['pages']) == {'inputs', 'outputs', 'deliveries', 'errors'}
+ assert detail['pages']['inputs']['items'][0]['id'] == 'msg-user'
+ assert len(converters) == 4
+ assert 'events' not in detail
+
+
+@pytest.mark.asyncio
+async def test_conversation_pages_identities_from_covering_index_before_payloads(engine, service):
+ import sqlalchemy as sa
+
+ await _seed(engine)
+ message = persistence_monitoring.MonitoringMessage
+ payload = 'historical inline media ' * 4096
+ async with engine.begin() as connection:
+ await connection.execute(
+ sa.insert(message),
+ [
+ dict(
+ id=f'history-{index:02d}',
+ workspace_uuid=workspace,
+ bot_id=bot,
+ bot_name='Bot',
+ pipeline_id='pipeline',
+ pipeline_name='Pipeline',
+ session_id='session-1',
+ timestamp=_dt(12),
+ role='user',
+ message_content=payload,
+ variables=payload,
+ status='success',
+ level='info',
+ )
+ for index, (workspace, bot) in enumerate(
+ [(WORKSPACE, 'bot-1')] * 5 + [(WORKSPACE, 'other-bot'), ('other-workspace', 'bot-1')]
+ )
+ ],
+ )
+ statements = []
+
+ def capture(connection, cursor, statement, parameters, context, executemany):
+ if statement.startswith('SELECT'):
+ statements.append((statement, parameters))
+
+ sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture)
+ try:
+ first = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user', section='conversation', limit=2)
+ second = await service.get_execution_detail(
+ WORKSPACE, 'pipeline', 'msg-user', section='conversation', limit=2, offset=2
+ )
+ finally:
+ sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture)
+
+ page = first['pages']['conversation']
+ assert [row['id'] for row in page['items']] == ['history-04', 'history-03']
+ assert page['has_more'] and page['next_offset'] == 2
+ assert all(row['content'] == payload for row in page['items'])
+ assert [row['id'] for row in second['pages']['conversation']['items']] == ['history-02', 'history-01']
+ identities = [(sql, params) for sql, params in statements if sql.startswith('SELECT monitoring_messages.id \n')]
+ assert len(identities) == 2
+ async with engine.connect() as connection:
+ for sql, params in identities:
+ assert 'message_content' not in sql and 'variables' not in sql
+ plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + sql, params)).all()
+ assert any('COVERING INDEX ix_monitoring_messages_conversation_page' in row[3] for row in plan), plan
+ assert not any('TEMP B-TREE' in row[3] for row in plan), plan
+ payload_queries = [(sql, params) for sql, params in statements if 'JOIN monitoring_messages ON' in sql]
+ assert len(payload_queries) == 2
+ assert {value for value in payload_queries[0][1] if str(value).startswith('history-')} == {
+ 'history-04',
+ 'history-03',
+ }
+ for sql, params in payload_queries:
+ plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + sql, params)).all()
+ assert any('sqlite_autoindex_monitoring_messages_1 (id=?)' in row[3] for row in plan), plan
+
+
@pytest.mark.asyncio
async def test_explicit_deliveries_context_and_tenant_isolation(engine, service):
import sqlalchemy as sa
@@ -739,6 +832,217 @@ async def test_pipeline_debug_is_consistent_across_rows_rollups_and_telemetry(en
assert sum(p['llm_calls'] for p in traffic['points']) == 1
+@pytest.mark.asyncio
+async def test_inflight_excludes_unowned_and_expired_messages_without_rewriting_history(engine, service):
+ import sqlalchemy as sa
+
+ now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
+ Message = persistence_monitoring.MonitoringMessage
+ async with AsyncSession(engine) as session:
+ for message_id, owner, expiry, timestamp in [
+ ('legacy', None, None, _dt(1)),
+ ('recent-unowned', None, now + datetime.timedelta(minutes=1), now),
+ ('expired', 'stopped-host', now - datetime.timedelta(seconds=1), _dt(1)),
+ ('long-running', 'live-host', now + datetime.timedelta(minutes=1), _dt(1)),
+ ]:
+ session.add(
+ Message(
+ id=message_id,
+ workspace_uuid=WORKSPACE,
+ timestamp=timestamp,
+ bot_id='bot',
+ bot_name='Bot',
+ pipeline_id='pipeline',
+ pipeline_name='Pipeline',
+ session_id='session',
+ message_content='historical message',
+ status='pending',
+ level='info',
+ execution_owner_id=owner,
+ execution_lease_expires_at=expiry,
+ )
+ )
+ session.add(
+ persistence_agent_run.AgentRun(
+ run_id='remote-queue',
+ workspace_id=WORKSPACE,
+ runner_id='runner',
+ status='queued',
+ queue_name='remote',
+ created_at=_dt(1),
+ )
+ )
+ await session.commit()
+ # A previous connection must not reintroduce unowned pending rows either.
+ snapshot = await service.get_inflight_snapshot(WORKSPACE, [{'source': 'pipeline', 'id': 'legacy'}])
+ assert snapshot['active_total'] == 2
+ assert {row['id'] for row in snapshot['items']} == {'long-running', 'remote-queue'}
+ async with AsyncSession(engine) as session:
+ statuses = (await session.execute(sa.select(Message.status))).scalars().all()
+ assert statuses == ['pending'] * 4
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('source', ['all', 'pipeline'])
+@pytest.mark.parametrize('statuses', [['running'], ['running', 'queued']])
+async def test_active_inflight_count_and_page_use_the_indexed_lease_range(engine, service, source, statuses):
+ import sqlalchemy as sa
+
+ await _seed(engine)
+ async with engine.begin() as connection:
+ await connection.execute(
+ sa.update(persistence_monitoring.MonitoringMessage)
+ .where(persistence_monitoring.MonitoringMessage.id == 'msg-user')
+ .values(status='pending', message_content='historical payload ' * 8192)
+ )
+ statements = []
+
+ def capture(connection, cursor, statement, parameters, context, executemany):
+ if statement.startswith('SELECT '):
+ statements.append((statement, parameters))
+
+ sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture)
+ try:
+ result = await service.get_executions(
+ WORKSPACE, statuses=statuses, source=source, include_summary=False, for_inflight=True
+ )
+ finally:
+ sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture)
+
+ assert result == {'items': [], 'total': 0, 'has_more': False}
+ assert len(statements) == 2 # Both COUNT and the sorted page must skip historical payloads.
+ async with engine.connect() as connection:
+ for statement, parameters in statements:
+ plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters)).all()
+ assert any(
+ 'ix_monitoring_messages_workspace_status_lease' in row[3] and 'execution_lease_expires_at>?' in row[3]
+ for row in plan
+ ), plan
+
+
+@pytest.mark.asyncio
+async def test_empty_inflight_snapshot_skips_linked_history_and_clears_tracking(engine, service, monkeypatch):
+ import sqlalchemy as sa
+ from langbot.pkg.utils.inflight import inflight_hub
+
+ await _seed(engine)
+ monkeypatch.setattr(inflight_hub, 'run_workspaces', {'finished-run': WORKSPACE, 'other-run': 'other-workspace'})
+ statements = []
+
+ def capture(connection, cursor, statement, parameters, context, executemany):
+ statements.append(statement)
+
+ sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture)
+ try:
+ snapshot = await service.get_inflight_snapshot(WORKSPACE)
+ finally:
+ sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture)
+
+ assert snapshot == {'items': [], 'active_total': 0, 'truncated': False}
+ assert inflight_hub.run_workspaces == {'other-run': 'other-workspace'}
+ assert not any('JOIN monitoring_messages ON' in query or 'FROM agent_run_event' in query for query in statements)
+
+
+@pytest.mark.asyncio
+async def test_inflight_never_loads_payloads_in_active_recent_or_terminal_rows(engine, service, monkeypatch):
+ import json
+ import sqlalchemy as sa
+ from langbot.pkg.entity.persistence.event_log import EventLog
+
+ now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
+ payload = json.dumps({'text': 'full input', 'attachment': 'x' * (2 * 1024 * 1024)})
+ async with AsyncSession(engine) as session:
+ for message_id, status, timestamp in [
+ ('active-message', 'pending', _dt(1)),
+ ('recent-message', 'success', now),
+ ('finished-message', 'success', _dt(1)),
+ ]:
+ session.add(
+ persistence_monitoring.MonitoringMessage(
+ id=message_id,
+ workspace_uuid=WORKSPACE,
+ timestamp=timestamp,
+ bot_id='bot',
+ bot_name='Bot',
+ pipeline_id='pipeline',
+ pipeline_name='Pipeline',
+ session_id='session',
+ message_content=payload,
+ variables=payload,
+ status=status,
+ level='info',
+ event_id=message_id,
+ run_id='linked-run' if status == 'pending' else None,
+ execution_owner_id='host' if status == 'pending' else None,
+ execution_lease_expires_at=now + datetime.timedelta(minutes=1) if status == 'pending' else None,
+ )
+ )
+ session.add(
+ EventLog(
+ event_id=message_id,
+ workspace_id=WORKSPACE,
+ event_type='message.received',
+ source='platform',
+ input_summary='small summary',
+ input_json=payload,
+ metadata_json=json.dumps({'platform': 'test', 'unused_payload': payload}),
+ )
+ )
+ for run_id in ['linked-run', 'standalone-run']:
+ session.add(
+ persistence_agent_run.AgentRun(
+ run_id=run_id,
+ workspace_id=WORKSPACE,
+ runner_id='runner',
+ status='running',
+ created_at=_dt(1),
+ authorization_json=payload,
+ metadata_json=json.dumps(
+ {'event_type': 'message.received', 'agent_name': 'Agent', 'input': {'text': payload}}
+ ),
+ )
+ )
+ await session.commit()
+
+ linkage_queries = []
+
+ def reject_payload_reads(connection, cursor, statement, parameters, context, executemany):
+ for column in [
+ 'monitoring_messages.message_content',
+ 'monitoring_messages.variables',
+ 'agent_run.authorization_json',
+ 'event_log.input_json',
+ ]:
+ assert column not in statement
+ if statement.startswith('SELECT agent_run.run_id,') and 'monitoring_messages.id AS message_id' in statement:
+ linkage_queries.append((statement, parameters))
+
+ deserialize = service._deserialize_json
+
+ def bounded_metadata(raw):
+ assert not raw or len(raw) < 10000
+ return deserialize(raw)
+
+ monkeypatch.setattr(service, '_deserialize_json', bounded_metadata)
+ sa.event.listen(engine.sync_engine, 'before_cursor_execute', reject_payload_reads)
+ try:
+ snapshot = await service.get_inflight_snapshot(WORKSPACE, [{'source': 'pipeline', 'id': 'finished-message'}])
+ rows = {row['id']: row for row in snapshot['items']}
+ assert rows['active-message']['input_preview'] == 'small summary'
+ assert rows['recent-message']['status_group'] == 'completed'
+ assert rows['finished-message']['status_group'] == 'completed'
+ assert rows['standalone-run']['target_name'] == 'Agent'
+ assert snapshot['active_total'] == 2
+ finally:
+ sa.event.remove(engine.sync_engine, 'before_cursor_execute', reject_payload_reads)
+ assert linkage_queries
+ async with engine.connect() as connection:
+ for statement, parameters in linkage_queries:
+ plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters)).all()
+ message_lookups = [row[3] for row in plan if 'SEARCH monitoring_messages' in row[3]]
+ assert message_lookups and all('(id=?)' in lookup for lookup in message_lookups), plan
+
+
@pytest.mark.asyncio
async def test_inflight_snapshot_is_scoped_and_keeps_terminal_transition(engine, service):
import sqlalchemy as sa
diff --git a/tests/unit_tests/api/service/test_monitoring_sessions.py b/tests/unit_tests/api/service/test_monitoring_sessions.py
index 891171ba3..5750e466e 100644
--- a/tests/unit_tests/api/service/test_monitoring_sessions.py
+++ b/tests/unit_tests/api/service/test_monitoring_sessions.py
@@ -245,3 +245,49 @@ async def test_old_active_sessions_are_listed_exported_and_not_cleaned(service):
assert count == 1
rows, total = await service.get_sessions(context())
assert total == 1 and rows[0]['bot_id'] == 'b'
+
+
+async def test_bot_session_summary_never_sorts_inline_message_payloads(service):
+ engine = service.ap.persistence_mgr.get_db_engine()
+ payload = 'historical inline media ' * 16384
+ now = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None)
+ async with engine.begin() as connection:
+ await connection.execute(
+ sa.insert(models.MonitoringMessage),
+ [
+ dict(
+ id=f'message-{index}',
+ workspace_uuid='workspace',
+ session_id='group_42',
+ **resource('a'),
+ timestamp=now + dt.timedelta(seconds=index),
+ role='user' if index == 0 else 'assistant',
+ message_content=payload,
+ variables=payload,
+ status='success',
+ level='info',
+ user_id='user',
+ user_name='User',
+ platform='test',
+ )
+ for index in range(2)
+ ],
+ )
+
+ def reject_payload_reads(connection, cursor, statement, parameters, context, executemany):
+ assert 'monitoring_messages.message_content' not in statement
+ assert 'monitoring_messages.variables' not in statement
+
+ sa.event.listen(engine.sync_engine, 'before_cursor_execute', reject_payload_reads)
+ try:
+ rows, total = await service.get_sessions(context('a'), bot_ids=['a'])
+ finally:
+ sa.event.remove(engine.sync_engine, 'before_cursor_execute', reject_payload_reads)
+
+ assert total == 1
+ assert rows[0]['session_id'] == 'group_42'
+ assert rows[0]['message_count'] == 2
+ assert rows[0]['user_name'] == 'User'
+ messages, total = await service.get_messages(context('a'), bot_ids=['a'], session_ids=['group_42'])
+ assert total == 2
+ assert all(message['message_content'] == payload and message['variables'] == payload for message in messages)
diff --git a/tests/unit_tests/persistence/test_conversation_page_index.py b/tests/unit_tests/persistence/test_conversation_page_index.py
new file mode 100644
index 000000000..5a2b46dc8
--- /dev/null
+++ b/tests/unit_tests/persistence/test_conversation_page_index.py
@@ -0,0 +1,52 @@
+"""Conversation paging index is safe for existing and fresh databases."""
+
+import importlib
+
+import sqlalchemy as sa
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+
+from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
+
+
+def test_conversation_index_migration_preserves_history_and_matches_metadata():
+ migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0039_execution_lease_indexes')
+ index_name = 'ix_monitoring_messages_conversation_page'
+ engine = sa.create_engine('sqlite:///:memory:')
+ metadata = sa.MetaData()
+ table = sa.Table(
+ 'monitoring_messages',
+ metadata,
+ *[
+ sa.Column(name, sa.String, primary_key=name == 'id')
+ for name in (
+ 'id',
+ 'workspace_uuid',
+ 'bot_id',
+ 'session_id',
+ 'timestamp',
+ 'message_content',
+ 'status',
+ 'execution_lease_expires_at',
+ )
+ ],
+ )
+ sa.Index('existing_index', table.c.workspace_uuid)
+ try:
+ with engine.begin() as connection:
+ metadata.create_all(connection)
+ connection.execute(table.insert().values(id='history', message_content='inline media'))
+ with Operations.context(MigrationContext.configure(connection)):
+ migration.upgrade()
+ migration.upgrade()
+ indexes = {
+ index['name']: index['column_names'] for index in sa.inspect(connection).get_indexes(table.name)
+ }
+ expected = next(index for index in MonitoringMessage.__table__.indexes if index.name == index_name)
+ assert indexes[index_name] == [column.name for column in expected.columns]
+ migration.downgrade()
+ assert {index['name'] for index in sa.inspect(connection).get_indexes(table.name)} == {'existing_index'}
+ assert connection.scalar(sa.select(table.c.message_content)) == 'inline media'
+ migration.upgrade()
+ finally:
+ engine.dispose()
diff --git a/tests/unit_tests/persistence/test_execution_lease_indexes.py b/tests/unit_tests/persistence/test_execution_lease_indexes.py
new file mode 100644
index 000000000..d09e1dbfd
--- /dev/null
+++ b/tests/unit_tests/persistence/test_execution_lease_indexes.py
@@ -0,0 +1,136 @@
+"""Lease maintenance must not scan a Workspace's completed execution history."""
+
+import datetime
+import importlib
+
+import pytest
+import sqlalchemy as sa
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy.ext.asyncio import create_async_engine
+
+# Register users and workspaces so create_all can resolve the foreign keys.
+from langbot.pkg.entity.persistence import user, workspace # noqa: F401
+from langbot.pkg.entity.persistence.agent_run import AgentRun
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
+from langbot.pkg.utils.execution_lease import reconcile_execution_leases
+
+
+MIGRATION = importlib.import_module('langbot.pkg.persistence.alembic.versions.0039_execution_lease_indexes')
+LEASE_INDEXES = {
+ 'agent_run': ('ix_agent_run_workspace_status_lease', ['workspace_id', 'status', 'execution_lease_expires_at']),
+ 'monitoring_messages': (
+ 'ix_monitoring_messages_workspace_status_lease',
+ ['workspace_uuid', 'status', 'execution_lease_expires_at'],
+ ),
+}
+
+
+def _create_schema(connection, source):
+ Base.metadata.create_all(connection)
+ if source == 'migration':
+ for table, (index, _) in LEASE_INDEXES.items():
+ next(item for item in Base.metadata.tables[table].indexes if item.name == index).drop(connection)
+ with Operations.context(MigrationContext.configure(connection)):
+ MIGRATION.upgrade()
+
+
+@pytest.mark.parametrize('source', ['metadata', 'migration'])
+def test_lease_index_migration_preserves_data_and_existing_indexes(source):
+ engine = sa.create_engine('sqlite:///:memory:')
+ try:
+ with engine.begin() as connection:
+ _create_schema(connection, source)
+ connection.execute(sa.insert(AgentRun).values(run_id='history', runner_id='runner', status='completed'))
+ original_indexes = {
+ table: {index['name'] for index in sa.inspect(connection).get_indexes(table)} for table in LEASE_INDEXES
+ }
+ with Operations.context(MigrationContext.configure(connection)):
+ MIGRATION.upgrade()
+ for table, (index, columns) in LEASE_INDEXES.items():
+ indexes = {item['name']: item['column_names'] for item in sa.inspect(connection).get_indexes(table)}
+ assert indexes[index] == columns
+ assert original_indexes[table] <= indexes.keys()
+
+ MIGRATION.downgrade()
+ for table in LEASE_INDEXES:
+ remaining = {item['name'] for item in sa.inspect(connection).get_indexes(table)}
+ removed = {index for index_table, index, _ in MIGRATION.INDEXES if index_table == table}
+ assert remaining == original_indexes[table] - removed
+ assert connection.scalar(sa.select(AgentRun.status)) == 'completed'
+
+ MIGRATION.upgrade()
+ assert connection.scalar(sa.select(AgentRun.status)) == 'completed'
+ finally:
+ engine.dispose()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('source', ['metadata', 'migration'])
+@pytest.mark.parametrize('model', [AgentRun, MonitoringMessage])
+async def test_lease_queries_use_workspace_status_expiry_index(model, source):
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ now = datetime.datetime(2026, 10, 17)
+ statements = []
+
+ def capture_statement(connection, cursor, statement, parameters, context, executemany):
+ if statement.startswith(('SELECT ', 'UPDATE ')) and model.__tablename__ in statement:
+ statements.append((statement, parameters))
+
+ try:
+ async with engine.begin() as connection:
+ await connection.run_sync(lambda sync: _create_schema(sync, source))
+ common = {
+ 'execution_owner_id': 'live-host',
+ 'execution_lease_expires_at': now - datetime.timedelta(seconds=1),
+ }
+ if model is AgentRun:
+ rows = [
+ dict(
+ common,
+ run_id=str(i),
+ runner_id='runner',
+ workspace_id='workspace',
+ status='completed',
+ created_at=now,
+ updated_at=now,
+ )
+ for i in range(2000)
+ ]
+ else:
+ rows = [
+ dict(
+ common,
+ id=str(i),
+ workspace_uuid='workspace',
+ timestamp=now,
+ bot_id='bot',
+ bot_name='bot',
+ pipeline_id='pipeline',
+ pipeline_name='pipeline',
+ message_content='Historical message ' * 64,
+ session_id='session',
+ status='success',
+ level='info',
+ )
+ for i in range(2000)
+ ]
+ await connection.execute(sa.insert(model), rows)
+
+ sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture_statement)
+ try:
+ assert await reconcile_execution_leases(engine, 'workspace', now=now, owner_id='live-host') == 0
+ finally:
+ sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture_statement)
+
+ assert len(statements) == 2
+ async with engine.connect() as connection:
+ for statement, parameters in statements:
+ plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters)).all()
+ details = ' '.join(row[3] for row in plan)
+ assert LEASE_INDEXES[model.__tablename__][0] in details, details
+ assert 'execution_lease_expires_at' in details, details
+ assert 'USE TEMP B-TREE' not in details, details
+ finally:
+ await engine.dispose()
diff --git a/tests/unit_tests/persistence/test_media_reference_text.py b/tests/unit_tests/persistence/test_media_reference_text.py
new file mode 100644
index 000000000..9d2d7c899
--- /dev/null
+++ b/tests/unit_tests/persistence/test_media_reference_text.py
@@ -0,0 +1,139 @@
+"""Image reference contracts apply to ORM and Core writes, including new callers."""
+
+import json
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql, sqlite
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy.orm import DeclarativeBase
+
+from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent
+from langbot.pkg.entity.persistence.event_log import EventLog
+from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
+from langbot.pkg.entity.persistence.transcript import Transcript
+from langbot.pkg.persistence.media import MediaReferenceText
+from langbot.pkg.persistence.tenant_uow import ScopedSessionTransactionError, _validate_scoped_statement_call
+
+
+class PayloadBase(DeclarativeBase):
+ pass
+
+
+class Payload(PayloadBase):
+ __tablename__ = 'payload'
+ id = sa.Column(sa.Integer, primary_key=True)
+ content = sa.Column(MediaReferenceText)
+
+
+@pytest.fixture
+async def engine():
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ async with engine.begin() as conn:
+ await conn.run_sync(PayloadBase.metadata.create_all)
+ yield engine
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('writer', ['orm', 'insert', 'update'])
+@pytest.mark.parametrize(
+ 'image',
+ [
+ {'type': 'Image', 'base64': 'eA=='},
+ {'type': 'Image', 'url': 'data:image/png;base64,eA=='},
+ {'type': 'image_base64', 'image_base64': 'eA=='},
+ {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}},
+ {'type': 'image', 'image_url': 'data:image/png;base64,eA=='},
+ ],
+)
+async def test_unprocessed_images_are_rejected_at_the_bind_boundary(engine, writer, image):
+ content = json.dumps({'nested': [image]})
+ async with engine.begin() as conn:
+ await conn.execute(sa.insert(Payload).values(id=1, content='original'))
+ with pytest.raises(sa.exc.StatementError, match='use MediaCache.externalize_chain_dump'):
+ if writer == 'orm':
+ async with AsyncSession(engine) as session:
+ session.add(Payload(id=2, content=content))
+ await session.commit()
+ else:
+ statement = (
+ sa.insert(Payload).values(id=2, content=content)
+ if writer == 'insert'
+ else sa.update(Payload).where(Payload.id == 1).values(content=content)
+ )
+ async with engine.begin() as conn:
+ await conn.execute(statement)
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(Payload.content))).scalars().all() == ['original']
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ 'content',
+ [
+ None,
+ 'plain text',
+ '{historical invalid JSON',
+ json.dumps({'type': 'Plain', 'text': 'base64 is a transport encoding'}),
+ json.dumps({'type': 'Image', 'url': 'https://example.com/image.png'}),
+ json.dumps({'type': 'Image', 'url': '/api/v1/files/media/hash.png', 'base64': None}),
+ ],
+)
+async def test_plain_text_and_references_round_trip_unchanged(engine, content):
+ async with AsyncSession(engine) as session:
+ session.add(Payload(id=1, content=content))
+ await session.commit()
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(Payload.content))).scalar_one() == content
+
+
+@pytest.mark.asyncio
+async def test_legacy_images_can_be_read_compared_and_redacted(engine):
+ content = json.dumps({'type': 'Image', 'base64': 'eA=='})
+ async with engine.begin() as conn:
+ # Simulate a row created before the contract was introduced.
+ await conn.execute(sa.text('INSERT INTO payload (id, content) VALUES (1, :content)'), {'content': content})
+ assert (
+ await conn.execute(sa.select(Payload.content).where(Payload.content == content))
+ ).scalar_one() == content
+ assert (await conn.execute(sa.select(Payload.id).where(Payload.content.like('%base64%')))).scalar_one() == 1
+ updated = await conn.execute(
+ sa.update(Payload).where(Payload.content == content).values(content='{"type": "Image", "base64": null}')
+ )
+ assert updated.rowcount == 1
+
+
+@pytest.mark.parametrize('dialect', [sqlite.dialect(), postgresql.dialect()], ids=['sqlite', 'postgresql'])
+def test_contract_keeps_text_schema_and_handles_escaped_json_keys(dialect):
+ column_type = MediaReferenceText()
+ assert column_type.compile(dialect=dialect) == 'TEXT'
+ process = column_type.dialect_impl(dialect).bind_processor(dialect)
+ with pytest.raises(ValueError, match='Inline images cannot be persisted'):
+ process(r'{"type": "Image", "base\u00364": "eA=="}')
+
+
+@pytest.mark.parametrize(
+ 'model, fields',
+ [
+ (MonitoringMessage, ['message_content', 'variables']),
+ (EventLog, ['input_json', 'metadata_json']),
+ (AgentRun, ['metadata_json']),
+ (AgentRunEvent, ['data_json', 'metadata_json']),
+ (Transcript, ['content_json', 'attachment_refs_json', 'metadata_json']),
+ ],
+)
+def test_conversation_payload_columns_declare_the_contract(model, fields):
+ for field in fields:
+ assert isinstance(model.__table__.c[field].type, MediaReferenceText), f'{model.__tablename__}.{field}'
+
+
+def test_tenant_queries_admit_only_the_exact_trusted_media_type():
+ _validate_scoped_statement_call((sa.select(Payload.content),), {})
+
+ class UntrustedMediaReferenceText(MediaReferenceText):
+ pass
+
+ for column_type in (UntrustedMediaReferenceText(), MediaReferenceText(collation=sa.sql.quoted_name('x', False))):
+ with pytest.raises(ScopedSessionTransactionError):
+ _validate_scoped_statement_call((sa.select(sa.column('content', column_type)),), {})
diff --git a/tests/unit_tests/pipeline/test_resprule.py b/tests/unit_tests/pipeline/test_resprule.py
index 63dfbfd0d..4032ad6ff 100644
--- a/tests/unit_tests/pipeline/test_resprule.py
+++ b/tests/unit_tests/pipeline/test_resprule.py
@@ -138,3 +138,147 @@ async def test_atbot_rule_no_match(mock_app, sample_query):
result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query)
assert result.matching is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('routed', [False, True])
+@pytest.mark.parametrize('kind', ['plain', 'mention', 'prefix'])
+async def test_routed_group_messages_still_obey_response_rules(mock_app, sample_query, routed, kind):
+ resprule, entities, _, _ = get_modules()
+ sample_query.launcher_type = provider_session.LauncherTypes.GROUP
+ sample_query.adapter.bot_account_id = '999'
+ sample_query.variables = {'_routed_by_rule': routed}
+ sample_query.pipeline_config = {
+ 'trigger': {'group-respond-rules': {'at': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}}
+ }
+ parts = [platform_message.Plain(text='!bot hello' if kind == 'prefix' else 'hello')]
+ if kind == 'mention':
+ parts.insert(0, platform_message.At(target='999'))
+ sample_query.message_chain = platform_message.MessageChain(parts)
+ stage = resprule.GroupRespondRuleCheckStage(mock_app)
+ await stage.initialize(sample_query.pipeline_config)
+
+ result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')
+
+ expected = entities.ResultType.INTERRUPT if kind == 'plain' else entities.ResultType.CONTINUE
+ assert result.result_type == expected
+ if kind != 'plain':
+ assert str(sample_query.message_chain) == 'hello'
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('routed', [False, True])
+@pytest.mark.parametrize(
+ 'overrides,text,mention,draw,matches,expected_text',
+ [
+ pytest.param({'regexp': ['^hello$']}, 'hello', False, 0.9, True, 'hello', id='regexp-match'),
+ pytest.param({'regexp': ['^hello$']}, 'bye', False, 0.9, False, 'bye', id='regexp-miss'),
+ pytest.param({'regexp': ['^bye$', '^hello$']}, 'hello', False, 0.9, True, 'hello', id='second-regexp'),
+ pytest.param({'regexp': ['[']}, 'hello', False, 0.9, False, 'hello', id='invalid-regexp'),
+ pytest.param({'random': 0}, 'hello', False, 0.0, False, 'hello', id='random-disabled'),
+ pytest.param({'random': 1}, 'hello', False, 0.999, True, 'hello', id='random-always'),
+ pytest.param({'random': 0.25}, 'hello', False, 0.249, True, 'hello', id='random-hit'),
+ pytest.param({'random': 0.25}, 'hello', False, 0.25, False, 'hello', id='random-boundary'),
+ pytest.param({}, 'hello', True, 0.0, False, 'hello', id='mention-disabled'),
+ pytest.param({}, '!bot hello', False, 0.0, False, '!bot hello', id='prefix-disabled'),
+ pytest.param({'prefix': ['!other ', '!bot ']}, '!bot hello', False, 0.9, True, 'hello', id='second-prefix'),
+ pytest.param(
+ {'at': True, 'prefix': ['!bot '], 'regexp': ['^hello$'], 'random': 0.25},
+ 'hello',
+ False,
+ 0.9,
+ True,
+ 'hello',
+ id='combined-regexp-match',
+ ),
+ pytest.param(
+ {'at': True, 'prefix': ['!bot '], 'regexp': ['^bye$'], 'random': 0.25},
+ 'hello',
+ False,
+ 0.1,
+ True,
+ 'hello',
+ id='combined-random-fallback',
+ ),
+ pytest.param(
+ {'at': True, 'prefix': ['!bot '], 'regexp': ['^bye$'], 'random': 0.25},
+ 'hello',
+ False,
+ 0.9,
+ False,
+ 'hello',
+ id='combined-all-miss',
+ ),
+ ],
+)
+async def test_group_response_rule_configuration(
+ mock_app, sample_query, monkeypatch, routed, overrides, text, mention, draw, matches, expected_text
+):
+ resprule, entities, _, _ = get_modules()
+ random_rule = import_module('langbot.pkg.pipeline.resprule.rules.random')
+ monkeypatch.setattr(random_rule.random, 'random', lambda: draw)
+ sample_query.launcher_type = provider_session.LauncherTypes.GROUP
+ sample_query.adapter.bot_account_id = '999'
+ sample_query.variables = {'_routed_by_rule': routed}
+ sample_query.pipeline_config = {
+ 'trigger': {'group-respond-rules': {'at': False, 'prefix': [], 'regexp': [], 'random': 0, **overrides}}
+ }
+ parts = [platform_message.Plain(text=text)]
+ if mention:
+ parts.insert(0, platform_message.At(target='999'))
+ sample_query.message_chain = platform_message.MessageChain(parts)
+ stage = resprule.GroupRespondRuleCheckStage(mock_app)
+ await stage.initialize(sample_query.pipeline_config)
+
+ result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')
+
+ assert result.result_type == (entities.ResultType.CONTINUE if matches else entities.ResultType.INTERRUPT)
+ assert str(result.new_query.message_chain) == expected_text
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('callback_key', ['_interaction_submission', '_dify_form_action'])
+async def test_interaction_callbacks_do_not_require_another_mention(mock_app, sample_query, callback_key):
+ resprule, entities, _, _ = get_modules()
+ sample_query.launcher_type = provider_session.LauncherTypes.GROUP
+ sample_query.adapter.bot_account_id = '999'
+ submission = {'action_id': 'approve'}
+ sample_query.variables = {'_routed_by_rule': True, callback_key: submission}
+ sample_query.message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Action: Approve]')])
+ sample_query.pipeline_config = {
+ 'trigger': {'group-respond-rules': {'at': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}}
+ }
+ stage = resprule.GroupRespondRuleCheckStage(mock_app)
+ await stage.initialize(sample_query.pipeline_config)
+
+ result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')
+
+ assert result.result_type == entities.ResultType.CONTINUE
+ assert str(result.new_query.message_chain) == '[Form Action: Approve]'
+ assert result.new_query.variables[callback_key] == submission
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ 'variables',
+ [
+ {'_routed_by_rule': True, '_dify_form_action': None},
+ {'_routed_by_rule': True, '_dify_form_action': {}},
+ {'_routed_by_rule': False, '_dify_form_action': {'action_id': 'approve'}},
+ ],
+)
+async def test_incomplete_form_callback_does_not_bypass_response_rules(mock_app, sample_query, variables):
+ resprule, entities, _, _ = get_modules()
+ sample_query.launcher_type = provider_session.LauncherTypes.GROUP
+ sample_query.adapter.bot_account_id = '999'
+ sample_query.variables = variables
+ sample_query.message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Action: Approve]')])
+ sample_query.pipeline_config = {
+ 'trigger': {'group-respond-rules': {'at': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}}
+ }
+ stage = resprule.GroupRespondRuleCheckStage(mock_app)
+ await stage.initialize(sample_query.pipeline_config)
+
+ result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')
+
+ assert result.result_type == entities.ResultType.INTERRUPT
diff --git a/tests/unit_tests/provider/test_provider_specific_fields.py b/tests/unit_tests/provider/test_provider_specific_fields.py
index fe239f3ea..803754066 100644
--- a/tests/unit_tests/provider/test_provider_specific_fields.py
+++ b/tests/unit_tests/provider/test_provider_specific_fields.py
@@ -4,15 +4,17 @@ This includes provider_specific_fields round-trip coverage for GitHub issue
#1899 and token counting preflight behavior for Runner context budgeting.
"""
+import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
+import httpx
import pytest
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot.pkg.provider.modelmgr import requester as model_requester
-from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
+from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester, _LeasedHTTPHandler
def _make_requester() -> LiteLLMRequester:
@@ -148,6 +150,132 @@ def test_convert_messages_preserves_message_provider_specific_fields():
assert out[0]['provider_specific_fields']['thought_signatures'] == ['sig1', 'sig2']
+@pytest.mark.parametrize('family', ['', 'openai', 'gemini', 'groq'])
+def test_response_finish_reason_is_not_replayed(family):
+ req = _make_requester()
+ msg = provider_message.Message(
+ role='assistant', content='Hello', provider_specific_fields={'finish_reason': 'stop'}
+ )
+
+ assert req._convert_messages([msg], reasoning_family=family) == [{'role': 'assistant', 'content': 'Hello'}]
+ assert msg.provider_specific_fields == {'finish_reason': 'stop'}
+
+
+def test_response_metadata_removal_preserves_gemini_signatures():
+ req = _make_requester()
+ msg = provider_message.Message(
+ role='assistant',
+ content='Hello',
+ provider_specific_fields={'finish_reason': 'stop', 'thought_signatures': ['signature']},
+ )
+
+ out = req._convert_messages([msg], reasoning_family='gemini')
+
+ assert out[0]['provider_specific_fields'] == {'thought_signatures': ['signature']}
+
+
+@pytest.fixture
+def replayed_tool_message():
+ return provider_message.Message(
+ role='assistant',
+ content='Hello',
+ provider_specific_fields={'finish_reason': 'tool_calls', 'other_response_metadata': True},
+ tool_calls=[
+ provider_message.ToolCall(
+ id='call-1',
+ type='function',
+ function=provider_message.FunctionCall(name='search', arguments='{}'),
+ provider_specific_fields={'thought_signature': 'signature'},
+ )
+ ],
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('stream', [False, True])
+@pytest.mark.parametrize('chat_provider,family', [('groq', 'openai'), ('gemini', 'groq'), ('openrouter', 'groq')])
+async def test_completion_metadata_adaptation_uses_chat_provider(stream, chat_provider, family, replayed_tool_message):
+ req = _make_configured_requester()
+ req.requester_cfg['custom_llm_provider'] = chat_provider
+ model = _make_runtime_model()
+ model.model_entity.name = 'openai/gpt-oss-120b'
+ msg = replayed_tool_message
+ original = msg.model_dump()
+
+ with patch.object(req, '_reasoning_family', return_value=family):
+ args = await req._build_completion_args(model, [msg], None, {}, stream=stream)
+
+ assert args['model'] == f'{chat_provider}/openai/gpt-oss-120b'
+ expected = msg.model_dump(exclude_none=True)
+ expected['provider_specific_fields'].pop('finish_reason')
+ if chat_provider == 'groq':
+ expected.pop('provider_specific_fields')
+ expected['tool_calls'][0].pop('provider_specific_fields')
+ assert args['messages'] == [expected]
+ assert msg.model_dump() == original
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('stream', [False, True])
+async def test_groq_http_request_excludes_replayed_metadata(stream, replayed_tool_message):
+ req = _make_configured_requester()
+ req.requester_cfg.update(custom_llm_provider='groq', base_url='https://groq.test/openai/v1')
+ model = _make_runtime_model()
+ model.model_entity.name = 'openai/gpt-oss-120b'
+ msg = replayed_tool_message
+ original = msg.model_dump()
+ bodies = []
+
+ def handle(request):
+ assert request.url == 'https://groq.test/openai/v1/chat/completions'
+ bodies.append(json.loads(request.content))
+ response = {
+ 'id': 'completion-1',
+ 'object': 'chat.completion',
+ 'created': 1,
+ 'model': 'openai/gpt-oss-120b',
+ 'service_tier': 'on_demand',
+ 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'Done'}, 'finish_reason': 'stop'}],
+ 'usage': {'prompt_tokens': 1, 'completion_tokens': 1, 'total_tokens': 2},
+ }
+ if stream:
+ response['object'] = 'chat.completion.chunk'
+ response['choices'][0]['delta'] = response['choices'][0].pop('message')
+ return httpx.Response(
+ 200,
+ headers={'content-type': 'text/event-stream'},
+ content=f'data: {json.dumps(response)}\n\ndata: [DONE]\n\n',
+ )
+ return httpx.Response(200, json=response)
+
+ messages = [
+ provider_message.Message(role='user', content='Search'),
+ msg,
+ provider_message.Message(role='tool', tool_call_id='call-1', content='Found'),
+ ]
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
+ extra_args = {'client': _LeasedHTTPHandler(client, 10)}
+ if stream:
+ chunks = [chunk async for chunk in req.invoke_llm_stream(None, model, messages, extra_args=extra_args)]
+ assert ''.join(chunk.content or '' for chunk in chunks) == 'Done'
+ else:
+ result, _ = await req.invoke_llm(None, model, messages, extra_args=extra_args)
+ assert result.content == 'Done'
+
+ assert len(bodies) == 1
+ assert bodies[0]['model'] == 'openai/gpt-oss-120b'
+ assert bodies[0]['messages'] == [
+ {'role': 'user', 'content': 'Search'},
+ {
+ 'role': 'assistant',
+ 'content': 'Hello',
+ 'tool_calls': [{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}],
+ },
+ {'role': 'tool', 'tool_call_id': 'call-1', 'content': 'Found'},
+ ]
+ assert msg.model_dump() == original
+
+
def test_normalize_stream_tool_calls_preserves_provider_specific_fields():
"""Streaming tool calls should retain provider_specific_fields."""
req = _make_requester()
diff --git a/tests/unit_tests/storage/test_inline_media_cleanup.py b/tests/unit_tests/storage/test_inline_media_cleanup.py
new file mode 100644
index 000000000..e3aa1bafe
--- /dev/null
+++ b/tests/unit_tests/storage/test_inline_media_cleanup.py
@@ -0,0 +1,356 @@
+"""Historical image TTL preserves records and makes bounded, durable progress."""
+
+import datetime as dt
+import importlib
+import json
+import logging
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import sqlalchemy as sa
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.api.http.service.maintenance import MaintenanceService
+from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.event_log import EventLog
+from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
+from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
+from langbot.pkg.entity.persistence.transcript import Transcript
+from langbot.pkg.entity.persistence.workspace import Workspace
+from langbot.pkg.persistence.mgr import PersistenceManager
+from langbot.pkg.storage import inline_media_cleanup as cleanup
+from langbot.pkg.storage.media import MediaCache
+
+
+WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
+WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
+CONTEXT = ExecutionContext(instance_uuid='instance', workspace_uuid=WORKSPACE_A, placement_generation=1)
+NOW = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None)
+OLD = NOW - dt.timedelta(days=40)
+PAYLOAD = json.dumps(
+ {
+ 'content': [
+ {'type': 'Plain', 'text': 'Keep the message'},
+ {'type': 'Image', 'base64': 'eA==', 'url': 'https://upstream/image'},
+ {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}},
+ {'type': 'image_base64', 'image_base64': 'eA=='},
+ {'type': 'Image', 'url': '/api/v1/files/media/already-externalized.png'},
+ ],
+ 'custom': {'count': 0, 'enabled': False},
+ }
+)
+SOURCES = [MonitoringMessage, EventLog, AgentRun, AgentRunEvent, Transcript]
+
+
+@pytest.fixture
+async def application(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "inline-media.db"}')
+ ap = SimpleNamespace(logger=logging.getLogger(__name__), instance_config=SimpleNamespace(data={}))
+ ap.persistence_mgr = PersistenceManager(ap)
+ ap.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ await conn.execute(
+ sa.insert(Workspace),
+ [
+ {
+ 'uuid': workspace,
+ 'instance_uuid': 'instance',
+ 'name': workspace,
+ 'slug': workspace,
+ 'source': 'cloud_projection',
+ }
+ for workspace in (WORKSPACE_A, WORKSPACE_B)
+ ],
+ )
+ yield ap
+ await engine.dispose()
+
+
+async def insert_record(ap, model, number, workspace=WORKSPACE_A, timestamp=OLD, payload=PAYLOAD):
+ if model is MonitoringMessage:
+ values = dict(
+ id=str(number),
+ workspace_uuid=workspace,
+ timestamp=timestamp,
+ message_content=payload,
+ bot_id='bot',
+ bot_name='Bot',
+ pipeline_id='pipeline',
+ pipeline_name='Pipeline',
+ session_id='session',
+ status='success',
+ level='info',
+ )
+ field = 'message_content'
+ elif model is AgentRunEvent:
+ run_id = f'parent-{number}'
+ await ap.persistence_mgr.execute_async(
+ sa.insert(AgentRun).values(
+ run_id=run_id, runner_id='runner', workspace_id=workspace, status='completed', created_at=timestamp
+ )
+ )
+ values = dict(
+ id=number, run_id=run_id, sequence=1, type='message.completed', created_at=timestamp, data_json=payload
+ )
+ field = 'data_json'
+ else:
+ values = dict(id=number, workspace_id=workspace, created_at=timestamp)
+ if model is EventLog:
+ values.update(
+ event_id=f'event-{number}', event_type='message.received', source='platform', input_json=payload
+ )
+ field = 'input_json'
+ elif model is AgentRun:
+ values.update(
+ run_id=f'run-{number}',
+ runner_id='runner',
+ status='completed',
+ metadata_json=payload,
+ updated_at=timestamp,
+ )
+ field = 'metadata_json'
+ else:
+ values.update(
+ transcript_id=f'transcript-{number}',
+ event_id=f'event-{number}',
+ conversation_id='conversation',
+ role='user',
+ item_type='message',
+ seq=number,
+ content_json=payload,
+ )
+ field = 'content_json'
+ # Seed the pre-contract database format without applying today's write policy.
+ values[field] = sa.bindparam('historical_payload', payload, type_=sa.Text())
+ await ap.persistence_mgr.execute_async(sa.insert(model).values(**values))
+ return field
+
+
+async def read_record(ap, model, number, field):
+ return (
+ await ap.persistence_mgr.execute_async(sa.select(getattr(model, field)).where(model.id == number))
+ ).scalar_one()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('model', SOURCES, ids=lambda model: model.__tablename__)
+async def test_ttl_expires_only_old_images_in_the_current_workspace(application, model):
+ field = await insert_record(application, model, 1)
+ await insert_record(application, model, 2, timestamp=NOW)
+ await insert_record(application, model, 3, workspace=WORKSPACE_B)
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1
+ saved = json.loads(await read_record(application, model, 1, field))
+ assert not MediaCache.has_inline_images(saved)
+ assert saved['content'][0]['text'] == 'Keep the message'
+ assert saved['content'][1]['url'] == 'https://upstream/image'
+ assert saved['content'][-1]['url'].startswith('/api/v1/files/media/')
+ assert saved['custom'] == {'count': 0, 'enabled': False}
+ assert await read_record(application, model, 2, field) == PAYLOAD
+ assert await read_record(application, model, 3, field) == PAYLOAD
+ if model is AgentRun:
+ assert await read_record(application, model, 1, 'updated_at') == OLD
+
+
+@pytest.mark.asyncio
+async def test_row_budget_resumes_persisted_progress_and_later_expiry(application, monkeypatch):
+ monkeypatch.setattr(cleanup, 'ROWS_PER_TABLE', 1)
+ for number in (1, 2):
+ await insert_record(application, MonitoringMessage, number)
+ await insert_record(application, MonitoringMessage, 3, timestamp=NOW - dt.timedelta(days=5))
+ for number in (1, 2):
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1
+ assert not MediaCache.has_inline_images(
+ json.loads(await read_record(application, MonitoringMessage, number, 'message_content'))
+ )
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 1) == 1
+ assert (await application.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value))).first()
+
+
+@pytest.mark.asyncio
+async def test_event_scan_budget_advances_past_foreign_events_without_reading_payloads(application, monkeypatch):
+ monkeypatch.setattr(cleanup, 'ROWS_PER_TABLE', 2)
+ for number in range(1, 5):
+ await insert_record(application, AgentRunEvent, number, workspace=WORKSPACE_B)
+ await application.persistence_mgr.execute_async(sa.delete(AgentRun).where(AgentRun.run_id == 'parent-3'))
+ await insert_record(application, AgentRunEvent, 5)
+ await insert_record(application, AgentRunEvent, 6, timestamp=NOW)
+ engine = application.persistence_mgr.get_db_engine()
+ statements = []
+
+ def capture_statement(connection, cursor, statement, parameters, context, executemany):
+ if statement.startswith('SELECT ') and 'FROM agent_run_event' in statement:
+ statements.append((statement, parameters))
+
+ sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture_statement)
+ try:
+ for expected_id in (2, 4):
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0
+ cursor = (
+ await application.persistence_mgr.execute_async(
+ sa.select(WorkspaceMetadata.value).where(
+ WorkspaceMetadata.workspace_uuid == WORKSPACE_A,
+ WorkspaceMetadata.key == cleanup.CURSOR_PREFIX + 'agent_run_event',
+ )
+ )
+ ).scalar_one()
+ assert json.loads(cursor) == [OLD.isoformat(), expected_id]
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0
+ finally:
+ sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture_statement)
+
+ assert not MediaCache.has_inline_images(json.loads(await read_record(application, AgentRunEvent, 5, 'data_json')))
+ for number in (1, 2, 3, 4, 6):
+ assert await read_record(application, AgentRunEvent, number, 'data_json') == PAYLOAD
+ async with engine.connect() as connection:
+ for statement, parameters in statements:
+ plan = ' '.join(
+ row[3] for row in await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters)
+ )
+ if 'CORRELATED' in plan:
+ assert 'SEARCH agent_run_event USING INTEGER PRIMARY KEY' in plan, plan
+ else:
+ assert 'COVERING INDEX ix_agent_run_event_media_ttl' in plan, plan
+ assert 'TEMP B-TREE' not in plan, plan
+
+
+@pytest.mark.asyncio
+async def test_payload_budget_processes_one_oversized_row_then_yields(application, monkeypatch):
+ monkeypatch.setattr(cleanup, 'CHARACTERS_PER_TABLE', 1)
+ await insert_record(application, MonitoringMessage, 1)
+ await insert_record(application, MonitoringMessage, 2)
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1
+ assert await read_record(application, MonitoringMessage, 2, 'message_content') == PAYLOAD
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1
+
+
+@pytest.mark.asyncio
+async def test_payload_update_and_cursor_roll_back_together(application, monkeypatch):
+ await insert_record(application, MonitoringMessage, 1)
+ execute = application.persistence_mgr.execute_async
+
+ async def fail_checkpoint(statement, *args, **kwargs):
+ if isinstance(statement, sa.sql.dml.Insert) and statement.table.name == 'workspace_metadata':
+ raise RuntimeError('Checkpoint failed')
+ return await execute(statement, *args, **kwargs)
+
+ with monkeypatch.context() as patch:
+ patch.setattr(application.persistence_mgr, 'execute_async', fail_checkpoint)
+ with pytest.raises(RuntimeError, match='Checkpoint failed'):
+ await cleanup.cleanup_inline_media(application, CONTEXT, 30)
+ assert await read_record(application, MonitoringMessage, 1, 'message_content') == PAYLOAD
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1
+
+
+@pytest.mark.asyncio
+async def test_unchanged_or_invalid_records_are_not_rewritten_or_revisited(application):
+ values = [
+ 'plain text',
+ '{invalid base64 JSON',
+ '{"count": 0, "enabled": false}',
+ json.dumps({'type': 'Image', 'url': '/api/v1/files/media/existing.png'}),
+ ]
+ for number, payload in enumerate(values, 1):
+ await insert_record(application, MonitoringMessage, number, payload=payload)
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0
+ for number, payload in enumerate(values, 1):
+ assert await read_record(application, MonitoringMessage, number, 'message_content') == payload
+ before = (await application.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0
+ after = (await application.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
+ assert before == after
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('model', [MonitoringMessage, AgentRunEvent])
+async def test_concurrent_payload_change_does_not_advance_cursor(application, monkeypatch, model):
+ field = await insert_record(application, model, 1)
+ execute = application.persistence_mgr.execute_async
+
+ async def lose_update(statement, *args, **kwargs):
+ if isinstance(statement, sa.sql.dml.Update) and statement.table.name == model.__tablename__:
+ return SimpleNamespace(rowcount=0)
+ return await execute(statement, *args, **kwargs)
+
+ with monkeypatch.context() as patch:
+ patch.setattr(application.persistence_mgr, 'execute_async', lose_update)
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0
+ assert (
+ await execute(
+ sa.select(WorkspaceMetadata.value).where(
+ WorkspaceMetadata.workspace_uuid == WORKSPACE_A,
+ WorkspaceMetadata.key == cleanup.CURSOR_PREFIX + model.__tablename__,
+ )
+ )
+ ).first() is None
+ assert await read_record(application, model, 1, field) == PAYLOAD
+ assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1
+
+
+@pytest.mark.asyncio
+async def test_storage_maintenance_uses_media_ttl_for_inline_records(application):
+ await insert_record(application, MonitoringMessage, 1, timestamp=NOW - dt.timedelta(days=5))
+ application.instance_config.data = {'storage': {'media_cache': {'retention_days': 2}}}
+ cache = SimpleNamespace(cleanup=AsyncMock(return_value={}))
+ application.storage_mgr = SimpleNamespace(media_cache=cache)
+ service = MaintenanceService(application)
+ service._is_oss_singleton = AsyncMock(return_value=False)
+ service._cleanup_expired_uploaded_files = AsyncMock(return_value=0)
+ assert (await service.cleanup_expired_files(CONTEXT))['inline_media_records'] == 1
+ cache.cleanup.assert_not_awaited()
+
+
+def test_ttl_indexes_migrate_idempotently_and_support_cursor_queries():
+ migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0039_execution_lease_indexes')
+ engine = sa.create_engine('sqlite:///:memory:')
+ try:
+ with engine.begin() as connection:
+ Base.metadata.create_all(connection)
+ with Operations.context(MigrationContext.configure(connection)):
+ migration.downgrade()
+ migration.upgrade()
+ migration.upgrade()
+ for table, name, columns in migration.INDEXES:
+ indexes = {
+ index['name']: index['column_names'] for index in sa.inspect(connection).get_indexes(table)
+ }
+ assert indexes[name] == columns
+ orm_index = next(index for index in Base.metadata.tables[table].indexes if index.name == name)
+ assert [column.name for column in orm_index.columns] == columns
+ query = (
+ sa.select(MonitoringMessage.id, MonitoringMessage.message_content)
+ .where(
+ MonitoringMessage.workspace_uuid == WORKSPACE_A,
+ MonitoringMessage.timestamp < NOW,
+ sa.tuple_(MonitoringMessage.timestamp, MonitoringMessage.id) > sa.tuple_(OLD, '0'),
+ )
+ .order_by(MonitoringMessage.timestamp, MonitoringMessage.id)
+ .limit(1)
+ )
+ sql = str(query.compile(dialect=engine.dialect, compile_kwargs={'literal_binds': True}))
+ plan = ' '.join(str(row) for row in connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + sql))
+ assert 'ix_monitoring_messages_media_ttl' in plan
+ assert 'TEMP B-TREE' not in plan
+ migration.downgrade()
+ finally:
+ engine.dispose()
+
+
+def test_index_migration_allows_historical_schemas_without_monitoring_tables():
+ migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0039_execution_lease_indexes')
+ engine = sa.create_engine('sqlite:///:memory:')
+ try:
+ with engine.begin() as connection:
+ with Operations.context(MigrationContext.configure(connection)):
+ migration.upgrade()
+ migration.downgrade()
+ assert not sa.inspect(connection).get_table_names()
+ finally:
+ engine.dispose()
diff --git a/tests/unit_tests/storage/test_media_cache.py b/tests/unit_tests/storage/test_media_cache.py
index 054ee725b..5ba502ad3 100644
--- a/tests/unit_tests/storage/test_media_cache.py
+++ b/tests/unit_tests/storage/test_media_cache.py
@@ -1,5 +1,6 @@
import base64
import datetime
+import json
import os
import tempfile
from pathlib import Path
@@ -127,6 +128,51 @@ class TestMediaCache:
# Should not raise; base64 should be stripped as fallback
assert result[0]['base64'] is None
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ 'image',
+ [
+ {'type': 'Image', 'base64': 'eA=='},
+ {'type': 'image_base64', 'image_base64': 'eA=='},
+ {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA==', 'detail': 'low'}},
+ {'type': 'image', 'image_url': 'data:image/png;base64,eA=='},
+ {'type': 'Image', 'url': 'data:image/png;base64,eA=='},
+ ],
+ )
+ async def test_externalize_image_encodings_without_mutating_runtime_input(self, image):
+ original = json.dumps(image)
+ result = await self.media_cache.externalize_chain_dump({'nested': [image]})
+ saved = result['nested'][0]
+ assert saved['storage_key'].startswith('media_cache/')
+ self.mock_provider.save.assert_awaited_once_with(saved['storage_key'], b'x')
+ assert not MediaCache.has_inline_images(result)
+ assert json.dumps(image) == original
+ if image.get('type') == 'image_url':
+ assert saved['image_url']['detail'] == 'low'
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize('payload', ['invalid!!!', 'https://example.com/image.png'])
+ async def test_invalid_base64_is_removed_without_saving_or_downloading(self, payload):
+ result = await self.media_cache.externalize_chain_dump({'type': 'Image', 'base64': payload})
+ assert result['base64'] is None
+ self.mock_provider.save.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_url_only_images_do_not_enter_cas(self):
+ image = {'type': 'Image', 'url': 'https://example.com/image.png'}
+ assert not MediaCache.has_inline_images(image)
+ assert await self.media_cache.externalize_chain_dump(image) == image
+ self.mock_provider.exists.assert_not_awaited()
+ self.mock_provider.save.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_oversized_inline_image_is_removed_before_decode(self):
+ image = {'type': 'Image', 'base64': 'data:image/png;base64,' + 'A' * 100}
+ with patch('langbot.pkg.storage.media.DEFAULT_BASE64_MEDIA_LIMIT', 16):
+ result = await self.media_cache.externalize_chain_dump(image)
+ assert result['base64'] is None
+ self.mock_provider.save.assert_not_awaited()
+
@pytest.mark.asyncio
async def test_cleanup_retention_and_max_size(self):
with tempfile.TemporaryDirectory() as temp_dir:
@@ -197,8 +243,8 @@ class TestMonitoringServiceSanitization:
def test_sanitize_oversized_base64_payload(self):
svc = MonitoringService.__new__(MonitoringService)
small_content = '{"type": "Image", "base64": "data:image/png;base64,tiny"}'
- # Should leave small contents untouched
- assert svc._sanitize_message_content(small_content) == small_content
+ # Metadata-only records must not retain even small inline images.
+ assert json.loads(svc._sanitize_message_content(small_content))['base64'] is None
# Large content with base64 data URL
huge_b64 = 'A' * 60000
@@ -213,3 +259,21 @@ class TestMonitoringServiceSanitization:
sanitized_raw = svc._sanitize_message_content(raw_corrupted)
assert '[base64 image omitted]' in sanitized_raw
assert multiline_b64 not in sanitized_raw
+
+ @pytest.mark.parametrize(
+ 'image',
+ [
+ {'type': 'Image', 'base64': 'eA=='},
+ {'type': 'Image', 'base64': 'A' * 20000},
+ {'type': 'image_base64', 'image_base64': 'eA=='},
+ {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}},
+ ],
+ )
+ def test_redaction_covers_raw_and_provider_images_at_any_size(self, image):
+ svc = MonitoringService.__new__(MonitoringService)
+ payload = {'event': {'images': [image]}, 'count': 0, 'enabled': False}
+ saved = json.loads(svc._sanitize_message_content(json.dumps(payload)))
+ assert not MediaCache.has_inline_images(saved)
+ assert saved['count'] == 0
+ assert saved['enabled'] is False
+ assert MediaCache.has_inline_images(payload)
diff --git a/tests/unit_tests/storage/test_media_persistence.py b/tests/unit_tests/storage/test_media_persistence.py
new file mode 100644
index 000000000..0b1fb9318
--- /dev/null
+++ b/tests/unit_tests/storage/test_media_persistence.py
@@ -0,0 +1,256 @@
+"""Persist media references without changing live messages or metadata-only policy."""
+
+import base64
+import copy
+import json
+import logging
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import sqlalchemy as sa
+
+from langbot.pkg.agent.runner.run_journal import AgentRunJournal
+from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore
+from langbot.pkg.entity.persistence.agent_run import AgentRunEvent
+from langbot.pkg.entity.persistence.event_log import EventLog
+from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
+from langbot.pkg.entity.persistence.transcript import Transcript
+from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper
+from langbot.pkg.storage.media import MediaCache
+from tests.unit_tests.api.service.test_monitoring_tenancy import (
+ WORKSPACE_A,
+ _context,
+ _record_message,
+ service as base_service, # noqa: F401
+)
+
+
+@pytest.fixture
+def media_service(base_service): # noqa: F811
+ service = base_service
+ objects = {}
+
+ async def save(key, data):
+ objects[key] = data
+
+ provider = SimpleNamespace(
+ exists=AsyncMock(side_effect=lambda key: key in objects),
+ save=AsyncMock(side_effect=save),
+ )
+ service.ap.logger = logging.getLogger(__name__)
+ service.ap.storage_mgr = SimpleNamespace(storage_provider=provider)
+ cache = MediaCache(service.ap, service.ap.storage_mgr)
+ service.ap.storage_mgr.media_cache = cache
+ service.ap.monitoring_service = service
+ return service, objects, cache
+
+
+def image(raw=b'tiny adapter image', *, encoded=True):
+ payload = base64.b64encode(raw).decode()
+ return {'type': 'Image', 'base64': f'data:image/png;base64,{payload}' if encoded else payload}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ 'response_kind',
+ ['input', 'resp_message_chain', 'resp_messages', 'direct', 'structured', 'structured_dict', 'discarded'],
+)
+async def test_all_retained_monitoring_messages_use_cas(media_service, response_kind, monkeypatch):
+ service, objects, _ = media_service
+ context = _context(WORKSPACE_A)
+ chain = [image()]
+ original = copy.deepcopy(chain)
+ legacy_content = json.dumps(chain)
+ json_dumps = json.dumps
+
+ def serialize_references(value, *args, **kwargs):
+ assert not MediaCache.has_inline_images(value), 'Inline images must reach CAS before JSON serialization'
+ return json_dumps(value, *args, **kwargs)
+
+ monkeypatch.setattr(json, 'dumps', serialize_references)
+ query = SimpleNamespace(
+ _execution_context=context,
+ launcher_type='person',
+ launcher_id='user',
+ sender_id='user',
+ message_chain=SimpleNamespace(model_dump=lambda: chain),
+ variables={},
+ )
+ resource = dict(bot_id='same-bot', bot_name='Bot', pipeline_id='pipeline', pipeline_name='Pipeline')
+ if response_kind == 'input':
+ await MonitoringHelper.record_query_start(service.ap, query, **resource)
+ elif response_kind == 'direct':
+ await _record_message(service, context, legacy_content)
+ elif response_kind in ('structured', 'structured_dict'):
+ content = {'chain': chain} if response_kind == 'structured_dict' else chain
+ await _record_message(service, context, content)
+ elif response_kind == 'discarded':
+ from langbot.pkg.platform.botmgr import RuntimeBot
+
+ bot = SimpleNamespace(
+ ap=service.ap,
+ execution_context=context,
+ bot_entity=SimpleNamespace(uuid='same-bot', name='Bot'),
+ PIPELINE_DISCARD=RuntimeBot.PIPELINE_DISCARD,
+ PIPELINE_DISCARD_DISPLAY_NAME=RuntimeBot.PIPELINE_DISCARD_DISPLAY_NAME,
+ logger=SimpleNamespace(error=AsyncMock()),
+ )
+ await RuntimeBot._record_discarded_message(
+ bot, 'person', 'user', 'user', SimpleNamespace(), query.message_chain
+ )
+ else:
+ response = SimpleNamespace(model_dump=lambda: chain)
+ if response_kind == 'resp_message_chain':
+ query.resp_message_chain = [response]
+ else:
+ query.resp_messages = [SimpleNamespace(get_content_platform_message_chain=lambda: response)]
+ await MonitoringHelper.record_query_response(service.ap, query, **resource)
+
+ async with service.ap.persistence_mgr.get_db_engine().connect() as conn:
+ stored = (await conn.execute(sa.select(MonitoringMessage.message_content))).scalar_one()
+ saved_chain = json.loads(stored)
+ saved = (saved_chain['chain'] if isinstance(saved_chain, dict) else saved_chain)[0]
+ assert saved['base64'] is None
+ assert saved['url'].startswith('/api/v1/files/media/')
+ assert objects[saved['storage_key']] == b'tiny adapter image'
+ assert chain == original
+
+
+@pytest.mark.asyncio
+async def test_metadata_only_ingress_discards_images_without_populating_cas(media_service):
+ service, objects, _ = media_service
+ payload = {'type': 'message.received', 'message_chain': [image(encoded=False)], 'count': 0}
+ original = copy.deepcopy(payload)
+ await service.record_ingress_event(
+ _context(WORKSPACE_A),
+ event_id='ingress',
+ event_type='message.received',
+ bot_id='same-bot',
+ bot_name='Bot',
+ platform='slack',
+ payload=payload,
+ )
+ async with service.ap.persistence_mgr.get_db_engine().connect() as conn:
+ stored = (await conn.execute(sa.select(EventLog.input_json))).scalar_one()
+ assert json.loads(stored)['message_chain'][0]['base64'] is None
+ assert not objects
+ assert payload == original
+
+
+@pytest.mark.asyncio
+async def test_repeated_images_deduplicate_across_messages_and_run_events(media_service):
+ service, objects, cache = media_service
+ raw = b'shared image'
+ payload = image(raw)
+ await _record_message(service, _context(WORKSPACE_A), json.dumps([payload]))
+ journal = AgentRunJournal(service.ap)
+ result = {
+ 'type': 'message.completed',
+ 'data': {
+ 'message': {'content': [payload]},
+ 'provider_image': {'type': 'image_base64', 'image_base64': payload['base64']},
+ },
+ }
+ original = copy.deepcopy(result)
+ metadata = {'diagnostic_image': image(b'metadata-only image')}
+ await journal.append_run_result(result_dict=result, run_id='run', sequence=1, metadata=metadata)
+ async with service.ap.persistence_mgr.get_db_engine().connect() as conn:
+ stored = (await conn.execute(sa.select(AgentRunEvent.data_json))).scalar_one()
+ saved_metadata = (await conn.execute(sa.select(AgentRunEvent.metadata_json))).scalar_one()
+ saved = json.loads(stored)
+ assert saved['message']['content'][0]['hash'] == cache.hash_bytes(raw)
+ assert saved['provider_image']['image_base64'] is None
+ assert saved['provider_image']['image_url']['url'].startswith('/api/v1/files/media/')
+ assert len(objects) == 1
+ service.ap.storage_mgr.storage_provider.save.assert_awaited_once()
+ assert result == original
+ assert not MediaCache.has_inline_images(json.loads(saved_metadata))
+ assert MediaCache.has_inline_images(metadata)
+
+
+@pytest.mark.asyncio
+async def test_cas_failure_does_not_write_inline_payload_to_database(media_service):
+ service, objects, _ = media_service
+ service.ap.storage_mgr.storage_provider.save.side_effect = OSError('Disk full')
+ message_id = await _record_message(service, _context(WORKSPACE_A), json.dumps([image()]))
+ async with service.ap.persistence_mgr.get_db_engine().connect() as conn:
+ stored = (
+ await conn.execute(sa.select(MonitoringMessage.message_content).where(MonitoringMessage.id == message_id))
+ ).scalar_one()
+ assert json.loads(stored)[0]['base64'] is None
+ assert not objects
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('field', ['data', 'metadata'])
+async def test_direct_ledger_writes_cannot_bypass_image_persistence(media_service, field):
+ service, objects, _ = media_service
+ engine = service.ap.persistence_mgr.get_db_engine()
+ with pytest.raises(sa.exc.StatementError, match='Inline images cannot be persisted'):
+ await RunLedgerStore(engine).append_event(
+ run_id='run', sequence=1, event_type='tool.call.completed', **{field: {'image': image()}}
+ )
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(sa.func.count()).select_from(AgentRunEvent))).scalar_one() == 0
+ assert not objects
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('write_kind', ['create', 'update'])
+async def test_monitoring_variables_follow_metadata_only_policy(media_service, write_kind):
+ service, objects, _ = media_service
+ variables = json.dumps({'preview': image(), 'count': 0})
+ context = _context(WORKSPACE_A)
+ if write_kind == 'create':
+ message_id = await service.record_message(
+ context,
+ bot_id='same-bot',
+ bot_name='Bot',
+ pipeline_id='pipeline',
+ pipeline_name='Pipeline',
+ message_content='message',
+ session_id='session',
+ variables=variables,
+ )
+ else:
+ message_id = await _record_message(service, context, 'message')
+ await service.update_message_status(context, message_id, 'success', variables=variables)
+ async with service.ap.persistence_mgr.get_db_engine().connect() as conn:
+ stored = (
+ await conn.execute(sa.select(MonitoringMessage.variables).where(MonitoringMessage.id == message_id))
+ ).scalar_one()
+ assert json.loads(stored) == {'preview': {'type': 'Image', 'base64': None}, 'count': 0}
+ assert not objects
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('role', ['user', 'assistant'])
+async def test_transcript_remains_metadata_only_without_creating_cas_objects(media_service, role):
+ service, objects, _ = media_service
+ contents = [image(), {'type': 'image_base64', 'image_base64': 'eA=='}]
+ original = copy.deepcopy(contents)
+ event = SimpleNamespace(
+ event_id='event',
+ conversation_id='conversation',
+ bot_id='same-bot',
+ workspace_id=WORKSPACE_A,
+ thread_id=None,
+ actor=None,
+ input=SimpleNamespace(text='image', contents=contents, attachments=[]),
+ )
+ journal = AgentRunJournal(service.ap)
+ if role == 'user':
+ await journal.write_user_transcript(event, 'event')
+ else:
+ await journal.write_assistant_transcript(
+ result_dict={'data': {'message': {'role': 'assistant', 'content': contents}}},
+ event=event,
+ run_id='run',
+ runner_id='runner',
+ )
+ async with service.ap.persistence_mgr.get_db_engine().connect() as conn:
+ stored = (await conn.execute(sa.select(Transcript.content_json))).scalar_one()
+ assert not MediaCache.has_inline_images(json.loads(stored))
+ assert not objects
+ assert contents == original
diff --git a/web/src/app/home/monitoring/components/executions/ExecutionDetailSheet.tsx b/web/src/app/home/monitoring/components/executions/ExecutionDetailSheet.tsx
index 70d226487..bba19cb48 100644
--- a/web/src/app/home/monitoring/components/executions/ExecutionDetailSheet.tsx
+++ b/web/src/app/home/monitoring/components/executions/ExecutionDetailSheet.tsx
@@ -87,10 +87,14 @@ export default function ExecutionDetailSheet({
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
- const [sectionError, setSectionError] = useState(
- null,
+ const [sectionError, setSectionError] = useState<
+ Partial>
+ >({});
+ const [busy, setBusy] = useState>>(
+ {},
);
- const [busy, setBusy] = useState(null);
+ const pendingSections = useRef(new Set());
+ const expandedSections = useRef(new Set());
const [refresh, setRefresh] = useState(0);
const request = useRef(0);
const identity = `${workspace}:${row?.source}:${row?.id}`;
@@ -106,20 +110,43 @@ export default function ExecutionDetailSheet({
if (requestedIdentity.current !== identity) {
setDetail(null);
requestedIdentity.current = identity;
+ expandedSections.current.clear();
}
setError(false);
- setSectionError(null);
- setBusy(null);
+ setSectionError({});
+ setBusy({});
+ pendingSections.current.clear();
if (!id || !source) {
setLoading(false);
return;
}
setLoading(true);
- backendClient
- .getExecutionDetail(source, id)
- .then((result) => {
+ const expanded = [...expandedSections.current].filter(
+ (section) => section !== 'errors',
+ );
+ Promise.all([
+ backendClient.getExecutionDetail(source, id, { section: 'overview' }),
+ ...expanded.map((section) =>
+ backendClient
+ .getExecutionDetail(source, id, { section })
+ .catch(() => null),
+ ),
+ ])
+ .then(([result, ...sections]) => {
if (version !== request.current) return;
- setDetail(result);
+ if (!result) return;
+ const pages = { ...result.pages };
+ const failures: Partial> = {};
+ sections.forEach((loaded, index) => {
+ const section = expanded[index];
+ if (loaded) Object.assign(pages, loaded.pages);
+ else failures[section] = true;
+ });
+ setDetail((previous) => ({
+ ...result,
+ pages: { ...previous?.pages, ...pages },
+ }));
+ setSectionError((previous) => ({ ...previous, ...failures }));
setLoadedIdentity(identity);
})
.catch(() => {
@@ -144,10 +171,11 @@ export default function ExecutionDetailSheet({
return () => clearInterval(timer);
}, [status, visible]);
const loadMore = async (section: ExecutionSection) => {
- if (!visible || !row || busy) return;
+ if (!visible || !row || pendingSections.current.has(section)) return;
const version = request.current;
- setBusy(section);
- setSectionError(null);
+ pendingSections.current.add(section);
+ setBusy((previous) => ({ ...previous, [section]: true }));
+ setSectionError((previous) => ({ ...previous, [section]: false }));
try {
const result = await backendClient.getExecutionDetail(
row.source,
@@ -174,9 +202,13 @@ export default function ExecutionDetailSheet({
},
);
} catch {
- if (version === request.current) setSectionError(section);
+ if (version === request.current)
+ setSectionError((previous) => ({ ...previous, [section]: true }));
} finally {
- if (version === request.current) setBusy(null);
+ if (version === request.current) {
+ pendingSections.current.delete(section);
+ setBusy((previous) => ({ ...previous, [section]: false }));
+ }
}
};
const renderItem = (section: ExecutionSection, item: ExecutionDetailItem) => {
@@ -298,9 +330,12 @@ export default function ExecutionDetailSheet({
};
const renderSection = (section: ExecutionSection, primary = false) => {
const page = visible?.pages[section];
+ if (primary && !page) return null;
if (
- !page ||
- (!primary && page.items.length === 0 && section !== 'conversation')
+ !primary &&
+ page?.items.length === 0 &&
+ section !== 'conversation' &&
+ !sectionError[section]
)
return null;
const body = (
@@ -310,26 +345,29 @@ export default function ExecutionDetailSheet({
{t('monitoring.unified.contextHint')}
)}
- {page.items.length === 0 && (
+ {!page && !sectionError[section] && (
+
+ )}
+ {page?.items.length === 0 && (
{t('monitoring.unified.noRecords')}
)}
- {page.items.map((item, i) => (
+ {page?.items.map((item, i) => (
{renderItem(section, item)}
))}
- {sectionError === section && (
+ {sectionError[section] && (
loadMore(section)}
/>
)}
- {page.has_more && (
+ {page?.has_more && (