mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 04:37:13 +00:00
refactor(bots): remove route execution test
This commit is contained in:
@@ -216,11 +216,11 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /Test route|测试路由|ルートをテスト/ })
|
.getByRole("button", { name: /Check route|检查路由|ルートを確認/ })
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("dialog").waitFor();
|
await page.getByRole("dialog").waitFor();
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /Preview route|预览路由|ルートをプレビュー/ })
|
.getByRole("button", { name: /View match|查看匹配结果|一致結果を確認/ })
|
||||||
.click();
|
.click();
|
||||||
await page
|
await page
|
||||||
.getByText(/Route matched|已命中路由|ルートに一致しました/)
|
.getByText(/Route matched|已命中路由|ルートに一致しました/)
|
||||||
@@ -231,28 +231,11 @@ try {
|
|||||||
.waitFor();
|
.waitFor();
|
||||||
result.visible_signals.push("dry-run-matched", "discard-target");
|
result.visible_signals.push("dry-run-matched", "discard-target");
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole("button", {
|
|
||||||
name: /Run saved route|运行已保存路由|保存済みルートを実行/,
|
|
||||||
})
|
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByText(
|
|
||||||
/saved route ran successfully|已保存路由运行成功|保存済みルートを実行しました/,
|
|
||||||
)
|
|
||||||
.waitFor({ timeout: 20_000 });
|
|
||||||
result.visible_signals.push("test-event-dispatched");
|
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /Close|关闭|閉じる/ })
|
.getByRole("button", { name: /Close|关闭|閉じる/ })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||||
await page
|
|
||||||
.getByText(/Discarded|已丢弃|破棄済み/)
|
|
||||||
.first()
|
|
||||||
.waitFor({ timeout: 10_000 });
|
|
||||||
result.visible_signals.push("route-status-discarded");
|
|
||||||
|
|
||||||
const text = await bodyText(page);
|
const text = await bodyText(page);
|
||||||
if (/\bEBA event\b/.test(text)) {
|
if (/\bEBA event\b/.test(text)) {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ The tools wrap the LangBot service layer. Current tools (v1):
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `get_system_info` | Version, edition, instance id |
|
| `get_system_info` | Version, edition, instance id |
|
||||||
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
|
| `list_bots` / `get_bot` / `create_bot` / `update_bot` / `delete_bot` | Manage messaging-platform bots (secrets redacted on read) |
|
||||||
| `list_bot_event_route_statuses` / `test_bot_event_route` | Inspect bot event-route runtime status and dispatch a synthetic test event through saved routes without sending real outbound platform messages |
|
| `list_bot_event_route_statuses` | Inspect bot event-route runtime status |
|
||||||
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types |
|
| `list_processors` / `get_processor` / `create_processor` / `update_processor` / `delete_processor` | Manage the peer Agent and Pipeline processor types |
|
||||||
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
|
| `list_pipelines` / `get_pipeline` / `create_pipeline` / `update_pipeline` / `delete_pipeline` | Manage pipelines |
|
||||||
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
|
| `list_llm_models` / `get_llm_model` / `list_embedding_models` / `list_model_providers` | Inspect models & providers |
|
||||||
@@ -78,12 +78,6 @@ shape as the corresponding HTTP API request body. Discover resources with the
|
|||||||
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
`resource.view`; mutations require `resource.manage`. All service calls inherit
|
||||||
the immutable Workspace context authenticated at the MCP transport boundary.
|
the immutable Workspace context authenticated at the MCP transport boundary.
|
||||||
|
|
||||||
`test_bot_event_route` uses the bot's saved runtime route table, injects a
|
|
||||||
synthetic event such as `message.received`, and suppresses platform delivery.
|
|
||||||
It still executes the selected processor, so tools and external services may
|
|
||||||
have side effects. Use `payload` for sample event fields, for example
|
|
||||||
`{"message_text": "hello", "chat_type": "private", "chat_id": "u1"}`.
|
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml).
|
1. Get an API key (web UI key, or set `api.global_api_key` in config.yaml).
|
||||||
|
|||||||
@@ -90,15 +90,9 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)
|
)
|
||||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||||
return self.success(
|
return self.success(data=await self.ap.bot_service.list_event_route_statuses(request_context, bot_uuid))
|
||||||
data=await self.ap.bot_service.list_event_route_statuses(
|
|
||||||
request_context, bot_uuid
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _dry_run_event_route(
|
async def _dry_run_event_route(bot_uuid: str, request_context: RequestContext) -> str:
|
||||||
bot_uuid: str, request_context: RequestContext
|
|
||||||
) -> str:
|
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
if not isinstance(json_data, dict):
|
if not isinstance(json_data, dict):
|
||||||
return self.http_status(400, -1, 'invalid request body')
|
return self.http_status(400, -1, 'invalid request body')
|
||||||
@@ -128,24 +122,6 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
permission=Permission.RESOURCE_VIEW,
|
permission=Permission.RESOURCE_VIEW,
|
||||||
)(_dry_run_event_route)
|
)(_dry_run_event_route)
|
||||||
|
|
||||||
@self.route(
|
|
||||||
'/<bot_uuid>/event-routes/test',
|
|
||||||
methods=['POST'],
|
|
||||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
|
||||||
permission=Permission.RUNTIME_OPERATE,
|
|
||||||
)
|
|
||||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
|
||||||
json_data = await quart.request.json
|
|
||||||
if not isinstance(json_data, dict):
|
|
||||||
return self.http_status(400, -1, 'invalid request body')
|
|
||||||
result = await self.ap.bot_service.dispatch_test_event_route(
|
|
||||||
request_context,
|
|
||||||
bot_uuid=bot_uuid,
|
|
||||||
event_type=json_data.get('event_type'),
|
|
||||||
payload=json_data.get('event_data', json_data.get('payload')),
|
|
||||||
)
|
|
||||||
return self.success(data=result)
|
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/<bot_uuid>/send_message',
|
'/<bot_uuid>/send_message',
|
||||||
methods=['POST'],
|
methods=['POST'],
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class BotService:
|
|||||||
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
|
FAILURE_PROCESSOR_NOT_FOUND = 'processor_not_found'
|
||||||
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
|
FAILURE_PROCESSOR_INCOMPATIBLE = 'processor_incompatible'
|
||||||
FAILURE_INVALID_EVENT = 'invalid_event'
|
FAILURE_INVALID_EVENT = 'invalid_event'
|
||||||
FAILURE_BOT_RUNTIME_UNAVAILABLE = 'bot_runtime_unavailable'
|
|
||||||
ROUTE_TRACE_KIND = 'event_route_trace'
|
ROUTE_TRACE_KIND = 'event_route_trace'
|
||||||
|
|
||||||
BOT_FIELDS = {
|
BOT_FIELDS = {
|
||||||
@@ -798,68 +797,6 @@ class BotService:
|
|||||||
'stale_routes': stale_routes,
|
'stale_routes': stale_routes,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def dispatch_test_event_route(
|
|
||||||
self,
|
|
||||||
context: TenantContext,
|
|
||||||
bot_uuid: str,
|
|
||||||
event_type: str,
|
|
||||||
payload: dict[str, typing.Any] | None = None,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Dispatch a synthetic event through the saved Bot runtime route configuration."""
|
|
||||||
event_type = str(event_type or '').strip()
|
|
||||||
if not event_type:
|
|
||||||
return {
|
|
||||||
'dispatched': False,
|
|
||||||
'event_type': '',
|
|
||||||
'failure_code': self.FAILURE_INVALID_EVENT,
|
|
||||||
'reason': 'event_type is required',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if payload is not None and not isinstance(payload, dict):
|
|
||||||
return {
|
|
||||||
'dispatched': False,
|
|
||||||
'event_type': event_type,
|
|
||||||
'failure_code': self.FAILURE_INVALID_EVENT,
|
|
||||||
'reason': 'payload must be an object',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if await self.get_bot(context, bot_uuid, include_secret=False) is None:
|
|
||||||
raise WorkspaceNotFoundError('Bot not found')
|
|
||||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
|
||||||
if runtime_bot is None:
|
|
||||||
return {
|
|
||||||
'dispatched': False,
|
|
||||||
'event_type': event_type,
|
|
||||||
'failure_code': self.FAILURE_BOT_RUNTIME_UNAVAILABLE,
|
|
||||||
'reason': 'Bot runtime is unavailable',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': await self.list_event_route_statuses(context, bot_uuid),
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch_result = await runtime_bot.dispatch_test_event(event_type, payload or {})
|
|
||||||
route_status = await self.list_event_route_statuses(context, bot_uuid)
|
|
||||||
return {
|
|
||||||
'dispatched': bool(dispatch_result.get('dispatched')),
|
|
||||||
'event_type': event_type,
|
|
||||||
'status': dispatch_result.get('status'),
|
|
||||||
'binding_id': dispatch_result.get('binding_id'),
|
|
||||||
'failure_code': dispatch_result.get('failure_code'),
|
|
||||||
'reason': dispatch_result.get('reason'),
|
|
||||||
'suppressed_outputs': dispatch_result.get('suppressed_outputs', []),
|
|
||||||
'route_status': route_status,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
|
|||||||
@@ -132,25 +132,6 @@ class LangBotMCPServer:
|
|||||||
async def list_bot_event_route_statuses(bot_uuid: str) -> str:
|
async def list_bot_event_route_statuses(bot_uuid: str) -> str:
|
||||||
return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid))
|
return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid))
|
||||||
|
|
||||||
@mcp.tool(
|
|
||||||
description=(
|
|
||||||
'Dispatch a synthetic event through the saved bot event routes. '
|
|
||||||
'This validates routing without sending real outbound platform messages.'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
async def test_bot_event_route(
|
|
||||||
bot_uuid: str,
|
|
||||||
event_type: str,
|
|
||||||
payload: dict | None = None,
|
|
||||||
) -> str:
|
|
||||||
return _dump(
|
|
||||||
await ap.bot_service.dispatch_test_event_route(
|
|
||||||
bot_uuid=bot_uuid,
|
|
||||||
event_type=event_type,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# ----- Pipelines ----------------------------------------------- #
|
# ----- Pipelines ----------------------------------------------- #
|
||||||
@mcp.tool(description='List all pipelines.')
|
@mcp.tool(description='List all pipelines.')
|
||||||
async def list_pipelines() -> str:
|
async def list_pipelines() -> str:
|
||||||
|
|||||||
@@ -51,226 +51,6 @@ from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
|
|||||||
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
|
||||||
|
|
||||||
|
|
||||||
class SyntheticRouteTestAdapter:
|
|
||||||
"""Adapter wrapper that suppresses outbound platform delivery for test events."""
|
|
||||||
|
|
||||||
SIDE_EFFECT_API_NAMES = {
|
|
||||||
'send_message',
|
|
||||||
'reply_message',
|
|
||||||
'reply_message_chunk',
|
|
||||||
'create_message_card',
|
|
||||||
'edit_message',
|
|
||||||
'delete_message',
|
|
||||||
'add_reaction',
|
|
||||||
'remove_reaction',
|
|
||||||
'forward_message',
|
|
||||||
'set_group_name',
|
|
||||||
'mute_member',
|
|
||||||
'unmute_member',
|
|
||||||
'kick_member',
|
|
||||||
'leave_group',
|
|
||||||
'approve_friend_request',
|
|
||||||
'approve_group_invite',
|
|
||||||
'upload_file',
|
|
||||||
'call_platform_api',
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, source: abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|
||||||
self.source = source
|
|
||||||
self.bot_account_id = getattr(source, 'bot_account_id', '')
|
|
||||||
self.config = getattr(source, 'config', {})
|
|
||||||
self.logger = getattr(source, 'logger', None)
|
|
||||||
self.suppressed_outputs: list[dict[str, typing.Any]] = []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _message_to_payload(message: platform_message.MessageChain) -> typing.Any:
|
|
||||||
return message.model_dump() if hasattr(message, 'model_dump') else str(message)
|
|
||||||
|
|
||||||
def _suppress(self, method: str, **payload: typing.Any) -> None:
|
|
||||||
self.suppressed_outputs.append({'method': method, **payload})
|
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> typing.Any:
|
|
||||||
return getattr(self.source, name)
|
|
||||||
|
|
||||||
def get_supported_apis(self) -> list[str]:
|
|
||||||
get_supported_apis = getattr(self.source, 'get_supported_apis', None)
|
|
||||||
if not callable(get_supported_apis):
|
|
||||||
return []
|
|
||||||
return [api_name for api_name in get_supported_apis() if api_name not in self.SIDE_EFFECT_API_NAMES]
|
|
||||||
|
|
||||||
async def send_message(
|
|
||||||
self,
|
|
||||||
target_type: str,
|
|
||||||
target_id: str,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
self._suppress(
|
|
||||||
'send_message',
|
|
||||||
target_type=target_type,
|
|
||||||
target_id=target_id,
|
|
||||||
message=self._message_to_payload(message),
|
|
||||||
)
|
|
||||||
return {'suppressed': True}
|
|
||||||
|
|
||||||
async def reply_message(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
self._suppress(
|
|
||||||
'reply_message',
|
|
||||||
message=self._message_to_payload(message),
|
|
||||||
quote_origin=quote_origin,
|
|
||||||
)
|
|
||||||
return {'suppressed': True}
|
|
||||||
|
|
||||||
async def reply_message_chunk(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
bot_message: dict,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
is_final: bool = False,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
self._suppress(
|
|
||||||
'reply_message_chunk',
|
|
||||||
message=self._message_to_payload(message),
|
|
||||||
quote_origin=quote_origin,
|
|
||||||
is_final=is_final,
|
|
||||||
)
|
|
||||||
return {'suppressed': True}
|
|
||||||
|
|
||||||
async def create_message_card(
|
|
||||||
self,
|
|
||||||
message_id: str | int,
|
|
||||||
event: platform_events.MessageEvent,
|
|
||||||
) -> bool:
|
|
||||||
self._suppress('create_message_card', message_id=str(message_id))
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def edit_message(
|
|
||||||
self,
|
|
||||||
chat_type: str,
|
|
||||||
chat_id: typing.Union[int, str],
|
|
||||||
message_id: typing.Union[int, str],
|
|
||||||
new_content: platform_message.MessageChain,
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'edit_message',
|
|
||||||
chat_type=str(chat_type),
|
|
||||||
chat_id=str(chat_id),
|
|
||||||
message_id=str(message_id),
|
|
||||||
new_content=self._message_to_payload(new_content),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def delete_message(
|
|
||||||
self,
|
|
||||||
chat_type: str,
|
|
||||||
chat_id: typing.Union[int, str],
|
|
||||||
message_id: typing.Union[int, str],
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'delete_message',
|
|
||||||
chat_type=str(chat_type),
|
|
||||||
chat_id=str(chat_id),
|
|
||||||
message_id=str(message_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def forward_message(
|
|
||||||
self,
|
|
||||||
from_chat_type: str,
|
|
||||||
from_chat_id: typing.Union[int, str],
|
|
||||||
message_id: typing.Union[int, str],
|
|
||||||
to_chat_type: str,
|
|
||||||
to_chat_id: typing.Union[int, str],
|
|
||||||
) -> platform_events.MessageResult:
|
|
||||||
self._suppress(
|
|
||||||
'forward_message',
|
|
||||||
from_chat_type=str(from_chat_type),
|
|
||||||
from_chat_id=str(from_chat_id),
|
|
||||||
message_id=str(message_id),
|
|
||||||
to_chat_type=str(to_chat_type),
|
|
||||||
to_chat_id=str(to_chat_id),
|
|
||||||
)
|
|
||||||
return platform_events.MessageResult(raw={'suppressed': True})
|
|
||||||
|
|
||||||
async def set_group_name(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
name: str,
|
|
||||||
) -> None:
|
|
||||||
self._suppress('set_group_name', group_id=str(group_id), name=name)
|
|
||||||
|
|
||||||
async def mute_member(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
user_id: typing.Union[int, str],
|
|
||||||
duration: int = 0,
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'mute_member',
|
|
||||||
group_id=str(group_id),
|
|
||||||
user_id=str(user_id),
|
|
||||||
duration=duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def unmute_member(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
user_id: typing.Union[int, str],
|
|
||||||
) -> None:
|
|
||||||
self._suppress('unmute_member', group_id=str(group_id), user_id=str(user_id))
|
|
||||||
|
|
||||||
async def kick_member(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
user_id: typing.Union[int, str],
|
|
||||||
) -> None:
|
|
||||||
self._suppress('kick_member', group_id=str(group_id), user_id=str(user_id))
|
|
||||||
|
|
||||||
async def leave_group(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
) -> None:
|
|
||||||
self._suppress('leave_group', group_id=str(group_id))
|
|
||||||
|
|
||||||
async def approve_friend_request(
|
|
||||||
self,
|
|
||||||
request_id: typing.Union[int, str],
|
|
||||||
approve: bool = True,
|
|
||||||
remark: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'approve_friend_request',
|
|
||||||
request_id=str(request_id),
|
|
||||||
approve=approve,
|
|
||||||
remark=remark,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def approve_group_invite(
|
|
||||||
self,
|
|
||||||
request_id: typing.Union[int, str],
|
|
||||||
approve: bool = True,
|
|
||||||
) -> None:
|
|
||||||
self._suppress(
|
|
||||||
'approve_group_invite',
|
|
||||||
request_id=str(request_id),
|
|
||||||
approve=approve,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
|
||||||
self._suppress('upload_file', filename=filename, size=len(file_data))
|
|
||||||
return f'suppressed:{filename}'
|
|
||||||
|
|
||||||
async def call_platform_api(self, action: str, params: dict | None = None) -> dict:
|
|
||||||
self._suppress('call_platform_api', action=action, params=params or {})
|
|
||||||
return {'suppressed': True}
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeBot:
|
class RuntimeBot:
|
||||||
"""运行时机器人"""
|
"""运行时机器人"""
|
||||||
|
|
||||||
@@ -568,126 +348,6 @@ class RuntimeBot:
|
|||||||
"""Return the selected event binding plus per-binding diagnostic steps."""
|
"""Return the selected event binding plus per-binding diagnostic steps."""
|
||||||
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
|
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_test_platform_event(
|
|
||||||
event_type: str,
|
|
||||||
payload: dict[str, typing.Any] | None = None,
|
|
||||||
) -> platform_events.EBAEvent:
|
|
||||||
"""Build a synthetic platform event for route validation."""
|
|
||||||
payload = payload or {}
|
|
||||||
now = time.time()
|
|
||||||
common = {
|
|
||||||
'type': event_type,
|
|
||||||
'timestamp': payload.get('timestamp') or now,
|
|
||||||
'adapter_name': payload.get('adapter_name') or 'test-event',
|
|
||||||
'source_platform_object': {'synthetic': True, 'payload': payload},
|
|
||||||
}
|
|
||||||
|
|
||||||
user_id = str(payload.get('user_id') or payload.get('sender_id') or 'test-user')
|
|
||||||
user_name = str(payload.get('user_name') or payload.get('sender_name') or 'Test User')
|
|
||||||
group_id = str(payload.get('group_id') or payload.get('chat_id') or 'test-group')
|
|
||||||
group_name = str(payload.get('group_name') or 'Test Group')
|
|
||||||
|
|
||||||
if event_type == 'message.received':
|
|
||||||
chat_type_value = str(payload.get('chat_type') or 'private')
|
|
||||||
chat_type = (
|
|
||||||
platform_entities.ChatType.GROUP
|
|
||||||
if chat_type_value == platform_entities.ChatType.GROUP.value
|
|
||||||
else platform_entities.ChatType.PRIVATE
|
|
||||||
)
|
|
||||||
chat_id = str(
|
|
||||||
payload.get('chat_id') or (group_id if chat_type == platform_entities.ChatType.GROUP else user_id)
|
|
||||||
)
|
|
||||||
message_text = str(payload.get('message_text') or payload.get('text') or '')
|
|
||||||
message_chain_data = payload.get('message_chain')
|
|
||||||
if message_chain_data is None:
|
|
||||||
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
|
|
||||||
else:
|
|
||||||
message_chain = platform_message.MessageChain.model_validate(message_chain_data)
|
|
||||||
group = (
|
|
||||||
platform_entities.UserGroup(id=chat_id, name=group_name)
|
|
||||||
if chat_type == platform_entities.ChatType.GROUP
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return platform_events.MessageReceivedEvent(
|
|
||||||
**common,
|
|
||||||
message_id=str(payload.get('message_id') or f'test-message:{uuid.uuid4()}'),
|
|
||||||
message_chain=message_chain,
|
|
||||||
sender=platform_entities.User(id=user_id, nickname=user_name),
|
|
||||||
chat_type=chat_type,
|
|
||||||
chat_id=chat_id,
|
|
||||||
group=group,
|
|
||||||
)
|
|
||||||
|
|
||||||
if event_type == 'group.member_joined':
|
|
||||||
return platform_events.MemberJoinedEvent(
|
|
||||||
**common,
|
|
||||||
group=platform_entities.UserGroup(id=group_id, name=group_name),
|
|
||||||
member=platform_entities.User(id=user_id, nickname=user_name),
|
|
||||||
inviter=platform_entities.User(
|
|
||||||
id=str(payload.get('inviter_id')),
|
|
||||||
nickname=str(payload.get('inviter_name') or ''),
|
|
||||||
)
|
|
||||||
if payload.get('inviter_id')
|
|
||||||
else None,
|
|
||||||
join_type=payload.get('join_type'),
|
|
||||||
)
|
|
||||||
|
|
||||||
if event_type == 'group.member_left':
|
|
||||||
return platform_events.MemberLeftEvent(
|
|
||||||
**common,
|
|
||||||
group=platform_entities.UserGroup(id=group_id, name=group_name),
|
|
||||||
member=platform_entities.User(id=user_id, nickname=user_name),
|
|
||||||
is_kicked=bool(payload.get('is_kicked', False)),
|
|
||||||
operator=platform_entities.User(
|
|
||||||
id=str(payload.get('operator_id')),
|
|
||||||
nickname=str(payload.get('operator_name') or ''),
|
|
||||||
)
|
|
||||||
if payload.get('operator_id')
|
|
||||||
else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if event_type == 'platform.specific':
|
|
||||||
return platform_events.PlatformSpecificEvent(
|
|
||||||
**common,
|
|
||||||
action=str(payload.get('action') or 'test'),
|
|
||||||
data=payload.get('data') if isinstance(payload.get('data'), dict) else payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
return platform_events.EBAEvent(**common)
|
|
||||||
|
|
||||||
async def dispatch_test_event(
|
|
||||||
self,
|
|
||||||
event_type: str,
|
|
||||||
payload: dict[str, typing.Any] | None = None,
|
|
||||||
) -> dict[str, typing.Any]:
|
|
||||||
"""Dispatch a synthetic event through the real runtime route path."""
|
|
||||||
event_type = str(event_type or '').strip()
|
|
||||||
if not event_type:
|
|
||||||
raise ValueError('event_type is required')
|
|
||||||
|
|
||||||
event = self._build_test_platform_event(event_type, payload)
|
|
||||||
await self._record_event_route_trace(
|
|
||||||
event_type=event_type,
|
|
||||||
status='test_started',
|
|
||||||
reason='Synthetic test event dispatched from control plane',
|
|
||||||
text=f'Test event {event_type} dispatched from control plane',
|
|
||||||
)
|
|
||||||
test_adapter = SyntheticRouteTestAdapter(self.adapter)
|
|
||||||
outcome = await self._dispatch_eba_event_to_processor(
|
|
||||||
event,
|
|
||||||
typing.cast(abstract_platform_adapter.AbstractMessagePlatformAdapter, test_adapter),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
'event_type': event_type,
|
|
||||||
'dispatched': outcome['status'] in {'delivered', 'discarded'},
|
|
||||||
'status': outcome['status'],
|
|
||||||
'binding_id': outcome.get('binding_id'),
|
|
||||||
'failure_code': outcome.get('failure_code'),
|
|
||||||
'reason': outcome.get('reason'),
|
|
||||||
'suppressed_outputs': test_adapter.suppressed_outputs,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _record_event_route_trace(
|
async def _record_event_route_trace(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1170,7 +830,7 @@ class RuntimeBot:
|
|||||||
self,
|
self,
|
||||||
envelope: AgentEventEnvelope,
|
envelope: AgentEventEnvelope,
|
||||||
outputs: list[provider_message.Message | provider_message.MessageChunk],
|
outputs: list[provider_message.Message | provider_message.MessageChunk],
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | SyntheticRouteTestAdapter | None = None,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not outputs or not envelope.delivery.reply_target:
|
if not outputs or not envelope.delivery.reply_target:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -171,18 +171,6 @@ def fake_bot_app():
|
|||||||
'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}],
|
'diagnostic_details': [{'step': 'evaluate_binding', 'binding_id': 'binding-1', 'matched': True}],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
app.bot_service.dispatch_test_event_route = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'dispatched': True,
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
app.bot_service.send_message = AsyncMock()
|
app.bot_service.send_message = AsyncMock()
|
||||||
|
|
||||||
# Platform manager
|
# Platform manager
|
||||||
@@ -373,35 +361,6 @@ class TestBotEventRouteStatusEndpoint:
|
|||||||
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid')
|
fake_bot_app.bot_service.list_event_route_statuses.assert_awaited_with(ANY, 'test-bot-uuid')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
|
||||||
class TestBotEventRouteTestEndpoint:
|
|
||||||
"""Tests for bot event route synthetic dispatch endpoint."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_test_event_route_success(self, quart_test_client, fake_bot_app):
|
|
||||||
"""POST test route dispatches a synthetic event."""
|
|
||||||
response = await quart_test_client.post(
|
|
||||||
'/api/v1/platform/bots/test-bot-uuid/event-routes/test',
|
|
||||||
headers={'Authorization': 'Bearer test_token'},
|
|
||||||
json={
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'payload': {'message_text': 'hello'},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = await response.get_json()
|
|
||||||
assert data['code'] == 0
|
|
||||||
assert data['data']['dispatched'] is True
|
|
||||||
assert data['data']['event_type'] == 'message.received'
|
|
||||||
fake_bot_app.bot_service.dispatch_test_event_route.assert_awaited_with(
|
|
||||||
ANY,
|
|
||||||
bot_uuid='test-bot-uuid',
|
|
||||||
event_type='message.received',
|
|
||||||
payload={'message_text': 'hello'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||||
class TestBotSendMessageEndpoint:
|
class TestBotSendMessageEndpoint:
|
||||||
"""Tests for bot send message endpoint."""
|
"""Tests for bot send message endpoint."""
|
||||||
|
|||||||
@@ -56,18 +56,6 @@ def build_ap() -> SimpleNamespace:
|
|||||||
ap.bot_service = SimpleNamespace(
|
ap.bot_service = SimpleNamespace(
|
||||||
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]),
|
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}]),
|
||||||
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
||||||
dispatch_test_event_route=AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'dispatched': True,
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
|
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
|
||||||
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
|
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
|
||||||
@@ -126,7 +114,7 @@ async def main() -> int:
|
|||||||
tools = await session.list_tools()
|
tools = await session.list_tools()
|
||||||
names = [t.name for t in tools.tools]
|
names = [t.name for t in tools.tools]
|
||||||
print(f'PASS: listed {len(names)} tools')
|
print(f'PASS: listed {len(names)} tools')
|
||||||
for required in ('list_bots', 'get_system_info', 'list_skills', 'test_bot_event_route'):
|
for required in ('list_bots', 'get_system_info', 'list_skills'):
|
||||||
if required not in names:
|
if required not in names:
|
||||||
failures.append(f'missing tool {required}')
|
failures.append(f'missing tool {required}')
|
||||||
|
|
||||||
@@ -144,20 +132,6 @@ async def main() -> int:
|
|||||||
else:
|
else:
|
||||||
print('PASS: get_system_info returned version')
|
print('PASS: get_system_info returned version')
|
||||||
|
|
||||||
res3 = await session.call_tool(
|
|
||||||
'test_bot_event_route',
|
|
||||||
{
|
|
||||||
'bot_uuid': 'bot-1',
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'payload': {'message_text': 'hello'},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
text3 = res3.content[0].text if res3.content else ''
|
|
||||||
if '"dispatched": true' not in text3:
|
|
||||||
failures.append(f'test_bot_event_route wrong: {text3!r}')
|
|
||||||
else:
|
|
||||||
print('PASS: test_bot_event_route returned dispatch result')
|
|
||||||
|
|
||||||
shutdown.set()
|
shutdown.set()
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await asyncio.wait_for(server_task, timeout=5)
|
await asyncio.wait_for(server_task, timeout=5)
|
||||||
|
|||||||
@@ -656,46 +656,6 @@ class TestBotServiceListEventRouteStatuses:
|
|||||||
assert result['stale_routes'] == []
|
assert result['stale_routes'] == []
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceDispatchTestEventRoute:
|
|
||||||
"""Tests for dispatching a synthetic event through a saved route."""
|
|
||||||
|
|
||||||
async def test_returns_actionable_failure_when_runtime_bot_is_unavailable(self):
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.platform_mgr = SimpleNamespace()
|
|
||||||
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
service = BotService(ap)
|
|
||||||
service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'})
|
|
||||||
service.list_event_route_statuses = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await service.dispatch_test_event_route(
|
|
||||||
WORKSPACE_UUID,
|
|
||||||
'bot-uuid',
|
|
||||||
'message.received',
|
|
||||||
{'message_text': 'Hello'},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
'dispatched': False,
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'failure_code': 'bot_runtime_unavailable',
|
|
||||||
'reason': 'Bot runtime is unavailable',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
service.list_event_route_statuses.assert_awaited_once_with(WORKSPACE_UUID, 'bot-uuid')
|
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceSendMessage:
|
class TestBotServiceSendMessage:
|
||||||
"""Tests for send_message method."""
|
"""Tests for send_message method."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
@@ -20,18 +19,6 @@ def _make_app() -> SimpleNamespace:
|
|||||||
update_bot=AsyncMock(),
|
update_bot=AsyncMock(),
|
||||||
delete_bot=AsyncMock(),
|
delete_bot=AsyncMock(),
|
||||||
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
list_event_route_statuses=AsyncMock(return_value={'routes': [], 'unmatched_events': [], 'stale_routes': []}),
|
||||||
dispatch_test_event_route=AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'dispatched': True,
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'suppressed_outputs': [],
|
|
||||||
'route_status': {
|
|
||||||
'routes': [],
|
|
||||||
'unmatched_events': [],
|
|
||||||
'stale_routes': [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
app.pipeline_service = SimpleNamespace(
|
app.pipeline_service = SimpleNamespace(
|
||||||
get_pipelines=AsyncMock(return_value=[]),
|
get_pipelines=AsyncMock(return_value=[]),
|
||||||
@@ -75,30 +62,6 @@ async def test_mcp_server_exposes_bot_event_route_tools():
|
|||||||
tool_names = {tool.name for tool in tools}
|
tool_names = {tool.name for tool in tools}
|
||||||
|
|
||||||
assert 'list_bot_event_route_statuses' in tool_names
|
assert 'list_bot_event_route_statuses' in tool_names
|
||||||
assert 'test_bot_event_route' in tool_names
|
assert 'test_bot_event_route' not in tool_names
|
||||||
assert 'list_processors' in tool_names
|
assert 'list_processors' in tool_names
|
||||||
assert 'list_agents' not in tool_names
|
assert 'list_agents' not in tool_names
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mcp_test_bot_event_route_calls_service_layer():
|
|
||||||
app = _make_app()
|
|
||||||
server = LangBotMCPServer(app)
|
|
||||||
|
|
||||||
result_blocks, _ = await server.mcp.call_tool(
|
|
||||||
'test_bot_event_route',
|
|
||||||
{
|
|
||||||
'bot_uuid': 'bot-1',
|
|
||||||
'event_type': 'message.received',
|
|
||||||
'payload': {'message_text': 'hello'},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
app.bot_service.dispatch_test_event_route.assert_awaited_once_with(
|
|
||||||
bot_uuid='bot-1',
|
|
||||||
event_type='message.received',
|
|
||||||
payload={'message_text': 'hello'},
|
|
||||||
)
|
|
||||||
data = json.loads(result_blocks[0].text)
|
|
||||||
assert data['dispatched'] is True
|
|
||||||
assert data['event_type'] == 'message.received'
|
|
||||||
|
|||||||
@@ -95,63 +95,6 @@ class TestEventRouteTrace:
|
|||||||
assert metadata['target_uuid'] == 'agent-1'
|
assert metadata['target_uuid'] == 'agent-1'
|
||||||
assert metadata['status'] == 'failed'
|
assert metadata['status'] == 'failed'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_test_event_suppresses_agent_output_delivery(self):
|
|
||||||
"""Synthetic test dispatch runs the route but does not call the real adapter."""
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
||||||
|
|
||||||
captured_envelopes = []
|
|
||||||
|
|
||||||
async def fake_run(envelope, binding, adapter_context=None):
|
|
||||||
captured_envelopes.append(envelope)
|
|
||||||
yield provider_message.Message(role='assistant', content='test response')
|
|
||||||
|
|
||||||
bot = self._make_bot(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'id': 'agent-binding',
|
|
||||||
'enabled': True,
|
|
||||||
'event_pattern': 'message.received',
|
|
||||||
'target_type': 'agent',
|
|
||||||
'target_uuid': 'agent-1',
|
|
||||||
'priority': 0,
|
|
||||||
'order': 0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
bot.ap = SimpleNamespace(
|
|
||||||
workspace_service=active_workspace_service(),
|
|
||||||
agent_service=SimpleNamespace(
|
|
||||||
get_agent=AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'uuid': 'agent-1',
|
|
||||||
'kind': 'agent',
|
|
||||||
'enabled': True,
|
|
||||||
'supported_event_patterns': ['message.received'],
|
|
||||||
'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
),
|
|
||||||
agent_run_orchestrator=SimpleNamespace(run=fake_run),
|
|
||||||
)
|
|
||||||
bot.adapter = SimpleNamespace(
|
|
||||||
bot_account_id='bot-account',
|
|
||||||
config={},
|
|
||||||
logger=bot.logger,
|
|
||||||
send_message=AsyncMock(),
|
|
||||||
get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'get_group_info']),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await bot.dispatch_test_event('message.received', {'chat_id': 'user-1', 'message_text': 'hello'})
|
|
||||||
|
|
||||||
bot.adapter.send_message.assert_not_awaited()
|
|
||||||
assert result['dispatched'] is True
|
|
||||||
assert result['status'] == 'delivered'
|
|
||||||
assert result['suppressed_outputs'][0]['method'] == 'send_message'
|
|
||||||
assert captured_envelopes[0].delivery.supports_edit is False
|
|
||||||
assert captured_envelopes[0].delivery.supports_reaction is False
|
|
||||||
assert captured_envelopes[0].delivery.platform_capabilities['supported_apis'] == ['get_group_info']
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
|
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
|
||||||
"""Persisted malformed Agent config cannot escape the per-event route boundary."""
|
"""Persisted malformed Agent config cannot escape the per-event route boundary."""
|
||||||
@@ -208,128 +151,6 @@ class TestEventRouteTrace:
|
|||||||
assert delivered['status'] == 'delivered'
|
assert delivered['status'] == 'delivered'
|
||||||
assert len(runner_calls) == 1
|
assert len(runner_calls) == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_test_event_pipeline_receives_synthetic_adapter(self):
|
|
||||||
"""Pipeline route tests enqueue queries with the no-op adapter."""
|
|
||||||
bot = self._make_bot(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'id': 'pipeline-binding',
|
|
||||||
'enabled': True,
|
|
||||||
'event_pattern': 'message.received',
|
|
||||||
'target_type': 'pipeline',
|
|
||||||
'target_uuid': 'pipeline-1',
|
|
||||||
'priority': 0,
|
|
||||||
'order': 0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
bot.ap = SimpleNamespace(
|
|
||||||
workspace_service=active_workspace_service(),
|
|
||||||
msg_aggregator=SimpleNamespace(add_message=AsyncMock()),
|
|
||||||
)
|
|
||||||
bot.adapter = SimpleNamespace(
|
|
||||||
bot_account_id='bot-account',
|
|
||||||
config={},
|
|
||||||
logger=bot.logger,
|
|
||||||
send_message=AsyncMock(),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await bot.dispatch_test_event(
|
|
||||||
'message.received',
|
|
||||||
{'chat_id': 'user-1', 'message_text': 'hello'},
|
|
||||||
)
|
|
||||||
|
|
||||||
bot.adapter.send_message.assert_not_awaited()
|
|
||||||
bot.ap.msg_aggregator.add_message.assert_awaited_once()
|
|
||||||
_, kwargs = bot.ap.msg_aggregator.add_message.await_args
|
|
||||||
query_adapter = kwargs['adapter']
|
|
||||||
assert query_adapter is not bot.adapter
|
|
||||||
assert getattr(query_adapter, 'source') is bot.adapter
|
|
||||||
assert result['dispatched'] is True
|
|
||||||
assert result['status'] == 'delivered'
|
|
||||||
assert result['suppressed_outputs'] == []
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_test_event_reports_unmatched_route_as_failure(self):
|
|
||||||
"""Synthetic dispatch does not report success when no saved route matches."""
|
|
||||||
bot = self._make_bot([])
|
|
||||||
bot.adapter = SimpleNamespace(
|
|
||||||
bot_account_id='bot-account',
|
|
||||||
config={},
|
|
||||||
logger=bot.logger,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await bot.dispatch_test_event(
|
|
||||||
'message.received',
|
|
||||||
{'chat_id': 'user-1', 'message_text': 'hello'},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result['dispatched'] is False
|
|
||||||
assert result['status'] == 'not_matched'
|
|
||||||
assert result['failure_code'] == 'route_not_found'
|
|
||||||
assert result['reason'] == 'No event route matched'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_synthetic_adapter_suppresses_platform_side_effect_apis(self):
|
|
||||||
"""Synthetic adapter blocks optional platform APIs that mutate external state."""
|
|
||||||
from langbot.pkg.platform.botmgr import SyntheticRouteTestAdapter
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
|
|
||||||
source = SimpleNamespace(
|
|
||||||
bot_account_id='bot-account',
|
|
||||||
config={},
|
|
||||||
logger=Mock(),
|
|
||||||
get_supported_apis=Mock(
|
|
||||||
return_value=[
|
|
||||||
'send_message',
|
|
||||||
'delete_message',
|
|
||||||
'get_group_info',
|
|
||||||
'call_platform_api',
|
|
||||||
]
|
|
||||||
),
|
|
||||||
delete_message=AsyncMock(),
|
|
||||||
call_platform_api=AsyncMock(),
|
|
||||||
)
|
|
||||||
adapter = SyntheticRouteTestAdapter(source)
|
|
||||||
|
|
||||||
await adapter.delete_message('group', 'group-1', 'message-1')
|
|
||||||
await adapter.call_platform_api('set_title', {'name': 'New Title'})
|
|
||||||
upload_result = await adapter.upload_file(b'data', 'test.txt')
|
|
||||||
|
|
||||||
source.delete_message.assert_not_awaited()
|
|
||||||
source.call_platform_api.assert_not_awaited()
|
|
||||||
assert upload_result == 'suppressed:test.txt'
|
|
||||||
assert [item['method'] for item in adapter.suppressed_outputs] == [
|
|
||||||
'delete_message',
|
|
||||||
'call_platform_api',
|
|
||||||
'upload_file',
|
|
||||||
]
|
|
||||||
assert adapter.get_supported_apis() == ['get_group_info']
|
|
||||||
assert adapter._message_to_payload(platform_message.MessageChain([platform_message.Plain(text='ok')]))
|
|
||||||
|
|
||||||
def test_build_test_platform_event_message_received_uses_payload(self):
|
|
||||||
"""Synthetic message events preserve common route filter fields."""
|
|
||||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
|
||||||
|
|
||||||
event = RuntimeBot._build_test_platform_event(
|
|
||||||
'message.received',
|
|
||||||
{
|
|
||||||
'chat_type': 'group',
|
|
||||||
'chat_id': 'group-1',
|
|
||||||
'group_name': 'QA Group',
|
|
||||||
'user_id': 'user-1',
|
|
||||||
'user_name': 'QA User',
|
|
||||||
'message_text': 'hello',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert event.type == 'message.received'
|
|
||||||
assert str(event.chat_id) == 'group-1'
|
|
||||||
assert event.group.name == 'QA Group'
|
|
||||||
assert event.sender.nickname == 'QA User'
|
|
||||||
assert str(event.message_chain) == 'hello'
|
|
||||||
|
|
||||||
def test_agent_envelope_projects_adapter_delivery_capabilities(self):
|
def test_agent_envelope_projects_adapter_delivery_capabilities(self):
|
||||||
"""Runner delivery context reflects the active adapter's declared APIs."""
|
"""Runner delivery context reflects the active adapter's declared APIs."""
|
||||||
from langbot_plugin.api.entities.builtin.platform import entities, events, message
|
from langbot_plugin.api.entities.builtin.platform import entities, events, message
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ import {
|
|||||||
Agent,
|
Agent,
|
||||||
BotRouteDryRunResult,
|
BotRouteDryRunResult,
|
||||||
BotEventRouteStatus,
|
BotEventRouteStatus,
|
||||||
BotRouteTestResult,
|
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { backendClient } from '@/app/infra/http';
|
import { backendClient } from '@/app/infra/http';
|
||||||
import {
|
import {
|
||||||
@@ -885,13 +884,11 @@ function RouteDryRunDialog({
|
|||||||
bindings,
|
bindings,
|
||||||
eventOptions,
|
eventOptions,
|
||||||
agentOptions,
|
agentOptions,
|
||||||
onRouteStatusUpdate,
|
|
||||||
}: {
|
}: {
|
||||||
botId?: string;
|
botId?: string;
|
||||||
bindings: EventBinding[];
|
bindings: EventBinding[];
|
||||||
eventOptions: string[];
|
eventOptions: string[];
|
||||||
agentOptions: Agent[];
|
agentOptions: Agent[];
|
||||||
onRouteStatusUpdate?: (statuses: BotEventRouteStatus[]) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
|
const firstEvent = eventOptions[0] ?? DEFAULT_EVENTS[0];
|
||||||
@@ -902,12 +899,9 @@ function RouteDryRunDialog({
|
|||||||
);
|
);
|
||||||
const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
|
const [advancedPayloadOpen, setAdvancedPayloadOpen] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [isDispatching, setIsDispatching] = useState(false);
|
|
||||||
const [payloadError, setPayloadError] = useState<string | null>(null);
|
const [payloadError, setPayloadError] = useState<string | null>(null);
|
||||||
const [runError, setRunError] = useState<string | null>(null);
|
const [runError, setRunError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
|
const [result, setResult] = useState<BotRouteDryRunResult | null>(null);
|
||||||
const [dispatchResult, setDispatchResult] =
|
|
||||||
useState<BotRouteTestResult | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!eventOptions.includes(eventType)) {
|
if (!eventOptions.includes(eventType)) {
|
||||||
@@ -919,7 +913,6 @@ function RouteDryRunDialog({
|
|||||||
setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
|
setPayloadText(JSON.stringify(samplePayloadForEvent(eventType), null, 2));
|
||||||
setPayloadError(null);
|
setPayloadError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setDispatchResult(null);
|
|
||||||
}, [eventType]);
|
}, [eventType]);
|
||||||
|
|
||||||
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
|
function resolveTargetName(resultTarget?: BotRouteDryRunResult['target']) {
|
||||||
@@ -956,7 +949,6 @@ function RouteDryRunDialog({
|
|||||||
async function runDryRun() {
|
async function runDryRun() {
|
||||||
setRunError(null);
|
setRunError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setDispatchResult(null);
|
|
||||||
|
|
||||||
const payload = parsePayload();
|
const payload = parsePayload();
|
||||||
if (payload === null) return;
|
if (payload === null) return;
|
||||||
@@ -985,44 +977,6 @@ function RouteDryRunDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchTestEvent() {
|
|
||||||
setRunError(null);
|
|
||||||
setResult(null);
|
|
||||||
setDispatchResult(null);
|
|
||||||
|
|
||||||
const payload = parsePayload();
|
|
||||||
if (payload === null) return;
|
|
||||||
|
|
||||||
if (!botId) {
|
|
||||||
setRunError(t('bots.dryRunNeedsSavedBot'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDispatching(true);
|
|
||||||
try {
|
|
||||||
const testResult = await backendClient.testBotEventRoute(botId, {
|
|
||||||
event_type: eventType,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
setDispatchResult(testResult);
|
|
||||||
onRouteStatusUpdate?.(testResult.route_status?.routes || []);
|
|
||||||
if (!testResult.dispatched) {
|
|
||||||
setRunError(
|
|
||||||
localizedFailureReason(
|
|
||||||
testResult.failure_code,
|
|
||||||
testResult.reason,
|
|
||||||
t,
|
|
||||||
) || t('bots.routeTestFailed'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as { msg?: string };
|
|
||||||
setRunError(err.msg || t('bots.routeTestFailed'));
|
|
||||||
} finally {
|
|
||||||
setIsDispatching(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetName = result ? resolveTargetName(result.target) : '';
|
const targetName = result ? resolveTargetName(result.target) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1206,24 +1160,6 @@ function RouteDryRunDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dispatchResult?.dispatched && (
|
|
||||||
<Alert>
|
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
{t('bots.routeTestDispatched', {
|
|
||||||
count: dispatchResult.suppressed_outputs?.length || 0,
|
|
||||||
})}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Alert className="border-amber-200 bg-amber-50/60 px-3 py-2 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-200">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription className="text-xs">
|
|
||||||
{t('bots.routeTestSideEffectWarning')}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -1234,25 +1170,10 @@ function RouteDryRunDialog({
|
|||||||
>
|
>
|
||||||
{t('common.close')}
|
{t('common.close')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" onClick={runDryRun} disabled={isRunning}>
|
||||||
type="button"
|
|
||||||
onClick={runDryRun}
|
|
||||||
disabled={isRunning || isDispatching}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4 mr-1" />
|
<Play className="h-4 w-4 mr-1" />
|
||||||
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
|
{isRunning ? t('bots.dryRunRunning') : t('bots.dryRunAction')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={dispatchTestEvent}
|
|
||||||
disabled={isRunning || isDispatching}
|
|
||||||
>
|
|
||||||
<Activity className="h-4 w-4 mr-1" />
|
|
||||||
{isDispatching
|
|
||||||
? t('bots.routeTestRunning')
|
|
||||||
: t('bots.routeTestAction')}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -1878,7 +1799,6 @@ export default function EventBindingsEditor({
|
|||||||
bindings={bindings}
|
bindings={bindings}
|
||||||
eventOptions={dryRunEventOptions}
|
eventOptions={dryRunEventOptions}
|
||||||
agentOptions={agentOptions}
|
agentOptions={agentOptions}
|
||||||
onRouteStatusUpdate={setRouteStatuses}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -275,11 +275,6 @@ export interface BotRouteDryRunRequest {
|
|||||||
event_bindings?: EventBinding[];
|
event_bindings?: EventBinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotRouteTestRequest {
|
|
||||||
event_type: string;
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotRouteDryRunTarget {
|
export interface BotRouteDryRunTarget {
|
||||||
target_type: EventBinding['target_type'];
|
target_type: EventBinding['target_type'];
|
||||||
target_uuid?: string | null;
|
target_uuid?: string | null;
|
||||||
@@ -334,17 +329,6 @@ export interface BotEventRouteStatusResponse {
|
|||||||
stale_routes: BotEventRouteStatus[];
|
stale_routes: BotEventRouteStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotRouteTestResult {
|
|
||||||
dispatched: boolean;
|
|
||||||
event_type: string;
|
|
||||||
status?: BotEventRouteStatus['last_status'];
|
|
||||||
binding_id?: string | null;
|
|
||||||
failure_code?: string | null;
|
|
||||||
reason?: string | null;
|
|
||||||
suppressed_outputs: Array<Record<string, unknown>>;
|
|
||||||
route_status: BotEventRouteStatusResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiRespKnowledgeBases {
|
export interface ApiRespKnowledgeBases {
|
||||||
bases: KnowledgeBase[];
|
bases: KnowledgeBase[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,8 +61,6 @@ import {
|
|||||||
ApiRespSkill,
|
ApiRespSkill,
|
||||||
BotRouteDryRunRequest,
|
BotRouteDryRunRequest,
|
||||||
BotRouteDryRunResult,
|
BotRouteDryRunResult,
|
||||||
BotRouteTestRequest,
|
|
||||||
BotRouteTestResult,
|
|
||||||
BotEventRouteStatusResponse,
|
BotEventRouteStatusResponse,
|
||||||
} from '@/app/infra/entities/api';
|
} from '@/app/infra/entities/api';
|
||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
@@ -512,16 +510,6 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
|
return this.get(`/api/v1/platform/bots/${botId}/event-routes/status`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public testBotEventRoute(
|
|
||||||
botId: string,
|
|
||||||
request: BotRouteTestRequest,
|
|
||||||
): Promise<BotRouteTestResult> {
|
|
||||||
return this.post(
|
|
||||||
`/api/v1/platform/bots/${botId}/event-routes/test`,
|
|
||||||
request,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public deleteBot(uuid: string): Promise<object> {
|
public deleteBot(uuid: string): Promise<object> {
|
||||||
return this.delete(`/api/v1/platform/bots/${uuid}`);
|
return this.delete(`/api/v1/platform/bots/${uuid}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ const enUS = {
|
|||||||
routeFallbackCatchAll: '{{route}} is the catch-all route.',
|
routeFallbackCatchAll: '{{route}} is the catch-all route.',
|
||||||
routeFallbackIgnored:
|
routeFallbackIgnored:
|
||||||
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
|
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
|
||||||
testRoute: 'Test route',
|
testRoute: 'Check route',
|
||||||
refreshRouteStatus: 'Refresh status',
|
refreshRouteStatus: 'Refresh status',
|
||||||
routeStatusIdle: 'No run yet',
|
routeStatusIdle: 'No run yet',
|
||||||
routeStatusRefreshFailed: 'Failed to refresh route status.',
|
routeStatusRefreshFailed: 'Failed to refresh route status.',
|
||||||
@@ -460,7 +460,6 @@ const enUS = {
|
|||||||
discarded: 'Discarded',
|
discarded: 'Discarded',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
not_matched: 'Not matched',
|
not_matched: 'Not matched',
|
||||||
test_started: 'Testing',
|
|
||||||
},
|
},
|
||||||
routeStatusDetail: {
|
routeStatusDetail: {
|
||||||
matched: 'This route matched the event.',
|
matched: 'This route matched the event.',
|
||||||
@@ -468,7 +467,6 @@ const enUS = {
|
|||||||
discarded: 'The event was intentionally discarded.',
|
discarded: 'The event was intentionally discarded.',
|
||||||
failed: 'The route could not finish.',
|
failed: 'The route could not finish.',
|
||||||
not_matched: 'No configured route matched the event.',
|
not_matched: 'No configured route matched the event.',
|
||||||
test_started: 'The saved route is running.',
|
|
||||||
},
|
},
|
||||||
routeFailure: {
|
routeFailure: {
|
||||||
binding_disabled: 'This route is disabled.',
|
binding_disabled: 'This route is disabled.',
|
||||||
@@ -479,21 +477,12 @@ const enUS = {
|
|||||||
processor_incompatible:
|
processor_incompatible:
|
||||||
'The selected processor cannot handle this event.',
|
'The selected processor cannot handle this event.',
|
||||||
processor_not_found: 'The selected processor is unavailable.',
|
processor_not_found: 'The selected processor is unavailable.',
|
||||||
bot_runtime_unavailable:
|
|
||||||
'The bot is not running. Check its platform settings and enable it before running a full test.',
|
|
||||||
runner_failed: 'The Agent runner failed while processing the event.',
|
runner_failed: 'The Agent runner failed while processing the event.',
|
||||||
delivery_failed: 'The processor finished, but delivery failed.',
|
delivery_failed: 'The processor finished, but delivery failed.',
|
||||||
},
|
},
|
||||||
routeTestAction: 'Run full test',
|
dryRunTitle: 'Check event route',
|
||||||
routeTestRunning: 'Running…',
|
|
||||||
routeTestFailed: 'Failed to run the saved route. Try again later.',
|
|
||||||
routeTestDispatched:
|
|
||||||
'The saved route ran successfully. {{count}} platform actions were blocked.',
|
|
||||||
routeTestSideEffectWarning:
|
|
||||||
'The processor and tools will run, but replies will not be sent to messaging platforms.',
|
|
||||||
dryRunTitle: 'Test route',
|
|
||||||
dryRunDescription:
|
dryRunDescription:
|
||||||
'Choose an event to see which processor handles it, or run a test.',
|
'Choose an event to see which route and processor it matches.',
|
||||||
dryRunEventType: 'Event type',
|
dryRunEventType: 'Event type',
|
||||||
dryRunSampleReady: 'Sample event is ready',
|
dryRunSampleReady: 'Sample event is ready',
|
||||||
dryRunSampleDescription:
|
dryRunSampleDescription:
|
||||||
@@ -504,10 +493,10 @@ const enUS = {
|
|||||||
dryRunPayloadHint: 'Use this to test message and conversation conditions.',
|
dryRunPayloadHint: 'Use this to test message and conversation conditions.',
|
||||||
dryRunPayloadJsonError: 'Enter valid JSON.',
|
dryRunPayloadJsonError: 'Enter valid JSON.',
|
||||||
dryRunPayloadObjectError: 'Payload must be a JSON object.',
|
dryRunPayloadObjectError: 'Payload must be a JSON object.',
|
||||||
dryRunNeedsSavedBot: 'Save the bot before testing routes.',
|
dryRunNeedsSavedBot: 'Save the bot before checking routes.',
|
||||||
dryRunFailed: 'Route test failed. Try again later.',
|
dryRunFailed: 'Failed to check the route. Try again later.',
|
||||||
dryRunAction: 'Preview match',
|
dryRunAction: 'View match',
|
||||||
dryRunRunning: 'Testing…',
|
dryRunRunning: 'Checking…',
|
||||||
dryRunMatched: 'Route matched',
|
dryRunMatched: 'Route matched',
|
||||||
dryRunNotMatched: 'No route matched',
|
dryRunNotMatched: 'No route matched',
|
||||||
dryRunTarget: 'Target processor',
|
dryRunTarget: 'Target processor',
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ const jaJP = {
|
|||||||
routeFallbackCatchAll: '{{route}} はフォールバックルートです。',
|
routeFallbackCatchAll: '{{route}} はフォールバックルートです。',
|
||||||
routeFallbackIgnored:
|
routeFallbackIgnored:
|
||||||
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
|
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
|
||||||
testRoute: 'ルートをテスト',
|
testRoute: 'ルートを確認',
|
||||||
refreshRouteStatus: '状態を更新',
|
refreshRouteStatus: '状態を更新',
|
||||||
routeStatusIdle: '実行記録なし',
|
routeStatusIdle: '実行記録なし',
|
||||||
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
|
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
|
||||||
@@ -467,7 +467,6 @@ const jaJP = {
|
|||||||
discarded: '破棄済み',
|
discarded: '破棄済み',
|
||||||
failed: '失敗',
|
failed: '失敗',
|
||||||
not_matched: '未一致',
|
not_matched: '未一致',
|
||||||
test_started: 'テスト中',
|
|
||||||
},
|
},
|
||||||
routeStatusDetail: {
|
routeStatusDetail: {
|
||||||
matched: 'このルートがイベントに一致しました。',
|
matched: 'このルートがイベントに一致しました。',
|
||||||
@@ -475,7 +474,6 @@ const jaJP = {
|
|||||||
discarded: '設定に従ってイベントを破棄しました。',
|
discarded: '設定に従ってイベントを破棄しました。',
|
||||||
failed: 'ルートを完了できませんでした。',
|
failed: 'ルートを完了できませんでした。',
|
||||||
not_matched: '設定済みルートに一致しませんでした。',
|
not_matched: '設定済みルートに一致しませんでした。',
|
||||||
test_started: '保存済みルートを実行しています。',
|
|
||||||
},
|
},
|
||||||
routeFailure: {
|
routeFailure: {
|
||||||
binding_disabled: 'このルートは無効です。',
|
binding_disabled: 'このルートは無効です。',
|
||||||
@@ -486,22 +484,11 @@ const jaJP = {
|
|||||||
processor_incompatible:
|
processor_incompatible:
|
||||||
'選択したプロセッサーはこのイベントを処理できません。',
|
'選択したプロセッサーはこのイベントを処理できません。',
|
||||||
processor_not_found: '選択したプロセッサーを利用できません。',
|
processor_not_found: '選択したプロセッサーを利用できません。',
|
||||||
bot_runtime_unavailable:
|
|
||||||
'ボットが実行されていません。プラットフォーム設定を確認してボットを有効にした後、完全テストを実行してください。',
|
|
||||||
runner_failed: 'Agent Runner がイベント処理中に失敗しました。',
|
runner_failed: 'Agent Runner がイベント処理中に失敗しました。',
|
||||||
delivery_failed: '処理は完了しましたが、結果の配信に失敗しました。',
|
delivery_failed: '処理は完了しましたが、結果の配信に失敗しました。',
|
||||||
},
|
},
|
||||||
routeTestAction: '完全テストを実行',
|
dryRunTitle: 'イベントルートを確認',
|
||||||
routeTestRunning: '実行中…',
|
dryRunDescription: 'イベントを選び、一致するルートと処理先を確認します。',
|
||||||
routeTestFailed:
|
|
||||||
'保存済みルートの実行に失敗しました。後でもう一度お試しください。',
|
|
||||||
routeTestDispatched:
|
|
||||||
'保存済みルートを実行しました。{{count}} 件のプラットフォーム操作を抑制しました。',
|
|
||||||
routeTestSideEffectWarning:
|
|
||||||
'プロセッサーとツールは実行されますが、返信はメッセージプラットフォームへ送信されません。',
|
|
||||||
dryRunTitle: 'ルートをテスト',
|
|
||||||
dryRunDescription:
|
|
||||||
'イベントを選び、処理先を確認するか、テストを実行します。',
|
|
||||||
dryRunEventType: 'イベントタイプ',
|
dryRunEventType: 'イベントタイプ',
|
||||||
dryRunSampleReady: 'サンプルイベントを準備しました',
|
dryRunSampleReady: 'サンプルイベントを準備しました',
|
||||||
dryRunSampleDescription:
|
dryRunSampleDescription:
|
||||||
@@ -513,10 +500,10 @@ const jaJP = {
|
|||||||
dryRunPayloadJsonError: '有効な JSON を入力してください。',
|
dryRunPayloadJsonError: '有効な JSON を入力してください。',
|
||||||
dryRunPayloadObjectError:
|
dryRunPayloadObjectError:
|
||||||
'ペイロードは JSON オブジェクトである必要があります。',
|
'ペイロードは JSON オブジェクトである必要があります。',
|
||||||
dryRunNeedsSavedBot: 'ルートをテストする前にボットを保存してください。',
|
dryRunNeedsSavedBot: 'ルートを確認する前にボットを保存してください。',
|
||||||
dryRunFailed: 'ルートテストに失敗しました。後でもう一度お試しください。',
|
dryRunFailed: 'ルートを確認できませんでした。後でもう一度お試しください。',
|
||||||
dryRunAction: '一致を確認',
|
dryRunAction: '一致結果を確認',
|
||||||
dryRunRunning: 'テスト中…',
|
dryRunRunning: '確認中…',
|
||||||
dryRunMatched: 'ルートに一致しました',
|
dryRunMatched: 'ルートに一致しました',
|
||||||
dryRunNotMatched: '一致するルートはありません',
|
dryRunNotMatched: '一致するルートはありません',
|
||||||
dryRunTarget: '対象プロセッサー',
|
dryRunTarget: '対象プロセッサー',
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ const zhHans = {
|
|||||||
routeFallbackCatchAll: '{{route}} 是兜底路由。',
|
routeFallbackCatchAll: '{{route}} 是兜底路由。',
|
||||||
routeFallbackIgnored:
|
routeFallbackIgnored:
|
||||||
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
|
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
|
||||||
testRoute: '测试路由',
|
testRoute: '检查路由',
|
||||||
refreshRouteStatus: '刷新状态',
|
refreshRouteStatus: '刷新状态',
|
||||||
routeStatusIdle: '暂无运行记录',
|
routeStatusIdle: '暂无运行记录',
|
||||||
routeStatusRefreshFailed: '刷新路由状态失败。',
|
routeStatusRefreshFailed: '刷新路由状态失败。',
|
||||||
@@ -439,7 +439,6 @@ const zhHans = {
|
|||||||
discarded: '已丢弃',
|
discarded: '已丢弃',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
not_matched: '未命中',
|
not_matched: '未命中',
|
||||||
test_started: '测试中',
|
|
||||||
},
|
},
|
||||||
routeStatusDetail: {
|
routeStatusDetail: {
|
||||||
matched: '此路由已命中事件。',
|
matched: '此路由已命中事件。',
|
||||||
@@ -447,7 +446,6 @@ const zhHans = {
|
|||||||
discarded: '此事件已按路由配置丢弃。',
|
discarded: '此事件已按路由配置丢弃。',
|
||||||
failed: '此路由未能完成。',
|
failed: '此路由未能完成。',
|
||||||
not_matched: '没有已配置路由命中此事件。',
|
not_matched: '没有已配置路由命中此事件。',
|
||||||
test_started: '正在运行已保存路由。',
|
|
||||||
},
|
},
|
||||||
routeFailure: {
|
routeFailure: {
|
||||||
binding_disabled: '此路由已禁用。',
|
binding_disabled: '此路由已禁用。',
|
||||||
@@ -457,19 +455,11 @@ const zhHans = {
|
|||||||
route_not_found: '没有路由命中此事件。',
|
route_not_found: '没有路由命中此事件。',
|
||||||
processor_incompatible: '所选处理器无法处理此事件。',
|
processor_incompatible: '所选处理器无法处理此事件。',
|
||||||
processor_not_found: '所选处理器不可用。',
|
processor_not_found: '所选处理器不可用。',
|
||||||
bot_runtime_unavailable:
|
|
||||||
'机器人尚未运行。请检查平台配置并启用机器人,再运行完整测试。',
|
|
||||||
runner_failed: 'Agent Runner 处理事件时失败。',
|
runner_failed: 'Agent Runner 处理事件时失败。',
|
||||||
delivery_failed: '处理器已完成,但结果投递失败。',
|
delivery_failed: '处理器已完成,但结果投递失败。',
|
||||||
},
|
},
|
||||||
routeTestAction: '运行完整测试',
|
dryRunTitle: '检查事件路由',
|
||||||
routeTestRunning: '运行中…',
|
dryRunDescription: '选择事件,查看它会匹配哪条路由、交给哪个处理器。',
|
||||||
routeTestFailed: '运行已保存路由失败,请稍后重试。',
|
|
||||||
routeTestDispatched: '已保存路由运行成功,{{count}} 个平台操作已被阻止。',
|
|
||||||
routeTestSideEffectWarning:
|
|
||||||
'测试会运行处理器和工具,但不会把回复发送到微信、QQ 等聊天平台。',
|
|
||||||
dryRunTitle: '测试路由',
|
|
||||||
dryRunDescription: '选择事件,查看它会交给哪个处理器,或运行一次测试。',
|
|
||||||
dryRunEventType: '事件类型',
|
dryRunEventType: '事件类型',
|
||||||
dryRunSampleReady: '示例事件已准备好',
|
dryRunSampleReady: '示例事件已准备好',
|
||||||
dryRunSampleDescription:
|
dryRunSampleDescription:
|
||||||
@@ -480,10 +470,10 @@ const zhHans = {
|
|||||||
dryRunPayloadHint: '用于测试消息内容、会话类型等条件。',
|
dryRunPayloadHint: '用于测试消息内容、会话类型等条件。',
|
||||||
dryRunPayloadJsonError: '请输入合法 JSON。',
|
dryRunPayloadJsonError: '请输入合法 JSON。',
|
||||||
dryRunPayloadObjectError: '载荷必须是 JSON 对象。',
|
dryRunPayloadObjectError: '载荷必须是 JSON 对象。',
|
||||||
dryRunNeedsSavedBot: '请先保存机器人后再测试路由。',
|
dryRunNeedsSavedBot: '请先保存机器人后再检查路由。',
|
||||||
dryRunFailed: '路由测试失败,请稍后重试。',
|
dryRunFailed: '无法检查路由,请稍后重试。',
|
||||||
dryRunAction: '预览匹配',
|
dryRunAction: '查看匹配结果',
|
||||||
dryRunRunning: '测试中…',
|
dryRunRunning: '检查中…',
|
||||||
dryRunMatched: '已命中路由',
|
dryRunMatched: '已命中路由',
|
||||||
dryRunNotMatched: '未命中路由',
|
dryRunNotMatched: '未命中路由',
|
||||||
dryRunTarget: '目标处理器',
|
dryRunTarget: '目标处理器',
|
||||||
|
|||||||
@@ -389,29 +389,6 @@ test.describe('bot advanced flows', () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await page.route('**/api/v1/platform/bots/*/event-routes/test', (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
msg: 'ok',
|
|
||||||
data: {
|
|
||||||
dispatched: false,
|
|
||||||
event_type: 'message.received',
|
|
||||||
failure_code: 'bot_runtime_unavailable',
|
|
||||||
reason: 'Bot runtime is unavailable',
|
|
||||||
suppressed_outputs: [],
|
|
||||||
route_status: {
|
|
||||||
routes: [],
|
|
||||||
unmatched_events: [],
|
|
||||||
stale_routes: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
await selectPlaywrightAdapter(page);
|
||||||
await page.locator('input[name="name"]').fill('Route Status Bot');
|
await page.locator('input[name="name"]').fill('Route Status Bot');
|
||||||
@@ -454,14 +431,14 @@ test.describe('bot advanced flows', () => {
|
|||||||
page.getByText('Failed to refresh route status.'),
|
page.getByText('Failed to refresh route status.'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Test route' }).click();
|
await page.getByRole('button', { name: 'Check route' }).click();
|
||||||
const routeDialog = page.getByRole('dialog');
|
const routeDialog = page.getByRole('dialog');
|
||||||
await expect(
|
await expect(
|
||||||
routeDialog.getByText('Test route', { exact: true }),
|
routeDialog.getByText('Check event route', { exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
routeDialog.getByText(
|
routeDialog.getByText(
|
||||||
'Choose an event to see which processor handles it, or run a test.',
|
'Choose an event to see which route and processor it matches.',
|
||||||
),
|
),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(routeDialog.getByText('Sample event is ready')).toHaveCount(0);
|
await expect(routeDialog.getByText('Sample event is ready')).toHaveCount(0);
|
||||||
@@ -469,11 +446,11 @@ test.describe('bot advanced flows', () => {
|
|||||||
routeDialog.getByRole('button', { name: 'Test data' }),
|
routeDialog.getByRole('button', { name: 'Test data' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
routeDialog.getByRole('button', { name: 'Preview match' }),
|
routeDialog.getByRole('button', { name: 'View match' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
routeDialog.getByRole('button', { name: 'Run full test' }),
|
routeDialog.getByRole('button', { name: 'Run full test' }),
|
||||||
).toBeVisible();
|
).toHaveCount(0);
|
||||||
const routeEventPicker = routeDialog.getByRole('combobox', {
|
const routeEventPicker = routeDialog.getByRole('combobox', {
|
||||||
name: 'Event type',
|
name: 'Event type',
|
||||||
});
|
});
|
||||||
@@ -494,25 +471,9 @@ test.describe('bot advanced flows', () => {
|
|||||||
);
|
);
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
await routeDialog.getByRole('button', { name: 'Preview match' }).click();
|
await routeDialog.getByRole('button', { name: 'View match' }).click();
|
||||||
await expect(routeDialog.getByText('Matched route')).toBeVisible();
|
await expect(routeDialog.getByText('Matched route')).toBeVisible();
|
||||||
|
|
||||||
await routeDialog.getByRole('button', { name: 'Run full test' }).click();
|
|
||||||
await expect(routeDialog.getByText('Matched route')).toHaveCount(0);
|
|
||||||
await expect(
|
|
||||||
routeDialog.getByText(
|
|
||||||
'The bot is not running. Check its platform settings and enable it before running a full test.',
|
|
||||||
),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(routeDialog.getByText('Internal server error')).toHaveCount(0);
|
await expect(routeDialog.getByText('Internal server error')).toHaveCount(0);
|
||||||
|
|
||||||
await routeDialog.getByRole('button', { name: 'Preview match' }).click();
|
|
||||||
await expect(
|
|
||||||
routeDialog.getByText(
|
|
||||||
'The bot is not running. Check its platform settings and enable it before running a full test.',
|
|
||||||
),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await expect(routeDialog.getByText('Matched route')).toBeVisible();
|
|
||||||
const dialogBox = await routeDialog.boundingBox();
|
const dialogBox = await routeDialog.boundingBox();
|
||||||
expect(dialogBox).not.toBeNull();
|
expect(dialogBox).not.toBeNull();
|
||||||
expect(dialogBox!.height).toBeLessThan(500);
|
expect(dialogBox!.height).toBeLessThan(500);
|
||||||
|
|||||||
Reference in New Issue
Block a user