diff --git a/pyproject.toml b/pyproject.toml index da87f64a6..629a450e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -232,4 +232,4 @@ line-ending = "auto" [tool.uv.sources] # Development contract: update to the matching SDK release before publishing. -langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "eac2c60509534512f9a373cd0c801e75985e0612" } +langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" } diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index a33a5e7a2..785e82f28 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -17,6 +17,7 @@ from quart import Quart, request, Response, jsonify from langbot.libs.wecom_ai_bot_api import wecombotevent from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt + if TYPE_CHECKING: from langbot.pkg.platform.logger import EventLogger from langbot.pkg.utils import httpclient diff --git a/src/langbot/pkg/agent/runner/event_log_store.py b/src/langbot/pkg/agent/runner/event_log_store.py index eb7277146..0212dc46e 100644 --- a/src/langbot/pkg/agent/runner/event_log_store.py +++ b/src/langbot/pkg/agent/runner/event_log_store.py @@ -1,4 +1,5 @@ """EventLog store for writing and querying event records.""" + from __future__ import annotations import json @@ -44,9 +45,7 @@ class EventLogStore: def __init__(self, engine: AsyncEngine): self.engine = engine - self._session_factory = sessionmaker( - engine, class_=AsyncSession, expire_on_commit=False - ) + self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async def append_event( self, @@ -101,7 +100,7 @@ class EventLogStore: # Truncate input summary if too long if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH: - input_summary = input_summary[:self.MAX_INPUT_SUMMARY_LENGTH - 3] + "..." + input_summary = input_summary[: self.MAX_INPUT_SUMMARY_LENGTH - 3] + '...' async with self._session_factory() as session: event = EventLog( @@ -144,9 +143,7 @@ class EventLogStore: Event record as dict, or None if not found """ async with self._session_factory() as session: - result = await session.execute( - sqlalchemy.select(EventLog).where(EventLog.event_id == event_id) - ) + result = await session.execute(sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)) row = result.scalars().first() if row is None: return None @@ -282,9 +279,7 @@ class EventLogStore: ) -> int: """Delete EventLog rows created before the supplied timestamp.""" async with self._session_factory() as session: - result = await session.execute( - sqlalchemy.delete(EventLog).where(EventLog.created_at < before) - ) + result = await session.execute(sqlalchemy.delete(EventLog).where(EventLog.created_at < before)) await session.commit() return result.rowcount or 0 diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py index 7696dfa4e..c00fb129e 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py @@ -71,17 +71,8 @@ class EmbedRouterGroup(group.RouterGroup): ``web_page_bot``, is disabled, or has no Pipeline target for messages. """ bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid) - pipeline_uuid = ( - bot.get_pipeline_target_for_event_type('message.received') - if bot is not None - else None - ) - if ( - bot is not None - and bot.bot_entity.adapter == 'web_page_bot' - and bot.bot_entity.enable - and pipeline_uuid - ): + pipeline_uuid = bot.get_pipeline_target_for_event_type('message.received') if bot is not None else None + if bot is not None and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.enable and pipeline_uuid: return bot, pipeline_uuid return None, None diff --git a/src/langbot/pkg/api/http/controller/groups/resources/tools.py b/src/langbot/pkg/api/http/controller/groups/resources/tools.py index 2d1cf66a0..357618e2d 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/tools.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/tools.py @@ -14,9 +14,7 @@ class ToolsRouterGroup(group.RouterGroup): self, request_context: RequestContext, ) -> list[dict] | None: - pipeline_uuid = quart.request.args.get( - 'pipeline_uuid' - ) or quart.request.args.get('pipeline_id') + pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id') bound_plugins: list[str] | None = None bound_mcp_servers: list[str] | None = None @@ -28,14 +26,9 @@ class ToolsRouterGroup(group.RouterGroup): if pipeline is None: return None - extensions_prefs = normalize_extension_preferences( - pipeline.get('extensions_preferences') - ) + extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences')) if not extensions_prefs['enable_all_plugins']: - bound_plugins = [ - f'{plugin["author"]}/{plugin["name"]}' - for plugin in extensions_prefs['plugins'] - ] + bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']] if not extensions_prefs['enable_all_mcp_servers']: bound_mcp_servers = extensions_prefs['mcp_servers'] diff --git a/src/langbot/pkg/entity/persistence/transcript.py b/src/langbot/pkg/entity/persistence/transcript.py index 5d66454e7..f975ab245 100644 --- a/src/langbot/pkg/entity/persistence/transcript.py +++ b/src/langbot/pkg/entity/persistence/transcript.py @@ -1,4 +1,5 @@ """Transcript persistence entity for conversation history projection.""" + from __future__ import annotations import sqlalchemy diff --git a/src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py b/src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py index c6d5ed36c..caaf613fc 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py +++ b/src/langbot/pkg/persistence/alembic/versions/0017_repair_local_workspace_owner.py @@ -48,12 +48,16 @@ def upgrade() -> None: sa.column('source', sa.String(32)), sa.column('created_by_account_uuid', sa.String(36)), ) - workspace_uuids = conn.execute( - sa.select(workspaces.c.uuid).where( - workspaces.c.instance_uuid == instance_uuid.strip(), - workspaces.c.source == 'local', + workspace_uuids = ( + conn.execute( + sa.select(workspaces.c.uuid).where( + workspaces.c.instance_uuid == instance_uuid.strip(), + workspaces.c.source == 'local', + ) ) - ).scalars().all() + .scalars() + .all() + ) if not workspace_uuids: return if len(workspace_uuids) > 1: @@ -99,10 +103,7 @@ def upgrade() -> None: sa.column('status', sa.String(32)), ) owner_account_uuid = conn.execute( - sa.select(users.c.uuid) - .where(users.c.status == 'active') - .order_by(users.c.id) - .limit(1) + sa.select(users.c.uuid).where(users.c.status == 'active').order_by(users.c.id).limit(1) ).scalar_one_or_none() if owner_account_uuid is None: return diff --git a/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py b/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py index 7d7c3746a..f04abd990 100644 --- a/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py +++ b/src/langbot/pkg/persistence/alembic/versions/58846a8d7a81_add_event_log_and_transcript_tables.py @@ -4,6 +4,7 @@ Revision ID: 58846a8d7a81 Revises: 0005_add_llm_context_length Create Date: 2026-05-23 15:41:47.030841 """ + from alembic import op import sqlalchemy as sa diff --git a/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py b/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py index 99da93c0e..0458d8cf5 100644 --- a/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py +++ b/src/langbot/pkg/persistence/alembic/versions/7b2c1d9e4f30_add_transcript_scope_columns.py @@ -4,6 +4,7 @@ Revision ID: 7b2c1d9e4f30 Revises: 6dfd3dd7f0c7 Create Date: 2026-06-12 """ + from alembic import op import sqlalchemy as sa diff --git a/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py b/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py index 88773c1b1..2ef480eba 100644 --- a/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py +++ b/src/langbot/pkg/persistence/alembic/versions/8d3a1f2c4b6e_add_agent_run_ledger.py @@ -90,9 +90,7 @@ def upgrade() -> None: ) else: _add_column_if_missing('agent_run', sa.Column('queue_name', sa.String(255), nullable=True)) - _add_column_if_missing( - 'agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0') - ) + _add_column_if_missing('agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0')) _add_column_if_missing('agent_run', sa.Column('requested_runtime_id', sa.String(255), nullable=True)) _add_column_if_missing('agent_run', sa.Column('claimed_by_runtime_id', sa.String(255), nullable=True)) _add_column_if_missing('agent_run', sa.Column('claim_token', sa.String(255), nullable=True)) diff --git a/src/langbot/pkg/pipeline/extension_preferences.py b/src/langbot/pkg/pipeline/extension_preferences.py index 42689b1e4..458700f15 100644 --- a/src/langbot/pkg/pipeline/extension_preferences.py +++ b/src/langbot/pkg/pipeline/extension_preferences.py @@ -50,22 +50,16 @@ def normalize_extension_preferences(value: typing.Any) -> dict[str, typing.Any]: normalized['enable_all_plugins'] = value.get('enable_all_plugins', True) is True normalized['enable_all_mcp_servers'] = value.get('enable_all_mcp_servers', True) is True normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True - normalized['mcp_resource_agent_read_enabled'] = ( - value.get('mcp_resource_agent_read_enabled', True) is True - ) + normalized['mcp_resource_agent_read_enabled'] = value.get('mcp_resource_agent_read_enabled', True) is True plugins = value.get('plugins', []) - plugins_are_valid = isinstance(plugins, list) and all( - _valid_plugin_binding(plugin) for plugin in plugins - ) + plugins_are_valid = isinstance(plugins, list) and all(_valid_plugin_binding(plugin) for plugin in plugins) normalized['plugins'] = list(plugins) if plugins_are_valid else [] if not plugins_are_valid: normalized['enable_all_plugins'] = False mcp_servers = value.get('mcp_servers', []) - mcp_servers_are_valid = isinstance(mcp_servers, list) and all( - _valid_name(server) for server in mcp_servers - ) + mcp_servers_are_valid = isinstance(mcp_servers, list) and all(_valid_name(server) for server in mcp_servers) normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else [] if not mcp_servers_are_valid: normalized['enable_all_mcp_servers'] = False @@ -151,6 +145,4 @@ def _validate_list_field( raise ValueError(f"{context} field '{field_label}' must be a list") for index, item in enumerate(items): if not item_validator(item): - raise ValueError( - f"{context} field '{field_label}[{index}]' must be {item_description}" - ) + raise ValueError(f"{context} field '{field_label}[{index}]' must be {item_description}") diff --git a/src/langbot/pkg/platform/adapters/officialaccount/message_converter.py b/src/langbot/pkg/platform/adapters/officialaccount/message_converter.py index 4def1937b..953f40101 100644 --- a/src/langbot/pkg/platform/adapters/officialaccount/message_converter.py +++ b/src/langbot/pkg/platform/adapters/officialaccount/message_converter.py @@ -65,8 +65,12 @@ class OfficialAccountMessageConverter(abstract_platform_adapter.AbstractMessageC else: components.append(platform_message.Unknown(text='[officialaccount voice message without media id]')) elif event.type == 'event': - components.append(platform_message.Unknown(text=f'[officialaccount event: {event.detail_type or "unknown"}]')) + components.append( + platform_message.Unknown(text=f'[officialaccount event: {event.detail_type or "unknown"}]') + ) else: - components.append(platform_message.Unknown(text=f'[unsupported officialaccount msgtype: {event.type or "unknown"}]')) + components.append( + platform_message.Unknown(text=f'[unsupported officialaccount msgtype: {event.type or "unknown"}]') + ) return platform_message.MessageChain(components) diff --git a/src/langbot/pkg/platform/adapters/qqofficial/__init__.py b/src/langbot/pkg/platform/adapters/qqofficial/__init__.py index d87bfc1dc..2d920ed1c 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/__init__.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/__init__.py @@ -3,4 +3,3 @@ from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter __all__ = ['QQOfficialAdapter'] - diff --git a/src/langbot/pkg/platform/adapters/qqofficial/api_impl.py b/src/langbot/pkg/platform/adapters/qqofficial/api_impl.py index d71847672..59ed22956 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/api_impl.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/api_impl.py @@ -54,7 +54,9 @@ class QQOfficialAPIMixin: self, group_id: typing.Union[int, str], ) -> list[platform_entities.UserGroupMember]: - return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)] + return [ + member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id) + ] async def edit_message( self, @@ -100,4 +102,3 @@ class QQOfficialAPIMixin: async def leave_group(self, group_id: typing.Union[int, str]): raise NotSupportedError('leave_group') - diff --git a/src/langbot/pkg/platform/adapters/qqofficial/errors.py b/src/langbot/pkg/platform/adapters/qqofficial/errors.py index 72483b096..b56f459ae 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/errors.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/errors.py @@ -8,4 +8,3 @@ except ModuleNotFoundError: def __init__(self, api_name: str, *args): super().__init__(f"API '{api_name}' is not supported by this adapter", *args) self.api_name = api_name - diff --git a/src/langbot/pkg/platform/adapters/qqofficial/platform_api.py b/src/langbot/pkg/platform/adapters/qqofficial/platform_api.py index b205cc774..143daaae7 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/platform_api.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/platform_api.py @@ -34,4 +34,3 @@ PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable 'get_gateway_url': get_gateway_url, 'get_mode': get_mode, } - diff --git a/src/langbot/pkg/platform/adapters/slack/api_impl.py b/src/langbot/pkg/platform/adapters/slack/api_impl.py index 46bc382a9..500738b15 100644 --- a/src/langbot/pkg/platform/adapters/slack/api_impl.py +++ b/src/langbot/pkg/platform/adapters/slack/api_impl.py @@ -47,7 +47,9 @@ class SlackAPIMixin: self, group_id: typing.Union[int, str], ) -> list[platform_entities.UserGroupMember]: - return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)] + return [ + member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id) + ] async def get_group_member_info( self, diff --git a/src/langbot/pkg/platform/adapters/slack/event_converter.py b/src/langbot/pkg/platform/adapters/slack/event_converter.py index 531f4082b..6ebd48d91 100644 --- a/src/langbot/pkg/platform/adapters/slack/event_converter.py +++ b/src/langbot/pkg/platform/adapters/slack/event_converter.py @@ -19,7 +19,9 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): async def yiri2target(event: platform_events.Event) -> typing.Any: return getattr(event, 'source_platform_object', None) - async def target2legacy(self, event: SlackEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + async def target2legacy( + self, event: SlackEvent + ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: eba_event = await self.target2yiri(event) if not isinstance(eba_event, platform_events.MessageReceivedEvent): return None diff --git a/src/langbot/pkg/platform/adapters/telegram/__init__.py b/src/langbot/pkg/platform/adapters/telegram/__init__.py index f4d2d73d7..b41991e1c 100644 --- a/src/langbot/pkg/platform/adapters/telegram/__init__.py +++ b/src/langbot/pkg/platform/adapters/telegram/__init__.py @@ -1,3 +1,3 @@ from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter -__all__ = ["TelegramAdapter"] +__all__ = ['TelegramAdapter'] diff --git a/src/langbot/pkg/platform/adapters/telegram/platform_api.py b/src/langbot/pkg/platform/adapters/telegram/platform_api.py index ccd81950d..56ca5dd56 100644 --- a/src/langbot/pkg/platform/adapters/telegram/platform_api.py +++ b/src/langbot/pkg/platform/adapters/telegram/platform_api.py @@ -14,7 +14,7 @@ async def pin_message(bot: telegram.Bot, params: dict) -> dict: message_id=params['message_id'], disable_notification=params.get('disable_notification', False), ) - return {"ok": True} + return {'ok': True} async def unpin_message(bot: telegram.Bot, params: dict) -> dict: @@ -23,26 +23,26 @@ async def unpin_message(bot: telegram.Bot, params: dict) -> dict: chat_id=params['chat_id'], message_id=params.get('message_id'), ) - return {"ok": True} + return {'ok': True} async def unpin_all_messages(bot: telegram.Bot, params: dict) -> dict: """Unpin all messages in a chat.""" await bot.unpin_all_chat_messages(chat_id=params['chat_id']) - return {"ok": True} + return {'ok': True} async def get_chat_administrators(bot: telegram.Bot, params: dict) -> dict: """Get chat administrator list.""" admins = await bot.get_chat_administrators(chat_id=params['chat_id']) return { - "administrators": [ + 'administrators': [ { - "user_id": a.user.id, - "username": a.user.username, - "first_name": a.user.first_name, - "status": a.status, - "custom_title": getattr(a, 'custom_title', None), + 'user_id': a.user.id, + 'username': a.user.username, + 'first_name': a.user.first_name, + 'status': a.status, + 'custom_title': getattr(a, 'custom_title', None), } for a in admins ] @@ -55,7 +55,7 @@ async def set_chat_title(bot: telegram.Bot, params: dict) -> dict: chat_id=params['chat_id'], title=params['title'], ) - return {"ok": True} + return {'ok': True} async def set_chat_description(bot: telegram.Bot, params: dict) -> dict: @@ -64,13 +64,13 @@ async def set_chat_description(bot: telegram.Bot, params: dict) -> dict: chat_id=params['chat_id'], description=params.get('description', ''), ) - return {"ok": True} + return {'ok': True} async def get_chat_member_count(bot: telegram.Bot, params: dict) -> dict: """Get chat member count.""" count = await bot.get_chat_member_count(chat_id=params['chat_id']) - return {"count": count} + return {'count': count} async def send_chat_action(bot: telegram.Bot, params: dict) -> dict: @@ -79,7 +79,7 @@ async def send_chat_action(bot: telegram.Bot, params: dict) -> dict: chat_id=params['chat_id'], action=params.get('action', 'typing'), ) - return {"ok": True} + return {'ok': True} async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict: @@ -91,10 +91,10 @@ async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict: member_limit=params.get('member_limit'), ) return { - "invite_link": link.invite_link, - "name": link.name, - "is_primary": link.is_primary, - "is_revoked": link.is_revoked, + 'invite_link': link.invite_link, + 'name': link.name, + 'is_primary': link.is_primary, + 'is_revoked': link.is_revoked, } @@ -106,20 +106,20 @@ async def answer_callback_query(bot: telegram.Bot, params: dict) -> dict: show_alert=params.get('show_alert', False), url=params.get('url'), ) - return {"ok": True} + return {'ok': True} # ---- Action dispatch table ---- PLATFORM_API_MAP: dict[str, typing.Callable[[telegram.Bot, dict], typing.Awaitable[dict]]] = { - "pin_message": pin_message, - "unpin_message": unpin_message, - "unpin_all_messages": unpin_all_messages, - "get_chat_administrators": get_chat_administrators, - "set_chat_title": set_chat_title, - "set_chat_description": set_chat_description, - "get_chat_member_count": get_chat_member_count, - "send_chat_action": send_chat_action, - "create_chat_invite_link": create_chat_invite_link, - "answer_callback_query": answer_callback_query, + 'pin_message': pin_message, + 'unpin_message': unpin_message, + 'unpin_all_messages': unpin_all_messages, + 'get_chat_administrators': get_chat_administrators, + 'set_chat_title': set_chat_title, + 'set_chat_description': set_chat_description, + 'get_chat_member_count': get_chat_member_count, + 'send_chat_action': send_chat_action, + 'create_chat_invite_link': create_chat_invite_link, + 'answer_callback_query': answer_callback_query, } diff --git a/src/langbot/pkg/platform/adapters/telegram/types.py b/src/langbot/pkg/platform/adapters/telegram/types.py index d36239ff6..eeb04ca15 100644 --- a/src/langbot/pkg/platform/adapters/telegram/types.py +++ b/src/langbot/pkg/platform/adapters/telegram/types.py @@ -7,7 +7,8 @@ from enum import Enum class TelegramChatType(str, Enum): """Telegram chat type.""" - PRIVATE = "private" - GROUP = "group" - SUPERGROUP = "supergroup" - CHANNEL = "channel" + + PRIVATE = 'private' + GROUP = 'group' + SUPERGROUP = 'supergroup' + CHANNEL = 'channel' diff --git a/src/langbot/pkg/platform/adapters/wecombot/api_impl.py b/src/langbot/pkg/platform/adapters/wecombot/api_impl.py index d2255cf85..15758fa5b 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/api_impl.py +++ b/src/langbot/pkg/platform/adapters/wecombot/api_impl.py @@ -54,7 +54,9 @@ class WecomBotAPIMixin: self, group_id: typing.Union[int, str], ) -> list[platform_entities.UserGroupMember]: - return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)] + return [ + member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id) + ] async def upload_file(self, file_data: bytes, filename: str) -> str: raise NotSupportedError('upload_file') diff --git a/src/langbot/pkg/platform/adapters/wecombot/event_converter.py b/src/langbot/pkg/platform/adapters/wecombot/event_converter.py index 5b94ce15e..31fefb559 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/event_converter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/event_converter.py @@ -19,7 +19,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): async def yiri2target(event: platform_events.Event) -> typing.Any: return getattr(event, 'source_platform_object', None) - async def target2legacy(self, event: WecomBotEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + async def target2legacy( + self, event: WecomBotEvent + ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: eba_event = await self.target2yiri(event) if not isinstance(eba_event, platform_events.MessageReceivedEvent): return None @@ -50,7 +52,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event: if event.type in {'single', 'group'} and event.msgtype != 'event': return await self.message_to_eba(event) - return self.platform_specific(event, f'wecombot.{event.get("eventtype") or event.msgtype or event.type or "unknown"}') + return self.platform_specific( + event, f'wecombot.{event.get("eventtype") or event.msgtype or event.type or "unknown"}' + ) async def message_to_eba(self, event: WecomBotEvent) -> platform_events.MessageReceivedEvent: sender = platform_entities.User(id=event.userid, nickname=event.username or event.userid) 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/human_input.py b/src/langbot/pkg/platform/human_input.py index 268885dc0..cc98ba06e 100644 --- a/src/langbot/pkg/platform/human_input.py +++ b/src/langbot/pkg/platform/human_input.py @@ -89,10 +89,12 @@ def format_human_input_text( if actions: lines.append('') if input_defs: - lines.extend([ - 'Reply with action plus field values to continue:', - ' action: ', - ]) + lines.extend( + [ + 'Reply with action plus field values to continue:', + ' action: ', + ] + ) else: lines.append('Reply with the number or title to continue:') for idx, action in enumerate(actions, start=1): diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 2f3b61be7..4fe12d9da 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -2327,15 +2327,9 @@ class MCPLoader(loader.ToolLoader): return items - def _session_by_source_id( - self, context: TenantContext, source_id: str - ) -> RuntimeMCPSession | None: + def _session_by_source_id(self, context: TenantContext, source_id: str) -> RuntimeMCPSession | None: return next( - ( - session - for session in self._sessions_for_context(context) - if session.server_uuid == source_id - ), + (session for session in self._sessions_for_context(context) if session.server_uuid == source_id), None, ) @@ -2397,9 +2391,7 @@ class MCPLoader(loader.ToolLoader): source_id: str | None = None, ) -> typing.Any: """执行工具调用""" - execution_context = await self._assert_execution_active( - _execution_context_from_query(query) - ) + execution_context = await self._assert_execution_active(_execution_context_from_query(query)) if source_id is None and name == MCP_TOOL_LIST_RESOURCES: if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True: raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled') diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index 13461e7da..b09dff9d8 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -368,9 +368,7 @@ class ToolManager: return None return await self.plugin_tool_loader.get_tool(name, source_id=source_id) if source == 'mcp': - return await self.mcp_tool_loader.get_tool( - context, name, source_id=source_id - ) + return await self.mcp_tool_loader.get_tool(context, name, source_id=source_id) return None async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list: @@ -488,9 +486,7 @@ class ToolManager: if source_ref is not None: execution_context = get_query_execution_context(query) await self._bind_plugin_workspace(execution_context) - sandbox_available = await self._workspace_sandbox_available( - execution_context - ) + sandbox_available = await self._workspace_sandbox_available(execution_context) source = source_ref['source'] source_id = source_ref.get('source_id') uses_source_id = False diff --git a/tests/e2e/test_local_runner_fake_provider.py b/tests/e2e/test_local_runner_fake_provider.py index 042cfc63f..e30cb3c2b 100644 --- a/tests/e2e/test_local_runner_fake_provider.py +++ b/tests/e2e/test_local_runner_fake_provider.py @@ -57,7 +57,7 @@ def _package_local_agent_plugin(tmpdir: Path) -> Path: package_source = tmpdir / 'local-agent-package' ignore = shutil.ignore_patterns( '.git', - '.venv', + '.venv*', '__pycache__', '.pytest_cache', '.ruff_cache', @@ -181,7 +181,20 @@ class _FakeToolManager: def __init__(self): self.calls: list[dict[str, Any]] = [] - async def get_tool_schema(self, tool_name: str): + async def get_resolved_tool_catalog( + self, + context, + bound_plugins=None, + bound_mcp_servers=None, + include_skill_authoring=True, + include_mcp_resource_tools=False, + ): + assert context.workspace_uuid + return [{'name': E2E_TOOL_NAME, 'source': 'native', 'source_id': None}] + + async def get_tool_schema(self, context, tool_name: str, source_ref=None): + assert context.workspace_uuid + assert source_ref == {'source': 'native', 'source_id': None} if tool_name != E2E_TOOL_NAME: return None, None return ( @@ -195,14 +208,15 @@ class _FakeToolManager: }, ) - async def get_tool_detail(self, tool_name: str): - description, parameters = await self.get_tool_schema(tool_name) + async def get_tool_detail(self, context, tool_name: str, source_ref=None): + description, parameters = await self.get_tool_schema(context, tool_name, source_ref=source_ref) if parameters is None: return None return {'name': tool_name, 'description': description, 'parameters': parameters} - async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None): - del query + async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None, source_ref=None): + assert query.workspace_uuid + assert source_ref == {'source': 'native', 'source_id': None} self.calls.append({'name': name, 'parameters': dict(parameters)}) return { 'value': f'tool-result:{parameters.get("query")}', @@ -223,7 +237,8 @@ class _FakeKnowledgeBase: def get_name(self) -> str: return 'E2E Fake KB' - async def retrieve(self, query_text: str, settings: dict[str, Any]): + async def retrieve(self, context, query_text: str, settings: dict[str, Any]): + assert context.workspace_uuid self.retrieve_calls.append({'query_text': query_text, 'settings': settings}) return [ SimpleNamespace( @@ -248,7 +263,8 @@ class _FakeRagManager: self.kb = kb self.knowledge_bases = {E2E_KB_UUID: kb} - async def get_knowledge_base_by_uuid(self, kb_uuid: str): + async def get_knowledge_base_by_uuid(self, context, kb_uuid: str): + assert context.workspace_uuid if kb_uuid == E2E_KB_UUID: return self.kb return None @@ -443,10 +459,27 @@ def _scripted_tool_call( async def _boot_local_agent_app(tmpdir: Path): """Boot LangBot and wait until the Local Agent runner is discoverable.""" from langbot.pkg.core import boot - from langbot_plugin.runtime.plugin.mgr import PluginInstallSource ap = await boot.make_app(asyncio.get_running_loop()) run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app') + try: + await _wait_for_local_agent_runner(ap, tmpdir) + except BaseException: + # The caller has not received ap yet. Close it here so a boot failure + # cannot reconnect after the probe restores the global transport mode. + try: + await ap.shutdown() + finally: + run_task.cancel() + await asyncio.gather(run_task, return_exceptions=True) + raise + return ap, run_task + + +async def _wait_for_local_agent_runner(ap, tmpdir: Path): + """Install and discover the runner on the application-owned connection.""" + from langbot_plugin.runtime.plugin.mgr import PluginInstallSource + for _ in range(60): handler = getattr(ap.plugin_connector, 'handler', None) if handler is not None: @@ -478,8 +511,6 @@ async def _boot_local_agent_app(tmpdir: Path): else: raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation') - return ap, run_task - def _run_local_agent_probe(tmpdir: Path, probe): """Run one Local Agent probe inside the temporary LangBot app.""" @@ -689,7 +720,13 @@ def test_local_runner_retrieves_authorized_rag_context_through_host_action( assert retrieve_calls == [ { 'query_text': 'Answer with the retrieved RAG sentinel.', - 'settings': {'top_k': 1, 'filters': {}}, + 'settings': { + 'top_k': 1, + 'filters': {}, + 'session_name': 'person_e2e-local-agent-rag-conversation', + 'bot_uuid': '', + 'sender_id': 'user-001', + }, } ] assert any( @@ -738,11 +775,13 @@ def test_local_runner_compacts_history_and_persists_checkpoint( ) store = TranscriptStore(ap.persistence_mgr.get_db_engine()) + execution_context = await ap.plugin_connector._current_execution_context() for index in range(12): await store.append_transcript( transcript_id=None, event_id=f'e2e-local-agent-history-{index}', conversation_id='e2e-local-agent-compaction-conversation', + workspace_id=execution_context.workspace_uuid, role='user' if index % 2 == 0 else 'assistant', content=( f'HIST_SENTINEL-{index} This is intentionally long deterministic history for compaction. ' * 10 @@ -847,11 +886,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop( ap.rag_mgr = _FakeRagManager(fake_kb) store = TranscriptStore(ap.persistence_mgr.get_db_engine()) + execution_context = await ap.plugin_connector._current_execution_context() for index in range(16): await store.append_transcript( transcript_id=None, event_id=f'e2e-local-agent-combo-history-{index}', conversation_id='e2e-local-agent-combo-conversation', + workspace_id=execution_context.workspace_uuid, role='user' if index % 2 == 0 else 'assistant', content=( f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL ' @@ -907,7 +948,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop( assert retrieve_calls == [ { 'query_text': 'current combo request must survive; use RAG and tools before answering.', - 'settings': {'top_k': 1, 'filters': {}}, + 'settings': { + 'top_k': 1, + 'filters': {}, + 'session_name': 'person_e2e-local-agent-combo-conversation', + 'bot_uuid': '', + 'sender_id': 'user-001', + }, } ] assert invoke_count >= 4 diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index da0ffd89d..d4b7c67d6 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -16,7 +16,9 @@ import sqlalchemy from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine +from langbot.pkg.entity import persistence from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.utils import importutil from langbot.pkg.persistence.alembic_runner import ( run_alembic_downgrade, run_alembic_upgrade, @@ -28,6 +30,11 @@ from alembic.config import Config from alembic.script import ScriptDirectory +# Match PersistenceManager's model registration before create_all, including +# workspace foreign-key targets, without relying on other tests being collected. +importutil.import_modules_in_pkg(persistence) + + def _get_script_head() -> str: """Resolve the current Alembic head revision from the script directory. @@ -108,7 +115,6 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') assert await get_alembic_current(sqlite_engine) == _get_script_head() - assert _get_script_head() == '0023_drop_agent_enabled' @pytest.mark.asyncio async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine): @@ -120,7 +126,6 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') assert await get_alembic_current(sqlite_engine) == _get_script_head() - assert _get_script_head() == '0023_drop_agent_enabled' @pytest.mark.asyncio async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine): @@ -131,7 +136,7 @@ class TestSQLiteMigrationUpgrade: await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config') await run_alembic_upgrade(sqlite_engine, 'head') - assert await get_alembic_current(sqlite_engine) == '0023_drop_agent_enabled' + assert await get_alembic_current(sqlite_engine) == _get_script_head() @pytest.mark.asyncio async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine): diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 5b0502e1f..259a0616f 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -856,7 +856,7 @@ class TestPostgreSQLTenantRuntime: adapter='capacity-probe', adapter_config={}, enable=False, - pipeline_routing_rules=[], + event_bindings=[], ), persistence_pipeline.LegacyPipeline( uuid=f'capacity-pipeline-{suffix}', diff --git a/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py b/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py new file mode 100644 index 000000000..46225fe20 --- /dev/null +++ b/tests/unit_tests/agent/test_local_runner_e2e_fixtures.py @@ -0,0 +1,123 @@ +"""Keep E2E fake resources compatible with the real Host contract.""" + +from types import SimpleNamespace +from unittest.mock import Mock +import pytest +from langbot.pkg.agent.runner.descriptor import RunnerDescriptor +from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder +from langbot.pkg.api.http.context import ExecutionContext +from tests.e2e.test_local_runner_fake_provider import ( + E2E_KB_UUID, + E2E_TOOL_NAME, + LOCAL_RUNNER_ID, + _FakeKnowledgeBase, + _FakeRagManager, + _FakeToolManager, + _binding, + _event, +) + +CONTEXT = ExecutionContext(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1) +SOURCE = {'source': 'native', 'source_id': None} + + +async def _resources(tool_mgr, rag_mgr, binding): + descriptor = RunnerDescriptor( + id=LOCAL_RUNNER_ID, + source='plugin', + label={'en_US': 'Local Agent'}, + plugin_author='langbot-team', + plugin_name='LocalAgent', + runner_name='default', + capabilities={'tool_calling': True, 'knowledge_retrieval': True}, + permissions={'tools': ['detail', 'call'], 'knowledge_bases': ['list', 'retrieve']}, + ) + app = SimpleNamespace(logger=Mock(), skill_mgr=None, tool_mgr=tool_mgr, rag_mgr=rag_mgr) + return await AgentResourceBuilder(app).build_resources_from_binding( + CONTEXT, + _event(event_id='evt', conversation_id='conv', text='test'), + binding, + descriptor, + ) + + +@pytest.mark.asyncio +async def test_fake_tool_is_projected_with_frozen_source_and_executed(): + manager = _FakeToolManager() + resources = await _resources( + manager, _FakeRagManager(_FakeKnowledgeBase()), _binding(allowed_tool_names=[E2E_TOOL_NAME]) + ) + assert len(resources['tools']) == 1 + tool = resources['tools'][0] + assert tool['tool_name'] == E2E_TOOL_NAME + assert {key: tool[key] for key in SOURCE} == SOURCE + assert tool['parameters']['required'] == ['query'] + detail = await manager.get_tool_detail(CONTEXT, E2E_TOOL_NAME, source_ref=SOURCE) + assert detail['parameters'] == tool['parameters'] + result = await manager.execute_func_call( + name=E2E_TOOL_NAME, + parameters={'query': 'alpha'}, + query=SimpleNamespace(workspace_uuid=CONTEXT.workspace_uuid), + source_ref=SOURCE, + ) + assert result['value'] == 'tool-result:alpha' + assert manager.calls == [{'name': E2E_TOOL_NAME, 'parameters': {'query': 'alpha'}}] + + +@pytest.mark.asyncio +async def test_fake_kb_is_projected_and_retrieved_with_execution_context(): + kb = _FakeKnowledgeBase() + manager = _FakeRagManager(kb) + resources = await _resources(_FakeToolManager(), manager, _binding(allowed_kb_uuids=[E2E_KB_UUID])) + assert [item['kb_id'] for item in resources['knowledge_bases']] == [E2E_KB_UUID] + resolved = await manager.get_knowledge_base_by_uuid(CONTEXT, E2E_KB_UUID) + entries = await resolved.retrieve(CONTEXT, 'test', settings={'top_k': 1, 'filters': {}}) + assert 'RAG_SENTINEL' in entries[0].content + assert kb.retrieve_calls == [{'query_text': 'test', 'settings': {'top_k': 1, 'filters': {}}}] + + +def test_local_agent_package_excludes_alternate_virtualenvs(tmp_path, monkeypatch): + import zipfile + from tests.e2e import test_local_runner_fake_provider as fixtures + + source = tmp_path / 'source' + source.mkdir() + (source / 'manifest.yaml').write_text('metadata: {author: langbot-team, name: LocalAgent}') + for name in ('.venv', '.venv311'): + (source / name).mkdir() + (source / name / 'not-plugin.py').write_text('pass') + monkeypatch.setattr(fixtures, '_local_agent_repo', lambda: source) + package = fixtures._package_local_agent_plugin(tmp_path / 'package') + with zipfile.ZipFile(package) as archive: + assert archive.namelist() == ['manifest.yaml'] + + +@pytest.mark.asyncio +async def test_failed_local_agent_boot_shuts_down_before_restoring_transport(monkeypatch, tmp_path): + import asyncio + from unittest.mock import AsyncMock + from langbot.pkg.core import boot + from tests.e2e import test_local_runner_fake_provider as fixtures + + async def running(): + await asyncio.Event().wait() + + app = SimpleNamespace( + run=running, + shutdown=AsyncMock(), + plugin_connector=SimpleNamespace( + handler=SimpleNamespace(ping=AsyncMock()), _current_execution_context=AsyncMock(return_value=CONTEXT) + ), + runner_registry=SimpleNamespace(list_runners=AsyncMock(side_effect=ValueError('probe boot failure'))), + ) + monkeypatch.setattr(boot, 'make_app', AsyncMock(return_value=app)) + try: + with pytest.raises(ValueError, match='probe boot failure'): + await fixtures._boot_local_agent_app(tmp_path) + app.shutdown.assert_awaited_once() + assert not any(task.get_name() == 'local-agent-e2e-app' for task in asyncio.all_tasks()) + finally: + tasks = [task for task in asyncio.all_tasks() if task.get_name() == 'local-agent-e2e-app'] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) diff --git a/uv.lock b/uv.lock index f60f34455..623f676c9 100644 --- a/uv.lock +++ b/uv.lock @@ -2119,7 +2119,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612" }, + { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2186,7 +2186,7 @@ dev = [ [[package]] name = "langbot-plugin" version = "0.5.5" -source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612#eac2c60509534512f9a373cd0c801e75985e0612" } +source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28#92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index 49ef50a49..715fd121b 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -89,7 +89,7 @@ test.describe('frontend CRUD smoke flows', () => { await expect( page.locator('[data-processor-kind="pipeline"]'), ).toContainText( - '流水线即为经典的“收到消息、请求AI、回复用户”流程,并辅以常用的配置功能。仅处理消息事件,适合步骤明确、需要稳定控制处理过程的场景。', + '流水线按“接收消息、调用 AI、回复用户”的固定流程运行,可配置知识库和插件扩展。仅处理消息事件,适合步骤明确、需要控制处理过程的场景。', ); }); @@ -1034,7 +1034,7 @@ test.describe('agent runner resource selectors', () => { await expect( page.getByText('No Runner extension is installed yet.'), ).toBeVisible(); - await expect(page.getByText('Runner Marketplace')).toBeVisible(); + await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible(); const selectorPopup = page.locator('[data-slot="select-content"]'); await expect( selectorPopup.getByText('Runner used by the grouped selector test.', { @@ -1055,20 +1055,35 @@ test.describe('agent runner resource selectors', () => { }); await page - .getByRole('option') - .filter({ hasText: 'Marketplace Runner' }) + .getByRole('button', { name: 'Install Marketplace Runner', exact: true }) .click(); await expect.poll(() => installRequests).toBe(1); await expect.poll(() => taskPolls).toBeGreaterThan(0); + await expect( + page.getByRole('option', { + name: 'Marketplace Runner Runner used by the grouped selector test.', + exact: true, + }), + ).toBeVisible(); + await page + .getByRole('option', { + name: 'Marketplace Runner Runner used by the grouped selector test.', + exact: true, + }) + .click(); await expect(runnerSelect).toContainText('Marketplace Runner'); await runnerSelect.click(); await expect( - page.getByRole('option').filter({ hasText: runnerId }), + page.getByRole('option', { + name: 'Marketplace Runner Runner used by the grouped selector test.', + exact: true, + }), ).toBeVisible(); await expect( - page.getByText('Runner used by the grouped selector test.', { + page.getByRole('button', { + name: 'Install Marketplace Runner', exact: true, }), ).toHaveCount(0); @@ -1130,7 +1145,7 @@ test.describe('agent runner resource selectors', () => { const runnerSelect = page.getByRole('combobox', { name: 'Runner' }); await runnerSelect.click(); await expect(page.getByText('Installed Runners')).toBeVisible(); - await expect(page.getByText('Runner Marketplace')).toBeVisible(); + await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible(); await expect( page .locator('[data-slot="select-content"]') @@ -1139,9 +1154,10 @@ test.describe('agent runner resource selectors', () => { }), ).toBeVisible(); await expect( - page - .getByRole('option') - .filter({ hasText: 'Pipeline Marketplace Runner' }), + page.getByRole('button', { + name: 'Install Pipeline Marketplace Runner', + exact: true, + }), ).toBeVisible(); }); @@ -1214,8 +1230,11 @@ test.describe('agent and pipeline save concurrency', () => { await page.goto('/home/agents?id=agent-save-race'); const saveButton = page.getByRole('button', { name: /^Save$/ }); - await page.getByRole('tab', { name: 'Bindable Event Range' }).click(); - const eventPatterns = page.getByLabel('Event Range'); + await page.getByRole('tab', { name: 'Events & tools' }).click(); + const eventPatterns = page.getByRole('button', { + name: 'Add event', + exact: true, + }); await expect(eventPatterns).toBeVisible(); await eventPatterns.click(); @@ -1231,8 +1250,8 @@ test.describe('agent and pipeline save concurrency', () => { await eventPatterns.click(); await page.getByRole('option').filter({ hasText: 'group.*' }).click(); await page - .getByRole('option') - .filter({ hasText: 'message.received' }) + .getByRole('button', { name: 'Remove event', exact: true }) + .first() .click(); await page.keyboard.press('Escape'); await forceFormSubmit(page, '#agent-form'); diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index ca2431635..4fa070ae4 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -725,10 +725,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { return fulfillJson(route, { agents: state.pipelines }); } - const agentDebugMatch = path.match(/^\/api\/v1\/agents\/([^/]+)\/debug$/); + const agentDebugMatch = path.match( + /^\/api\/v1\/agents\/([^/]+)\/debug(?:\/stream)?$/, + ); if (agentDebugMatch) { const payload = parseJsonBody(route); - return fulfillJson(route, { + const result = { event_id: nextId(state, 'event'), event_type: String(payload.event_type || 'message.received'), conversation_id: String(payload.conversation_id || 'debug-session'), @@ -740,7 +742,15 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { text: 'Mock Agent response', }, ], - }); + }; + if (path.endsWith('/stream')) { + return route.fulfill({ + status: 200, + contentType: 'application/x-ndjson', + body: JSON.stringify({ kind: 'completed', data: result }) + '\n', + }); + } + return fulfillJson(route, result); } const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/); diff --git a/web/tests/e2e/home-smoke.spec.ts b/web/tests/e2e/home-smoke.spec.ts index 789f6e3c1..fa15dcaba 100644 --- a/web/tests/e2e/home-smoke.spec.ts +++ b/web/tests/e2e/home-smoke.spec.ts @@ -11,7 +11,7 @@ const appRoutes = [ { path: '/home/agents', heading: 'Processors', - bodyText: 'Select an Agent or Pipeline from the sidebar', + bodyText: 'Select a processor from the sidebar', }, { path: '/home/extensions', diff --git a/web/tests/e2e/processor-detail-workbench.spec.ts b/web/tests/e2e/processor-detail-workbench.spec.ts index 4ec98b659..1f81c2cf8 100644 --- a/web/tests/e2e/processor-detail-workbench.spec.ts +++ b/web/tests/e2e/processor-detail-workbench.spec.ts @@ -60,8 +60,8 @@ test.describe('processor detail workbench', () => { const appShell = page.locator('[class*="group/sidebar-wrapper"]'); const sidebarInset = page.locator('[data-slot="sidebar-inset"]'); - await expect(appShell).toHaveCSS('overflow', 'clip'); - await expect(sidebarInset).toHaveCSS('overflow', 'clip'); + await expect(appShell).toHaveCSS('overflow', 'hidden'); + await expect(sidebarInset).toHaveCSS('overflow', 'hidden'); await appShell.evaluate((element) => { element.scrollTop = 300; }); @@ -79,9 +79,7 @@ test.describe('processor detail workbench', () => { const flow = configPanel.getByRole('tablist'); await expect(flow.getByRole('tab').nth(0)).toContainText('Runner'); await expect(flow.getByRole('tab').nth(1)).toContainText('Local Agent'); - await expect(flow.getByRole('tab').nth(2)).toContainText( - 'Bindable Event Range', - ); + await expect(flow.getByRole('tab').nth(2)).toContainText('Events & tools'); await expect(flow.getByRole('tab')).toHaveCount(3); await expect(flow.getByText('Management')).toHaveCount(0); @@ -126,19 +124,32 @@ test.describe('processor detail workbench', () => { await flow.getByRole('tab').nth(2).click(); await expect( - configPanel.getByText('Bindable Event Range', { exact: true }).last(), + configPanel.getByText('Events & tools', { exact: true }).last(), ).toBeVisible(); - const eventPicker = configPanel.getByRole('combobox', { - name: 'Event Range', + const eventPicker = configPanel.getByRole('button', { + name: 'Add event', + exact: true, }); await expect(eventPicker).toBeVisible(); - await expect(configPanel.getByRole('textbox')).toHaveCount(0); + await expect(configPanel.getByRole('textbox')).toHaveCount(1); + await expect( + configPanel.getByRole('textbox', { name: 'Search tools…' }), + ).toBeVisible(); await eventPicker.click(); await expect( page.getByRole('option').filter({ hasText: 'message.received' }), ).toBeVisible(); - await expect(page.getByRole('group', { name: 'Messages' })).toHaveCount(1); - await expect(page.getByRole('group', { name: 'Groups' })).toHaveCount(1); + await expect( + page.getByRole('option').filter({ hasText: 'group.*' }), + ).toBeVisible(); + await expect( + page + .locator('[cmdk-group-heading]') + .getByText('Messages', { exact: true }), + ).toBeVisible(); + await expect( + page.locator('[cmdk-group-heading]').getByText('Groups', { exact: true }), + ).toBeVisible(); await page .getByRole('option') .filter({ hasText: 'message.*' }) @@ -189,7 +200,7 @@ test.describe('processor detail workbench', () => { } if ( request.method() === 'POST' && - path === '/api/v1/agents/agent-workbench/debug' + path === '/api/v1/agents/agent-workbench/debug/stream' ) { requests.push('debug'); } @@ -237,15 +248,17 @@ test.describe('processor detail workbench', () => { }) => { await installLangBotApiMocks(page, { authenticated: true }); await page.route( - '**/api/v1/agents/agent-workbench/debug', + '**/api/v1/agents/agent-workbench/debug/stream', async (route) => { await route.fulfill({ - status: 422, - contentType: 'application/json', - body: JSON.stringify({ - code: 'dify.config_invalid', - msg: 'api-key is required', - }), + status: 200, + contentType: 'application/x-ndjson', + body: + JSON.stringify({ + kind: 'error', + code: 'dify.config_invalid', + msg: 'api-key is required', + }) + '\n', }); }, ); @@ -454,8 +467,8 @@ test.describe('processor detail workbench', () => { const appShell = page.locator('[class*="group/sidebar-wrapper"]'); const sidebarInset = page.locator('[data-slot="sidebar-inset"]'); - await expect(appShell).toHaveCSS('overflow', 'clip'); - await expect(sidebarInset).toHaveCSS('overflow', 'clip'); + await expect(appShell).toHaveCSS('overflow', 'hidden'); + await expect(sidebarInset).toHaveCSS('overflow', 'hidden'); await expect .poll(() => appShell.evaluate((element) => element.scrollTop)) .toBe(0); diff --git a/web/tests/unit/agent-debug-execution.test.mjs b/web/tests/unit/agent-debug-execution.test.mjs index cd39ca6f1..75ab910ad 100644 --- a/web/tests/unit/agent-debug-execution.test.mjs +++ b/web/tests/unit/agent-debug-execution.test.mjs @@ -2,69 +2,137 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; import ts from 'typescript'; -const source = fs.readFileSync(new URL('../../src/app/home/agents/components/debug-execution.ts', import.meta.url), 'utf8'); +const source = fs.readFileSync( + new URL( + '../../src/app/home/agents/components/debug-execution.ts', + import.meta.url, + ), + 'utf8', +); const module = { exports: {} }; -new Function('exports', ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText)(module.exports); +new Function( + 'exports', + ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText, +)(module.exports); const { executionSteps } = module.exports; -const event = (type, data) => ({type, data}); +const event = (type, data) => ({ type, data }); test('separates streamed thinking and text, replaces final snapshot without duplication', () => { - assert.deepEqual(executionSteps([ - event('message.delta', {chunk: {content:'plan'}}), - event('message.delta', {chunk: {content:'hello'}}), - event('message.completed', {message: {content:'planhello'}}), - event('run.completed', {message: {content:'hello'}}), - ]), [{kind:'message', text:'hello', reasoning:'plan'}]); + assert.deepEqual( + executionSteps([ + event('message.delta', { chunk: { content: 'plan' } }), + event('message.delta', { chunk: { content: 'hello' } }), + event('message.completed', { + message: { content: 'planhello' }, + }), + event('run.completed', { message: { content: 'hello' } }), + ]), + [{ kind: 'message', text: 'hello', reasoning: 'plan' }], + ); }); test('retains structured reasoning and tool parameters/results in order', () => { const steps = executionSteps([ - event('message.delta', {chunk: {provider_specific_fields:{reasoning_content:'plan'}}}), - event('message.completed', {message: {content:''}}), - event('tool.call.started', {tool_call_id:'1',tool_name:'exec', parameters:{command:'echo hi'}}), - event('tool.call.started', {tool_call_id:'2',tool_name:'exec', parameters:{command:'bad'}}), - event('tool.call.completed', {tool_call_id:'2',tool_name:'exec', error:'failed'}), - event('tool.call.completed', {tool_call_id:'1',tool_name:'exec', result:{stdout:'hi'}}), + event('message.delta', { + chunk: { provider_specific_fields: { reasoning_content: 'plan' } }, + }), + event('message.completed', { message: { content: '' } }), + event('tool.call.started', { + tool_call_id: '1', + tool_name: 'exec', + parameters: { command: 'echo hi' }, + }), + event('tool.call.started', { + tool_call_id: '2', + tool_name: 'exec', + parameters: { command: 'bad' }, + }), + event('tool.call.completed', { + tool_call_id: '2', + tool_name: 'exec', + error: 'failed', + }), + event('tool.call.completed', { + tool_call_id: '1', + tool_name: 'exec', + result: { stdout: 'hi' }, + }), event('run.failed', {}), ]); assert.equal(steps[0].reasoning, 'plan'); assert.equal(steps[1].parameters.command, 'echo hi'); - assert.deepEqual(steps[1].result, {stdout:'hi'}); + assert.deepEqual(steps[1].result, { stdout: 'hi' }); assert.equal(steps[2].status, 'failed'); assert.equal(steps[2].error, 'failed'); }); test('replaces LocalAgent cumulative snapshots instead of repeating text', () => { - assert.deepEqual(executionSteps([ - event('message.delta', {chunk: {content:'hello', msg_sequence:1}}), - event('message.delta', {chunk: {content:'hello world', msg_sequence:2}}), - event('message.delta', {chunk: {content:'hello world', msg_sequence:3, is_final:true}}), - ]), [{kind:'message', text:'hello world', reasoning:''}]); + assert.deepEqual( + executionSteps([ + event('message.delta', { chunk: { content: 'hello', msg_sequence: 1 } }), + event('message.delta', { + chunk: { content: 'hello world', msg_sequence: 2 }, + }), + event('message.delta', { + chunk: { content: 'hello world', msg_sequence: 3, is_final: true }, + }), + ]), + [{ kind: 'message', text: 'hello world', reasoning: '' }], + ); }); test('shows failed tool results even when the call transport completed', () => { const steps = executionSteps([ - event('tool.call.started', {tool_call_id:'exit7', tool_name:'exec', parameters:{command:'exit 7'}}), - event('tool.call.completed', {tool_call_id:'exit7', tool_name:'exec', result:{ok:false, exit_code:7, stderr:'expected'}}), + event('tool.call.started', { + tool_call_id: 'exit7', + tool_name: 'exec', + parameters: { command: 'exit 7' }, + }), + event('tool.call.completed', { + tool_call_id: 'exit7', + tool_name: 'exec', + result: { ok: false, exit_code: 7, stderr: 'expected' }, + }), ]); - assert.equal(steps[0].status,'failed'); - assert.equal(steps[0].result.exit_code,7); + assert.equal(steps[0].status, 'failed'); + assert.equal(steps[0].result.exit_code, 7); }); test('does not repeat prior thinking across LocalAgent tool turns', () => { const prefix = 'first thought'; const steps = executionSteps([ - event('message.delta', {chunk:{content:prefix, msg_sequence:1}}), - event('tool.call.started', {tool_call_id:'w',tool_name:'write',parameters:{path:'/workspace/a'}}), - event('tool.call.completed', {tool_call_id:'w',tool_name:'write',result:{ok:true}}), - event('message.delta', {chunk:{content:prefix+'now read',msg_sequence:1}}), - event('tool.call.started', {tool_call_id:'r',tool_name:'read'}), - event('tool.call.completed', {tool_call_id:'r',tool_name:'read',result:{ok:true}}), - event('message.delta', {chunk:{content:prefix+'now read'+'done',msg_sequence:1}}), - event('message.completed', {message:{content:'done'}}), + event('message.delta', { chunk: { content: prefix, msg_sequence: 1 } }), + event('tool.call.started', { + tool_call_id: 'w', + tool_name: 'write', + parameters: { path: '/workspace/a' }, + }), + event('tool.call.completed', { + tool_call_id: 'w', + tool_name: 'write', + result: { ok: true }, + }), + event('message.delta', { + chunk: { content: prefix + 'now read', msg_sequence: 1 }, + }), + event('tool.call.started', { tool_call_id: 'r', tool_name: 'read' }), + event('tool.call.completed', { + tool_call_id: 'r', + tool_name: 'read', + result: { ok: true }, + }), + event('message.delta', { + chunk: { content: prefix + 'now read' + 'done', msg_sequence: 1 }, + }), + event('message.completed', { message: { content: 'done' } }), ]); - const messages = steps.filter(s=>s.kind==='message'); + const messages = steps.filter((s) => s.kind === 'message'); assert.deepEqual(messages, [ - {kind:'message',text:'',reasoning:'first thought'}, - {kind:'message',text:'now read',reasoning:''}, - {kind:'message',text:'done',reasoning:''}, + { kind: 'message', text: '', reasoning: 'first thought' }, + { kind: 'message', text: 'now read', reasoning: '' }, + { kind: 'message', text: 'done', reasoning: '' }, ]); });