fix(runner): align SDK pin and complete real runtime verification (#2525)

* fix(runner): align SDK pin and workspace-aware integration fixtures

* fix(ci): format sources and resolve current migration head

* test(persistence): align standalone migration fixtures with current models

* test(web): align smoke fixtures with current processor UI

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-09-11 12:57:29 +08:00
committed by GitHub
parent 24ddcfe13e
commit e631da0073
37 changed files with 485 additions and 219 deletions
+1 -1
View File
@@ -232,4 +232,4 @@ line-ending = "auto"
[tool.uv.sources] [tool.uv.sources]
# Development contract: update to the matching SDK release before publishing. # 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" }
+1
View File
@@ -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 import wecombotevent
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
if TYPE_CHECKING: if TYPE_CHECKING:
from langbot.pkg.platform.logger import EventLogger from langbot.pkg.platform.logger import EventLogger
from langbot.pkg.utils import httpclient from langbot.pkg.utils import httpclient
@@ -1,4 +1,5 @@
"""EventLog store for writing and querying event records.""" """EventLog store for writing and querying event records."""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -44,9 +45,7 @@ class EventLogStore:
def __init__(self, engine: AsyncEngine): def __init__(self, engine: AsyncEngine):
self.engine = engine self.engine = engine
self._session_factory = sessionmaker( self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
engine, class_=AsyncSession, expire_on_commit=False
)
async def append_event( async def append_event(
self, self,
@@ -101,7 +100,7 @@ class EventLogStore:
# Truncate input summary if too long # Truncate input summary if too long
if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH: 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: async with self._session_factory() as session:
event = EventLog( event = EventLog(
@@ -144,9 +143,7 @@ class EventLogStore:
Event record as dict, or None if not found Event record as dict, or None if not found
""" """
async with self._session_factory() as session: async with self._session_factory() as session:
result = await session.execute( result = await session.execute(sqlalchemy.select(EventLog).where(EventLog.event_id == event_id))
sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)
)
row = result.scalars().first() row = result.scalars().first()
if row is None: if row is None:
return None return None
@@ -282,9 +279,7 @@ class EventLogStore:
) -> int: ) -> int:
"""Delete EventLog rows created before the supplied timestamp.""" """Delete EventLog rows created before the supplied timestamp."""
async with self._session_factory() as session: async with self._session_factory() as session:
result = await session.execute( result = await session.execute(sqlalchemy.delete(EventLog).where(EventLog.created_at < before))
sqlalchemy.delete(EventLog).where(EventLog.created_at < before)
)
await session.commit() await session.commit()
return result.rowcount or 0 return result.rowcount or 0
@@ -71,17 +71,8 @@ class EmbedRouterGroup(group.RouterGroup):
``web_page_bot``, is disabled, or has no Pipeline target for messages. ``web_page_bot``, is disabled, or has no Pipeline target for messages.
""" """
bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid) bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
pipeline_uuid = ( pipeline_uuid = bot.get_pipeline_target_for_event_type('message.received') if bot is not None else None
bot.get_pipeline_target_for_event_type('message.received') if bot is not None and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.enable and pipeline_uuid:
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 bot, pipeline_uuid
return None, None return None, None
@@ -14,9 +14,7 @@ class ToolsRouterGroup(group.RouterGroup):
self, self,
request_context: RequestContext, request_context: RequestContext,
) -> list[dict] | None: ) -> list[dict] | None:
pipeline_uuid = quart.request.args.get( pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
'pipeline_uuid'
) or quart.request.args.get('pipeline_id')
bound_plugins: list[str] | None = None bound_plugins: list[str] | None = None
bound_mcp_servers: list[str] | None = None bound_mcp_servers: list[str] | None = None
@@ -28,14 +26,9 @@ class ToolsRouterGroup(group.RouterGroup):
if pipeline is None: if pipeline is None:
return None return None
extensions_prefs = normalize_extension_preferences( extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences'))
pipeline.get('extensions_preferences')
)
if not extensions_prefs['enable_all_plugins']: if not extensions_prefs['enable_all_plugins']:
bound_plugins = [ bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']]
f'{plugin["author"]}/{plugin["name"]}'
for plugin in extensions_prefs['plugins']
]
if not extensions_prefs['enable_all_mcp_servers']: if not extensions_prefs['enable_all_mcp_servers']:
bound_mcp_servers = extensions_prefs['mcp_servers'] bound_mcp_servers = extensions_prefs['mcp_servers']
@@ -1,4 +1,5 @@
"""Transcript persistence entity for conversation history projection.""" """Transcript persistence entity for conversation history projection."""
from __future__ import annotations from __future__ import annotations
import sqlalchemy import sqlalchemy
@@ -48,12 +48,16 @@ def upgrade() -> None:
sa.column('source', sa.String(32)), sa.column('source', sa.String(32)),
sa.column('created_by_account_uuid', sa.String(36)), sa.column('created_by_account_uuid', sa.String(36)),
) )
workspace_uuids = conn.execute( workspace_uuids = (
conn.execute(
sa.select(workspaces.c.uuid).where( sa.select(workspaces.c.uuid).where(
workspaces.c.instance_uuid == instance_uuid.strip(), workspaces.c.instance_uuid == instance_uuid.strip(),
workspaces.c.source == 'local', workspaces.c.source == 'local',
) )
).scalars().all() )
.scalars()
.all()
)
if not workspace_uuids: if not workspace_uuids:
return return
if len(workspace_uuids) > 1: if len(workspace_uuids) > 1:
@@ -99,10 +103,7 @@ def upgrade() -> None:
sa.column('status', sa.String(32)), sa.column('status', sa.String(32)),
) )
owner_account_uuid = conn.execute( owner_account_uuid = conn.execute(
sa.select(users.c.uuid) sa.select(users.c.uuid).where(users.c.status == 'active').order_by(users.c.id).limit(1)
.where(users.c.status == 'active')
.order_by(users.c.id)
.limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
if owner_account_uuid is None: if owner_account_uuid is None:
return return
@@ -4,6 +4,7 @@ Revision ID: 58846a8d7a81
Revises: 0005_add_llm_context_length Revises: 0005_add_llm_context_length
Create Date: 2026-05-23 15:41:47.030841 Create Date: 2026-05-23 15:41:47.030841
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
@@ -4,6 +4,7 @@ Revision ID: 7b2c1d9e4f30
Revises: 6dfd3dd7f0c7 Revises: 6dfd3dd7f0c7
Create Date: 2026-06-12 Create Date: 2026-06-12
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
@@ -90,9 +90,7 @@ def upgrade() -> None:
) )
else: else:
_add_column_if_missing('agent_run', sa.Column('queue_name', sa.String(255), nullable=True)) _add_column_if_missing('agent_run', sa.Column('queue_name', sa.String(255), nullable=True))
_add_column_if_missing( _add_column_if_missing('agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0'))
'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('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('claimed_by_runtime_id', sa.String(255), nullable=True))
_add_column_if_missing('agent_run', sa.Column('claim_token', sa.String(255), nullable=True)) _add_column_if_missing('agent_run', sa.Column('claim_token', sa.String(255), nullable=True))
@@ -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_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_mcp_servers'] = value.get('enable_all_mcp_servers', True) is True
normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True
normalized['mcp_resource_agent_read_enabled'] = ( normalized['mcp_resource_agent_read_enabled'] = value.get('mcp_resource_agent_read_enabled', True) is True
value.get('mcp_resource_agent_read_enabled', True) is True
)
plugins = value.get('plugins', []) plugins = value.get('plugins', [])
plugins_are_valid = isinstance(plugins, list) and all( plugins_are_valid = isinstance(plugins, list) and all(_valid_plugin_binding(plugin) for plugin in plugins)
_valid_plugin_binding(plugin) for plugin in plugins
)
normalized['plugins'] = list(plugins) if plugins_are_valid else [] normalized['plugins'] = list(plugins) if plugins_are_valid else []
if not plugins_are_valid: if not plugins_are_valid:
normalized['enable_all_plugins'] = False normalized['enable_all_plugins'] = False
mcp_servers = value.get('mcp_servers', []) mcp_servers = value.get('mcp_servers', [])
mcp_servers_are_valid = isinstance(mcp_servers, list) and all( mcp_servers_are_valid = isinstance(mcp_servers, list) and all(_valid_name(server) for server in mcp_servers)
_valid_name(server) for server in mcp_servers
)
normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else [] normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else []
if not mcp_servers_are_valid: if not mcp_servers_are_valid:
normalized['enable_all_mcp_servers'] = False 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") raise ValueError(f"{context} field '{field_label}' must be a list")
for index, item in enumerate(items): for index, item in enumerate(items):
if not item_validator(item): if not item_validator(item):
raise ValueError( raise ValueError(f"{context} field '{field_label}[{index}]' must be {item_description}")
f"{context} field '{field_label}[{index}]' must be {item_description}"
)
@@ -65,8 +65,12 @@ class OfficialAccountMessageConverter(abstract_platform_adapter.AbstractMessageC
else: else:
components.append(platform_message.Unknown(text='[officialaccount voice message without media id]')) components.append(platform_message.Unknown(text='[officialaccount voice message without media id]'))
elif event.type == 'event': 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: 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) return platform_message.MessageChain(components)
@@ -3,4 +3,3 @@
from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
__all__ = ['QQOfficialAdapter'] __all__ = ['QQOfficialAdapter']
@@ -54,7 +54,9 @@ class QQOfficialAPIMixin:
self, self,
group_id: typing.Union[int, str], group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]: ) -> 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( async def edit_message(
self, self,
@@ -100,4 +102,3 @@ class QQOfficialAPIMixin:
async def leave_group(self, group_id: typing.Union[int, str]): async def leave_group(self, group_id: typing.Union[int, str]):
raise NotSupportedError('leave_group') raise NotSupportedError('leave_group')
@@ -8,4 +8,3 @@ except ModuleNotFoundError:
def __init__(self, api_name: str, *args): def __init__(self, api_name: str, *args):
super().__init__(f"API '{api_name}' is not supported by this adapter", *args) super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
self.api_name = api_name self.api_name = api_name
@@ -34,4 +34,3 @@ PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable
'get_gateway_url': get_gateway_url, 'get_gateway_url': get_gateway_url,
'get_mode': get_mode, 'get_mode': get_mode,
} }
@@ -47,7 +47,9 @@ class SlackAPIMixin:
self, self,
group_id: typing.Union[int, str], group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]: ) -> 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( async def get_group_member_info(
self, self,
@@ -19,7 +19,9 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def yiri2target(event: platform_events.Event) -> typing.Any: async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None) 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) eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent): if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None return None
@@ -1,3 +1,3 @@
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
__all__ = ["TelegramAdapter"] __all__ = ['TelegramAdapter']
@@ -14,7 +14,7 @@ async def pin_message(bot: telegram.Bot, params: dict) -> dict:
message_id=params['message_id'], message_id=params['message_id'],
disable_notification=params.get('disable_notification', False), disable_notification=params.get('disable_notification', False),
) )
return {"ok": True} return {'ok': True}
async def unpin_message(bot: telegram.Bot, params: dict) -> dict: 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'], chat_id=params['chat_id'],
message_id=params.get('message_id'), message_id=params.get('message_id'),
) )
return {"ok": True} return {'ok': True}
async def unpin_all_messages(bot: telegram.Bot, params: dict) -> dict: async def unpin_all_messages(bot: telegram.Bot, params: dict) -> dict:
"""Unpin all messages in a chat.""" """Unpin all messages in a chat."""
await bot.unpin_all_chat_messages(chat_id=params['chat_id']) 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: async def get_chat_administrators(bot: telegram.Bot, params: dict) -> dict:
"""Get chat administrator list.""" """Get chat administrator list."""
admins = await bot.get_chat_administrators(chat_id=params['chat_id']) admins = await bot.get_chat_administrators(chat_id=params['chat_id'])
return { return {
"administrators": [ 'administrators': [
{ {
"user_id": a.user.id, 'user_id': a.user.id,
"username": a.user.username, 'username': a.user.username,
"first_name": a.user.first_name, 'first_name': a.user.first_name,
"status": a.status, 'status': a.status,
"custom_title": getattr(a, 'custom_title', None), 'custom_title': getattr(a, 'custom_title', None),
} }
for a in admins for a in admins
] ]
@@ -55,7 +55,7 @@ async def set_chat_title(bot: telegram.Bot, params: dict) -> dict:
chat_id=params['chat_id'], chat_id=params['chat_id'],
title=params['title'], title=params['title'],
) )
return {"ok": True} return {'ok': True}
async def set_chat_description(bot: telegram.Bot, params: dict) -> dict: 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'], chat_id=params['chat_id'],
description=params.get('description', ''), description=params.get('description', ''),
) )
return {"ok": True} return {'ok': True}
async def get_chat_member_count(bot: telegram.Bot, params: dict) -> dict: async def get_chat_member_count(bot: telegram.Bot, params: dict) -> dict:
"""Get chat member count.""" """Get chat member count."""
count = await bot.get_chat_member_count(chat_id=params['chat_id']) 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: 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'], chat_id=params['chat_id'],
action=params.get('action', 'typing'), action=params.get('action', 'typing'),
) )
return {"ok": True} return {'ok': True}
async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict: 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'), member_limit=params.get('member_limit'),
) )
return { return {
"invite_link": link.invite_link, 'invite_link': link.invite_link,
"name": link.name, 'name': link.name,
"is_primary": link.is_primary, 'is_primary': link.is_primary,
"is_revoked": link.is_revoked, '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), show_alert=params.get('show_alert', False),
url=params.get('url'), url=params.get('url'),
) )
return {"ok": True} return {'ok': True}
# ---- Action dispatch table ---- # ---- Action dispatch table ----
PLATFORM_API_MAP: dict[str, typing.Callable[[telegram.Bot, dict], typing.Awaitable[dict]]] = { PLATFORM_API_MAP: dict[str, typing.Callable[[telegram.Bot, dict], typing.Awaitable[dict]]] = {
"pin_message": pin_message, 'pin_message': pin_message,
"unpin_message": unpin_message, 'unpin_message': unpin_message,
"unpin_all_messages": unpin_all_messages, 'unpin_all_messages': unpin_all_messages,
"get_chat_administrators": get_chat_administrators, 'get_chat_administrators': get_chat_administrators,
"set_chat_title": set_chat_title, 'set_chat_title': set_chat_title,
"set_chat_description": set_chat_description, 'set_chat_description': set_chat_description,
"get_chat_member_count": get_chat_member_count, 'get_chat_member_count': get_chat_member_count,
"send_chat_action": send_chat_action, 'send_chat_action': send_chat_action,
"create_chat_invite_link": create_chat_invite_link, 'create_chat_invite_link': create_chat_invite_link,
"answer_callback_query": answer_callback_query, 'answer_callback_query': answer_callback_query,
} }
@@ -7,7 +7,8 @@ from enum import Enum
class TelegramChatType(str, Enum): class TelegramChatType(str, Enum):
"""Telegram chat type.""" """Telegram chat type."""
PRIVATE = "private"
GROUP = "group" PRIVATE = 'private'
SUPERGROUP = "supergroup" GROUP = 'group'
CHANNEL = "channel" SUPERGROUP = 'supergroup'
CHANNEL = 'channel'
@@ -54,7 +54,9 @@ class WecomBotAPIMixin:
self, self,
group_id: typing.Union[int, str], group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]: ) -> 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: async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file') raise NotSupportedError('upload_file')
@@ -19,7 +19,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def yiri2target(event: platform_events.Event) -> typing.Any: async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None) 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) eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent): if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None return None
@@ -50,7 +52,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event: async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event:
if event.type in {'single', 'group'} and event.msgtype != 'event': if event.type in {'single', 'group'} and event.msgtype != 'event':
return await self.message_to_eba(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: async def message_to_eba(self, event: WecomBotEvent) -> platform_events.MessageReceivedEvent:
sender = platform_entities.User(id=event.userid, nickname=event.username or event.userid) sender = platform_entities.User(id=event.userid, nickname=event.username or event.userid)
@@ -17,7 +17,9 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
return getattr(event, 'source_platform_object', None) return getattr(event, 'source_platform_object', None)
@staticmethod @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) eba_event = await WecomCSEventConverter.target2yiri(event, bot)
if hasattr(eba_event, 'to_legacy_event'): if hasattr(eba_event, 'to_legacy_event'):
return 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"}') return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}')
@staticmethod @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) message_chain = await WecomCSMessageConverter.target2yiri(event)
sender = await WecomCSEventConverter.user_from_event(event, bot) sender = await WecomCSEventConverter.user_from_event(event, bot)
return platform_events.MessageReceivedEvent( return platform_events.MessageReceivedEvent(
+4 -2
View File
@@ -89,10 +89,12 @@ def format_human_input_text(
if actions: if actions:
lines.append('') lines.append('')
if input_defs: if input_defs:
lines.extend([ lines.extend(
[
'Reply with action plus field values to continue:', 'Reply with action plus field values to continue:',
' action: <number or title>', ' action: <number or title>',
]) ]
)
else: else:
lines.append('Reply with the number or title to continue:') lines.append('Reply with the number or title to continue:')
for idx, action in enumerate(actions, start=1): for idx, action in enumerate(actions, start=1):
+3 -11
View File
@@ -2327,15 +2327,9 @@ class MCPLoader(loader.ToolLoader):
return items return items
def _session_by_source_id( def _session_by_source_id(self, context: TenantContext, source_id: str) -> RuntimeMCPSession | None:
self, context: TenantContext, source_id: str
) -> RuntimeMCPSession | None:
return next( 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, None,
) )
@@ -2397,9 +2391,7 @@ class MCPLoader(loader.ToolLoader):
source_id: str | None = None, source_id: str | None = None,
) -> typing.Any: ) -> typing.Any:
"""执行工具调用""" """执行工具调用"""
execution_context = await self._assert_execution_active( execution_context = await self._assert_execution_active(_execution_context_from_query(query))
_execution_context_from_query(query)
)
if source_id is None and name == MCP_TOOL_LIST_RESOURCES: 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: if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True:
raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled') raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled')
+2 -6
View File
@@ -368,9 +368,7 @@ class ToolManager:
return None return None
return await self.plugin_tool_loader.get_tool(name, source_id=source_id) return await self.plugin_tool_loader.get_tool(name, source_id=source_id)
if source == 'mcp': if source == 'mcp':
return await self.mcp_tool_loader.get_tool( return await self.mcp_tool_loader.get_tool(context, name, source_id=source_id)
context, name, source_id=source_id
)
return None return None
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list: 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: if source_ref is not None:
execution_context = get_query_execution_context(query) execution_context = get_query_execution_context(query)
await self._bind_plugin_workspace(execution_context) await self._bind_plugin_workspace(execution_context)
sandbox_available = await self._workspace_sandbox_available( sandbox_available = await self._workspace_sandbox_available(execution_context)
execution_context
)
source = source_ref['source'] source = source_ref['source']
source_id = source_ref.get('source_id') source_id = source_ref.get('source_id')
uses_source_id = False uses_source_id = False
+60 -13
View File
@@ -57,7 +57,7 @@ def _package_local_agent_plugin(tmpdir: Path) -> Path:
package_source = tmpdir / 'local-agent-package' package_source = tmpdir / 'local-agent-package'
ignore = shutil.ignore_patterns( ignore = shutil.ignore_patterns(
'.git', '.git',
'.venv', '.venv*',
'__pycache__', '__pycache__',
'.pytest_cache', '.pytest_cache',
'.ruff_cache', '.ruff_cache',
@@ -181,7 +181,20 @@ class _FakeToolManager:
def __init__(self): def __init__(self):
self.calls: list[dict[str, Any]] = [] 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: if tool_name != E2E_TOOL_NAME:
return None, None return None, None
return ( return (
@@ -195,14 +208,15 @@ class _FakeToolManager:
}, },
) )
async def get_tool_detail(self, tool_name: str): async def get_tool_detail(self, context, tool_name: str, source_ref=None):
description, parameters = await self.get_tool_schema(tool_name) description, parameters = await self.get_tool_schema(context, tool_name, source_ref=source_ref)
if parameters is None: if parameters is None:
return None return None
return {'name': tool_name, 'description': description, 'parameters': parameters} return {'name': tool_name, 'description': description, 'parameters': parameters}
async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None): async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None, source_ref=None):
del query assert query.workspace_uuid
assert source_ref == {'source': 'native', 'source_id': None}
self.calls.append({'name': name, 'parameters': dict(parameters)}) self.calls.append({'name': name, 'parameters': dict(parameters)})
return { return {
'value': f'tool-result:{parameters.get("query")}', 'value': f'tool-result:{parameters.get("query")}',
@@ -223,7 +237,8 @@ class _FakeKnowledgeBase:
def get_name(self) -> str: def get_name(self) -> str:
return 'E2E Fake KB' 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}) self.retrieve_calls.append({'query_text': query_text, 'settings': settings})
return [ return [
SimpleNamespace( SimpleNamespace(
@@ -248,7 +263,8 @@ class _FakeRagManager:
self.kb = kb self.kb = kb
self.knowledge_bases = {E2E_KB_UUID: 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: if kb_uuid == E2E_KB_UUID:
return self.kb return self.kb
return None return None
@@ -443,10 +459,27 @@ def _scripted_tool_call(
async def _boot_local_agent_app(tmpdir: Path): async def _boot_local_agent_app(tmpdir: Path):
"""Boot LangBot and wait until the Local Agent runner is discoverable.""" """Boot LangBot and wait until the Local Agent runner is discoverable."""
from langbot.pkg.core import boot from langbot.pkg.core import boot
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
ap = await boot.make_app(asyncio.get_running_loop()) ap = await boot.make_app(asyncio.get_running_loop())
run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app') 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): for _ in range(60):
handler = getattr(ap.plugin_connector, 'handler', None) handler = getattr(ap.plugin_connector, 'handler', None)
if handler is not None: if handler is not None:
@@ -478,8 +511,6 @@ async def _boot_local_agent_app(tmpdir: Path):
else: else:
raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation') raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation')
return ap, run_task
def _run_local_agent_probe(tmpdir: Path, probe): def _run_local_agent_probe(tmpdir: Path, probe):
"""Run one Local Agent probe inside the temporary LangBot app.""" """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 == [ assert retrieve_calls == [
{ {
'query_text': 'Answer with the retrieved RAG sentinel.', '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( assert any(
@@ -738,11 +775,13 @@ def test_local_runner_compacts_history_and_persists_checkpoint(
) )
store = TranscriptStore(ap.persistence_mgr.get_db_engine()) store = TranscriptStore(ap.persistence_mgr.get_db_engine())
execution_context = await ap.plugin_connector._current_execution_context()
for index in range(12): for index in range(12):
await store.append_transcript( await store.append_transcript(
transcript_id=None, transcript_id=None,
event_id=f'e2e-local-agent-history-{index}', event_id=f'e2e-local-agent-history-{index}',
conversation_id='e2e-local-agent-compaction-conversation', conversation_id='e2e-local-agent-compaction-conversation',
workspace_id=execution_context.workspace_uuid,
role='user' if index % 2 == 0 else 'assistant', role='user' if index % 2 == 0 else 'assistant',
content=( content=(
f'HIST_SENTINEL-{index} This is intentionally long deterministic history for compaction. ' * 10 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) ap.rag_mgr = _FakeRagManager(fake_kb)
store = TranscriptStore(ap.persistence_mgr.get_db_engine()) store = TranscriptStore(ap.persistence_mgr.get_db_engine())
execution_context = await ap.plugin_connector._current_execution_context()
for index in range(16): for index in range(16):
await store.append_transcript( await store.append_transcript(
transcript_id=None, transcript_id=None,
event_id=f'e2e-local-agent-combo-history-{index}', event_id=f'e2e-local-agent-combo-history-{index}',
conversation_id='e2e-local-agent-combo-conversation', conversation_id='e2e-local-agent-combo-conversation',
workspace_id=execution_context.workspace_uuid,
role='user' if index % 2 == 0 else 'assistant', role='user' if index % 2 == 0 else 'assistant',
content=( content=(
f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL ' 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 == [ assert retrieve_calls == [
{ {
'query_text': 'current combo request must survive; use RAG and tools before answering.', '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 assert invoke_count >= 4
@@ -16,7 +16,9 @@ import sqlalchemy
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine 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.entity.persistence.base import Base
from langbot.pkg.utils import importutil
from langbot.pkg.persistence.alembic_runner import ( from langbot.pkg.persistence.alembic_runner import (
run_alembic_downgrade, run_alembic_downgrade,
run_alembic_upgrade, run_alembic_upgrade,
@@ -28,6 +30,11 @@ from alembic.config import Config
from alembic.script import ScriptDirectory 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: def _get_script_head() -> str:
"""Resolve the current Alembic head revision from the script directory. """Resolve the current Alembic head revision from the script directory.
@@ -108,7 +115,6 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head') await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head() assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0023_drop_agent_enabled'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine): 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') await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head() assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0023_drop_agent_enabled'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine): 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_stamp(sqlite_engine, '0018_llm_reasoning_config')
await run_alembic_upgrade(sqlite_engine, 'head') 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 @pytest.mark.asyncio
async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine): async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine):
@@ -856,7 +856,7 @@ class TestPostgreSQLTenantRuntime:
adapter='capacity-probe', adapter='capacity-probe',
adapter_config={}, adapter_config={},
enable=False, enable=False,
pipeline_routing_rules=[], event_bindings=[],
), ),
persistence_pipeline.LegacyPipeline( persistence_pipeline.LegacyPipeline(
uuid=f'capacity-pipeline-{suffix}', uuid=f'capacity-pipeline-{suffix}',
@@ -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)
Generated
+2 -2
View File
@@ -2119,7 +2119,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { 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", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2186,7 +2186,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.5.5" 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 = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },
+33 -14
View File
@@ -89,7 +89,7 @@ test.describe('frontend CRUD smoke flows', () => {
await expect( await expect(
page.locator('[data-processor-kind="pipeline"]'), page.locator('[data-processor-kind="pipeline"]'),
).toContainText( ).toContainText(
'流水线即为经典的“收到消息、请求AI、回复用户”流程,并辅以常用的配置功能。仅处理消息事件,适合步骤明确、需要稳定控制处理过程的场景。', '流水线按“接收消息、调用 AI、回复用户”的固定流程运行,可配置知识库和插件扩展。仅处理消息事件,适合步骤明确、需要控制处理过程的场景。',
); );
}); });
@@ -1034,7 +1034,7 @@ test.describe('agent runner resource selectors', () => {
await expect( await expect(
page.getByText('No Runner extension is installed yet.'), page.getByText('No Runner extension is installed yet.'),
).toBeVisible(); ).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"]'); const selectorPopup = page.locator('[data-slot="select-content"]');
await expect( await expect(
selectorPopup.getByText('Runner used by the grouped selector test.', { selectorPopup.getByText('Runner used by the grouped selector test.', {
@@ -1055,20 +1055,35 @@ test.describe('agent runner resource selectors', () => {
}); });
await page await page
.getByRole('option') .getByRole('button', { name: 'Install Marketplace Runner', exact: true })
.filter({ hasText: 'Marketplace Runner' })
.click(); .click();
await expect.poll(() => installRequests).toBe(1); await expect.poll(() => installRequests).toBe(1);
await expect.poll(() => taskPolls).toBeGreaterThan(0); 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 expect(runnerSelect).toContainText('Marketplace Runner');
await runnerSelect.click(); await runnerSelect.click();
await expect( await expect(
page.getByRole('option').filter({ hasText: runnerId }), page.getByRole('option', {
name: 'Marketplace Runner Runner used by the grouped selector test.',
exact: true,
}),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
page.getByText('Runner used by the grouped selector test.', { page.getByRole('button', {
name: 'Install Marketplace Runner',
exact: true, exact: true,
}), }),
).toHaveCount(0); ).toHaveCount(0);
@@ -1130,7 +1145,7 @@ test.describe('agent runner resource selectors', () => {
const runnerSelect = page.getByRole('combobox', { name: 'Runner' }); const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
await runnerSelect.click(); await runnerSelect.click();
await expect(page.getByText('Installed Runners')).toBeVisible(); 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( await expect(
page page
.locator('[data-slot="select-content"]') .locator('[data-slot="select-content"]')
@@ -1139,9 +1154,10 @@ test.describe('agent runner resource selectors', () => {
}), }),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
page page.getByRole('button', {
.getByRole('option') name: 'Install Pipeline Marketplace Runner',
.filter({ hasText: 'Pipeline Marketplace Runner' }), exact: true,
}),
).toBeVisible(); ).toBeVisible();
}); });
@@ -1214,8 +1230,11 @@ test.describe('agent and pipeline save concurrency', () => {
await page.goto('/home/agents?id=agent-save-race'); await page.goto('/home/agents?id=agent-save-race');
const saveButton = page.getByRole('button', { name: /^Save$/ }); const saveButton = page.getByRole('button', { name: /^Save$/ });
await page.getByRole('tab', { name: 'Bindable Event Range' }).click(); await page.getByRole('tab', { name: 'Events & tools' }).click();
const eventPatterns = page.getByLabel('Event Range'); const eventPatterns = page.getByRole('button', {
name: 'Add event',
exact: true,
});
await expect(eventPatterns).toBeVisible(); await expect(eventPatterns).toBeVisible();
await eventPatterns.click(); await eventPatterns.click();
@@ -1231,8 +1250,8 @@ test.describe('agent and pipeline save concurrency', () => {
await eventPatterns.click(); await eventPatterns.click();
await page.getByRole('option').filter({ hasText: 'group.*' }).click(); await page.getByRole('option').filter({ hasText: 'group.*' }).click();
await page await page
.getByRole('option') .getByRole('button', { name: 'Remove event', exact: true })
.filter({ hasText: 'message.received' }) .first()
.click(); .click();
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await forceFormSubmit(page, '#agent-form'); await forceFormSubmit(page, '#agent-form');
+12 -2
View File
@@ -725,10 +725,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { agents: state.pipelines }); 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) { if (agentDebugMatch) {
const payload = parseJsonBody(route); const payload = parseJsonBody(route);
return fulfillJson(route, { const result = {
event_id: nextId(state, 'event'), event_id: nextId(state, 'event'),
event_type: String(payload.event_type || 'message.received'), event_type: String(payload.event_type || 'message.received'),
conversation_id: String(payload.conversation_id || 'debug-session'), conversation_id: String(payload.conversation_id || 'debug-session'),
@@ -740,8 +742,16 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
text: 'Mock Agent response', 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\/([^/]+)$/); const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
if (agentMatch) { if (agentMatch) {
+1 -1
View File
@@ -11,7 +11,7 @@ const appRoutes = [
{ {
path: '/home/agents', path: '/home/agents',
heading: 'Processors', heading: 'Processors',
bodyText: 'Select an Agent or Pipeline from the sidebar', bodyText: 'Select a processor from the sidebar',
}, },
{ {
path: '/home/extensions', path: '/home/extensions',
@@ -60,8 +60,8 @@ test.describe('processor detail workbench', () => {
const appShell = page.locator('[class*="group/sidebar-wrapper"]'); const appShell = page.locator('[class*="group/sidebar-wrapper"]');
const sidebarInset = page.locator('[data-slot="sidebar-inset"]'); const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
await expect(appShell).toHaveCSS('overflow', 'clip'); await expect(appShell).toHaveCSS('overflow', 'hidden');
await expect(sidebarInset).toHaveCSS('overflow', 'clip'); await expect(sidebarInset).toHaveCSS('overflow', 'hidden');
await appShell.evaluate((element) => { await appShell.evaluate((element) => {
element.scrollTop = 300; element.scrollTop = 300;
}); });
@@ -79,9 +79,7 @@ test.describe('processor detail workbench', () => {
const flow = configPanel.getByRole('tablist'); const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText('Runner'); 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(1)).toContainText('Local Agent');
await expect(flow.getByRole('tab').nth(2)).toContainText( await expect(flow.getByRole('tab').nth(2)).toContainText('Events & tools');
'Bindable Event Range',
);
await expect(flow.getByRole('tab')).toHaveCount(3); await expect(flow.getByRole('tab')).toHaveCount(3);
await expect(flow.getByText('Management')).toHaveCount(0); await expect(flow.getByText('Management')).toHaveCount(0);
@@ -126,19 +124,32 @@ test.describe('processor detail workbench', () => {
await flow.getByRole('tab').nth(2).click(); await flow.getByRole('tab').nth(2).click();
await expect( await expect(
configPanel.getByText('Bindable Event Range', { exact: true }).last(), configPanel.getByText('Events & tools', { exact: true }).last(),
).toBeVisible(); ).toBeVisible();
const eventPicker = configPanel.getByRole('combobox', { const eventPicker = configPanel.getByRole('button', {
name: 'Event Range', name: 'Add event',
exact: true,
}); });
await expect(eventPicker).toBeVisible(); 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 eventPicker.click();
await expect( await expect(
page.getByRole('option').filter({ hasText: 'message.received' }), page.getByRole('option').filter({ hasText: 'message.received' }),
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole('group', { name: 'Messages' })).toHaveCount(1); await expect(
await expect(page.getByRole('group', { name: 'Groups' })).toHaveCount(1); 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 await page
.getByRole('option') .getByRole('option')
.filter({ hasText: 'message.*' }) .filter({ hasText: 'message.*' })
@@ -189,7 +200,7 @@ test.describe('processor detail workbench', () => {
} }
if ( if (
request.method() === 'POST' && request.method() === 'POST' &&
path === '/api/v1/agents/agent-workbench/debug' path === '/api/v1/agents/agent-workbench/debug/stream'
) { ) {
requests.push('debug'); requests.push('debug');
} }
@@ -237,15 +248,17 @@ test.describe('processor detail workbench', () => {
}) => { }) => {
await installLangBotApiMocks(page, { authenticated: true }); await installLangBotApiMocks(page, { authenticated: true });
await page.route( await page.route(
'**/api/v1/agents/agent-workbench/debug', '**/api/v1/agents/agent-workbench/debug/stream',
async (route) => { async (route) => {
await route.fulfill({ await route.fulfill({
status: 422, status: 200,
contentType: 'application/json', contentType: 'application/x-ndjson',
body: JSON.stringify({ body:
JSON.stringify({
kind: 'error',
code: 'dify.config_invalid', code: 'dify.config_invalid',
msg: 'api-key is required', msg: 'api-key is required',
}), }) + '\n',
}); });
}, },
); );
@@ -454,8 +467,8 @@ test.describe('processor detail workbench', () => {
const appShell = page.locator('[class*="group/sidebar-wrapper"]'); const appShell = page.locator('[class*="group/sidebar-wrapper"]');
const sidebarInset = page.locator('[data-slot="sidebar-inset"]'); const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
await expect(appShell).toHaveCSS('overflow', 'clip'); await expect(appShell).toHaveCSS('overflow', 'hidden');
await expect(sidebarInset).toHaveCSS('overflow', 'clip'); await expect(sidebarInset).toHaveCSS('overflow', 'hidden');
await expect await expect
.poll(() => appShell.evaluate((element) => element.scrollTop)) .poll(() => appShell.evaluate((element) => element.scrollTop))
.toBe(0); .toBe(0);
+90 -22
View File
@@ -2,27 +2,64 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import test from 'node:test'; import test from 'node:test';
import ts from 'typescript'; 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: {} }; 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 { 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', () => { test('separates streamed thinking and text, replaces final snapshot without duplication', () => {
assert.deepEqual(executionSteps([ assert.deepEqual(
executionSteps([
event('message.delta', { chunk: { content: '<think>plan' } }), event('message.delta', { chunk: { content: '<think>plan' } }),
event('message.delta', { chunk: { content: '</think>hello' } }), event('message.delta', { chunk: { content: '</think>hello' } }),
event('message.completed', {message: {content:'<think>plan</think>hello'}}), event('message.completed', {
message: { content: '<think>plan</think>hello' },
}),
event('run.completed', { message: { content: 'hello' } }), event('run.completed', { message: { content: 'hello' } }),
]), [{kind:'message', text:'hello', reasoning:'plan'}]); ]),
[{ kind: 'message', text: 'hello', reasoning: 'plan' }],
);
}); });
test('retains structured reasoning and tool parameters/results in order', () => { test('retains structured reasoning and tool parameters/results in order', () => {
const steps = executionSteps([ const steps = executionSteps([
event('message.delta', {chunk: {provider_specific_fields:{reasoning_content:'plan'}}}), event('message.delta', {
chunk: { provider_specific_fields: { reasoning_content: 'plan' } },
}),
event('message.completed', { message: { content: '' } }), event('message.completed', { message: { content: '' } }),
event('tool.call.started', {tool_call_id:'1',tool_name:'exec', parameters:{command:'echo hi'}}), event('tool.call.started', {
event('tool.call.started', {tool_call_id:'2',tool_name:'exec', parameters:{command:'bad'}}), tool_call_id: '1',
event('tool.call.completed', {tool_call_id:'2',tool_name:'exec', error:'failed'}), tool_name: 'exec',
event('tool.call.completed', {tool_call_id:'1',tool_name:'exec', result:{stdout:'hi'}}), 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', {}), event('run.failed', {}),
]); ]);
assert.equal(steps[0].reasoning, 'plan'); assert.equal(steps[0].reasoning, 'plan');
@@ -33,17 +70,32 @@ test('retains structured reasoning and tool parameters/results in order', () =>
}); });
test('replaces LocalAgent cumulative snapshots instead of repeating text', () => { test('replaces LocalAgent cumulative snapshots instead of repeating text', () => {
assert.deepEqual(executionSteps([ assert.deepEqual(
executionSteps([
event('message.delta', { chunk: { content: 'hello', msg_sequence: 1 } }), event('message.delta', { chunk: { content: 'hello', msg_sequence: 1 } }),
event('message.delta', {chunk: {content:'hello world', msg_sequence:2}}), event('message.delta', {
event('message.delta', {chunk: {content:'hello world', msg_sequence:3, is_final:true}}), chunk: { content: 'hello world', msg_sequence: 2 },
]), [{kind:'message', text:'hello world', reasoning:''}]); }),
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', () => { test('shows failed tool results even when the call transport completed', () => {
const steps = executionSteps([ const steps = executionSteps([
event('tool.call.started', {tool_call_id:'exit7', tool_name:'exec', parameters:{command:'exit 7'}}), event('tool.call.started', {
event('tool.call.completed', {tool_call_id:'exit7', tool_name:'exec', result:{ok:false, exit_code:7, stderr:'expected'}}), 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].status, 'failed');
assert.equal(steps[0].result.exit_code, 7); assert.equal(steps[0].result.exit_code, 7);
@@ -53,15 +105,31 @@ test('does not repeat prior thinking across LocalAgent tool turns', () => {
const prefix = '<think>first thought</think>'; const prefix = '<think>first thought</think>';
const steps = executionSteps([ const steps = executionSteps([
event('message.delta', { chunk: { content: prefix, msg_sequence: 1 } }), 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.started', {
event('tool.call.completed', {tool_call_id:'w',tool_name:'write',result:{ok:true}}), tool_call_id: 'w',
event('message.delta', {chunk:{content:prefix+'now read',msg_sequence:1}}), 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.started', { tool_call_id: 'r', tool_name: 'read' }),
event('tool.call.completed', {tool_call_id:'r',tool_name:'read',result:{ok:true}}), event('tool.call.completed', {
event('message.delta', {chunk:{content:prefix+'now read'+'done',msg_sequence:1}}), 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.completed', { message: { content: 'done' } }),
]); ]);
const messages = steps.filter(s=>s.kind==='message'); const messages = steps.filter((s) => s.kind === 'message');
assert.deepEqual(messages, [ assert.deepEqual(messages, [
{ kind: 'message', text: '', reasoning: 'first thought' }, { kind: 'message', text: '', reasoning: 'first thought' },
{ kind: 'message', text: 'now read', reasoning: '' }, { kind: 'message', text: 'now read', reasoning: '' },