diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index 65640b6e6..29dedcafb 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -138,6 +138,39 @@ class MonitoringRouterGroup(group.RouterGroup): } ) + @self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def get_tool_calls() -> str: + """Get tool call records""" + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + session_ids = quart.request.args.getlist('sessionId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 100)) + offset = int(quart.request.args.get('offset', 0)) + + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + tool_calls, total = await self.ap.monitoring_service.get_tool_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + session_ids=session_ids if session_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + + return self.success( + data={ + 'tool_calls': tool_calls, + 'total': total, + 'limit': limit, + 'offset': offset, + } + ) + @self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def get_embedding_calls() -> str: """Get embedding call records""" @@ -284,6 +317,16 @@ class MonitoringRouterGroup(group.RouterGroup): offset=0, ) + # Get tool calls + tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=0, + ) + # Get sessions sessions, sessions_total = await self.ap.monitoring_service.get_sessions( bot_ids=bot_ids if bot_ids else None, @@ -318,12 +361,14 @@ class MonitoringRouterGroup(group.RouterGroup): 'overview': overview, 'messages': messages, 'llmCalls': llm_calls, + 'toolCalls': tool_calls, 'embeddingCalls': embedding_calls, 'sessions': sessions, 'errors': errors, 'totalCount': { 'messages': messages_total, 'llmCalls': llm_calls_total, + 'toolCalls': tool_calls_total, 'embeddingCalls': embedding_calls_total, 'sessions': sessions_total, 'errors': errors_total, diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py index 4ee3f3e84..27654e70e 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -29,11 +29,11 @@ class MCPRouterGroup(group.RouterGroup): traceback.print_exc() return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}') - @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN + ) async def _(server_name: str) -> str: """获取、更新或删除MCP服务器配置""" - from urllib.parse import unquote - server_name = unquote(server_name) server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) @@ -58,17 +58,15 @@ class MCPRouterGroup(group.RouterGroup): except Exception as e: return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}') - @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: """测试MCP服务器连接""" - from urllib.parse import unquote - server_name = unquote(server_name) server_data = await quart.request.json task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) return self.success(data={'task_id': task_id}) - @self.route('/servers//resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + @self.route('/servers//resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: """Get resources from an MCP server""" server_name = unquote(server_name) @@ -86,7 +84,9 @@ class MCPRouterGroup(group.RouterGroup): except Exception as e: return self.http_status(500, -1, f'Failed to get resources: {str(e)}') - @self.route('/servers//resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + @self.route( + '/servers//resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN + ) async def _(server_name: str) -> str: """Get resource templates from an MCP server""" server_name = unquote(server_name) @@ -96,7 +96,20 @@ class MCPRouterGroup(group.RouterGroup): except Exception as e: return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}') - @self.route('/servers//resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + @self.route('/servers//logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Get logs from an MCP server""" + server_name = unquote(server_name) + try: + limit = int(quart.request.args.get('limit', 200)) + except (TypeError, ValueError): + limit = 200 + limit = min(limit, 500) + level = quart.request.args.get('level') or None + logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level) + return self.success(data={'logs': logs}) + + @self.route('/servers//resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) async def _(server_name: str) -> str: """Read a resource from an MCP server""" server_name = unquote(server_name) diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py index e755800ee..fa7359cba 100644 --- a/src/langbot/pkg/api/http/service/maintenance.py +++ b/src/langbot/pkg/api/http/service/maintenance.py @@ -243,6 +243,7 @@ class MaintenanceService: tables = { 'messages': persistence_monitoring.MonitoringMessage.id, 'llm_calls': persistence_monitoring.MonitoringLLMCall.id, + 'tool_calls': persistence_monitoring.MonitoringToolCall.id, 'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id, 'errors': persistence_monitoring.MonitoringError.id, 'sessions': persistence_monitoring.MonitoringSession.session_id, diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py index 22e281b6f..1dbceb6e5 100644 --- a/src/langbot/pkg/api/http/service/mcp.py +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -48,6 +48,17 @@ class MCPService: if total_extensions >= max_extensions: raise ValueError(f'Maximum number of extensions ({max_extensions}) reached') + server_name = str(server_data.get('name') or '').strip() + if not server_name: + raise ValueError('MCP server name is required') + server_data['name'] = server_name + + existing_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name) + ) + if existing_result.first() is not None: + raise ValueError(f'MCP server already exists: {server_name}') + server_data['uuid'] = str(uuid.uuid4()) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) @@ -177,10 +188,22 @@ class MCPService: persisted_session = runtime_mcp_session async def _refresh_and_report() -> None: - if persisted_session.status == MCPSessionStatus.ERROR: + # Testing a persisted server should REUSE its live shared-session + # process, not rebuild it. Try a lightweight refresh (a real + # list_tools probe over the existing connection) first; only fall + # back to a full start() when the session has no live connection + # to probe (never connected, or the process is actually gone). + needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None + if needs_start: await persisted_session.start() else: - await persisted_session.refresh() + try: + await persisted_session.refresh() + except Exception: + # The live connection was stale/dropped: reconnect once + # (reusing the live managed process where possible) and + # re-probe, instead of reporting a false failure. + await persisted_session.start() # Surface the discovered tools so the config page can render them # even for an already-hosted server. ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict() @@ -221,3 +244,19 @@ class MCPService: context=ctx, ) return wrapper.id + + async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]: + """Get recent log lines captured from the MCP server's stderr.""" + session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if not session: + return [] + + # Get logs from the session's buffer + logs = list(session._log_buffer) + + # Filter by level if specified + if level: + logs = [log for log in logs if log.get('level') == level] + + # Return the most recent 'limit' logs + return logs[-limit:] diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index 3e8e0cde0..46a352ad1 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -2,6 +2,7 @@ from __future__ import annotations import uuid import datetime +import json import sqlalchemy from ....core import app @@ -50,6 +51,12 @@ class MonitoringService: persistence_monitoring.MonitoringLLMCall.timestamp, persistence_monitoring.MonitoringLLMCall.id, ), + ( + 'monitoring_tool_calls', + persistence_monitoring.MonitoringToolCall, + persistence_monitoring.MonitoringToolCall.timestamp, + persistence_monitoring.MonitoringToolCall.id, + ), ( 'monitoring_embedding_calls', persistence_monitoring.MonitoringEmbeddingCall, @@ -131,6 +138,68 @@ class MonitoringService: await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)')) await autocommit_conn.execute(sqlalchemy.text('VACUUM')) + def _serialize_tool_payload(self, payload: object, max_length: int = 20000) -> str | None: + """Serialize tool arguments/results for monitoring storage.""" + if payload is None: + return None + + if isinstance(payload, str): + text = payload + else: + try: + text = json.dumps(payload, ensure_ascii=False, default=str) + except Exception: + text = str(payload) + + if len(text) <= max_length: + return text + + return f'{text[:max_length]}... [truncated {len(text) - max_length} chars]' + + async def _get_message_for_tool_context( + self, + message_id: str | None = None, + session_id: str | None = None, + ): + if message_id: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_monitoring.MonitoringMessage).where( + persistence_monitoring.MonitoringMessage.id == message_id + ) + ) + row = result.first() + if row: + return row[0] + + if not session_id: + return None + + user_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringMessage) + .where( + sqlalchemy.and_( + persistence_monitoring.MonitoringMessage.session_id == session_id, + persistence_monitoring.MonitoringMessage.role == 'user', + ) + ) + .order_by(persistence_monitoring.MonitoringMessage.timestamp.desc()) + .limit(1) + ) + result = await self.ap.persistence_mgr.execute_async(user_query) + row = result.first() + if row: + return row[0] + + any_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringMessage) + .where(persistence_monitoring.MonitoringMessage.session_id == session_id) + .order_by(persistence_monitoring.MonitoringMessage.timestamp.desc()) + .limit(1) + ) + result = await self.ap.persistence_mgr.execute_async(any_query) + row = result.first() + return row[0] if row else None + # ========== Recording Methods ========== async def record_message( @@ -220,6 +289,57 @@ class MonitoringService: return call_id + async def record_tool_call( + self, + tool_name: str, + tool_source: str, + duration: int, + status: str = 'success', + bot_id: str | None = None, + bot_name: str | None = None, + pipeline_id: str | None = None, + pipeline_name: str | None = None, + session_id: str | None = None, + message_id: str | None = None, + arguments: object | None = None, + result: object | None = None, + error_message: str | None = None, + ) -> str: + """Record a tool call.""" + context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id) + if context_message: + bot_id = bot_id or context_message.bot_id + bot_name = bot_name or context_message.bot_name + pipeline_id = pipeline_id or context_message.pipeline_id + pipeline_name = pipeline_name or context_message.pipeline_name + session_id = session_id or context_message.session_id + message_id = message_id or context_message.id + + call_id = str(uuid.uuid4()) + call_data = { + 'id': call_id, + 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + 'tool_name': tool_name, + 'tool_source': tool_source, + 'duration': max(0, duration), + 'status': status, + 'bot_id': bot_id or 'unknown', + 'bot_name': bot_name or 'Unknown', + 'pipeline_id': pipeline_id or 'unknown', + 'pipeline_name': pipeline_name or 'Unknown', + 'session_id': session_id, + 'message_id': message_id, + 'arguments': self._serialize_tool_payload(arguments), + 'result': self._serialize_tool_payload(result), + 'error_message': self._serialize_tool_payload(error_message), + } + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_monitoring.MonitoringToolCall).values(call_data) + ) + + return call_id + async def record_embedding_call( self, model_name: str, @@ -749,6 +869,58 @@ class MonitoringService: total, ) + async def get_tool_calls( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + session_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100, + offset: int = 0, + ) -> tuple[list[dict], int]: + """Get tool calls with filters""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringToolCall.pipeline_id.in_(pipeline_ids)) + if session_ids: + conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time) + + count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id)) + if conditions: + count_query = count_query.where(sqlalchemy.and_(*conditions)) + + count_result = await self.ap.persistence_mgr.execute_async(count_query) + total = count_result.scalar() or 0 + + query = sqlalchemy.select(persistence_monitoring.MonitoringToolCall).order_by( + persistence_monitoring.MonitoringToolCall.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit).offset(offset) + + result = await self.ap.persistence_mgr.execute_async(query) + tool_calls_rows = result.all() + + return ( + [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row + ) + for row in tool_calls_rows + ], + total, + ) + async def get_embedding_calls( self, start_time: datetime.datetime | None = None, @@ -971,6 +1143,34 @@ class MonitoringService: else: error_llm_calls += 1 + # Get tool calls for this session + tool_query = ( + sqlalchemy.select(persistence_monitoring.MonitoringToolCall) + .where(persistence_monitoring.MonitoringToolCall.session_id == session_id) + .order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc()) + ) + tool_result = await self.ap.persistence_mgr.execute_async(tool_query) + tool_rows = tool_result.all() + + tool_calls = [ + self.ap.persistence_mgr.serialize_model( + persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row + ) + for row in tool_rows + ] + + total_tool_calls = len(tool_rows) + success_tool_calls = 0 + error_tool_calls = 0 + total_tool_duration = 0 + for row in tool_rows: + tool_call = row[0] if isinstance(row, tuple) else row + total_tool_duration += tool_call.duration + if tool_call.status == 'success': + success_tool_calls += 1 + else: + error_tool_calls += 1 + # Get errors for this session error_query = ( sqlalchemy.select(persistence_monitoring.MonitoringError) @@ -1014,6 +1214,14 @@ class MonitoringService: 'total_tokens': total_tokens, 'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0, }, + 'tool_calls': tool_calls, + 'tool_stats': { + 'total_calls': total_tool_calls, + 'success_calls': success_tool_calls, + 'error_calls': error_tool_calls, + 'total_duration_ms': total_tool_duration, + 'average_duration_ms': int(total_tool_duration / total_tool_calls) if total_tool_calls > 0 else 0, + }, 'errors': errors, 'session_duration_seconds': session_duration_seconds, } diff --git a/src/langbot/pkg/entity/persistence/monitoring.py b/src/langbot/pkg/entity/persistence/monitoring.py index 01e4fdd39..f594b8187 100644 --- a/src/langbot/pkg/entity/persistence/monitoring.py +++ b/src/langbot/pkg/entity/persistence/monitoring.py @@ -49,6 +49,28 @@ class MonitoringLLMCall(Base): message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID +class MonitoringToolCall(Base): + """Tool call records""" + + __tablename__ = 'monitoring_tool_calls' + + id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True) + tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill + duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error + bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + arguments = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + result = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + + class MonitoringSession(Base): """Session tracking records""" diff --git a/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py b/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py new file mode 100644 index 000000000..8bd71b10c --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py @@ -0,0 +1,17 @@ +from langbot.pkg.entity.persistence import monitoring as persistence_monitoring +from .. import migration + + +@migration.migration_class(26) +class DBMigrateMonitoringToolCalls(migration.DBMigration): + """Add monitoring_tool_calls table""" + + async def upgrade(self): + """Upgrade""" + async with self.ap.persistence_mgr.get_db_engine().begin() as conn: + await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.create, checkfirst=True) + + async def downgrade(self): + """Downgrade""" + async with self.ap.persistence_mgr.get_db_engine().begin() as conn: + await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.drop, checkfirst=True) diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py index 7344c4b4b..9b1990ea9 100644 --- a/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py @@ -21,7 +21,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True) message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() - event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() + event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter) config: dict listeners: dict[ diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py index 507be3d4a..210f2125c 100644 --- a/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import time import typing import aiocqhttp @@ -10,6 +12,103 @@ from langbot_plugin.api.entities.builtin.platform import entities as platform_en from langbot_plugin.api.entities.builtin.platform import events as platform_events +_GROUP_NAME_CACHE_TTL_SECONDS = 3600 +_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60 +_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2 +_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400 +_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600 +_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2 + +_group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {} +_group_name_negative_cache: dict[typing.Union[int, str], float] = {} +_group_member_info_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float]] = {} +_group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {} + + +def _get_field(data: dict, key: str, default: str = '') -> str: + value = data.get(key) + if value is None: + return default + return str(value) + + +def _get_group_member_name(sender: dict) -> str: + return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id') + + +def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str: + return f'Group {group_id}' + + +async def _get_group_name(group_id: typing.Union[int, str], bot: aiocqhttp.CQHttp | None = None) -> str: + now = time.monotonic() + if group_id in _group_name_cache: + group_name, expires_at = _group_name_cache[group_id] + if expires_at > now: + return group_name + del _group_name_cache[group_id] + if group_id in _group_name_negative_cache: + expires_at = _group_name_negative_cache[group_id] + if expires_at > now: + return '' + del _group_name_negative_cache[group_id] + if bot is None: + return '' + try: + group_info = await asyncio.wait_for( + bot.get_group_info(group_id=group_id), + timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + _group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return '' + group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else '' + if group_name: + _group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS) + _group_name_negative_cache.pop(group_id, None) + else: + _group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return group_name + + +async def _get_group_member_info( + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + bot: aiocqhttp.CQHttp | None = None, +) -> dict: + now = time.monotonic() + cache_key = (group_id, user_id) + if cache_key in _group_member_info_cache: + member_info, expires_at = _group_member_info_cache[cache_key] + if expires_at > now: + return member_info + del _group_member_info_cache[cache_key] + if cache_key in _group_member_info_negative_cache: + expires_at = _group_member_info_negative_cache[cache_key] + if expires_at > now: + return {} + del _group_member_info_negative_cache[cache_key] + if bot is None: + return {} + try: + member_info = await asyncio.wait_for( + bot.get_group_member_info(group_id=group_id, user_id=user_id), + timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + _group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + if isinstance(member_info, dict) and member_info: + _group_member_info_cache[cache_key] = ( + member_info, + now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS, + ) + _group_member_info_negative_cache.pop(cache_key, None) + return member_info + _group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + + class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def yiri2target(event: platform_events.Event, bot_account_id: int | str | None = None): @@ -25,9 +124,9 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if event_type == 'message': return await AiocqhttpEventConverter.message_to_eba(event, bot) if event_type == 'notice': - return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id) + return await AiocqhttpEventConverter.notice_to_eba(event, bot, bot_user_id) if event_type == 'request': - return AiocqhttpEventConverter.request_to_eba(event) + return await AiocqhttpEventConverter.request_to_eba(event, bot) if event_type == 'meta_event': return AiocqhttpEventConverter.platform_specific(event, f'meta.{getattr(event, "detail_type", "")}') return None @@ -60,14 +159,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if message_type == 'group': chat_type = platform_entities.ChatType.GROUP chat_id = getattr(event, 'group_id', '') - group = AiocqhttpEventConverter.group_from_event(event) + group = await AiocqhttpEventConverter.group_from_event(event, bot) return platform_events.MessageReceivedEvent( type='message.received', adapter_name='aiocqhttp', message_id=getattr(event, 'message_id', ''), message_chain=message_chain, - sender=AiocqhttpEventConverter.user_from_sender(event), + sender=await AiocqhttpEventConverter.user_from_sender(event, bot), chat_type=chat_type, chat_id=chat_id, group=group, @@ -76,8 +175,9 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): ) @staticmethod - def notice_to_eba( + async def notice_to_eba( event: aiocqhttp.Event, + bot: aiocqhttp.CQHttp | None = None, bot_user_id: int | str | None = None, ) -> platform_events.EBAEvent: notice_type = getattr(event, 'notice_type', getattr(event, 'detail_type', '')) @@ -91,12 +191,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if notice_type == 'group_recall' else platform_entities.ChatType.PRIVATE, chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')), - group=AiocqhttpEventConverter.group_from_event(event) if notice_type == 'group_recall' else None, + group=await AiocqhttpEventConverter.group_from_event(event, bot) + if notice_type == 'group_recall' + else None, timestamp=float(getattr(event, 'time', 0) or 0), source_platform_object=event, ) if notice_type == 'group_increase': - group = AiocqhttpEventConverter.group_from_event(event) + group = await AiocqhttpEventConverter.group_from_event(event, bot) user = AiocqhttpEventConverter.user(getattr(event, 'user_id', '')) inviter_id = getattr(event, 'operator_id', None) if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): @@ -119,7 +221,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): source_platform_object=event, ) if notice_type == 'group_decrease': - group = AiocqhttpEventConverter.group_from_event(event) + group = await AiocqhttpEventConverter.group_from_event(event, bot) operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)) if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): return platform_events.BotRemovedFromGroupEvent( @@ -141,7 +243,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): source_platform_object=event, ) if notice_type == 'group_ban': - group = AiocqhttpEventConverter.group_from_event(event) + group = await AiocqhttpEventConverter.group_from_event(event, bot) duration = int(getattr(event, 'duration', 0) or 0) operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)) if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): @@ -179,7 +281,10 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): return AiocqhttpEventConverter.platform_specific(event, f'notice.{notice_type}') @staticmethod - def request_to_eba(event: aiocqhttp.Event) -> platform_events.EBAEvent: + async def request_to_eba( + event: aiocqhttp.Event, + bot: aiocqhttp.CQHttp | None = None, + ) -> platform_events.EBAEvent: request_type = getattr(event, 'request_type', getattr(event, 'detail_type', '')) if request_type == 'friend': return platform_events.FriendRequestReceivedEvent( @@ -195,7 +300,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_events.BotInvitedToGroupEvent( type='bot.invited_to_group', adapter_name='aiocqhttp', - group=AiocqhttpEventConverter.group_from_event(event), + group=await AiocqhttpEventConverter.group_from_event(event, bot), inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), request_id=getattr(event, 'flag', ''), timestamp=float(getattr(event, 'time', 0) or 0), @@ -204,13 +309,28 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): return AiocqhttpEventConverter.platform_specific(event, f'request.{request_type}') @staticmethod - def user_from_sender(event: aiocqhttp.Event) -> platform_entities.User: + async def user_from_sender( + event: aiocqhttp.Event, + bot: aiocqhttp.CQHttp | None = None, + ) -> platform_entities.User: sender = getattr(event, 'sender', {}) or {} - nickname = sender.get('card') or sender.get('nickname') or '' + user_id = sender.get('user_id', getattr(event, 'user_id', '')) + has_sender_display_name = bool(_get_field(sender, 'card') or _get_field(sender, 'nickname')) + nickname = _get_group_member_name(sender) + remark = sender.get('remark') + if ( + getattr(event, 'message_type', getattr(event, 'detail_type', 'private')) == 'group' + and user_id + and (not has_sender_display_name or not remark) + ): + member_info = await _get_group_member_info(getattr(event, 'group_id', ''), user_id, bot) + remark = _get_field(member_info, 'card') or _get_field(member_info, 'remark') or None + if not has_sender_display_name: + nickname = _get_group_member_name(member_info) or nickname return platform_entities.User( - id=sender.get('user_id', getattr(event, 'user_id', '')), + id=user_id, nickname=nickname, - remark=sender.get('remark'), + remark=remark, ) @staticmethod @@ -220,10 +340,19 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): return platform_entities.User(id=user_id, nickname=nickname) @staticmethod - def group_from_event(event: aiocqhttp.Event) -> platform_entities.UserGroup: + async def group_from_event( + event: aiocqhttp.Event, + bot: aiocqhttp.CQHttp | None = None, + ) -> platform_entities.UserGroup: + group_id = getattr(event, 'group_id', '') + group_name = getattr(event, 'group_name', '') or '' + if group_id and not group_name: + group_name = await _get_group_name(group_id, bot) + if group_id and not group_name: + group_name = _get_group_name_placeholder(group_id) return platform_entities.UserGroup( - id=getattr(event, 'group_id', ''), - name=getattr(event, 'group_name', '') or '', + id=group_id, + name=group_name, member_count=getattr(event, 'member_count', None), ) diff --git a/src/langbot/pkg/platform/adapters/wecomcs/adapter.py b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py index 017482b5d..902418fff 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py @@ -101,11 +101,13 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform if target_type not in ('person', 'private'): raise NotSupportedError(f'send_message:{target_type}') - external_userid, open_kfid = parse_private_chat_id(target_id) + external_userid, open_kfid = parse_private_chat_id(target_id, self.bot_account_id) content_list = await WecomCSMessageConverter.yiri2target(message, self.bot) raw_results = [] for content in content_list: - raw_results.append(await self._send_content(open_kfid, external_userid, self._make_outbound_msgid(), content)) + raw_results.append( + await self._send_content(open_kfid, external_userid, self._make_outbound_msgid(), content) + ) return platform_events.MessageResult(raw={'results': raw_results}) async def reply_message( diff --git a/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py b/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py index 7c0743657..3dc56c68e 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py +++ b/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py @@ -17,7 +17,9 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter): return getattr(event, 'source_platform_object', None) @staticmethod - async def target2legacy(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.FriendMessage | None: + async def target2legacy( + event: WecomCSEvent, bot: WecomCSClient | None = None + ) -> platform_events.FriendMessage | None: eba_event = await WecomCSEventConverter.target2yiri(event, bot) if hasattr(eba_event, 'to_legacy_event'): return eba_event.to_legacy_event() @@ -30,7 +32,9 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter): return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}') @staticmethod - async def message_to_eba(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.MessageReceivedEvent: + async def message_to_eba( + event: WecomCSEvent, bot: WecomCSClient | None = None + ) -> platform_events.MessageReceivedEvent: message_chain = await WecomCSMessageConverter.target2yiri(event) sender = await WecomCSEventConverter.user_from_event(event, bot) return platform_events.MessageReceivedEvent( diff --git a/src/langbot/pkg/platform/adapters/wecomcs/types.py b/src/langbot/pkg/platform/adapters/wecomcs/types.py index ce8c7e457..ca4e79d01 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/types.py +++ b/src/langbot/pkg/platform/adapters/wecomcs/types.py @@ -12,8 +12,29 @@ def make_private_chat_id(user_id: str | int | None, open_kfid: str | int | None) return f'{user}|{kfid}' -def parse_private_chat_id(chat_id: str | int) -> tuple[str, str]: - user_id, sep, open_kfid = str(chat_id).partition('|') - if not user_id or not sep or not open_kfid: - raise ValueError('WeComCS target_id must be formatted as "external_userid|open_kfid"') +def _strip_legacy_user_prefix(user_id: str) -> str: + if user_id.startswith('u'): + return user_id[1:] + return user_id + + +def _looks_like_open_kfid(value: str) -> bool: + return value.startswith(('kf', 'wk', 'open_kfid')) + + +def parse_private_chat_id(chat_id: str | int, default_open_kfid: str | int | None = None) -> tuple[str, str]: + raw = str(chat_id) + left, sep, right = raw.partition('|') + if sep: + if _looks_like_open_kfid(left) or right.startswith('u'): + open_kfid, user_id = left, right + else: + user_id, open_kfid = left, right + else: + user_id = raw + open_kfid = str(default_open_kfid or '') + user_id = _strip_legacy_user_prefix(str(user_id or '')) + open_kfid = str(open_kfid or '') + if not user_id or not open_kfid: + raise ValueError('WeComCS target_id must include external_userid and open_kfid') return user_id, open_kfid diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.py b/src/langbot/pkg/platform/sources/aiocqhttp.py index fc57a5661..abe1ca95b 100644 --- a/src/langbot/pkg/platform/sources/aiocqhttp.py +++ b/src/langbot/pkg/platform/sources/aiocqhttp.py @@ -4,6 +4,7 @@ import asyncio import traceback import datetime import json +import time import aiocqhttp import pydantic @@ -16,6 +17,14 @@ from ...utils import image import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +_GROUP_NAME_CACHE_TTL_SECONDS = 3600 +_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60 +_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2 +_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400 +_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600 +_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2 + + def _normalize_base64_payload(value: str) -> str: if value.startswith('base64://'): return value.removeprefix('base64://') @@ -24,6 +33,21 @@ def _normalize_base64_payload(value: str) -> str: return value +def _get_field(data: dict, key: str, default: str = '') -> str: + value = data.get(key) + if value is None: + return default + return str(value) + + +def _get_group_member_name(sender: dict) -> str: + return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id') + + +def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str: + return f'Group {group_id}' + + class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target( @@ -335,16 +359,96 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): + def __init__(self): + self._group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {} + self._group_name_negative_cache: dict[typing.Union[int, str], float] = {} + self._group_member_info_cache: dict[ + tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float] + ] = {} + self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {} + @staticmethod async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int): return event.source_platform_object - @staticmethod - async def target2yiri(event: aiocqhttp.Event, bot=None): + async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str: + now = time.monotonic() + if group_id in self._group_name_cache: + group_name, expires_at = self._group_name_cache[group_id] + if expires_at > now: + return group_name + del self._group_name_cache[group_id] + if group_id in self._group_name_negative_cache: + expires_at = self._group_name_negative_cache[group_id] + if expires_at > now: + return '' + del self._group_name_negative_cache[group_id] + if bot is None: + return '' + try: + group_info = await asyncio.wait_for( + bot.get_group_info(group_id=group_id), + timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return '' + group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else '' + if group_name: + self._group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS) + self._group_name_negative_cache.pop(group_id, None) + else: + self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS + return group_name + + async def _get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + bot=None, + ) -> dict: + now = time.monotonic() + cache_key = (group_id, user_id) + if cache_key in self._group_member_info_cache: + member_info, expires_at = self._group_member_info_cache[cache_key] + if expires_at > now: + return member_info + del self._group_member_info_cache[cache_key] + if cache_key in self._group_member_info_negative_cache: + expires_at = self._group_member_info_negative_cache[cache_key] + if expires_at > now: + return {} + del self._group_member_info_negative_cache[cache_key] + if bot is None: + return {} + try: + member_info = await asyncio.wait_for( + bot.get_group_member_info(group_id=group_id, user_id=user_id), + timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS, + ) + except Exception: + self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + if isinstance(member_info, dict) and member_info: + self._group_member_info_cache[cache_key] = ( + member_info, + now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS, + ) + self._group_member_info_negative_cache.pop(cache_key, None) + return member_info + self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS + return {} + + async def target2yiri(self, event: aiocqhttp.Event, bot=None): yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot) if event.message_type == 'group': permission = 'MEMBER' + group_name = await self._get_group_name(event.group_id, bot) or _get_group_name_placeholder(event.group_id) + special_title = _get_field(event.sender, 'title') + if not special_title: + member_info = await self._get_group_member_info(event.group_id, event.sender['user_id'], bot) + special_title = _get_field(member_info, 'title') if 'role' in event.sender: if event.sender['role'] == 'admin': @@ -354,14 +458,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): converted_event = platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.sender['user_id'], # message_seq 放哪? - member_name=event.sender['nickname'], + member_name=_get_group_member_name(event.sender), permission=permission, group=platform_entities.Group( id=event.group_id, - name=event.sender['nickname'], + name=group_name, permission=platform_entities.Permission.Member, ), - special_title=event.sender['title'] if 'title' in event.sender else '', + special_title=special_title, ), message_chain=yiri_chain, time=event.time, @@ -385,7 +489,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter) bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp) message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() - event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() + event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter) on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = [] diff --git a/src/langbot/pkg/platform/sources/wecomcs.py b/src/langbot/pkg/platform/sources/wecomcs.py index 9af809f7d..ea7d8ef5c 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.py +++ b/src/langbot/pkg/platform/sources/wecomcs.py @@ -2,6 +2,7 @@ from __future__ import annotations import typing import asyncio import traceback +import uuid import datetime import pydantic @@ -182,7 +183,28 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): - pass + if target_type != 'person': + raise ValueError('WeCom customer service only supports sending messages to person targets') + + open_kfid = self.bot_account_id + external_userid = target_id + if '|' in target_id: + open_kfid, external_userid = target_id.split('|', 1) + if external_userid.startswith('u'): + external_userid = external_userid[1:] + if not open_kfid: + raise ValueError('WeCom customer service open_kfid is required before sending messages') + + content_list = await WecomMessageConverter.yiri2target(message, self.bot) + for content in content_list: + msgid = f'langbot_{uuid.uuid4().hex}' + if content['type'] == 'text': + await self.bot.send_text_msg( + open_kfid=open_kfid, + external_userid=external_userid, + msgid=msgid, + content=content['content'], + ) def set_bot_uuid(self, bot_uuid: str): """设置 bot UUID(用于生成 webhook URL)""" diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index e059a756d..97b88d8a5 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -25,7 +25,7 @@ from ....core import app import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.provider.message as provider_message from ....entity.persistence import mcp as persistence_mcp -from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase # noqa: F401 +from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401 # Synthesized LLM tools for MCP resources (not from server tools/list). # Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used. @@ -185,6 +185,16 @@ class MCPSessionStatus(enum.Enum): ERROR = 'error' +class _TransportReconnect(Exception): + """Internal signal: the Box stdio WS transport dropped but the managed + process is still alive. Triggers a lightweight transport reconnect that + reuses the live process, instead of a full process rebuild. + + Reconnect attempts are NOT counted toward the fatal retry budget, so a + long-lived session can survive arbitrarily many transient drops. + """ + + class RuntimeMCPSession: """运行时 MCP 会话""" @@ -254,6 +264,16 @@ class RuntimeMCPSession: self._lifecycle_task = None self._shutdown_event = asyncio.Event() self._ready_event = asyncio.Event() + # Set transiently when a WS transport drop should NOT stop the managed + # process (it will be re-attached on the next initialize()). + self._preserve_managed_process = False + + # Log buffer for capturing stderr from Box managed process (maxlen=500 keeps + # recent lines without unbounded memory growth) + import collections as _collections + + self._log_buffer: _collections.deque = _collections.deque(maxlen=500) + self._last_stderr_text: str = '' self._box_stdio_runtime = BoxStdioSessionRuntime(self) self.box_config = self._box_stdio_runtime.config @@ -399,11 +419,39 @@ class RuntimeMCPSession: task.cancel() for task in done: if task is monitor_task and not self._shutdown_event.is_set(): + # The monitor completed. This is EITHER the managed + # process actually exiting OR just the WS transport + # dropping while the process stays alive in the Box + # runtime. Re-check the real process state so a + # transient transport drop reconnects (reusing the live + # process) instead of tearing the process down and + # running a full rebuild+backoff cycle. + process_still_running = False + try: + process_still_running = await self._box_stdio_runtime._managed_process_is_running() + except Exception: + process_still_running = False + if process_still_running: + self.ap.logger.info( + f'MCP server {self.server_name}: transport dropped but ' + f'managed process is still running; reconnecting transport' + ) + self.error_phase = MCPSessionErrorPhase.RELAY_CONNECT + # Preserve the live process across the finally-block + # cleanup: only the WS transport should be torn down. + self._preserve_managed_process = True + raise _TransportReconnect('Box managed process transport dropped; reconnecting') self.error_phase = MCPSessionErrorPhase.RUNTIME raise Exception('Box managed process exited unexpectedly') else: await self._shutdown_event.wait() + except _ColdStartRetry: + # Cold-start in progress: set the preserve flag BEFORE the finally + # block runs so it does not stop the live managed process. The outer + # _lifecycle_loop_with_retry will reuse it on the next attempt. + self._preserve_managed_process = True + raise except Exception as e: self.status = MCPSessionStatus.ERROR self.error_message = str(e) @@ -424,14 +472,55 @@ class RuntimeMCPSession: except Exception as e: self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}') finally: - await self._cleanup_box_stdio_session() + # On a transport-only reconnect the managed process is healthy + # and will be re-attached on the next initialize(); do NOT stop + # it. Any other exit path fully tears the session down. + if getattr(self, '_preserve_managed_process', False): + self._preserve_managed_process = False + else: + await self._cleanup_box_stdio_session() async def _lifecycle_loop_with_retry(self): """Wrap _lifecycle_loop with retry and exponential backoff.""" - for attempt in range(self._MAX_RETRIES + 1): + attempt = 0 + while attempt <= self._MAX_RETRIES: try: await self._lifecycle_loop() return # Normal shutdown, don't retry + except _TransportReconnect as e: + # Transient WS transport drop while the managed process is still + # alive. Reconnect promptly WITHOUT consuming the fatal retry + # budget and WITHOUT stopping the process — initialize() will + # re-attach to the live process. This is what lets a long-lived + # stdio MCP survive repeated brief event-loop stalls / pings. + if self._shutdown_event.is_set(): + return + self.ap.logger.info( + f'MCP session {self.server_name}: reconnecting transport ({self._describe_exception(e)})' + ) + self.status = MCPSessionStatus.CONNECTING + self.error_message = None + self.error_phase = None + await asyncio.sleep(1) + continue + except _ColdStartRetry as e: + # The managed process is alive but still cold-starting (e.g. + # `npx -y ` is still installing) and cannot yet answer the + # handshake. Reuse the live process and retry the attach WITHOUT + # consuming the fatal retry budget or stopping the process, so a + # slow cold start is waited out instead of failing. Preserve the + # process across the finally-block cleanup. + if self._shutdown_event.is_set(): + return + self._preserve_managed_process = True + self.ap.logger.debug( + f'MCP session {self.server_name}: waiting for cold start ({self._describe_exception(e)})' + ) + self.status = MCPSessionStatus.CONNECTING + self.error_message = None + self.error_phase = None + await asyncio.sleep(2) + continue except Exception as e: self.retry_count = attempt + 1 if self._shutdown_event.is_set(): @@ -460,6 +549,7 @@ class RuntimeMCPSession: self.error_message = None self.error_phase = None await asyncio.sleep(delay) + attempt += 1 @staticmethod def _describe_exception(exc: BaseException) -> str: @@ -927,11 +1017,14 @@ class RuntimeMCPSession: return self._box_stdio_runtime.uses_box_stdio() def _build_box_session_id(self) -> str: - # Transient test sessions get their own isolated Box session so a - # failing/short-lived test can never disturb the shared session that - # hosts live, already-connected MCP servers. - if self.is_transient: - return f'mcp-test-{self.server_uuid}' + # Both live servers and transient config-page tests share ONE Box + # session ('mcp-shared'). A test therefore reuses the already-running + # container (and, for an existing server, its live managed process) + # instead of paying a full per-test session cold-start + dependency + # bootstrap. Isolation between a test and the live servers is provided + # at the *process* level: each server/test has its own process_id and a + # test only ever stops its own process_id (see cleanup_session), so it + # never disturbs another server's process or the shared session itself. return 'mcp-shared' def _rewrite_path(self, path: str, host_path: str | None) -> str: diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py index dcfbb9132..fe7ccd11a 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py @@ -6,7 +6,7 @@ import os import shutil import shlex import threading -from contextlib import suppress +from contextlib import suppress, AsyncExitStack from typing import TYPE_CHECKING, Any import pydantic @@ -74,6 +74,35 @@ class MCPServerBoxConfig(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra='ignore') +_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0 + + +class _TransferredStack: + """Adapts an already-populated AsyncExitStack into an async context manager + so ownership of its resources can be transferred into another exit stack. + Entering is a no-op; exiting closes the wrapped stack (and thus the live WS + transport + ClientSession) when the owning session shuts down.""" + + def __init__(self, stack: AsyncExitStack): + self._stack = stack + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + await self._stack.aclose() + return False + + +class _ColdStartRetry(Exception): + """Signal: the managed process is alive but not yet answering the MCP + handshake because it is still cold-starting (e.g. `npx -y ` is still + installing). The outer lifecycle retry treats this like a transient + reconnect: it reuses the live process and does not count toward the fatal + retry budget, so a slow cold start is waited out rather than failing. + """ + + class BoxStdioSessionRuntime: """Encapsulate Box-backed stdio MCP session orchestration.""" @@ -173,28 +202,55 @@ class BoxStdioSessionRuntime: stderr_preview = (result.stderr or '')[:500] raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}') - try: - process_workspace = ( - self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd) - if host_path - else workspace + # Reuse an already-running managed process instead of rebuilding it. + # The Box runtime keeps the managed process alive across a transient + # WebSocket transport drop, so on a reconnect we only need to re-attach + # the WS below. Rebuilding here would needlessly stop a healthy process + # and re-run the (slow, network-touching) dependency bootstrap. + if not await self._managed_process_is_running(): + try: + process_workspace = ( + self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd) + if host_path + else workspace + ) + payload = process_workspace.build_process_payload( + self.server_config['command'], + self.server_config.get('args', []), + env=self.server_config.get('env', {}), + cwd=process_cwd, + ) + if install_cmd: + payload = self._wrap_process_payload_with_python_env(payload, process_cwd) + payload['process_id'] = self.process_id + await workspace.box_service.start_managed_process(workspace.session_id, payload) + except Exception: + self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START + raise + else: + self.ap.logger.info( + f'MCP server {self.server_name}: reusing live managed process ' + f'process_id={self.process_id} (transport reconnect)' ) - payload = process_workspace.build_process_payload( - self.server_config['command'], - self.server_config.get('args', []), - env=self.server_config.get('env', {}), - cwd=process_cwd, - ) - if install_cmd: - payload = self._wrap_process_payload_with_python_env(payload, process_cwd) - payload['process_id'] = self.process_id - await workspace.box_service.start_managed_process(workspace.session_id, payload) - except Exception: - self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START - raise + websocket_url = workspace.get_managed_process_websocket_url(self.process_id) + + # Attach the WS transport + MCP session ONCE, on the owner's exit stack, + # in the same task as the serve loop that follows. websocket_client and + # ClientSession use anyio task groups whose cancel scope is bound to the + # frame/stack that entered them, so they must live on the owner exit + # stack (not a deferred/transferred one) or the streams close the moment + # initialize() returns and the next request fails with "Connection + # closed". + # + # A slow (`npx -y `) cold start makes this single attempt fail + # while the process is still alive — the package is still installing and + # cannot answer the handshake. We surface that to the outer retry loop + # as a _ColdStartRetry: it must NOT stop the process (it is healthy and + # will be reused) and must NOT consume the fatal retry budget. The next + # attempt re-attaches to the same live process; once it has finished + # cold start the handshake succeeds and stays healthy. try: - websocket_url = workspace.get_managed_process_websocket_url(self.process_id) transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url)) read_stream, write_stream = transport self.owner.session = await self.owner.exit_stack.enter_async_context( @@ -202,12 +258,19 @@ class BoxStdioSessionRuntime: ) except Exception: self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT + if not await self._managed_process_has_exited(): + # Process is alive but not yet serving (cold start) — reconnect. + raise _ColdStartRetry(f'{self.server_name}: transport not ready during cold start') raise try: - await self.owner.session.initialize() - except Exception: + await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC) + except Exception as exc: self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT + if not await self._managed_process_has_exited(): + raise _ColdStartRetry( + f'{self.server_name}: handshake not ready during cold start ({type(exc).__name__})' + ) raise async def monitor_process_health(self) -> None: @@ -234,8 +297,74 @@ class BoxStdioSessionRuntime: ) if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS: return + + # Capture stderr logs from the managed process + if isinstance(info, dict): + stderr_text = info.get('stderr', '') or info.get('stderr_preview', '') + else: + stderr_text = getattr(info, 'stderr', '') or getattr(info, 'stderr_preview', '') + + if stderr_text and stderr_text != self.owner._last_stderr_text: + # Find new lines not in the previous snapshot + old_lines = set(self.owner._last_stderr_text.splitlines()) if self.owner._last_stderr_text else set() + new_lines = [l for l in stderr_text.splitlines() if l and l not in old_lines] + self.owner._last_stderr_text = stderr_text + + import time as _time + + for line in new_lines: + level = ( + 'error' + if any(k in line.upper() for k in ('ERROR', 'CRITICAL')) + else 'warning' + if 'WARNING' in line.upper() + else 'debug' + if 'DEBUG' in line.upper() + else 'info' + ) + self.owner._log_buffer.append({'ts': _time.time(), 'level': level, 'text': line}) + await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL) + async def _managed_process_is_running(self) -> bool: + """Return True if this server's managed process exists and is running. + + Used to decide whether initialize() must (re)start the process or can + simply re-attach the WebSocket transport to a process the Box runtime + kept alive across a transient transport drop. + """ + from langbot_plugin.box.models import BoxManagedProcessStatus + + workspace = self._build_workspace() + try: + info = await workspace.get_managed_process(self.process_id) + except Exception: + return False + status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '') + return status in (BoxManagedProcessStatus.RUNNING.value, BoxManagedProcessStatus.RUNNING) + + async def _managed_process_has_exited(self) -> bool: + """Return True only if the process is DEFINITIVELY gone (reports EXITED). + + Distinct from ``not _managed_process_is_running()``: a process that has + just been spawned may not yet report RUNNING, and a transient query + error is not proof of exit. During the cold-start handshake retry we + must NOT treat 'not yet running' or 'query failed' as a terminal + failure, or we bail out to the outer rebuild path and churn the + process (relay then rejects the early re-attach with HTTP 400). Only a + successful query that reports EXITED stops the retry loop. + """ + from langbot_plugin.box.models import BoxManagedProcessStatus + + workspace = self._build_workspace() + try: + info = await workspace.get_managed_process(self.process_id) + except Exception: + # Unknown — treat as 'still coming up', not exited. + return False + status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '') + return status in (BoxManagedProcessStatus.EXITED.value, BoxManagedProcessStatus.EXITED) + async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str: source_path = normalize_host_path(host_path) if not source_path: @@ -342,16 +471,20 @@ class BoxStdioSessionRuntime: workspace = self._build_workspace(host_path=None) - # Transient test sessions own their isolated Box session, so tear the - # whole session down rather than leaking it. This cannot affect live - # servers because they live in the separate shared session. + # Transient config-page tests now share the same 'mcp-shared' Box + # session as live servers, so we must NOT tear the session down here — + # that would kill every other MCP server in the container. A test is + # isolated at the process level: it ran under its own process_id, so we + # stop only that process, exactly like a live server does below. The + # shared session and all other servers' live processes are untouched. + # (Staged per-test workspace files are still cleaned up.) if getattr(self.owner, 'is_transient', False): try: - await workspace.cleanup() + await workspace.stop_managed_process(self.process_id) except Exception as exc: self.ap.logger.warning( - f'MCP server {self.server_name}: failed to delete transient test session ' - f'{self.owner._build_box_session_id()}: {type(exc).__name__}: {exc}' + f'MCP server {self.server_name}: failed to stop transient test process ' + f'process_id={self.process_id}: {type(exc).__name__}: {exc}' ) await self._cleanup_staged_workspace() return diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index b5cfbdc07..c297ac03f 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -1,6 +1,7 @@ from __future__ import annotations import typing +import time from typing import TYPE_CHECKING import langbot_plugin.api.entities.builtin.resource.tool as resource_tool @@ -175,21 +176,130 @@ class ToolManager: return tools + def _get_query_session_id(self, query: pipeline_query.Query) -> str | None: + launcher_type = getattr(query, 'launcher_type', None) + launcher_id = getattr(query, 'launcher_id', None) + if launcher_type is None or launcher_id is None: + return None + + launcher_type_value = launcher_type.value if hasattr(launcher_type, 'value') else launcher_type + return f'{launcher_type_value}_{launcher_id}' + + async def _record_tool_call( + self, + *, + name: str, + source: str, + parameters: dict, + query: pipeline_query.Query, + duration_ms: int, + status: str, + result: typing.Any = None, + error_message: str | None = None, + ) -> None: + monitoring_service = getattr(self.ap, 'monitoring_service', None) + if not monitoring_service: + return + + variables = getattr(query, 'variables', {}) or {} + message_id = variables.get('_monitoring_message_id') if isinstance(variables, dict) else None + bot_name = variables.get('_monitoring_bot_name') if isinstance(variables, dict) else None + pipeline_name = variables.get('_monitoring_pipeline_name') if isinstance(variables, dict) else None + + try: + await monitoring_service.record_tool_call( + tool_name=name, + tool_source=source, + duration=duration_ms, + status=status, + bot_id=getattr(query, 'bot_uuid', None), + bot_name=bot_name, + pipeline_name=pipeline_name, + session_id=self._get_query_session_id(query), + message_id=message_id, + arguments=parameters, + result=result, + error_message=error_message, + ) + except Exception as e: + self.ap.logger.warning(f'Failed to record tool call: {e}') + + async def _invoke_tool_with_monitoring( + self, + *, + source: str, + name: str, + parameters: dict, + query: pipeline_query.Query, + invoke: typing.Callable[[], typing.Awaitable[typing.Any]], + ) -> typing.Any: + start_time = time.perf_counter() + try: + result = await invoke() + except Exception as e: + duration_ms = int((time.perf_counter() - start_time) * 1000) + await self._record_tool_call( + name=name, + source=source, + parameters=parameters, + query=query, + duration_ms=duration_ms, + status='error', + error_message=str(e), + ) + raise + + duration_ms = int((time.perf_counter() - start_time) * 1000) + await self._record_tool_call( + name=name, + source=source, + parameters=parameters, + query=query, + duration_ms=duration_ms, + status='success', + result=result, + ) + return result + async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: from langbot.pkg.telemetry import features as telemetry_features if await self.native_tool_loader.has_tool(name): telemetry_features.increment(query, 'tool_calls', 'native') - return await self.native_tool_loader.invoke_tool(name, parameters, query) + return await self._invoke_tool_with_monitoring( + source='native', + name=name, + parameters=parameters, + query=query, + invoke=lambda: self.native_tool_loader.invoke_tool(name, parameters, query), + ) if await self.plugin_tool_loader.has_tool(name): telemetry_features.increment(query, 'tool_calls', 'plugin') - return await self.plugin_tool_loader.invoke_tool(name, parameters, query) + return await self._invoke_tool_with_monitoring( + source='plugin', + name=name, + parameters=parameters, + query=query, + invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query), + ) if await self.mcp_tool_loader.has_tool(name): telemetry_features.increment(query, 'tool_calls', 'mcp') - return await self.mcp_tool_loader.invoke_tool(name, parameters, query) + return await self._invoke_tool_with_monitoring( + source='mcp', + name=name, + parameters=parameters, + query=query, + invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query), + ) if await self.skill_tool_loader.has_tool(name): telemetry_features.increment(query, 'tool_calls', 'skill') - return await self.skill_tool_loader.invoke_tool(name, parameters, query) + return await self._invoke_tool_with_monitoring( + source='skill', + name=name, + parameters=parameters, + query=query, + invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query), + ) raise ToolNotFoundError(name) async def shutdown(self): diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py index 6a65790ff..64c155840 100644 --- a/tests/integration/api/test_monitoring.py +++ b/tests/integration/api/test_monitoring.py @@ -81,6 +81,7 @@ def fake_monitoring_app(): ) app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100)) app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50)) + app.monitoring_service.get_tool_calls = AsyncMock(return_value=([{'id': 'tool-1'}], 5)) app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10)) app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20)) app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2)) diff --git a/tests/unit_tests/api/service/test_mcp_service.py b/tests/unit_tests/api/service/test_mcp_service.py index 8e3e6cd98..ea08897a1 100644 --- a/tests/unit_tests/api/service/test_mcp_service.py +++ b/tests/unit_tests/api/service/test_mcp_service.py @@ -280,6 +280,25 @@ class TestMCPServiceCreateMCPServer: assert server_uuid is not None assert len(server_uuid) == 36 # UUID format + async def test_create_mcp_server_duplicate_name_raises(self): + """Rejects duplicate MCP server names.""" + # Setup + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.instance_config = SimpleNamespace() + ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}} + ap.tool_mgr = None + + existing_server = _create_mock_mcp_server(name='Existing Server') + ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_server)) + ap.persistence_mgr.serialize_model = Mock(return_value={}) + + service = MCPService(ap) + + # Execute & Verify + with pytest.raises(ValueError, match='MCP server already exists: Existing Server'): + await service.create_mcp_server({'name': 'Existing Server'}) + async def test_create_mcp_server_loads_server(self): """Loads server into tool_mgr when enabled.""" # Setup @@ -301,7 +320,7 @@ class TestMCPServiceCreateMCPServer: nonlocal call_count call_count += 1 if call_count == 1: - return _create_mock_result([]) # Empty list for limit check + return _create_mock_result([]) # Empty result for duplicate-name check elif call_count == 2: return Mock() # Insert return _create_mock_result(first_item=server_entity) # Select created diff --git a/tests/unit_tests/api/test_mcp_controller.py b/tests/unit_tests/api/test_mcp_controller.py new file mode 100644 index 000000000..47f2247f2 --- /dev/null +++ b/tests/unit_tests/api/test_mcp_controller.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import sys +import types +from importlib import import_module +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import quart + +core_app_module = types.ModuleType('langbot.pkg.core.app') +core_app_module.Application = object +sys.modules.setdefault('langbot.pkg.core.app', core_app_module) + + +pytestmark = pytest.mark.asyncio + + +async def _create_test_client(mcp_service: SimpleNamespace): + app = quart.Quart(__name__) + user_service = SimpleNamespace( + verify_jwt_token=AsyncMock(return_value='test@example.com'), + get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')), + ) + ap = SimpleNamespace(mcp_service=mcp_service, user_service=user_service) + MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup + group = MCPRouterGroup(ap, app) + await group.initialize() + return app.test_client() + + +async def test_mcp_server_route_accepts_encoded_slash_name(): + mcp_service = SimpleNamespace( + get_mcp_server_by_name=AsyncMock( + return_value={ + 'uuid': 'test-uuid', + 'name': 'pab1it0/prometheus', + 'enable': True, + 'mode': 'stdio', + 'extra_args': {}, + } + ) + ) + client = await _create_test_client(mcp_service) + + response = await client.get( + '/api/v1/mcp/servers/pab1it0%2Fprometheus', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + mcp_service.get_mcp_server_by_name.assert_awaited_once_with('pab1it0/prometheus') + payload = await response.get_json() + assert payload['data']['server']['name'] == 'pab1it0/prometheus' + + +async def test_mcp_resource_route_accepts_encoded_slash_name(): + mcp_service = SimpleNamespace( + get_mcp_server_by_name=AsyncMock(), + get_mcp_server_resources=AsyncMock(return_value=[]), + get_mcp_server_resource_templates=AsyncMock(return_value=[]), + get_runtime_info=AsyncMock(return_value={'resource_capabilities': {'subscribe': False}}), + ) + client = await _create_test_client(mcp_service) + + response = await client.get( + '/api/v1/mcp/servers/pab1it0%2Fprometheus/resources', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + mcp_service.get_mcp_server_by_name.assert_not_awaited() + mcp_service.get_mcp_server_resources.assert_awaited_once_with('pab1it0/prometheus') + payload = await response.get_json() + assert payload['data']['resource_capabilities'] == {'subscribe': False} diff --git a/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py b/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py index 5b42aef1d..46ba6ecec 100644 --- a/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py +++ b/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py @@ -283,8 +283,51 @@ async def test_aiocqhttp_event_converter_maps_private_and_group_messages(): assert isinstance(group_event.message_chain[1], platform_message.At) -def test_aiocqhttp_event_converter_maps_notice_and_request_events(): - deleted = AiocqhttpEventConverter.notice_to_eba( +@pytest.mark.asyncio +async def test_aiocqhttp_event_converter_enriches_group_message_metadata(): + class Bot: + group_info_calls = 0 + member_info_calls = 0 + + async def get_group_info(self, group_id): + self.group_info_calls += 1 + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + return {'group_id': group_id, 'user_id': user_id, 'card': 'Group Card', 'nickname': 'QQ Nickname'} + + group = onebot_event( + { + 'post_type': 'message', + 'message_type': 'group', + 'sub_type': 'normal', + 'time': 1710000000, + 'self_id': 999, + 'message_id': 12, + 'group_id': 20002, + 'user_id': 10002, + 'message': [{'type': 'text', 'data': {'text': 'hello'}}], + 'raw_message': 'hello', + 'sender': {'user_id': 10002, 'nickname': '', 'card': '', 'role': 'member'}, + } + ) + + bot = Bot() + first = await AiocqhttpEventConverter.target2yiri(group, bot) + second = await AiocqhttpEventConverter.target2yiri(group, bot) + + assert first.group.name == 'Test Group' + assert first.sender.nickname == 'Group Card' + assert first.sender.remark == 'Group Card' + assert second.group.name == 'Test Group' + assert bot.group_info_calls == 1 + assert bot.member_info_calls == 1 + + +@pytest.mark.asyncio +async def test_aiocqhttp_event_converter_maps_notice_and_request_events(): + deleted = await AiocqhttpEventConverter.notice_to_eba( onebot_event( { 'post_type': 'notice', @@ -301,7 +344,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events(): assert isinstance(deleted, platform_events.MessageDeletedEvent) assert deleted.message_id == 33 - joined = AiocqhttpEventConverter.notice_to_eba( + joined = await AiocqhttpEventConverter.notice_to_eba( onebot_event( { 'post_type': 'notice', @@ -319,7 +362,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events(): assert isinstance(joined, platform_events.MemberJoinedEvent) assert joined.join_type == 'invite' - bot_muted = AiocqhttpEventConverter.notice_to_eba( + bot_muted = await AiocqhttpEventConverter.notice_to_eba( onebot_event( { 'post_type': 'notice', @@ -338,7 +381,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events(): assert isinstance(bot_muted, platform_events.BotMutedEvent) assert bot_muted.duration == 60 - friend_request = AiocqhttpEventConverter.request_to_eba( + friend_request = await AiocqhttpEventConverter.request_to_eba( onebot_event( { 'post_type': 'request', @@ -354,7 +397,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events(): assert isinstance(friend_request, platform_events.FriendRequestReceivedEvent) assert friend_request.request_id == 'flag-1' - group_invite = AiocqhttpEventConverter.request_to_eba( + group_invite = await AiocqhttpEventConverter.request_to_eba( onebot_event( { 'post_type': 'request', @@ -371,7 +414,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events(): assert isinstance(group_invite, platform_events.BotInvitedToGroupEvent) assert group_invite.request_id == 'group-flag' - member_left = AiocqhttpEventConverter.notice_to_eba( + member_left = await AiocqhttpEventConverter.notice_to_eba( onebot_event( { 'post_type': 'notice', @@ -389,7 +432,7 @@ def test_aiocqhttp_event_converter_maps_notice_and_request_events(): assert isinstance(member_left, platform_events.MemberLeftEvent) assert member_left.is_kicked is True - friend_added = AiocqhttpEventConverter.notice_to_eba( + friend_added = await AiocqhttpEventConverter.notice_to_eba( onebot_event( { 'post_type': 'notice', diff --git a/tests/unit_tests/platform/test_aiocqhttp_message_converter.py b/tests/unit_tests/platform/test_aiocqhttp_message_converter.py index 55f835c97..2f97176ec 100644 --- a/tests/unit_tests/platform/test_aiocqhttp_message_converter.py +++ b/tests/unit_tests/platform/test_aiocqhttp_message_converter.py @@ -1,7 +1,12 @@ import pytest +import aiocqhttp import langbot_plugin.api.entities.builtin.platform.message as platform_message -from langbot.pkg.platform.sources.aiocqhttp import AiocqhttpAdapter, AiocqhttpMessageConverter +from langbot.pkg.platform.sources.aiocqhttp import ( + AiocqhttpAdapter, + AiocqhttpEventConverter, + AiocqhttpMessageConverter, +) async def _convert_single(component: platform_message.MessageComponent): @@ -103,3 +108,431 @@ async def test_forward_image_base64_payload_is_normalized(): 'type': 'image', 'data': {'file': 'base64://raw-forward-image'}, } + + +@pytest.mark.asyncio +async def test_group_message_member_name_prefers_group_card(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': 'Special Title', + }, + } + ) + + class Bot: + async def get_group_info(self, group_id): + assert group_id == 2000 + return {'group_id': group_id, 'group_name': 'Test Group'} + + converted = await AiocqhttpEventConverter().target2yiri(event, Bot()) + + assert converted.sender.member_name == 'Group Card' + assert converted.sender.group.id == 2000 + assert converted.sender.group.name == 'Test Group' + assert converted.sender.special_title == 'Special Title' + + +@pytest.mark.asyncio +async def test_group_message_member_name_falls_back_to_nickname(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': '', + 'role': 'member', + }, + } + ) + + converted = await AiocqhttpEventConverter().target2yiri(event) + + assert converted.sender.member_name == 'QQ Nickname' + + +@pytest.mark.asyncio +async def test_group_message_special_title_uses_group_member_info_when_sender_title_is_empty(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + + class Bot: + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + assert group_id == 2000 + assert user_id == 3000 + return {'group_id': group_id, 'user_id': user_id, 'title': 'Member Title'} + + converted = await AiocqhttpEventConverter().target2yiri(event, Bot()) + + assert converted.sender.special_title == 'Member Title' + + +@pytest.mark.asyncio +async def test_group_message_special_title_does_not_lookup_when_sender_title_exists(): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': 'Event Title', + }, + } + ) + + class Bot: + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + raise AssertionError('get_group_member_info should not be called') + + converted = await AiocqhttpEventConverter().target2yiri(event, Bot()) + + assert converted.sender.special_title == 'Event Title' + + +@pytest.mark.asyncio +async def test_group_message_special_title_member_info_failure_is_cached(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + now = 1000.0 + + class Bot: + member_info_calls = 0 + + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + raise RuntimeError('api unavailable') + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + second = await converter.target2yiri(event, bot) + + assert first.sender.special_title == '' + assert second.sender.special_title == '' + assert bot.member_info_calls == 1 + + +@pytest.mark.asyncio +async def test_group_message_special_title_member_info_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + now = 1000.0 + + class Bot: + member_info_calls = 0 + + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + return { + 'group_id': group_id, + 'user_id': user_id, + 'title': f'Member Title {self.member_info_calls}', + } + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + now = 87401.0 + second = await converter.target2yiri(event, bot) + + assert first.sender.special_title == 'Member Title 1' + assert second.sender.special_title == 'Member Title 2' + assert bot.member_info_calls == 2 + + +@pytest.mark.asyncio +async def test_group_message_special_title_retries_after_negative_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + 'title': '', + }, + } + ) + now = 1000.0 + + class Bot: + member_info_calls = 0 + + async def get_group_info(self, group_id): + return {'group_id': group_id, 'group_name': 'Test Group'} + + async def get_group_member_info(self, group_id, user_id): + self.member_info_calls += 1 + if self.member_info_calls == 1: + raise RuntimeError('api unavailable') + return {'group_id': group_id, 'user_id': user_id, 'title': 'Recovered Title'} + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + failed = await converter.target2yiri(event, bot) + now = 1601.0 + recovered = await converter.target2yiri(event, bot) + + assert failed.sender.special_title == '' + assert recovered.sender.special_title == 'Recovered Title' + assert bot.member_info_calls == 2 + + +@pytest.mark.asyncio +async def test_group_message_group_name_is_cached(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + assert group_id == 2000 + return {'group_id': group_id, 'group_name': 'Cached Group'} + + monotonic = 1000.0 + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: monotonic) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + second = await converter.target2yiri(event, bot) + + assert first.sender.group.name == 'Cached Group' + assert second.sender.group.name == 'Cached Group' + assert bot.calls == 1 + + +@pytest.mark.asyncio +async def test_group_message_group_name_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + now = 1000.0 + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + return {'group_id': group_id, 'group_name': f'Group Name {self.calls}'} + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + first = await converter.target2yiri(event, bot) + now = 4601.0 + second = await converter.target2yiri(event, bot) + + assert first.sender.group.name == 'Group Name 1' + assert second.sender.group.name == 'Group Name 2' + assert bot.calls == 2 + + +@pytest.mark.asyncio +async def test_group_message_group_name_uses_placeholder_when_lookup_fails(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + now = 1000.0 + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + raise RuntimeError('api unavailable') + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + converted = await converter.target2yiri(event, bot) + cached_failure = await converter.target2yiri(event, bot) + + assert converted.sender.group.name == 'Group 2000' + assert cached_failure.sender.group.name == 'Group 2000' + assert bot.calls == 1 + + +@pytest.mark.asyncio +async def test_group_message_group_name_retries_after_negative_cache_expires(monkeypatch): + event = aiocqhttp.Event( + { + 'post_type': 'message', + 'message_type': 'group', + 'message_id': 1000, + 'message': '', + 'time': 1776491725, + 'group_id': 2000, + 'sender': { + 'user_id': 3000, + 'nickname': 'QQ Nickname', + 'card': 'Group Card', + 'role': 'member', + }, + } + ) + now = 1000.0 + + class Bot: + calls = 0 + + async def get_group_info(self, group_id): + self.calls += 1 + if self.calls == 1: + raise RuntimeError('api unavailable') + return {'group_id': group_id, 'group_name': 'Recovered Group'} + + monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now) + + bot = Bot() + converter = AiocqhttpEventConverter() + + failed = await converter.target2yiri(event, bot) + now = 1061.0 + recovered = await converter.target2yiri(event, bot) + + assert failed.sender.group.name == 'Group 2000' + assert recovered.sender.group.name == 'Recovered Group' + assert bot.calls == 2 diff --git a/tests/unit_tests/platform/test_wecomcs_adapter.py b/tests/unit_tests/platform/test_wecomcs_adapter.py new file mode 100644 index 000000000..ee3425b61 --- /dev/null +++ b/tests/unit_tests/platform/test_wecomcs_adapter.py @@ -0,0 +1,91 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from langbot.pkg.platform.sources.wecomcs import WecomCSAdapter + + +class DummyLogger(abstract_platform_logger.AbstractEventLogger): + async def info(self, *args, **kwargs): + pass + + async def debug(self, *args, **kwargs): + pass + + async def warning(self, *args, **kwargs): + pass + + async def error(self, *args, **kwargs): + pass + + +def make_adapter(): + return WecomCSAdapter( + config={ + 'corpid': 'corp-id', + 'secret': 'secret', + 'token': 'token', + 'EncodingAESKey': 'encoding-key', + }, + logger=DummyLogger(), + ) + + +@pytest.mark.asyncio +async def test_send_message_sends_text_to_customer_service_user(): + adapter = make_adapter() + adapter.bot_account_id = 'kf-test' + adapter.bot = SimpleNamespace(send_text_msg=AsyncMock()) + + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + await adapter.send_message('person', 'uexternal-user', message) + + adapter.bot.send_text_msg.assert_awaited_once() + kwargs = adapter.bot.send_text_msg.await_args.kwargs + assert kwargs['open_kfid'] == 'kf-test' + assert kwargs['external_userid'] == 'external-user' + assert kwargs['content'] == 'hello' + assert kwargs['msgid'].startswith('langbot_') + + +@pytest.mark.asyncio +async def test_send_message_allows_explicit_open_kfid_in_target_id(): + adapter = make_adapter() + adapter.bot = SimpleNamespace(send_text_msg=AsyncMock()) + + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + await adapter.send_message('person', 'kf-explicit|uexternal-user', message) + + kwargs = adapter.bot.send_text_msg.await_args.kwargs + assert kwargs['open_kfid'] == 'kf-explicit' + assert kwargs['external_userid'] == 'external-user' + + +@pytest.mark.asyncio +async def test_send_message_requires_open_kfid(): + adapter = make_adapter() + adapter.bot = SimpleNamespace(send_text_msg=AsyncMock()) + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + with pytest.raises(ValueError, match='open_kfid is required'): + await adapter.send_message('person', 'uexternal-user', message) + + adapter.bot.send_text_msg.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_message_rejects_group_targets(): + adapter = make_adapter() + adapter.bot_account_id = 'kf-test' + adapter.bot = SimpleNamespace(send_text_msg=AsyncMock()) + message = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + with pytest.raises(ValueError, match='only supports sending messages to person'): + await adapter.send_message('group', 'group-id', message) + + adapter.bot.send_text_msg.assert_not_called() diff --git a/tests/unit_tests/platform/test_wecomcs_eba_adapter.py b/tests/unit_tests/platform/test_wecomcs_eba_adapter.py index 3d147b5d9..13d9b6df8 100644 --- a/tests/unit_tests/platform/test_wecomcs_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecomcs_eba_adapter.py @@ -243,6 +243,15 @@ async def test_wecomcs_send_reply_and_platform_api_use_underlying_client(): assert (open_kfid, external_userid, content) == ('kf-1', 'external-1', 'hello') assert msgid.startswith('lb-') + await adapter.send_message('person', 'kf-explicit|uexternal-legacy', message) + open_kfid, external_userid, _, content = adapter.bot.send_text_msg.await_args.args + assert (open_kfid, external_userid, content) == ('kf-explicit', 'external-legacy', 'hello') + + adapter.bot_account_id = 'kf-default' + await adapter.send_message('person', 'uexternal-default', message) + open_kfid, external_userid, _, content = adapter.bot.send_text_msg.await_args.args + assert (open_kfid, external_userid, content) == ('kf-default', 'external-default', 'hello') + image = platform_message.MessageChain([platform_message.Image(base64='data:image/png;base64,AAAA')]) await adapter.send_message('person', 'external-1|kf-1', image) adapter.bot.send_image_msg.assert_awaited_once() diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py index 74cd2487c..bd0ab8a3d 100644 --- a/tests/unit_tests/provider/test_mcp_box_integration.py +++ b/tests/unit_tests/provider/test_mcp_box_integration.py @@ -639,10 +639,13 @@ class TestGetRuntimeInfoDict: assert info['box_session_id'] == 'mcp-shared' assert info['box_enabled'] is True - def test_transient_test_session_is_isolated_from_shared(self, mcp_module): - """A transient test session (config-page "test", no persisted UUID) - must NOT share the live "mcp-shared" Box session. Regression: a failing - test churned the shared session and tore down healthy live servers.""" + def test_transient_test_shares_session_but_isolated_by_process(self, mcp_module): + """A transient config-page "test" now shares the same 'mcp-shared' Box + session as live servers (so a test reuses the running container / live + process instead of a cold per-test session bootstrap). Isolation is at + the PROCESS level: the test runs under its own process_id and only ever + stops that process_id, so it cannot disturb another server's live + process or the shared session itself.""" ap = _make_ap() ap.box_service.available = True transient = _make_session( @@ -670,10 +673,12 @@ class TestGetRuntimeInfoDict: ) assert transient.is_transient is True assert live.is_transient is False - # Isolated session id for the test, shared for the live server. - assert transient._build_box_session_id() == 'mcp-test-gen-uuid-123' + # Both share ONE Box session ... + assert transient._build_box_session_id() == 'mcp-shared' assert live._build_box_session_id() == 'mcp-shared' - assert transient._build_box_session_id() != live._build_box_session_id() + assert transient._build_box_session_id() == live._build_box_session_id() + # ... but are isolated by distinct process_ids within that session. + assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id def test_stdio_session_refuses_when_box_unavailable(self, mcp_module): """Policy: when Box is configured but unavailable (disabled in config @@ -824,3 +829,129 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo assert process_payload['command'] == 'python' assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py'] assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace' + + +@pytest.mark.asyncio +async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_module, tmp_path, monkeypatch): + """During a slow (npx) cold start the handshake fails while the managed + process is still alive. initialize() must raise _ColdStartRetry (so the + outer lifecycle loop reuses the live process and retries without stopping it + or consuming the fatal budget), NOT a fatal error.""" + from contextlib import asynccontextmanager + + mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio'] + + class ColdClientSession: + def __init__(self, *_args): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def initialize(self): + # Process still cold-starting: handshake fails. + raise Exception('Connection closed') + + @asynccontextmanager + async def fake_websocket_client(_url: str): + yield ('read-stream', 'write-stream') + + monkeypatch.setattr(mcp_stdio_module, 'ClientSession', ColdClientSession) + monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client) + monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False) + + ap = _make_ap() + ap.box_service.available = True + ap.box_service.create_session = AsyncMock(return_value={}) + ap.box_service.start_managed_process = AsyncMock(return_value={}) + ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p') + + session = _make_session( + mcp_module, + { + 'name': 'slow', + 'uuid': 'slow-uuid', + 'mode': 'stdio', + 'command': 'npx', + 'args': ['-y', 'some-mcp'], + }, + ap=ap, + ) + + # Process is NOT exited (still cold-starting) and not yet running for reuse. + async def _not_exited(): + return False + + session._box_stdio_runtime._managed_process_has_exited = _not_exited + + async def _not_running(): + return False + + session._box_stdio_runtime._managed_process_is_running = _not_running + + with pytest.raises(mcp_stdio_module._ColdStartRetry): + await session._init_box_stdio_server() + + # Process was started exactly once (the retry will reuse it, not rebuild). + assert ap.box_service.start_managed_process.await_count == 1 + await session.exit_stack.aclose() + + +@pytest.mark.asyncio +async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_path, monkeypatch): + """If the handshake fails AND the process has definitively exited, that is a + real failure — initialize() must NOT swallow it as a cold-start retry.""" + from contextlib import asynccontextmanager + + mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio'] + + class DeadClientSession: + def __init__(self, *_args): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def initialize(self): + raise Exception('Connection closed') + + @asynccontextmanager + async def fake_websocket_client(_url: str): + yield ('read-stream', 'write-stream') + + monkeypatch.setattr(mcp_stdio_module, 'ClientSession', DeadClientSession) + monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client) + monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False) + + ap = _make_ap() + ap.box_service.available = True + ap.box_service.create_session = AsyncMock(return_value={}) + ap.box_service.start_managed_process = AsyncMock(return_value={}) + ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p') + + session = _make_session( + mcp_module, + {'name': 'dead', 'uuid': 'dead-uuid', 'mode': 'stdio', 'command': 'npx', 'args': ['-y', 'x']}, + ap=ap, + ) + + async def _exited(): + return True + + session._box_stdio_runtime._managed_process_has_exited = _exited + + async def _not_running(): + return False + + session._box_stdio_runtime._managed_process_is_running = _not_running + + with pytest.raises(Exception) as ei: + await session._init_box_stdio_server() + assert not isinstance(ei.value, mcp_stdio_module._ColdStartRetry) + await session.exit_stack.aclose() diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx index 8440e47ac..6c0a9ccc1 100644 --- a/web/src/app/home/bots/BotDetailContent.tsx +++ b/web/src/app/home/bots/BotDetailContent.tsx @@ -191,45 +191,47 @@ export default function BotDetailContent({ id }: { id: string }) { onValueChange={setActiveTab} className="flex flex-1 flex-col min-h-0" > - - - - {t('bots.configuration')} - - - - {t('bots.logs')} - - - - {t('bots.sessionMonitor.title')} - {activeTab === 'sessions' && ( - - )} - - +
+ + + + {t('bots.configuration')} + + + + {t('bots.logs')} + + + + {t('bots.sessionMonitor.title')} + + + {activeTab === 'sessions' && ( + + )} +
{/* Tab: Configuration */} Promise; } @@ -100,6 +133,10 @@ const BotSessionMonitor = forwardRef< const [feedbackMap, setFeedbackMap] = useState< Record >({}); + const [toolCalls, setToolCalls] = useState([]); + const [expandedToolCallIds, setExpandedToolCallIds] = useState< + Record + >({}); const messagesContainerRef = useRef(null); const { admins, reload: reloadAdmins } = useBotAdmins(botId); const [adminsDialogOpen, setAdminsDialogOpen] = useState(false); @@ -189,6 +226,7 @@ const BotSessionMonitor = forwardRef< const loadMessages = useCallback( async (sessionId: string) => { setLoadingMessages(true); + setExpandedToolCallIds({}); try { const messagesRes = await httpClient.getSessionMessages(sessionId); const sorted = (messagesRes.messages ?? []).sort( @@ -197,6 +235,18 @@ const BotSessionMonitor = forwardRef< ); setMessages(sorted); + try { + const analysisRes = await httpClient.get<{ + tool_calls?: SessionToolCall[]; + }>( + `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`, + ); + setToolCalls(analysisRes?.tool_calls ?? []); + } catch (analysisError) { + console.error('Failed to load session tool calls:', analysisError); + setToolCalls([]); + } + // Collect user message IDs for feedback matching const userMsgIds = new Set( sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id), @@ -240,11 +290,14 @@ const BotSessionMonitor = forwardRef< loadMessages(selectedSessionId); } else { setMessages([]); + setToolCalls([]); + setExpandedToolCallIds({}); + setFeedbackMap({}); } }, [selectedSessionId, loadMessages]); useEffect(() => { - if (messages.length === 0) return; + if (messages.length === 0 && toolCalls.length === 0) return; // Wait for DOM to render the new messages before scrolling requestAnimationFrame(() => { const container = messagesContainerRef.current; @@ -256,7 +309,7 @@ const BotSessionMonitor = forwardRef< scrollTarget.scrollTop = scrollTarget.scrollHeight; } }); - }, [messages]); + }, [messages, toolCalls]); const parseMessageChain = (content: string): MessageChainComponent[] => { try { @@ -431,6 +484,71 @@ const BotSessionMonitor = forwardRef< return `${diffDays}d`; }; + const formatDuration = (durationMs: number): string => { + if (!durationMs) return '0ms'; + if (durationMs < 1000) return `${durationMs}ms`; + return `${(durationMs / 1000).toFixed(2)}s`; + }; + + const truncateToolDetail = (value?: string | null): string => { + if (!value) return ''; + return value.length > 600 ? `${value.slice(0, 600)}...` : value; + }; + + const toggleToolCallDetails = (toolCallId: string) => { + setExpandedToolCallIds((previous) => ({ + ...previous, + [toolCallId]: !previous[toolCallId], + })); + }; + + const feedbackByMessageId = useMemo(() => { + const map: Record = {}; + + for (let index = 0; index < messages.length; index++) { + const msg = messages[index]; + if (isUserMessage(msg)) continue; + + for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) { + const previousMessage = messages[previousIndex]; + if (isUserMessage(previousMessage)) { + const feedback = feedbackMap[previousMessage.id]; + if (feedback) { + map[msg.id] = feedback; + } + break; + } + } + } + + return map; + }, [feedbackMap, messages]); + + const timelineItems = useMemo(() => { + const messageItems: SessionTimelineItem[] = messages.map( + (message, index) => ({ + id: `message-${message.id}`, + type: 'message', + timestamp: parseTimestamp(message.timestamp).getTime(), + order: index * 2, + message, + }), + ); + const toolItems: SessionTimelineItem[] = toolCalls.map( + (toolCall, index) => ({ + id: `tool-${toolCall.id}`, + type: 'tool', + timestamp: parseTimestamp(toolCall.timestamp).getTime(), + order: index * 2 + 1, + toolCall, + }), + ); + + return [...messageItems, ...toolItems].sort( + (a, b) => a.timestamp - b.timestamp || a.order - b.order, + ); + }, [messages, toolCalls]); + const selectedSession = sessions.find( (s) => s.session_id === selectedSessionId, ); @@ -612,29 +730,162 @@ const BotSessionMonitor = forwardRef<
{t('bots.sessionMonitor.loading')}
- ) : messages.length === 0 ? ( + ) : timelineItems.length === 0 ? (
{t('bots.sessionMonitor.noMessages')}
) : ( - messages.map((msg, msgIndex) => { + timelineItems.map((item) => { + if (item.type === 'tool') { + const call = item.toolCall; + const hasToolDetails = Boolean( + call.arguments || call.result || call.error_message, + ); + const expandedToolCall = Boolean( + expandedToolCallIds[call.id], + ); + const detailsId = `tool-call-details-${call.id}`; + return ( +
+
+ + + {hasToolDetails && expandedToolCall && ( +
+ {(call.arguments || call.result) && ( +
+ {call.arguments && ( +
+
+ {t( + 'monitoring.toolCalls.arguments', + { + defaultValue: '参数', + }, + )} +
+
+                                            {truncateToolDetail(call.arguments)}
+                                          
+
+ )} + {call.result && ( +
+
+ {t('monitoring.toolCalls.result', { + defaultValue: '结果', + })} +
+
+                                            {truncateToolDetail(call.result)}
+                                          
+
+ )} +
+ )} + + {call.error_message && ( +
+ {call.error_message} +
+ )} +
+ )} + +
+ + {t('monitoring.toolCalls.title', { + defaultValue: '工具调用', + })} + + + {formatTime(call.timestamp)} + + {hasToolDetails && ( + <> + · + + {expandedToolCall + ? t( + 'monitoring.toolCalls.hideDetails', + { + defaultValue: '隐藏详情', + }, + ) + : t( + 'monitoring.toolCalls.showDetails', + { + defaultValue: '查看详情', + }, + )} + + + )} +
+
+
+ ); + } + + const msg = item.message; const isUser = isUserMessage(msg); const isDiscarded = msg.status === 'discarded' || msg.pipeline_id === PIPELINE_DISCARD; - // For bot replies, find feedback linked to the preceding user message - let msgFeedback: SessionFeedback | undefined; - if (!isUser) { - for (let i = msgIndex - 1; i >= 0; i--) { - if (isUserMessage(messages[i])) { - msgFeedback = feedbackMap[messages[i].id]; - break; - } - } - } + const msgFeedback = feedbackByMessageId[msg.id]; return (
( + item: T, +): item is T & { uuid: string } { + return typeof item.uuid === 'string' && item.uuid.trim().length > 0; +} + +function hasUsableOptionName(option: { name?: string | null }): boolean { + return typeof option.name === 'string' && option.name.trim().length > 0; +} + export default function DynamicFormItemComponent({ config, field, @@ -142,7 +152,7 @@ export default function DynamicFormItemComponent({ httpClient .getProviderLLMModels() .then((resp) => { - setLlmModels(resp.models); + setLlmModels(resp.models.filter(hasUsableUuid)); }) .catch((err) => { toast.error(t('models.getModelListError') + err.msg); @@ -153,7 +163,7 @@ export default function DynamicFormItemComponent({ httpClient .getProviderEmbeddingModels() .then((resp) => { - setEmbeddingModels(resp.models); + setEmbeddingModels(resp.models.filter(hasUsableUuid)); }) .catch((err) => { toast.error(t('embedding.getModelListError') + err.msg); @@ -164,7 +174,7 @@ export default function DynamicFormItemComponent({ httpClient .getProviderRerankModels() .then((resp) => { - setRerankModels(resp.models); + setRerankModels(resp.models.filter(hasUsableUuid)); }) .catch((err) => { toast.error('Failed to load rerank models: ' + err.msg); @@ -268,7 +278,7 @@ export default function DynamicFormItemComponent({ httpClient .getKnowledgeBases() .then((resp) => { - setKnowledgeBases(resp.bases); + setKnowledgeBases(resp.bases.filter(hasUsableUuid)); }) .catch((err) => { toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg); @@ -281,7 +291,7 @@ export default function DynamicFormItemComponent({ httpClient .getBots() .then((resp) => { - setBots(resp.bots); + setBots(resp.bots.filter(hasUsableUuid)); }) .catch((err) => { toast.error(t('bots.getBotListError') + err.msg); @@ -461,7 +471,7 @@ export default function DynamicFormItemComponent({ - {config.options?.map((option) => ( + {config.options?.filter(hasUsableOptionName).map((option) => ( { const engineName = kb.knowledge_engine?.name ? extractI18nObject(kb.knowledge_engine.name) @@ -1263,7 +1274,7 @@ export default function DynamicFormItemComponent({ acc[engineName].push(kb); return acc; }, - {} as Record, + {} as Record, ); return ( @@ -1271,7 +1282,7 @@ export default function DynamicFormItemComponent({ {field.value && field.value !== '__none__' ? ( (() => { - const selectedKb = knowledgeBases.find( + const selectedKb = validKnowledgeBases.find( (kb) => kb.uuid === field.value, ); return ( @@ -1300,7 +1311,7 @@ export default function DynamicFormItemComponent({ {engineName} {kbs.map((base) => ( - +
{base.emoji && ( {base.emoji} @@ -1317,7 +1328,8 @@ export default function DynamicFormItemComponent({ case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR: // Group KBs by Knowledge Engine name for multi-selector - const multiKbsByEngine = knowledgeBases.reduce( + const validMultiKnowledgeBases = knowledgeBases.filter(hasUsableUuid); + const multiKbsByEngine = validMultiKnowledgeBases.reduce( (acc, kb) => { const engineName = kb.knowledge_engine?.name ? extractI18nObject(kb.knowledge_engine.name) @@ -1328,7 +1340,7 @@ export default function DynamicFormItemComponent({ acc[engineName].push(kb); return acc; }, - {} as Record, + {} as Record, ); return ( @@ -1337,7 +1349,7 @@ export default function DynamicFormItemComponent({ {field.value && field.value.length > 0 ? (
{field.value.map((kbId: string) => { - const currentKb = knowledgeBases.find( + const currentKb = validMultiKnowledgeBases.find( (base) => base.uuid === kbId, ); if (!currentKb) return null; @@ -1423,15 +1435,13 @@ export default function DynamicFormItemComponent({ {engineName}
{kbs.map((base) => { - const isSelected = tempSelectedKBIds.includes( - base.uuid ?? '', - ); + const isSelected = tempSelectedKBIds.includes(base.uuid); return (
{ - const kbId = base.uuid ?? ''; + const kbId = base.uuid; setTempSelectedKBIds((prev) => prev.includes(kbId) ? prev.filter((id) => id !== kbId) @@ -1493,8 +1503,8 @@ export default function DynamicFormItemComponent({ - {bots.map((bot) => ( - + {bots.filter(hasUsableUuid).map((bot) => ( + {bot.name} ))} diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 189d7a70e..1c9bf6e7b 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -268,6 +268,48 @@ function saveListExpansionState(state: SidebarListExpansionState) { // Maximum number of entity sub-items visible before "More" toggle const MAX_VISIBLE_ITEMS = 5; +const MCP_REFRESH_POLL_INTERVAL_MS = 1000; +const MCP_REFRESH_TIMEOUT_MS = 60000; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForMCPRefreshTask(taskId: number) { + const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS; + + while (Date.now() < deadline) { + const task = await httpClient.getAsyncTask(taskId); + if (task.runtime.done) return task; + await sleep(MCP_REFRESH_POLL_INTERVAL_MS); + } + + throw new Error(`Timed out waiting for MCP refresh task ${taskId}`); +} + +async function refreshEnabledMCPConnections() { + const resp = await httpClient.getMCPServers(); + const enabledServers = resp.servers.filter((server) => server.enable); + if (enabledServers.length === 0) return; + + const taskResults = await Promise.allSettled( + enabledServers.map((server) => httpClient.testMCPServer(server.name, {})), + ); + const taskIds: number[] = []; + + for (const result of taskResults) { + if ( + result.status === 'fulfilled' && + typeof result.value.task_id === 'number' + ) { + taskIds.push(result.value.task_id); + } else if (result.status === 'rejected') { + console.error('Failed to start MCP refresh task:', result.reason); + } + } + + await Promise.allSettled(taskIds.map(waitForMCPRefreshTask)); +} // Sort entity items by updatedAt descending (most recent first), items without updatedAt go last function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] { @@ -356,11 +398,19 @@ function NavItems({ if (extRefreshing) return; setExtRefreshing(true); try { - await Promise.all([ + const results = await Promise.allSettled([ sidebarData.refreshPlugins(), - sidebarData.refreshMCPServers(), sidebarData.refreshSkills(), + refreshEnabledMCPConnections(), ]); + const mcpRefreshResult = results[2]; + if (mcpRefreshResult.status === 'rejected') { + console.error( + 'Failed to refresh MCP connections:', + mcpRefreshResult.reason, + ); + } + await sidebarData.refreshMCPServers(); } finally { setExtRefreshing(false); } diff --git a/web/src/app/home/mcp/MCPDetailContent.tsx b/web/src/app/home/mcp/MCPDetailContent.tsx index 473676abd..91a0801f6 100644 --- a/web/src/app/home/mcp/MCPDetailContent.tsx +++ b/web/src/app/home/mcp/MCPDetailContent.tsx @@ -157,6 +157,10 @@ export default function MCPDetailContent({ id }: { id: string }) { navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`); } + const handlePersistedTestComplete = useCallback(async () => { + await refreshMCPServers(); + }, [refreshMCPServers]); + function confirmDelete() { httpClient .deleteMCPServer(id) @@ -364,6 +368,7 @@ export default function MCPDetailContent({ id }: { id: string }) { onRuntimeInfoChange={(runtimeInfo) => setDetailRuntimeStatus(runtimeInfo?.status ?? null) } + onPersistedTestComplete={handlePersistedTestComplete} />
diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index a5a057d61..753e7a73c 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -41,6 +41,7 @@ import { } from '@/components/ui/card'; import { httpClient } from '@/app/infra/http/HttpClient'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import MCPLogs from '@/app/home/mcp/components/mcp-form/MCPLogs'; import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme'; import { MCPServerRuntimeInfo, @@ -487,6 +488,7 @@ interface MCPFormProps { onDirtyChange?: (dirty: boolean) => void; onTestingChange?: (testing: boolean) => void; onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void; + onPersistedTestComplete?: (serverName: string) => void | Promise; /** Reported when the form cannot be saved because the current mode is * ``stdio`` and the Box sandbox is disabled/unavailable. Parents that * render the Save button outside this component should disable it. */ @@ -511,6 +513,7 @@ const MCPForm = forwardRef(function MCPForm( onDirtyChange, onTestingChange, onRuntimeInfoChange, + onPersistedTestComplete, onSaveBlockedChange, layout = 'stacked', sideHeader, @@ -750,6 +753,8 @@ const MCPForm = forwardRef(function MCPForm( } try { let serverConfig: MCPServer; + const serverName = + isEditMode && initServerName ? initServerName : value.name; if (value.mode === 'remote') { const headers: Record = {}; @@ -758,7 +763,7 @@ const MCPForm = forwardRef(function MCPForm( }); serverConfig = { - name: value.name, + name: serverName, mode: 'remote', enable: true, extra_args: { @@ -774,7 +779,7 @@ const MCPForm = forwardRef(function MCPForm( }); serverConfig = { - name: value.name, + name: serverName, mode: 'stdio', enable: true, extra_args: { @@ -818,6 +823,10 @@ const MCPForm = forwardRef(function MCPForm( // `uvx` with no package (exit 2 / "Connection closed", no detail). // The form values are kept in sync on every edit and on load, so they // are always current. + const serverName = + isEditMode && initServerName ? initServerName : form.getValues('name'); + const shouldTestPersistedServer = + isEditMode && !!initServerName && !form.formState.isDirty; const formExtraArgs = form.getValues('extra_args') ?? []; const formStdioArgs = form.getValues('args') ?? []; let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio; @@ -840,12 +849,20 @@ const MCPForm = forwardRef(function MCPForm( }; } - const { task_id } = await httpClient.testMCPServer('_', { - name: form.getValues('name'), - mode, - enable: true, - extra_args: extraArgsData, - } as MCPServer); + const testTarget = shouldTestPersistedServer ? serverName : '_'; + const testPayload = shouldTestPersistedServer + ? {} + : ({ + name: serverName, + mode, + enable: true, + extra_args: extraArgsData, + } as MCPServer); + + const { task_id } = await httpClient.testMCPServer( + testTarget, + testPayload, + ); if (!task_id) { throw new Error(t('mcp.noTaskId')); @@ -871,14 +888,18 @@ const MCPForm = forwardRef(function MCPForm( resource_count: 0, resources: [], }); + if (shouldTestPersistedServer) { + await onPersistedTestComplete?.(serverName); + } } else { - if (isEditMode) { - await loadServerForEdit(form.getValues('name')); + if (shouldTestPersistedServer) { + await loadServerForEdit(serverName); + await onPersistedTestComplete?.(serverName); } else { - // Create mode has no persisted server to reload tools from. + // Transient tests have no persisted server to reload tools from. // The backend stashes the discovered runtime info (status + - // tools) in the test task's metadata before tearing the - // transient session down — surface it so a successful test + // tools) in the task metadata before tearing the transient + // session down — surface it so a successful test // shows the tool list instead of "no tools found". const runtimeInfoFromTest = taskResp.task_context?.metadata ?.runtime_info as MCPServerRuntimeInfo | undefined; @@ -1163,11 +1184,14 @@ const MCPForm = forwardRef(function MCPForm( ); + const persistedServerName = + isEditMode && initServerName ? initServerName : form.getValues('name'); + const runtimePanel = ( ); @@ -1200,6 +1224,9 @@ const MCPForm = forwardRef(function MCPForm( {resourcesTabLabel} + + {t('mcp.tabLogs')} + @@ -1211,7 +1238,7 @@ const MCPForm = forwardRef(function MCPForm( @@ -1223,11 +1250,14 @@ const MCPForm = forwardRef(function MCPForm( + + {persistedServerName && } + ) : ( runtimePanel diff --git a/web/src/app/home/mcp/components/mcp-form/MCPLogs.tsx b/web/src/app/home/mcp/components/mcp-form/MCPLogs.tsx new file mode 100644 index 000000000..16d2547a4 --- /dev/null +++ b/web/src/app/home/mcp/components/mcp-form/MCPLogs.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { useTranslation } from 'react-i18next'; +import { PluginLogEntry } from '@/app/infra/entities/plugin'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { RefreshCw } from 'lucide-react'; + +const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const; + +function levelClassName(level: string): string { + switch (level) { + case 'ERROR': + case 'CRITICAL': + return 'text-red-500'; + case 'WARNING': + return 'text-amber-500'; + case 'DEBUG': + return 'text-gray-400 dark:text-gray-500'; + default: + return 'text-gray-700 dark:text-gray-300'; + } +} + +export default function MCPLogs({ serverName }: { serverName: string }) { + const { t } = useTranslation(); + const [logs, setLogs] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [level, setLevel] = useState('ALL'); + const [autoRefresh, setAutoRefresh] = useState(true); + const scrollRef = useRef(null); + const atBottomRef = useRef(true); + + const fetchLogs = useCallback(() => { + setIsLoading(true); + httpClient + .getMcpServerLogs(serverName, 500, level === 'ALL' ? undefined : level) + .then((res) => { + setLogs(res.logs ?? []); + }) + .catch(() => { + setLogs([]); + }) + .finally(() => { + setIsLoading(false); + }); + }, [serverName, level]); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + // Auto-refresh poll loop. + useEffect(() => { + if (!autoRefresh) return; + const timer = setInterval(fetchLogs, 3000); + return () => clearInterval(timer); + }, [autoRefresh, fetchLogs]); + + // Keep view pinned to bottom when the user is already at the bottom. + useEffect(() => { + const el = scrollRef.current; + if (el && atBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [logs]); + + function handleScroll() { + const el = scrollRef.current; + if (!el) return; + atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + } + + return ( +
+
+ + +
+ + +
+
+ +
+ {logs.length === 0 ? ( +
+ {t('mcp.logsEmpty')} +
+ ) : ( + logs.map((entry, idx) => ( +
+ {entry.text} +
+ )) + )} +
+
+ ); +} diff --git a/web/src/app/home/monitoring/components/ConversationTurnList.tsx b/web/src/app/home/monitoring/components/ConversationTurnList.tsx new file mode 100644 index 000000000..afcaa1388 --- /dev/null +++ b/web/src/app/home/monitoring/components/ConversationTurnList.tsx @@ -0,0 +1,649 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertCircle, + Bot, + ChevronDown, + ChevronRight, + Clock, + Cpu, + Hash, + User, + Wrench, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { MessageContentRenderer } from './MessageContentRenderer'; +import { + ConversationTurn, + hasRenderableMessageContent, +} from '../utils/conversationTurns'; +import { MonitoringMessage } from '../types/monitoring'; + +interface ConversationTurnListProps { + turns: ConversationTurn[]; + expandedTurnId: string | null; + onToggleTurn: (turnId: string) => void; +} + +function shortId(id?: string) { + if (!id) return '-'; + if (id.length <= 12) return id; + return `${id.slice(0, 8)}...${id.slice(-4)}`; +} + +function formatDuration(ms: number) { + if (!ms) return '0ms'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +function truncateDetail(value?: string) { + if (!value) return ''; + return value.length > 1200 ? `${value.slice(0, 1200)}...` : value; +} + +function roleLabel(message: MonitoringMessage | undefined) { + const role = message?.role?.toLowerCase(); + if (role === 'assistant') return 'assistant'; + if (role === 'user') return 'user'; + return 'message'; +} + +function statusClass(level: ConversationTurn['level']) { + if (level === 'error') { + return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'; + } + if (level === 'warning') { + return 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900 dark:bg-yellow-950/40 dark:text-yellow-300'; + } + return 'border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300'; +} + +function Metric({ + icon, + label, + tone = 'default', +}: { + icon: React.ReactNode; + label: string; + tone?: 'default' | 'error'; +}) { + return ( + + {icon} + {label} + + ); +} + +function MetaItem({ label, value }: { label: string; value?: string }) { + return ( +
+
{label}
+
+ {value || '-'} +
+
+ ); +} + +function MessageLane({ + label, + icon, + content, + empty, + maxLines, +}: { + label: string; + icon: React.ReactNode; + content?: string; + empty: string; + maxLines: number; +}) { + return ( +
+
+ {icon} + {label} +
+
+ {content && hasRenderableMessageContent(content) ? ( + + ) : ( + {empty} + )} +
+
+ ); +} + +function ExpandedMessage({ + message, + label, +}: { + message: MonitoringMessage; + label: string; +}) { + return ( +
+
+ + {label} + + {message.timestamp.toLocaleString()} + ID: {shortId(message.id)} +
+
+ +
+
+ ); +} + +export function ConversationTurnList({ + turns, + expandedTurnId, + onToggleTurn, +}: ConversationTurnListProps) { + const { t } = useTranslation(); + const [expandedToolCallIds, setExpandedToolCallIds] = React.useState< + Record + >({}); + + const toggleToolCallDetails = (toolCallKey: string) => { + setExpandedToolCallIds((previous) => ({ + ...previous, + [toolCallKey]: !previous[toolCallKey], + })); + }; + + return ( +
+
+ + {t('monitoring.messageList.turns', { + defaultValue: '{{count}} 轮对话', + count: turns.length, + })} + +
+ + {turns.map((turn) => { + const expanded = expandedTurnId === turn.id; + const firstAssistant = turn.assistantMessages[0]; + const assistantOverflow = Math.max( + turn.assistantMessages.length - 1, + 0, + ); + + return ( +
+
onToggleTurn(turn.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggleTurn(turn.id); + } + }} + > +
+
+
+ {expanded ? ( + + ) : ( + + )} + + Turn: {shortId(turn.id)} + +
+ +
+ + {turn.botName} + + + + {turn.pipelineName} + + {turn.runnerName && ( + <> + + + {turn.runnerName} + + + )} +
+ +
+ } + content={turn.userMessage?.messageContent} + empty={t('monitoring.messageList.noUserMessage', { + defaultValue: '未记录用户输入', + })} + maxLines={2} + /> + 0 + ? t('monitoring.messageList.assistantMessageCount', { + defaultValue: '助手 +{{count}}', + count: assistantOverflow, + }) + : t('monitoring.messageList.assistantMessage', { + defaultValue: '助手', + }) + } + icon={} + content={firstAssistant?.messageContent} + empty={t('monitoring.messageList.noAssistantMessage', { + defaultValue: '未记录助手回复', + })} + maxLines={2} + /> +
+
+ +
+
+ {turn.lastActivityAt.toLocaleString()} +
+
+ {turn.level} +
+
+ } + label={`${turn.llmCalls.length} LLM`} + /> + {turn.toolCalls.length > 0 && ( + } + label={`${turn.toolCalls.length} tools`} + /> + )} + } + label={`${turn.totalTokens.toLocaleString()} tokens`} + /> + } + label={formatDuration(turn.totalDuration)} + /> + {turn.errors.length > 0 && ( + } + label={`${turn.errors.length} errors`} + tone="error" + /> + )} +
+
+
+
+ + {expanded && ( +
+
+
+ + + + + +
+ +
+

+ + {t('monitoring.messageList.conversationTrace', { + defaultValue: '消息链路', + })} +

+
+ {turn.messages.map((message) => ( + + ))} +
+
+ +
+

+ + {t('monitoring.llmCalls.title', { + defaultValue: 'LLM 调用', + })}{' '} + ({turn.llmCalls.length}) +

+ +
+ + + +
+ +
+ {turn.llmCalls.length > 0 ? ( + turn.llmCalls.map((call, index) => ( +
+
+
+ + #{index + 1} {call.modelName} + + + {call.status} + +
+ + {formatDuration(call.duration)} + +
+
+ In: {call.tokens.input} + Out: {call.tokens.output} + Total: {call.tokens.total} + + ID: {shortId(call.id)} + +
+ {call.errorMessage && ( +
+ {call.errorMessage} +
+ )} +
+ )) + ) : ( +
+ {t('monitoring.messageList.noLlmCalls', { + defaultValue: '未记录模型调用', + })} +
+ )} +
+
+ +
+

+ + {t('monitoring.toolCalls.title', { + defaultValue: '工具调用', + })}{' '} + ({turn.toolCalls.length}) +

+ +
+ + + call.status === 'error', + ).length, + )} + /> +
+ +
+ {turn.toolCalls.length > 0 ? ( + turn.toolCalls.map((call, index) => { + const toolCallKey = `${turn.id}:${call.id}`; + const hasToolDetails = Boolean( + call.arguments || call.result || call.errorMessage, + ); + const expandedToolCall = Boolean( + expandedToolCallIds[toolCallKey], + ); + const detailsId = `monitoring-tool-call-details-${call.id}`; + + return ( +
+ + + {hasToolDetails && expandedToolCall && ( +
+ {call.arguments && ( +
+
+ {t('monitoring.toolCalls.arguments', { + defaultValue: '参数', + })} +
+
+                                        {truncateDetail(call.arguments)}
+                                      
+
+ )} + {call.result && ( +
+
+ {t('monitoring.toolCalls.result', { + defaultValue: '结果', + })} +
+
+                                        {truncateDetail(call.result)}
+                                      
+
+ )} + {call.errorMessage && ( +
+ {call.errorMessage} +
+ )} +
+ )} +
+ ); + }) + ) : ( +
+ {t('monitoring.toolCalls.noToolCalls', { + defaultValue: '未记录工具调用', + })} +
+ )} +
+
+ + {turn.errors.length > 0 && ( +
+

+ + {t('monitoring.errors.title', { + defaultValue: '错误日志', + })}{' '} + ({turn.errors.length}) +

+
+ {turn.errors.map((error) => ( +
+
+ + {error.errorType} + + + {error.timestamp.toLocaleString()} + +
+
+ {error.errorMessage} +
+
+ ))} +
+
+ )} +
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/web/src/app/home/monitoring/hooks/useMonitoringData.ts b/web/src/app/home/monitoring/hooks/useMonitoringData.ts index 7d69e586c..8b587cc43 100644 --- a/web/src/app/home/monitoring/hooks/useMonitoringData.ts +++ b/web/src/app/home/monitoring/hooks/useMonitoringData.ts @@ -106,6 +106,9 @@ export function useMonitoringData(filterState: FilterState) { const llmCalls = Array.isArray(response?.llmCalls) ? response.llmCalls : []; + const toolCalls = Array.isArray(response?.toolCalls) + ? response.toolCalls + : []; const embeddingCalls = Array.isArray(response?.embeddingCalls) ? response.embeddingCalls : []; @@ -116,6 +119,7 @@ export function useMonitoringData(filterState: FilterState) { const totalCount = response?.totalCount ?? { messages: messages.length, llmCalls: llmCalls.length, + toolCalls: toolCalls.length, embeddingCalls: embeddingCalls.length, sessions: sessions.length, errors: errors.length, @@ -145,8 +149,10 @@ export function useMonitoringData(filterState: FilterState) { level: string; platform?: string; user_id?: string; + user_name?: string; runner_name?: string; variables?: string; + role?: string; }) => ({ id: msg.id, timestamp: parseUTCTimestamp(msg.timestamp), @@ -160,8 +166,10 @@ export function useMonitoringData(filterState: FilterState) { level: msg.level as 'info' | 'warning' | 'error' | 'debug', platform: msg.platform, userId: msg.user_id, + userName: msg.user_name, runnerName: msg.runner_name, variables: msg.variables, + role: msg.role, }), ), llmCalls: llmCalls.map( @@ -179,6 +187,7 @@ export function useMonitoringData(filterState: FilterState) { bot_name: string; pipeline_id: string; pipeline_name: string; + session_id?: string; error_message?: string; message_id?: string; }) => ({ @@ -197,10 +206,46 @@ export function useMonitoringData(filterState: FilterState) { botName: call.bot_name, pipelineId: call.pipeline_id, pipelineName: call.pipeline_name, + sessionId: call.session_id, errorMessage: call.error_message, messageId: call.message_id, }), ), + toolCalls: toolCalls.map( + (call: { + id: string; + timestamp: string; + tool_name: string; + tool_source: string; + duration: number; + status: string; + bot_id: string; + bot_name: string; + pipeline_id: string; + pipeline_name: string; + session_id?: string; + message_id?: string; + arguments?: string; + result?: string; + error_message?: string; + }) => ({ + id: call.id, + timestamp: parseUTCTimestamp(call.timestamp), + toolName: call.tool_name, + toolSource: call.tool_source, + duration: call.duration, + status: call.status as 'success' | 'error', + botId: call.bot_id, + botName: call.bot_name, + pipelineId: call.pipeline_id, + pipelineName: call.pipeline_name, + sessionId: call.session_id, + messageId: call.message_id, + arguments: call.arguments, + result: call.result, + errorMessage: call.error_message, + }), + ), embeddingCalls: embeddingCalls.map( (call: { id: string; @@ -294,6 +339,7 @@ export function useMonitoringData(filterState: FilterState) { totalCount: { messages: totalCount.messages, llmCalls: totalCount.llmCalls, + toolCalls: totalCount.toolCalls ?? toolCalls.length, embeddingCalls: totalCount.embeddingCalls || 0, sessions: totalCount.sessions, errors: totalCount.errors, @@ -317,6 +363,7 @@ export function useMonitoringData(filterState: FilterState) { botName: call.botName, pipelineId: call.pipelineId, pipelineName: call.pipelineName, + sessionId: call.sessionId, }), ); diff --git a/web/src/app/home/monitoring/page.tsx b/web/src/app/home/monitoring/page.tsx index 17437140d..1ae5dd945 100644 --- a/web/src/app/home/monitoring/page.tsx +++ b/web/src/app/home/monitoring/page.tsx @@ -18,60 +18,12 @@ import { ExportDropdown } from './components/ExportDropdown'; import { useMonitoringFilters } from './hooks/useMonitoringFilters'; import { useMonitoringData } from './hooks/useMonitoringData'; import { useFeedbackData } from './hooks/useFeedbackData'; -import { MessageDetailsCard } from './components/MessageDetailsCard'; -import { MessageContentRenderer } from './components/MessageContentRenderer'; +import { ConversationTurnList } from './components/ConversationTurnList'; import { FeedbackStatsCards } from './components/FeedbackCard'; import { FeedbackList } from './components/FeedbackList'; -import { MessageDetails } from './types/monitoring'; -import { httpClient } from '@/app/infra/http/HttpClient'; +import { buildConversationTurns } from './utils/conversationTurns'; import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner'; -interface RawMessageData { - id: string; - timestamp: string; - bot_id: string; - bot_name: string; - pipeline_id: string; - pipeline_name: string; - message_content: string; - session_id: string; - status: string; - level: string; - platform: string; - user_id: string; - runner_name: string; - variables: Record; -} - -interface RawLLMCallData { - id: string; - timestamp: string; - model_name: string; - status: string; - duration: number; - error_message: string | null; - input_tokens: number; - output_tokens: number; - total_tokens: number; -} - -interface RawLLMStatsData { - total_calls: number; - total_input_tokens: number; - total_output_tokens: number; - total_tokens: number; - total_duration_ms: number; - average_duration_ms: number; -} - -interface RawErrorData { - id: string; - timestamp: string; - error_type: string; - error_message: string; - stack_trace: string | null; -} - function MonitoringPageContent() { const { t } = useTranslation(); const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } = @@ -146,115 +98,37 @@ function MonitoringPageContent() { setFeedbackRefreshKey((k) => k + 1); }, [refetch]); - const [expandedMessageId, setExpandedMessageId] = useState( - null, - ); - const [messageDetails, setMessageDetails] = useState< - Record - >({}); - const [loadingDetails, setLoadingDetails] = useState>( - {}, + const conversationTurns = useMemo( + () => + buildConversationTurns( + data?.messages || [], + data?.llmCalls || [], + data?.errors || [], + data?.toolCalls || [], + ), + [data?.messages, data?.llmCalls, data?.errors, data?.toolCalls], ); // State for expanded errors const [expandedErrorId, setExpandedErrorId] = useState(null); + const [expandedTurnId, setExpandedTurnId] = useState(null); // State for controlled tabs const [activeTab, setActiveTab] = useState('messages'); // Function to jump to a message record - const jumpToMessage = async (messageId: string) => { + const jumpToMessage = (messageId: string) => { setActiveTab('messages'); - // Small delay to ensure tab switch completes setTimeout(() => { - toggleMessageExpand(messageId); + const turn = conversationTurns.find((item) => + item.messages.some((message) => message.id === messageId), + ); + setExpandedTurnId(turn?.id ?? messageId); }, 100); }; - const toggleMessageExpand = async (messageId: string) => { - if (expandedMessageId === messageId) { - // Collapse - setExpandedMessageId(null); - } else { - // Expand - setExpandedMessageId(messageId); - - // Fetch details if not already loaded - if (!messageDetails[messageId]) { - setLoadingDetails({ ...loadingDetails, [messageId]: true }); - try { - // httpClient.get() returns the inner data directly (response.data.data) - const result = await httpClient.get<{ - message_id: string; - found: boolean; - message: RawMessageData | null; - llm_calls: RawLLMCallData[]; - llm_stats: RawLLMStatsData; - errors: RawErrorData[]; - }>(`/api/v1/monitoring/messages/${messageId}/details`); - - if (result) { - setMessageDetails((prev) => ({ - ...prev, - [messageId]: { - messageId: result.message_id, - found: result.found, - message: result.message - ? { - id: result.message.id, - timestamp: new Date(result.message.timestamp), - botId: result.message.bot_id, - botName: result.message.bot_name, - pipelineId: result.message.pipeline_id, - pipelineName: result.message.pipeline_name, - messageContent: result.message.message_content, - sessionId: result.message.session_id, - status: result.message.status, - level: result.message.level, - platform: result.message.platform, - userId: result.message.user_id, - runnerName: result.message.runner_name, - variables: result.message.variables, - } - : undefined, - llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({ - id: call.id, - timestamp: new Date(call.timestamp), - modelName: call.model_name, - status: call.status, - duration: call.duration, - errorMessage: call.error_message, - tokens: { - input: call.input_tokens || 0, - output: call.output_tokens || 0, - total: call.total_tokens || 0, - }, - })), - errors: result.errors.map((error: RawErrorData) => ({ - id: error.id, - timestamp: new Date(error.timestamp), - errorType: error.error_type, - errorMessage: error.error_message, - stackTrace: error.stack_trace, - })), - llmStats: { - totalCalls: result.llm_stats.total_calls, - totalInputTokens: result.llm_stats.total_input_tokens, - totalOutputTokens: result.llm_stats.total_output_tokens, - totalTokens: result.llm_stats.total_tokens, - totalDurationMs: result.llm_stats.total_duration_ms, - averageDurationMs: result.llm_stats.average_duration_ms, - }, - } as MessageDetails, - })); - } - } catch (error) { - console.error('Failed to fetch message details:', error); - } finally { - setLoadingDetails({ ...loadingDetails, [messageId]: false }); - } - } - } + const toggleTurnExpand = (turnId: string) => { + setExpandedTurnId((current) => (current === turnId ? null : turnId)); }; const toggleErrorExpand = (errorId: string) => { @@ -342,127 +216,22 @@ function MonitoringPageContent() {
)} - {!loading && - data && - data.messages && - data.messages.length > 0 && ( -
- {data.messages - .filter((msg) => { - // Filter out messages with empty content - const content = msg.messageContent?.trim(); - return ( - content && content !== '[]' && content !== '""' - ); - }) - .map((msg) => ( -
- {/* Message Header - Always Visible */} -
toggleMessageExpand(msg.id)} - > -
-
- {/* Expand Icon */} -
- {expandedMessageId === msg.id ? ( - - ) : ( - - )} -
+ {!loading && data && conversationTurns.length > 0 && ( + + )} - {/* Message Info */} -
-
- - ID: {msg.id} - -
-
- - {msg.botName} - - - → - - - {msg.pipelineName} - - {msg.runnerName && ( - <> - - → - - - {msg.runnerName} - - - )} -
-
- -
-
-
- - {/* Status and Timestamp */} -
- - {msg.timestamp.toLocaleString()} - - - {msg.level} - -
-
-
- - {/* Expanded Details */} - {expandedMessageId === msg.id && ( -
- {loadingDetails[msg.id] && ( -
- -
- )} - {!loadingDetails[msg.id] && - messageDetails[msg.id] && ( - - )} -
- )} -
- ))} + {!loading && (!data || conversationTurns.length === 0) && ( +
+ +
+ {t('monitoring.messageList.noMessages')}
- )} - - {!loading && - (!data || !data.messages || data.messages.length === 0) && ( -
- -
- {t('monitoring.messageList.noMessages')} -
-
- )} +
+ )}
diff --git a/web/src/app/home/monitoring/types/monitoring.ts b/web/src/app/home/monitoring/types/monitoring.ts index 564bd97ee..06e6cc677 100644 --- a/web/src/app/home/monitoring/types/monitoring.ts +++ b/web/src/app/home/monitoring/types/monitoring.ts @@ -11,8 +11,10 @@ export interface MonitoringMessage { level: 'info' | 'warning' | 'error' | 'debug'; platform?: string; userId?: string; + userName?: string; runnerName?: string; variables?: string; + role?: 'user' | 'assistant' | string; } export interface LLMCall { @@ -31,10 +33,29 @@ export interface LLMCall { botName: string; pipelineId: string; pipelineName: string; + sessionId?: string; errorMessage?: string; messageId?: string; } +export interface ToolCall { + id: string; + timestamp: Date; + toolName: string; + toolSource: 'native' | 'plugin' | 'mcp' | 'skill' | string; + duration: number; + status: 'success' | 'error'; + botId: string; + botName: string; + pipelineId: string; + pipelineName: string; + sessionId?: string; + messageId?: string; + arguments?: string; + result?: string; + errorMessage?: string; +} + export interface EmbeddingCall { id: string; timestamp: Date; @@ -199,6 +220,7 @@ export interface MonitoringData { overview: OverviewMetrics; messages: MonitoringMessage[]; llmCalls: LLMCall[]; + toolCalls: ToolCall[]; embeddingCalls: EmbeddingCall[]; modelCalls: ModelCall[]; sessions: SessionInfo[]; @@ -208,6 +230,7 @@ export interface MonitoringData { totalCount: { messages: number; llmCalls: number; + toolCalls?: number; embeddingCalls: number; sessions: number; errors: number; diff --git a/web/src/app/home/monitoring/utils/conversationTurns.ts b/web/src/app/home/monitoring/utils/conversationTurns.ts new file mode 100644 index 000000000..658540900 --- /dev/null +++ b/web/src/app/home/monitoring/utils/conversationTurns.ts @@ -0,0 +1,294 @@ +import { + ErrorLog, + LLMCall, + MonitoringMessage, + ToolCall, +} from '../types/monitoring'; + +type MessageRole = 'user' | 'assistant' | 'unknown'; + +export interface ConversationTurn { + id: string; + sessionId: string; + startedAt: Date; + lastActivityAt: Date; + botId: string; + botName: string; + pipelineId: string; + pipelineName: string; + runnerName?: string; + platform?: string; + userId?: string; + userName?: string; + userMessage?: MonitoringMessage; + assistantMessages: MonitoringMessage[]; + messages: MonitoringMessage[]; + llmCalls: LLMCall[]; + toolCalls: ToolCall[]; + errors: ErrorLog[]; + status: 'success' | 'error' | 'pending'; + level: 'info' | 'warning' | 'error' | 'debug'; + inputTokens: number; + outputTokens: number; + totalTokens: number; + totalDuration: number; + totalToolDuration: number; +} + +function normalizeRole( + message: MonitoringMessage, + llmMessageIds: Set, +): MessageRole { + const role = message.role?.toLowerCase(); + + if (role === 'user' || role === 'assistant') { + return role; + } + + if (llmMessageIds.has(message.id)) { + return 'user'; + } + + return 'unknown'; +} + +export function hasRenderableMessageContent(content?: string): boolean { + const trimmed = content?.trim(); + + if (!trimmed || trimmed === '[]' || trimmed === '""') { + return false; + } + + try { + const parsed = JSON.parse(trimmed); + + if (typeof parsed === 'string') { + return parsed.trim().length > 0; + } + + if (Array.isArray(parsed)) { + return parsed.some( + (component) => + typeof component !== 'object' || + component === null || + component.type !== 'Source', + ); + } + } catch { + return true; + } + + return true; +} + +function createTurn(message: MonitoringMessage): ConversationTurn { + return { + id: message.id, + sessionId: message.sessionId, + startedAt: message.timestamp, + lastActivityAt: message.timestamp, + botId: message.botId, + botName: message.botName, + pipelineId: message.pipelineId, + pipelineName: message.pipelineName, + runnerName: message.runnerName, + platform: message.platform, + userId: message.userId, + userName: message.userName, + assistantMessages: [], + messages: [], + llmCalls: [], + toolCalls: [], + errors: [], + status: message.status, + level: message.level, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + totalDuration: 0, + totalToolDuration: 0, + }; +} + +function updateTurnActivity(turn: ConversationTurn, timestamp: Date) { + if (timestamp.getTime() > turn.lastActivityAt.getTime()) { + turn.lastActivityAt = timestamp; + } +} + +function addMessageToTurn( + turn: ConversationTurn, + message: MonitoringMessage, + role: MessageRole, +) { + turn.messages.push(message); + updateTurnActivity(turn, message.timestamp); + + if (message.level === 'error') { + turn.level = 'error'; + } else if (message.level === 'warning' && turn.level !== 'error') { + turn.level = 'warning'; + } + + if (message.status === 'error') { + turn.status = 'error'; + } else if (message.status === 'pending' && turn.status !== 'error') { + turn.status = 'pending'; + } + + if (role === 'assistant') { + turn.assistantMessages.push(message); + return; + } + + if (!turn.userMessage) { + turn.userMessage = message; + turn.userId = message.userId ?? turn.userId; + turn.userName = message.userName ?? turn.userName; + return; + } + + turn.assistantMessages.push(message); +} + +function findTurnBySessionTime( + sessionTurns: Map, + sessionId: string | undefined, + timestamp: Date, +): ConversationTurn | undefined { + if (!sessionId) { + return undefined; + } + + const turns = sessionTurns.get(sessionId); + if (!turns?.length) { + return undefined; + } + + let nearest = turns[0]; + const targetTime = timestamp.getTime(); + + for (const turn of turns) { + if (turn.startedAt.getTime() <= targetTime) { + nearest = turn; + } else { + break; + } + } + + return nearest; +} + +export function buildConversationTurns( + messages: MonitoringMessage[], + llmCalls: LLMCall[], + errors: ErrorLog[], + toolCalls: ToolCall[] = [], +): ConversationTurn[] { + const activityMessageIds = new Set([ + ...llmCalls + .map((call) => call.messageId) + .filter((messageId): messageId is string => Boolean(messageId)), + ...toolCalls + .map((call) => call.messageId) + .filter((messageId): messageId is string => Boolean(messageId)), + ]); + const visibleMessages = messages + .filter((message) => hasRenderableMessageContent(message.messageContent)) + .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + + const sessionTurns = new Map(); + const lastTurnBySession = new Map(); + const messageIdToTurn = new Map(); + + for (const message of visibleMessages) { + const role = normalizeRole(message, activityMessageIds); + const previousTurn = lastTurnBySession.get(message.sessionId); + const shouldStartTurn = role === 'user' || !previousTurn; + const turn = shouldStartTurn ? createTurn(message) : previousTurn; + + if (shouldStartTurn) { + const turns = sessionTurns.get(message.sessionId) ?? []; + turns.push(turn); + sessionTurns.set(message.sessionId, turns); + lastTurnBySession.set(message.sessionId, turn); + } + + addMessageToTurn(turn, message, role); + messageIdToTurn.set(message.id, turn); + } + + const allTurns = Array.from(sessionTurns.values()).flat(); + + for (const call of llmCalls) { + const turn = + (call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ?? + findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp); + + if (!turn) { + continue; + } + + turn.llmCalls.push(call); + turn.inputTokens += call.tokens.input; + turn.outputTokens += call.tokens.output; + turn.totalTokens += call.tokens.total; + turn.totalDuration += call.duration; + updateTurnActivity(turn, call.timestamp); + + if (call.status === 'error') { + turn.status = 'error'; + turn.level = 'error'; + } + } + + for (const call of toolCalls) { + const turn = + (call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ?? + findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp); + + if (!turn) { + continue; + } + + turn.toolCalls.push(call); + turn.totalToolDuration += call.duration; + updateTurnActivity(turn, call.timestamp); + + if (call.status === 'error') { + turn.status = 'error'; + turn.level = 'error'; + } + } + + for (const error of errors) { + const turn = + (error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ?? + findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp); + + if (!turn) { + continue; + } + + turn.errors.push(error); + turn.status = 'error'; + turn.level = 'error'; + updateTurnActivity(turn, error.timestamp); + } + + for (const turn of allTurns) { + turn.messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + turn.assistantMessages.sort( + (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), + ); + turn.llmCalls.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + turn.toolCalls.sort( + (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), + ); + turn.errors.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + } + + return allTurns.sort( + (a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime(), + ); +} diff --git a/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx b/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx index 9364d9fea..a57c5cdd8 100644 --- a/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx +++ b/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx @@ -12,63 +12,15 @@ import { Monitor, } from 'lucide-react'; import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData'; -import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer'; +import { ConversationTurnList } from '@/app/home/monitoring/components/ConversationTurnList'; +import { buildConversationTurns } from '@/app/home/monitoring/utils/conversationTurns'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; -import { httpClient } from '@/app/infra/http/HttpClient'; -import { MessageDetails } from '@/app/home/monitoring/types/monitoring'; -import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils'; interface PipelineMonitoringTabProps { pipelineId: string; onNavigateToMonitoring?: () => void; } -interface RawMessageData { - id: string; - timestamp: string; - bot_id: string; - bot_name: string; - pipeline_id: string; - pipeline_name: string; - message_content: string; - session_id: string; - status: string; - level: string; - platform: string; - user_id: string; - runner_name: string; - variables: Record; -} - -interface RawLLMCallData { - id: string; - timestamp: string; - model_name: string; - status: string; - duration: number; - error_message: string | null; - input_tokens: number; - output_tokens: number; - total_tokens: number; -} - -interface RawLLMStatsData { - total_calls: number; - total_input_tokens: number; - total_output_tokens: number; - total_tokens: number; - total_duration_ms: number; - average_duration_ms: number; -} - -interface RawErrorData { - id: string; - timestamp: string; - error_type: string; - error_message: string; - stack_trace: string | null; -} - export default function PipelineMonitoringTab({ pipelineId, onNavigateToMonitoring, @@ -88,98 +40,24 @@ export default function PipelineMonitoringTab({ const { data, loading, refetch } = useMonitoringData(filterState); - const [expandedMessageId, setExpandedMessageId] = useState( - null, - ); - const [messageDetails, setMessageDetails] = useState< - Record - >({}); - const [loadingDetails, setLoadingDetails] = useState>( - {}, + const conversationTurns = useMemo( + () => + data + ? buildConversationTurns( + data.messages, + data.llmCalls, + data.errors, + data.toolCalls, + ) + : [], + [data], ); + const [expandedTurnId, setExpandedTurnId] = useState(null); const [expandedErrorId, setExpandedErrorId] = useState(null); const [activeTab, setActiveTab] = useState('messages'); - const toggleMessageExpand = async (messageId: string) => { - if (expandedMessageId === messageId) { - setExpandedMessageId(null); - } else { - setExpandedMessageId(messageId); - - if (!messageDetails[messageId]) { - setLoadingDetails((prev) => ({ ...prev, [messageId]: true })); - try { - const result = await httpClient.get<{ - message_id: string; - found: boolean; - message: RawMessageData | null; - llm_calls: RawLLMCallData[]; - llm_stats: RawLLMStatsData; - errors: RawErrorData[]; - }>(`/api/v1/monitoring/messages/${messageId}/details`); - - if (result) { - setMessageDetails((prev) => ({ - ...prev, - [messageId]: { - messageId: result.message_id, - found: result.found, - message: result.message - ? { - id: result.message.id, - timestamp: parseUTCTimestamp(result.message.timestamp), - botId: result.message.bot_id, - botName: result.message.bot_name, - pipelineId: result.message.pipeline_id, - pipelineName: result.message.pipeline_name, - messageContent: result.message.message_content, - sessionId: result.message.session_id, - status: result.message.status, - level: result.message.level, - platform: result.message.platform, - userId: result.message.user_id, - runnerName: result.message.runner_name, - variables: result.message.variables, - } - : undefined, - llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({ - id: call.id, - timestamp: parseUTCTimestamp(call.timestamp), - modelName: call.model_name, - status: call.status, - duration: call.duration, - errorMessage: call.error_message, - tokens: { - input: call.input_tokens || 0, - output: call.output_tokens || 0, - total: call.total_tokens || 0, - }, - })), - errors: result.errors.map((error: RawErrorData) => ({ - id: error.id, - timestamp: parseUTCTimestamp(error.timestamp), - errorType: error.error_type, - errorMessage: error.error_message, - stackTrace: error.stack_trace, - })), - llmStats: { - totalCalls: result.llm_stats.total_calls, - totalInputTokens: result.llm_stats.total_input_tokens, - totalOutputTokens: result.llm_stats.total_output_tokens, - totalTokens: result.llm_stats.total_tokens, - totalDurationMs: result.llm_stats.total_duration_ms, - averageDurationMs: result.llm_stats.average_duration_ms, - }, - } as MessageDetails, - })); - } - } catch (error) { - console.error('Failed to fetch message details:', error); - } finally { - setLoadingDetails((prev) => ({ ...prev, [messageId]: false })); - } - } - } + const toggleTurnExpand = (turnId: string) => { + setExpandedTurnId((current) => (current === turnId ? null : turnId)); }; const toggleErrorExpand = (errorId: string) => { @@ -190,12 +68,16 @@ export default function PipelineMonitoringTab({ } }; - const jumpToMessage = async (messageId: string) => { + const jumpToMessage = (messageId: string) => { setActiveTab('messages'); - // Small delay to ensure tab transition completes before expanding - setTimeout(() => { - toggleMessageExpand(messageId); - }, 100); + + const turn = conversationTurns.find((item) => + item.messages.some((message) => message.id === messageId), + ); + + if (turn) { + setExpandedTurnId(turn.id); + } }; return ( @@ -295,142 +177,22 @@ export default function PipelineMonitoringTab({ )} - {!loading && data && data.messages && data.messages.length > 0 && ( -
- {data.messages - .filter((msg) => { - const content = msg.messageContent?.trim(); - return content && content !== '[]' && content !== '""'; - }) - .map((msg) => ( -
-
toggleMessageExpand(msg.id)} - > -
-
-
- {expandedMessageId === msg.id ? ( - - ) : ( - - )} -
-
-
- - {msg.status} - - - {msg.botName} - -
-
- -
-
-
- - {msg.timestamp.toLocaleString()} - -
-
- - {expandedMessageId === msg.id && ( -
- {loadingDetails[msg.id] && ( -
- -
- )} - - {!loadingDetails[msg.id] && - messageDetails[msg.id] && ( -
- {messageDetails[msg.id].errors.length > 0 && ( -
-

- {t('monitoring.errors.errorMessage')} -

- {messageDetails[msg.id].errors.map( - (error) => ( -
-
- {error.errorType}:{' '} - {error.errorMessage} -
- {error.stackTrace && ( -
-                                              {error.stackTrace}
-                                            
- )} -
- ), - )} -
- )} - - {messageDetails[msg.id].llmCalls.length > 0 && ( -
-

- {t('monitoring.tabs.modelCalls')} ( - {messageDetails[msg.id].llmCalls.length}) -

-
-
- {t('monitoring.llmCalls.totalTokens')}:{' '} - { - messageDetails[msg.id].llmStats - .totalTokens - } -
-
- {t('monitoring.llmCalls.duration')}:{' '} - {messageDetails[ - msg.id - ].llmStats.totalDurationMs.toFixed(0)} - ms -
-
-
- )} -
- )} -
- )} -
- ))} -
+ {!loading && data && conversationTurns.length > 0 && ( + )} - {!loading && - (!data || !data.messages || data.messages.length === 0) && ( -
- -

- {t('monitoring.messageList.noMessages')} -

-
- )} + {!loading && (!data || conversationTurns.length === 0) && ( +
+ +

+ {t('monitoring.messageList.noMessages')} +

+
+ )} {/* Errors Tab */} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 8d1410f65..59fb6a077 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -706,6 +706,21 @@ export class BackendClient extends BaseHttpClient { ); } + public getMcpServerLogs( + serverName: string, + limit: number = 200, + level?: string, + ): Promise<{ logs: PluginLogEntry[] }> { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + if (level) { + params.set('level', level); + } + return this.get( + `/api/v1/mcp/servers/${encodeURIComponent(serverName)}/logs?${params.toString()}`, + ); + } + public getPluginAssetURL( author: string, name: string, @@ -1234,8 +1249,10 @@ export class BackendClient extends BaseHttpClient { level: string; platform?: string; user_id?: string; + user_name?: string; runner_name?: string; variables?: string; + role?: string; }>; llmCalls: Array<{ id: string; @@ -1251,9 +1268,27 @@ export class BackendClient extends BaseHttpClient { bot_name: string; pipeline_id: string; pipeline_name: string; + session_id?: string; error_message?: string; message_id?: string; }>; + toolCalls: Array<{ + id: string; + timestamp: string; + tool_name: string; + tool_source: string; + duration: number; + status: string; + bot_id: string; + bot_name: string; + pipeline_id: string; + pipeline_name: string; + session_id?: string; + message_id?: string; + arguments?: string; + result?: string; + error_message?: string; + }>; embeddingCalls: Array<{ id: string; timestamp: string; @@ -1298,6 +1333,7 @@ export class BackendClient extends BaseHttpClient { totalCount: { messages: number; llmCalls: number; + toolCalls?: number; embeddingCalls: number; sessions: number; errors: number; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 01923b284..a3b0a327c 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -939,6 +939,12 @@ const enUS = { tabTools: 'Tools', tabResources: 'Resources', tabDocs: 'Docs', + tabLogs: 'Logs', + logsLevelAll: 'All levels', + logsRefresh: 'Refresh', + logsAutoRefresh: 'Auto refresh', + logsEmpty: + 'No logs yet. Runtime logs from the MCP server will appear here.', noReadme: 'No documentation available', parseResultFailed: 'Failed to parse test result', noResultReturned: 'Test returned no result', @@ -1440,6 +1446,20 @@ const enUS = { level: 'Level', runner: 'Runner', viewConversation: 'View Conversation', + turns: '{{count}} conversation turns', + userMessage: 'User', + noUserMessage: 'No user input recorded', + assistantMessage: 'Assistant', + assistantMessageCount: 'Assistant +{{count}}', + noAssistantMessage: 'No assistant reply recorded', + messageCount: 'Messages', + conversationTrace: 'Conversation Trace', + noLlmCalls: 'No model calls recorded', + roles: { + user: 'User', + assistant: 'Assistant', + message: 'Message', + }, }, llmCalls: { title: 'LLM Calls', @@ -1454,6 +1474,15 @@ const enUS = { avgDuration: 'Avg Duration', calls: 'Calls', }, + toolCalls: { + title: 'Tool Calls', + totalCalls: 'Calls', + duration: 'Tool Duration', + errorCalls: 'Failed Calls', + arguments: 'Arguments', + result: 'Result', + noToolCalls: 'No tool calls recorded', + }, tokens: { totalTokens: 'Total Tokens', inputTokens: 'Input Tokens', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 191a55bad..abcc92fc5 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -901,6 +901,12 @@ const esES = { tabTools: 'Herramientas', tabResources: 'Recursos', tabDocs: 'Documentación', + tabLogs: 'Registros', + logsLevelAll: 'Todos los niveles', + logsRefresh: 'Actualizar', + logsAutoRefresh: 'Actualización automática', + logsEmpty: + 'Aún no hay registros. Los registros de ejecución del servidor MCP aparecerán aquí.', noReadme: 'No hay documentación disponible', parseResultFailed: 'Error al analizar el resultado de la prueba', noResultReturned: 'La prueba no devolvió resultados', @@ -1422,6 +1428,20 @@ const esES = { level: 'Nivel', runner: 'Ejecutor', viewConversation: 'Ver conversación', + turns: '{{count}} turnos de conversación', + userMessage: 'Usuario', + noUserMessage: 'No se registró entrada del usuario', + assistantMessage: 'Asistente', + assistantMessageCount: 'Asistente +{{count}}', + noAssistantMessage: 'No se registró respuesta del asistente', + messageCount: 'Mensajes', + conversationTrace: 'Flujo de conversación', + noLlmCalls: 'No se registraron llamadas al modelo', + roles: { + user: 'Usuario', + assistant: 'Asistente', + message: 'Mensaje', + }, }, llmCalls: { title: 'Llamadas LLM', @@ -1436,6 +1456,15 @@ const esES = { avgDuration: 'Duración promedio', calls: 'Llamadas', }, + toolCalls: { + title: 'Llamadas de herramientas', + totalCalls: 'Llamadas', + duration: 'Duración de herramientas', + errorCalls: 'Llamadas fallidas', + arguments: 'Argumentos', + result: 'Resultado', + noToolCalls: 'No se registraron llamadas de herramientas', + }, tokens: { totalTokens: 'Tokens totales', inputTokens: 'Tokens de entrada', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 1a0c14316..1ac873ade 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -927,6 +927,11 @@ const jaJP = { tabTools: 'ツール', tabResources: 'リソース', tabDocs: 'ドキュメント', + tabLogs: 'ログ', + logsLevelAll: 'すべてのレベル', + logsRefresh: '更新', + logsAutoRefresh: '自動更新', + logsEmpty: 'ログはありません。MCPサーバーの実行ログがここに表示されます。', noReadme: 'ドキュメントがありません', parseResultFailed: 'テスト結果の解析に失敗しました', noResultReturned: 'テスト結果が返されませんでした', @@ -1429,6 +1434,20 @@ const jaJP = { level: 'レベル', runner: 'ランナー', viewConversation: '会話詳細を表示', + turns: '{{count}} 会話ターン', + userMessage: 'ユーザー', + noUserMessage: 'ユーザー入力は記録されていません', + assistantMessage: 'アシスタント', + assistantMessageCount: 'アシスタント +{{count}}', + noAssistantMessage: 'アシスタントの返信は記録されていません', + messageCount: 'メッセージ数', + conversationTrace: '会話トレース', + noLlmCalls: 'モデル呼び出しは記録されていません', + roles: { + user: 'ユーザー', + assistant: 'アシスタント', + message: 'メッセージ', + }, }, llmCalls: { title: 'LLM呼び出し', @@ -1443,6 +1462,15 @@ const jaJP = { avgDuration: '平均期間', calls: '呼び出し', }, + toolCalls: { + title: 'ツール呼び出し', + totalCalls: '呼び出し', + duration: 'ツール時間', + errorCalls: '失敗した呼び出し', + arguments: '引数', + result: '結果', + noToolCalls: 'ツール呼び出しは記録されていません', + }, tokens: { totalTokens: '総トークン数', inputTokens: '入力トークン', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index cca2f6d4d..f359d8964 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -896,6 +896,12 @@ const ruRU = { tabTools: 'Инструменты', tabResources: 'Ресурсы', tabDocs: 'Документация', + tabLogs: 'Журнал', + logsLevelAll: 'Все уровни', + logsRefresh: 'Обновить', + logsAutoRefresh: 'Автообновление', + logsEmpty: + 'Журналов пока нет. Здесь будут отображаться журналы выполнения MCP-сервера.', noReadme: 'Документация отсутствует', parseResultFailed: 'Не удалось разобрать результат теста', noResultReturned: 'Тест не вернул результат', @@ -1396,6 +1402,20 @@ const ruRU = { level: 'Уровень', runner: 'Обработчик', viewConversation: 'Просмотр диалога', + turns: '{{count}} диалоговых ходов', + userMessage: 'Пользователь', + noUserMessage: 'Ввод пользователя не записан', + assistantMessage: 'Ассистент', + assistantMessageCount: 'Ассистент +{{count}}', + noAssistantMessage: 'Ответ ассистента не записан', + messageCount: 'Сообщения', + conversationTrace: 'Ход диалога', + noLlmCalls: 'Вызовы модели не записаны', + roles: { + user: 'Пользователь', + assistant: 'Ассистент', + message: 'Сообщение', + }, }, llmCalls: { title: 'Вызовы LLM', @@ -1410,6 +1430,15 @@ const ruRU = { avgDuration: 'Средняя длительность', calls: 'Вызовы', }, + toolCalls: { + title: 'Вызовы инструментов', + totalCalls: 'Вызовы', + duration: 'Длительность инструментов', + errorCalls: 'Неудачные вызовы', + arguments: 'Аргументы', + result: 'Результат', + noToolCalls: 'Вызовы инструментов не записаны', + }, tokens: { totalTokens: 'Всего токенов', inputTokens: 'Входные токены', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 403e590f1..119b18b61 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -874,6 +874,11 @@ const thTH = { tabTools: 'เครื่องมือ', tabResources: 'ทรัพยากร', tabDocs: 'เอกสาร', + tabLogs: 'บันทึก', + logsLevelAll: 'ทุกระดับ', + logsRefresh: 'รีเฟรช', + logsAutoRefresh: 'รีเฟรชอัตโนมัติ', + logsEmpty: 'ยังไม่มีบันทึก บันทึกการทำงานของ MCP Server จะแสดงที่นี่', noReadme: 'ไม่มีเอกสาร', parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้', noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา', @@ -1364,6 +1369,20 @@ const thTH = { level: 'ระดับ', runner: 'ตัวประมวลผล', viewConversation: 'ดูการสนทนา', + turns: '{{count}} รอบการสนทนา', + userMessage: 'ผู้ใช้', + noUserMessage: 'ยังไม่มีการบันทึกข้อความจากผู้ใช้', + assistantMessage: 'ผู้ช่วย', + assistantMessageCount: 'ผู้ช่วย +{{count}}', + noAssistantMessage: 'ยังไม่มีการบันทึกคำตอบจากผู้ช่วย', + messageCount: 'จำนวนข้อความ', + conversationTrace: 'ลำดับการสนทนา', + noLlmCalls: 'ยังไม่มีการบันทึกการเรียกโมเดล', + roles: { + user: 'ผู้ใช้', + assistant: 'ผู้ช่วย', + message: 'ข้อความ', + }, }, llmCalls: { title: 'การเรียก LLM', @@ -1378,6 +1397,15 @@ const thTH = { avgDuration: 'ระยะเวลาเฉลี่ย', calls: 'การเรียก', }, + toolCalls: { + title: 'การเรียกใช้เครื่องมือ', + totalCalls: 'การเรียก', + duration: 'ระยะเวลาเครื่องมือ', + errorCalls: 'การเรียกที่ล้มเหลว', + arguments: 'อาร์กิวเมนต์', + result: 'ผลลัพธ์', + noToolCalls: 'ยังไม่มีการบันทึกการเรียกใช้เครื่องมือ', + }, tokens: { totalTokens: 'Token ทั้งหมด', inputTokens: 'Token อินพุต', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 259dfd525..12481ed26 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -889,6 +889,12 @@ const viVN = { tabTools: 'Công cụ', tabResources: 'Tài nguyên', tabDocs: 'Tài liệu', + tabLogs: 'Nhật ký', + logsLevelAll: 'Tất cả cấp độ', + logsRefresh: 'Làm mới', + logsAutoRefresh: 'Tự động làm mới', + logsEmpty: + 'Chưa có nhật ký. Nhật ký chạy của MCP Server sẽ hiển thị ở đây.', noReadme: 'Không có tài liệu', parseResultFailed: 'Phân tích kết quả kiểm tra thất bại', noResultReturned: 'Kiểm tra không trả về kết quả', @@ -1389,6 +1395,20 @@ const viVN = { level: 'Mức', runner: 'Trình chạy', viewConversation: 'Xem cuộc trò chuyện', + turns: '{{count}} lượt hội thoại', + userMessage: 'Người dùng', + noUserMessage: 'Chưa ghi nhận đầu vào người dùng', + assistantMessage: 'Trợ lý', + assistantMessageCount: 'Trợ lý +{{count}}', + noAssistantMessage: 'Chưa ghi nhận phản hồi của trợ lý', + messageCount: 'Số tin nhắn', + conversationTrace: 'Luồng hội thoại', + noLlmCalls: 'Chưa ghi nhận lệnh gọi mô hình', + roles: { + user: 'Người dùng', + assistant: 'Trợ lý', + message: 'Tin nhắn', + }, }, llmCalls: { title: 'Cuộc gọi LLM', @@ -1403,6 +1423,15 @@ const viVN = { avgDuration: 'Thời lượng trung bình', calls: 'Cuộc gọi', }, + toolCalls: { + title: 'Lượt gọi công cụ', + totalCalls: 'Lượt gọi', + duration: 'Thời lượng công cụ', + errorCalls: 'Lượt gọi thất bại', + arguments: 'Tham số', + result: 'Kết quả', + noToolCalls: 'Chưa ghi nhận lượt gọi công cụ', + }, tokens: { totalTokens: 'Tổng số Token', inputTokens: 'Token đầu vào', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index bc50b68ff..4f32e36ba 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -902,6 +902,11 @@ const zhHans = { tabTools: '工具', tabResources: '资源', tabDocs: '文档', + tabLogs: '日志', + logsLevelAll: '全部级别', + logsRefresh: '刷新', + logsAutoRefresh: '自动刷新', + logsEmpty: '暂无日志。MCP 服务器的运行日志会显示在这里。', noReadme: '暂无文档', parseResultFailed: '解析测试结果失败', noResultReturned: '测试未返回结果', @@ -1374,6 +1379,20 @@ const zhHans = { level: '级别', runner: '执行器', viewConversation: '显示对话详情', + turns: '{{count}} 轮对话', + userMessage: '用户', + noUserMessage: '未记录用户输入', + assistantMessage: '助手', + assistantMessageCount: '助手 +{{count}}', + noAssistantMessage: '未记录助手回复', + messageCount: '消息数', + conversationTrace: '消息链路', + noLlmCalls: '未记录模型调用', + roles: { + user: '用户', + assistant: '助手', + message: '消息', + }, }, llmCalls: { title: 'LLM调用', @@ -1388,6 +1407,15 @@ const zhHans = { avgDuration: '平均耗时', calls: '调用次数', }, + toolCalls: { + title: '工具调用', + totalCalls: '调用次数', + duration: '工具耗时', + errorCalls: '失败次数', + arguments: '参数', + result: '结果', + noToolCalls: '未记录工具调用', + }, tokens: { totalTokens: '总 Token 数', inputTokens: '输入 Token', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 77284d89b..423faedf6 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -847,6 +847,11 @@ const zhHant = { tabTools: '工具', tabResources: '資源', tabDocs: '文件', + tabLogs: '日誌', + logsLevelAll: '全部級別', + logsRefresh: '重新整理', + logsAutoRefresh: '自動重新整理', + logsEmpty: '暫無日誌。MCP 服務器的運行日誌會顯示在這裡。', noReadme: '暫無文件', parseResultFailed: '解析測試結果失敗', noResultReturned: '測試未返回結果', @@ -1318,6 +1323,20 @@ const zhHant = { level: '級別', runner: '執行器', viewConversation: '顯示對話詳情', + turns: '{{count}} 輪對話', + userMessage: '使用者', + noUserMessage: '未記錄使用者輸入', + assistantMessage: '助手', + assistantMessageCount: '助手 +{{count}}', + noAssistantMessage: '未記錄助手回覆', + messageCount: '訊息數', + conversationTrace: '訊息鏈路', + noLlmCalls: '未記錄模型呼叫', + roles: { + user: '使用者', + assistant: '助手', + message: '訊息', + }, }, llmCalls: { title: 'LLM呼叫', @@ -1332,6 +1351,15 @@ const zhHant = { avgDuration: '平均持續時間', calls: '呼叫次數', }, + toolCalls: { + title: '工具呼叫', + totalCalls: '呼叫次數', + duration: '工具耗時', + errorCalls: '失敗次數', + arguments: '參數', + result: '結果', + noToolCalls: '未記錄工具呼叫', + }, tokens: { totalTokens: '總 Token 數', inputTokens: '輸入 Token', diff --git a/web/tests/e2e/bot-session-tool-timeline.spec.ts b/web/tests/e2e/bot-session-tool-timeline.spec.ts new file mode 100644 index 000000000..7214f1780 --- /dev/null +++ b/web/tests/e2e/bot-session-tool-timeline.spec.ts @@ -0,0 +1,179 @@ +import { expect, test } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +const botId = 'bot-tool-timeline'; +const sessionId = 'person-tool-timeline-user'; +const botName = 'Tool Timeline Bot'; +const pipelineId = 'pipeline-tool-timeline'; +const pipelineName = 'Tool Timeline Pipeline'; + +function at(minute: number, second = 0) { + return `2026-07-02T10:${String(minute).padStart(2, '0')}:${String( + second, + ).padStart(2, '0')}Z`; +} + +function sessionMessage( + id: string, + role: 'user' | 'assistant', + minute: number, + content: string, +) { + return { + id, + timestamp: at(minute), + bot_id: botId, + bot_name: botName, + pipeline_id: pipelineId, + pipeline_name: pipelineName, + message_content: content, + session_id: sessionId, + status: 'success', + level: 'info', + platform: role === 'user' ? 'person' : 'bot', + user_id: 'timeline-user', + user_name: 'Timeline User', + runner_name: role === 'assistant' ? 'local-agent' : null, + variables: '{}', + role, + }; +} + +function toolCall( + id: string, + minute: number, + toolName: string, + duration: number, + status: 'success' | 'error' = 'success', +) { + return { + id, + timestamp: at(minute, 30), + tool_name: toolName, + tool_source: 'native', + duration, + status, + bot_id: botId, + bot_name: botName, + pipeline_id: pipelineId, + pipeline_name: pipelineName, + session_id: sessionId, + message_id: 'user-message', + arguments: JSON.stringify({ target: toolName }), + result: status === 'success' ? JSON.stringify({ ok: true }) : null, + error_message: status === 'error' ? 'Tool execution failed' : null, + }; +} + +test.describe('bot session monitor tool timeline', () => { + test('renders tool calls as left-side agent events interleaved with messages', async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + monitoringSessions: [ + { + session_id: sessionId, + bot_id: botId, + bot_name: botName, + pipeline_id: pipelineId, + pipeline_name: pipelineName, + message_count: 3, + start_time: at(0), + last_activity: at(4), + is_active: true, + platform: 'person', + user_id: 'timeline-user', + user_name: 'Timeline User', + }, + ], + sessionMessages: { + [sessionId]: [ + sessionMessage('user-message', 'user', 0, 'Need a timeline check'), + sessionMessage( + 'assistant-step-1', + 'assistant', + 2, + 'Agent step 1: inspected repository files', + ), + sessionMessage( + 'assistant-step-2', + 'assistant', + 4, + 'Agent step 2: test suite finished', + ), + ], + }, + sessionAnalyses: { + [sessionId]: { + session_id: sessionId, + found: true, + tool_calls: [ + toolCall('tool-repo-read', 1, 'repo_file_read', 80), + toolCall('tool-test-run', 3, 'run_test_suite', 140), + ], + }, + }, + }); + + await page.goto(`/home/bots?id=${botId}`); + await page.getByRole('tab', { name: /Sessions/ }).click(); + await page.getByRole('button', { name: /Timeline User/ }).click(); + + await expect(page.getByText('Need a timeline check')).toBeVisible(); + await expect( + page.getByText('repo_file_read', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('Agent step 1: inspected repository files'), + ).toBeVisible(); + await expect( + page.getByText('run_test_suite', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('Agent step 2: test suite finished'), + ).toBeVisible(); + await expect(page.getByText('{"target":"repo_file_read"}')).toHaveCount(0); + await expect(page.getByText('{"ok":true}')).toHaveCount(0); + + await expect( + page.locator('div.flex.justify-start').filter({ + hasText: 'repo_file_read', + }), + ).toHaveCount(1); + await expect( + page.locator('div.flex.justify-start').filter({ + hasText: 'run_test_suite', + }), + ).toHaveCount(1); + await expect( + page.locator('div.flex.justify-end').filter({ + hasText: 'repo_file_read', + }), + ).toHaveCount(0); + await expect( + page.locator('div.flex.justify-end').filter({ + hasText: 'run_test_suite', + }), + ).toHaveCount(0); + + const text = await page.locator('body').innerText(); + expect(text.indexOf('Need a timeline check')).toBeLessThan( + text.indexOf('repo_file_read'), + ); + expect(text.indexOf('repo_file_read')).toBeLessThan( + text.indexOf('Agent step 1: inspected repository files'), + ); + expect( + text.indexOf('Agent step 1: inspected repository files'), + ).toBeLessThan(text.indexOf('run_test_suite')); + expect(text.indexOf('run_test_suite')).toBeLessThan( + text.indexOf('Agent step 2: test suite finished'), + ); + + await page.getByText('repo_file_read', { exact: true }).click(); + await expect(page.getByText('{"target":"repo_file_read"}')).toBeVisible(); + await expect(page.getByText('{"ok":true}').first()).toBeVisible(); + }); +}); diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index bba7467b3..863c8525e 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -88,6 +88,31 @@ test.describe('frontend CRUD smoke flows', () => { ).toBeVisible(); }); + test('opens pipeline AI capabilities with malformed model options', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + + await page.goto('/home/pipelines?id=pipeline-ai'); + + await expect(page.locator('input[name="basic.name"]')).toBeVisible(); + await page.getByRole('button', { name: /^AI$/ }).click(); + + await expect(page.getByText('Runtime')).toBeVisible(); + await expect( + page.locator('[data-slot="card-title"]').filter({ + hasText: 'Built-in Agent', + }), + ).toBeVisible(); + await expect( + page.locator('label').filter({ + hasText: 'Model', + }), + ).toBeVisible(); + await expect(page.getByText('A { await installLangBotApiMocks(page, { authenticated: true }); diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index e0be0ebc4..fda101148 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -72,7 +72,11 @@ interface LangBotApiMockState { counters: Record; knowledgeBases: KnowledgeBaseMock[]; mcpServers: MCPServerMock[]; + monitoringData: unknown; + monitoringSessions: unknown[]; pipelines: PipelineMock[]; + sessionAnalyses: Record; + sessionMessages: Record; skills: SkillMock[]; } @@ -122,12 +126,14 @@ function emptyMonitoringData() { }, messages: [], llmCalls: [], + toolCalls: [], embeddingCalls: [], sessions: [], errors: [], totalCount: { messages: 0, llmCalls: 0, + toolCalls: 0, embeddingCalls: 0, sessions: 0, errors: 0, @@ -188,6 +194,102 @@ function makePipeline( }; } +function pipelineMetadata() { + return { + configs: [ + { + name: 'ai', + label: { + en_US: 'AI Capabilities', + zh_Hans: 'AI 能力', + }, + stages: [ + { + name: 'runner', + label: { + en_US: 'Runtime', + zh_Hans: '运行方式', + }, + config: [ + { + id: 'runner', + name: 'runner', + label: { + en_US: 'Runner', + zh_Hans: '运行器', + }, + type: 'select', + required: true, + default: 'local-agent', + options: [ + { + name: 'local-agent', + label: { + en_US: 'Built-in Agent', + zh_Hans: '内置 Agent', + }, + }, + ], + }, + ], + }, + { + name: 'local-agent', + label: { + en_US: 'Built-in Agent', + zh_Hans: '内置 Agent', + }, + config: [ + { + id: 'model', + name: 'model', + label: { + en_US: 'Model', + zh_Hans: '模型', + }, + type: 'model-fallback-selector', + required: true, + default: { + primary: 'llm-valid', + fallbacks: [], + }, + }, + ], + }, + ], + }, + ], + }; +} + +function providerModelList() { + return { + models: [ + { + uuid: '', + name: 'Broken Empty UUID Model', + provider_uuid: 'provider-empty', + provider: { + uuid: 'provider-empty', + name: 'Broken Provider', + requester: 'mock-provider', + }, + }, + { + uuid: 'llm-valid', + name: 'Valid Mock Model', + provider_uuid: 'provider-valid', + provider: { + uuid: 'provider-valid', + name: 'Mock Provider', + requester: 'mock-provider', + }, + abilities: ['func_call'], + }, + ], + }; +} + function knowledgeEngine() { return { plugin_id: 'builtin/minimal-knowledge', @@ -389,8 +491,20 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { }); } + if (path === '/api/v1/provider/models/llm') { + return fulfillJson(route, providerModelList()); + } + + if (path === '/api/v1/provider/models/embedding') { + return fulfillJson(route, { models: [] }); + } + + if (path === '/api/v1/provider/models/rerank') { + return fulfillJson(route, { models: [] }); + } + if (path === '/api/v1/pipelines/_/metadata') { - return fulfillJson(route, { configs: [] }); + return fulfillJson(route, pipelineMetadata()); } if (path === '/api/v1/pipelines') { @@ -689,11 +803,43 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { } if (path === '/api/v1/monitoring/data') { - return fulfillJson(route, emptyMonitoringData()); + return fulfillJson(route, state.monitoringData); + } + + if (path === '/api/v1/monitoring/sessions') { + return fulfillJson(route, { + sessions: state.monitoringSessions, + total: state.monitoringSessions.length, + }); + } + + if (path === '/api/v1/monitoring/messages') { + const sessionId = url.searchParams.get('sessionId') || ''; + const messages = state.sessionMessages[sessionId] || []; + return fulfillJson(route, { + messages, + total: messages.length, + }); + } + + const sessionAnalysisMatch = path.match( + /^\/api\/v1\/monitoring\/sessions\/([^/]+)\/analysis$/, + ); + if (sessionAnalysisMatch) { + const sessionId = decodeURIComponent(sessionAnalysisMatch[1]); + return fulfillJson( + route, + state.sessionAnalyses[sessionId] || { + session_id: sessionId, + found: true, + tool_calls: [], + }, + ); } if (path === '/api/v1/monitoring/overview') { - return fulfillJson(route, emptyMonitoringData().overview); + const data = state.monitoringData as { overview?: unknown }; + return fulfillJson(route, data.overview || emptyMonitoringData().overview); } if (path === '/api/v1/monitoring/token-statistics') { @@ -798,15 +944,33 @@ async function handleCloudApi(route: Route) { export async function installLangBotApiMocks( page: Page, - options: { authenticated?: boolean; storage?: JsonRecord } = {}, + options: { + authenticated?: boolean; + monitoringData?: unknown; + monitoringSessions?: unknown[]; + sessionAnalyses?: Record; + sessionMessages?: Record; + storage?: JsonRecord; + } = {}, ) { - const { authenticated = false, storage = {} } = options; + const { + authenticated = false, + monitoringData, + monitoringSessions, + sessionAnalyses, + sessionMessages, + storage = {}, + } = options; const state: LangBotApiMockState = { bots: [], counters: {}, knowledgeBases: [], mcpServers: [], + monitoringData: monitoringData || emptyMonitoringData(), + monitoringSessions: monitoringSessions || [], pipelines: [], + sessionAnalyses: sessionAnalyses || {}, + sessionMessages: sessionMessages || {}, skills: [], }; diff --git a/web/tests/e2e/monitoring-turns.spec.ts b/web/tests/e2e/monitoring-turns.spec.ts new file mode 100644 index 000000000..89c98554f --- /dev/null +++ b/web/tests/e2e/monitoring-turns.spec.ts @@ -0,0 +1,453 @@ +import { expect, test } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; +import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns'; +import { + ErrorLog, + LLMCall, + MonitoringMessage, + ToolCall, +} from '../../src/app/home/monitoring/types/monitoring'; + +const bot = { + id: 'bot-monitoring', + name: 'Monitoring Bot', +}; + +const pipeline = { + id: 'pipeline-monitoring', + name: 'Monitoring Pipeline', +}; + +function time(minute: number) { + return new Date(`2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`); +} + +function message( + id: string, + role: 'user' | 'assistant', + minute: number, + content: string, + sessionId = 'session-agent', +): MonitoringMessage { + return { + id, + timestamp: time(minute), + botId: bot.id, + botName: bot.name, + pipelineId: pipeline.id, + pipelineName: pipeline.name, + messageContent: content, + sessionId, + status: 'success', + level: 'info', + platform: role === 'user' ? 'person' : 'bot', + userId: 'user-1', + userName: 'Playwright User', + runnerName: 'local-agent', + variables: '{}', + role, + }; +} + +function llmCall( + id: string, + minute: number, + messageId: string | undefined, + input: number, + output: number, + duration: number, + sessionId = 'session-agent', +): LLMCall { + return { + id, + timestamp: time(minute), + modelName: 'gpt-5.5', + tokens: { + input, + output, + total: input + output, + }, + duration, + status: 'success', + botId: bot.id, + botName: bot.name, + pipelineId: pipeline.id, + pipelineName: pipeline.name, + sessionId, + messageId, + }; +} + +function errorLog(id: string, minute: number, messageId: string): ErrorLog { + return { + id, + timestamp: time(minute), + errorType: 'ToolExecutionError', + errorMessage: 'Tool retry failed', + botId: bot.id, + botName: bot.name, + pipelineId: pipeline.id, + pipelineName: pipeline.name, + sessionId: 'session-agent', + messageId, + }; +} + +function toolCall( + id: string, + minute: number, + messageId: string | undefined, + toolName: string, + duration: number, + sessionId = 'session-agent', + status: 'success' | 'error' = 'success', +): ToolCall { + return { + id, + timestamp: time(minute), + toolName, + toolSource: 'native', + duration, + status, + botId: bot.id, + botName: bot.name, + pipelineId: pipeline.id, + pipelineName: pipeline.name, + sessionId, + messageId, + arguments: JSON.stringify({ query: toolName }), + result: status === 'success' ? JSON.stringify({ ok: true }) : undefined, + errorMessage: status === 'error' ? 'Tool failed' : undefined, + }; +} + +function rawMessage(message: MonitoringMessage) { + return { + id: message.id, + timestamp: message.timestamp.toISOString(), + bot_id: message.botId, + bot_name: message.botName, + pipeline_id: message.pipelineId, + pipeline_name: message.pipelineName, + message_content: message.messageContent, + session_id: message.sessionId, + status: message.status, + level: message.level, + platform: message.platform, + user_id: message.userId, + user_name: message.userName, + runner_name: message.runnerName, + variables: message.variables, + role: message.role, + }; +} + +function rawLlmCall(call: LLMCall) { + return { + id: call.id, + timestamp: call.timestamp.toISOString(), + model_name: call.modelName, + input_tokens: call.tokens.input, + output_tokens: call.tokens.output, + total_tokens: call.tokens.total, + duration: call.duration, + cost: call.cost, + status: call.status, + bot_id: call.botId, + bot_name: call.botName, + pipeline_id: call.pipelineId, + pipeline_name: call.pipelineName, + session_id: call.sessionId, + error_message: call.errorMessage, + message_id: call.messageId, + }; +} + +function rawError(error: ErrorLog) { + return { + id: error.id, + timestamp: error.timestamp.toISOString(), + error_type: error.errorType, + error_message: error.errorMessage, + bot_id: error.botId, + bot_name: error.botName, + pipeline_id: error.pipelineId, + pipeline_name: error.pipelineName, + session_id: error.sessionId, + stack_trace: error.stackTrace, + message_id: error.messageId, + }; +} + +function rawToolCall(call: ToolCall) { + return { + id: call.id, + timestamp: call.timestamp.toISOString(), + tool_name: call.toolName, + tool_source: call.toolSource, + duration: call.duration, + status: call.status, + bot_id: call.botId, + bot_name: call.botName, + pipeline_id: call.pipelineId, + pipeline_name: call.pipelineName, + session_id: call.sessionId, + message_id: call.messageId, + arguments: call.arguments, + result: call.result, + error_message: call.errorMessage, + }; +} + +function monitoringScenario() { + const messages = [ + message( + 'single-user', + 'user', + 1, + 'Standalone question with no reply', + 'session-single', + ), + message('agent-user-1', 'user', 10, 'Need deployment plan'), + message('agent-assistant-1', 'assistant', 11, 'Agent step 1: inspect repo'), + message('agent-assistant-2', 'assistant', 12, 'Agent step 2: run tests'), + message( + 'agent-assistant-3', + 'assistant', + 13, + 'Final answer: deployment plan ready', + ), + message('agent-user-2', 'user', 20, 'Continue with rollback plan'), + message('agent-assistant-4', 'assistant', 21, 'Rollback plan ready'), + ]; + const llmCalls = [ + llmCall('agent-call-1', 10, 'agent-user-1', 100, 40, 120), + llmCall('agent-call-2', 11, 'agent-user-1', 200, 60, 220), + llmCall('agent-call-3', 12, 'agent-user-1', 300, 90, 260), + llmCall('agent-call-4', 20, 'agent-user-2', 50, 25, 80), + ]; + const errors = [errorLog('agent-error-1', 12, 'agent-user-1')]; + const toolCalls = [ + toolCall('agent-tool-1', 11, 'agent-user-1', 'repo_search', 90), + toolCall('agent-tool-2', 12, 'agent-user-1', 'run_tests', 150), + toolCall('agent-tool-3', 20, 'agent-user-2', 'rollback_lookup', 70), + ]; + + return { + messages, + llmCalls, + toolCalls, + errors, + }; +} + +function rawMonitoringData() { + const scenario = monitoringScenario(); + + return { + overview: { + total_messages: scenario.messages.length, + llm_calls: scenario.llmCalls.length, + embedding_calls: 0, + model_calls: scenario.llmCalls.length, + success_rate: 100, + active_sessions: 2, + }, + messages: scenario.messages.map(rawMessage), + llmCalls: scenario.llmCalls.map(rawLlmCall), + toolCalls: scenario.toolCalls.map(rawToolCall), + embeddingCalls: [], + sessions: [], + errors: scenario.errors.map(rawError), + totalCount: { + messages: scenario.messages.length, + llmCalls: scenario.llmCalls.length, + toolCalls: scenario.toolCalls.length, + embeddingCalls: 0, + sessions: 0, + errors: scenario.errors.length, + }, + }; +} + +test.describe('monitoring conversation turn grouping', () => { + test('keeps a single user message as one observable turn', () => { + const userOnly = message( + 'single-user-only', + 'user', + 1, + 'No answer yet', + 'session-user-only', + ); + + const turns = buildConversationTurns([userOnly], [], []); + + expect(turns).toHaveLength(1); + expect(turns[0].id).toBe(userOnly.id); + expect(turns[0].userMessage?.messageContent).toBe('No answer yet'); + expect(turns[0].assistantMessages).toHaveLength(0); + expect(turns[0].llmCalls).toHaveLength(0); + expect(turns[0].totalTokens).toBe(0); + }); + + test('groups multi-step agent execution and multiple replies into one user turn', () => { + const scenario = monitoringScenario(); + const turns = buildConversationTurns( + scenario.messages, + scenario.llmCalls, + scenario.errors, + scenario.toolCalls, + ); + + const agentTurn = turns.find((turn) => turn.id === 'agent-user-1'); + + expect(agentTurn).toBeTruthy(); + expect(agentTurn?.userMessage?.messageContent).toBe('Need deployment plan'); + expect( + agentTurn?.assistantMessages.map((item) => item.messageContent), + ).toEqual([ + 'Agent step 1: inspect repo', + 'Agent step 2: run tests', + 'Final answer: deployment plan ready', + ]); + expect(agentTurn?.llmCalls).toHaveLength(3); + expect(agentTurn?.toolCalls).toHaveLength(2); + expect(agentTurn?.errors).toHaveLength(1); + expect(agentTurn?.totalTokens).toBe(790); + expect(agentTurn?.totalDuration).toBe(600); + expect(agentTurn?.totalToolDuration).toBe(240); + }); + + test('starts a new turn for each later user message in the same session', () => { + const firstUser = message('same-session-user-1', 'user', 1, 'First'); + const firstReply = message( + 'same-session-reply-1', + 'assistant', + 2, + 'First reply', + ); + const secondUser = message('same-session-user-2', 'user', 3, 'Second'); + const secondReply = message( + 'same-session-reply-2', + 'assistant', + 4, + 'Second reply', + ); + + const turns = buildConversationTurns( + [firstUser, firstReply, secondUser, secondReply], + [ + llmCall('same-session-call-1', 1, firstUser.id, 10, 5, 40), + llmCall('same-session-call-2', 3, secondUser.id, 20, 10, 50), + ], + [], + ); + + expect(turns.map((turn) => turn.id)).toEqual([ + 'same-session-user-2', + 'same-session-user-1', + ]); + expect( + turns[0].assistantMessages.map((item) => item.messageContent), + ).toEqual(['Second reply']); + expect( + turns[1].assistantMessages.map((item) => item.messageContent), + ).toEqual(['First reply']); + }); + + test('attaches calls without message ids by session time', () => { + const user = message('fallback-user', 'user', 1, 'Use session fallback'); + const assistant = message( + 'fallback-assistant', + 'assistant', + 2, + 'Fallback reply', + ); + const call = llmCall('fallback-call', 2, undefined, 25, 5, 70); + + const turns = buildConversationTurns([user, assistant], [call], []); + + expect(turns).toHaveLength(1); + expect(turns[0].llmCalls).toHaveLength(1); + expect(turns[0].llmCalls[0].id).toBe(call.id); + expect(turns[0].totalTokens).toBe(30); + }); + + test('attaches tool calls without message ids by session time', () => { + const user = message('tool-fallback-user', 'user', 1, 'Use tool fallback'); + const assistant = message( + 'tool-fallback-assistant', + 'assistant', + 2, + 'Tool fallback reply', + ); + const call = toolCall( + 'tool-fallback-call', + 2, + undefined, + 'memory_lookup', + 45, + ); + + const turns = buildConversationTurns([user, assistant], [], [], [call]); + + expect(turns).toHaveLength(1); + expect(turns[0].toolCalls).toHaveLength(1); + expect(turns[0].toolCalls[0].id).toBe(call.id); + expect(turns[0].totalToolDuration).toBe(45); + }); + + test('renders user-only, multi-agent, and multi-turn cases in the monitoring page', async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + monitoringData: rawMonitoringData(), + }); + + await page.goto('/home/monitoring'); + + await expect(page.getByText('3 conversation turns')).toBeVisible(); + await expect( + page.getByText('Standalone question with no reply'), + ).toBeVisible(); + await expect(page.getByText('No assistant reply recorded')).toBeVisible(); + await expect(page.getByText('Need deployment plan')).toBeVisible(); + await expect(page.getByText('Agent step 1: inspect repo')).toBeVisible(); + await expect(page.getByText('Assistant +2')).toBeVisible(); + await expect(page.getByText('3 LLM')).toBeVisible(); + await expect(page.getByText('2 tools')).toBeVisible(); + await expect(page.getByText('790 tokens')).toBeVisible(); + await expect(page.getByText('1 errors')).toBeVisible(); + await expect(page.getByText('Continue with rollback plan')).toBeVisible(); + await expect(page.getByText('Rollback plan ready')).toBeVisible(); + + const agentTurn = page + .locator('div[role="button"]') + .filter({ hasText: 'Need deployment plan' }); + await expect(agentTurn).toHaveCount(1); + await agentTurn.click(); + + await expect(page.getByText('Conversation Trace')).toBeVisible(); + await expect(page.getByText('Agent step 2: run tests')).toBeVisible(); + await expect( + page.getByText('Final answer: deployment plan ready'), + ).toBeVisible(); + await expect(page.getByText('LLM Calls (3)')).toBeVisible(); + await expect(page.getByText('#3 gpt-5.5')).toBeVisible(); + await expect(page.getByText('In: 300')).toBeVisible(); + await expect(page.getByText('Out: 90')).toBeVisible(); + await expect(page.getByText('Total: 390')).toBeVisible(); + await expect(page.getByText('Tool Calls (2)')).toBeVisible(); + await expect(page.getByText('#1 repo_search')).toBeVisible(); + await expect(page.getByText('#2 run_tests')).toBeVisible(); + await expect(page.getByText('Arguments')).toHaveCount(0); + await expect(page.getByText('Result')).toHaveCount(0); + + await page.getByText('#1 repo_search').click(); + await expect(page.getByText('Arguments').first()).toBeVisible(); + await expect(page.getByText('Result').first()).toBeVisible(); + await expect(page.getByText('Tool retry failed')).toBeVisible(); + }); +}); diff --git a/web/tests/e2e/pipeline-monitoring-turns.spec.ts b/web/tests/e2e/pipeline-monitoring-turns.spec.ts new file mode 100644 index 000000000..5ab4f9330 --- /dev/null +++ b/web/tests/e2e/pipeline-monitoring-turns.spec.ts @@ -0,0 +1,195 @@ +import { expect, test } from '@playwright/test'; + +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +const bot = { + id: 'bot-pipeline-monitoring', + name: 'Pipeline Bot', +}; + +const pipeline = { + id: 'pipeline-monitoring', + name: 'Pipeline Under Test', +}; + +function at(minute: number) { + return `2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`; +} + +function message( + id: string, + role: 'user' | 'assistant', + minute: number, + content: string, + sessionId = 'session-pipeline-agent', +) { + return { + id, + timestamp: at(minute), + bot_id: bot.id, + bot_name: bot.name, + pipeline_id: pipeline.id, + pipeline_name: pipeline.name, + message_content: content, + session_id: sessionId, + status: 'success', + level: 'info', + platform: role === 'user' ? 'person' : 'bot', + user_id: 'pipeline-user', + user_name: 'Pipeline User', + runner_name: 'local-agent', + variables: '{}', + role, + }; +} + +function llmCall( + id: string, + minute: number, + messageId: string, + input: number, + output: number, + duration: number, +) { + return { + id, + timestamp: at(minute), + model_name: 'gpt-5.5', + input_tokens: input, + output_tokens: output, + total_tokens: input + output, + duration, + cost: 0, + status: 'success', + bot_id: bot.id, + bot_name: bot.name, + pipeline_id: pipeline.id, + pipeline_name: pipeline.name, + session_id: 'session-pipeline-agent', + message_id: messageId, + }; +} + +function toolCall(id: string, minute: number, messageId: string, name: string) { + return { + id, + timestamp: at(minute), + tool_name: name, + tool_source: 'native', + duration: 120, + status: 'success', + bot_id: bot.id, + bot_name: bot.name, + pipeline_id: pipeline.id, + pipeline_name: pipeline.name, + session_id: 'session-pipeline-agent', + message_id: messageId, + arguments: JSON.stringify({ query: name }), + result: JSON.stringify({ ok: true }), + }; +} + +function monitoringData() { + const messages = [ + message( + 'single-user', + 'user', + 1, + 'Pipeline single user message without reply', + 'session-pipeline-single', + ), + message('agent-user', 'user', 10, 'Pipeline needs a deployment plan'), + message( + 'agent-assistant-1', + 'assistant', + 11, + 'Pipeline agent step 1: inspect repository', + ), + message( + 'agent-assistant-2', + 'assistant', + 12, + 'Pipeline agent step 2: run tests', + ), + message( + 'agent-assistant-3', + 'assistant', + 13, + 'Pipeline final answer: deployment ready', + ), + ]; + const llmCalls = [ + llmCall('pipeline-call-1', 10, 'agent-user', 100, 40, 180), + llmCall('pipeline-call-2', 11, 'agent-user', 140, 50, 220), + ]; + const toolCalls = [ + toolCall('pipeline-tool-1', 11, 'agent-user', 'repo_search'), + toolCall('pipeline-tool-2', 12, 'agent-user', 'run_tests'), + ]; + + return { + overview: { + total_messages: messages.length, + llm_calls: llmCalls.length, + embedding_calls: 0, + model_calls: llmCalls.length, + success_rate: 100, + active_sessions: 2, + }, + messages, + llmCalls, + toolCalls, + embeddingCalls: [], + sessions: [], + errors: [], + totalCount: { + messages: messages.length, + llmCalls: llmCalls.length, + toolCalls: toolCalls.length, + embeddingCalls: 0, + sessions: 0, + errors: 0, + }, + }; +} + +test.describe('pipeline monitoring conversation turns', () => { + test('uses conversation turns and folded tool calls in the pipeline dashboard', async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + monitoringData: monitoringData(), + }); + + await page.goto(`/home/pipelines?id=${pipeline.id}`); + await page.getByRole('tab', { name: 'Dashboard' }).click(); + + await expect(page.getByText('2 conversation turns')).toBeVisible(); + await expect( + page.getByText('Pipeline single user message without reply'), + ).toBeVisible(); + await expect( + page.getByText('Pipeline needs a deployment plan'), + ).toBeVisible(); + await expect( + page.getByText('Pipeline agent step 1: inspect repository'), + ).toBeVisible(); + await expect(page.getByText('Assistant +2')).toBeVisible(); + await expect(page.getByText('2 tools')).toBeVisible(); + + const agentTurn = page + .locator('div[role="button"]') + .filter({ hasText: 'Pipeline needs a deployment plan' }); + await expect(agentTurn).toHaveCount(1); + await agentTurn.click(); + + await expect(page.getByText('Tool Calls (2)')).toBeVisible(); + await expect(page.getByText('#1 repo_search')).toBeVisible(); + await expect(page.getByText('#2 run_tests')).toBeVisible(); + await expect(page.getByText('Arguments')).toHaveCount(0); + await page.getByText('#1 repo_search').click(); + await expect(page.getByText('Arguments')).toBeVisible(); + await expect(page.getByText('Result')).toBeVisible(); + }); +});