From 68620c45727eb1c2650aaf1bf18199e14608d1b6 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Tue, 25 Aug 2026 21:18:01 +0800 Subject: [PATCH] fix(bots): simplify event routing status --- src/langbot/pkg/api/http/service/bot.py | 43 ++++-------- .../api/service/test_bot_service.py | 54 ++++++++++++++- .../bot-form/EventBindingsEditor.tsx | 68 +++++++++++-------- web/src/i18n/locales/en-US.ts | 13 ++-- web/src/i18n/locales/ja-JP.ts | 13 ++-- web/src/i18n/locales/zh-Hans.ts | 13 ++-- web/tests/e2e/crud-smoke.spec.ts | 31 +++++++++ 7 files changed, 151 insertions(+), 84 deletions(-) diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 1caad0c9f..069e07da3 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -276,14 +276,10 @@ class BotService: ) return result.first() - async def _get_agent_entity( - self, context: TenantContext, agent_uuid: str - ) -> persistence_agent.Agent | None: + async def _get_agent_entity(self, context: TenantContext, agent_uuid: str) -> persistence_agent.Agent | None: result = await self.ap.persistence_mgr.execute_async( scope_statement( - sqlalchemy.select(persistence_agent.Agent).where( - persistence_agent.Agent.uuid == agent_uuid - ), + sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid), persistence_agent.Agent, context, ) @@ -390,11 +386,7 @@ class BotService: } ], ) - pipeline = ( - await self._get_pipeline_entity(tenant_context, target_uuid) - if target_uuid - else None - ) + pipeline = await self._get_pipeline_entity(tenant_context, target_uuid) if target_uuid else None if pipeline is None: return self._diagnostic_result( matched=False, @@ -509,9 +501,7 @@ class BotService: ], ) - async def _normalize_event_bindings( - self, context: TenantContext, bindings: list[dict] | None - ) -> list[dict]: + async def _normalize_event_bindings(self, context: TenantContext, bindings: list[dict] | None) -> list[dict]: """Validate and normalize Bot event bindings.""" if not bindings: return [] @@ -544,9 +534,7 @@ class BotService: elif target_type == 'agent': result = await self.ap.persistence_mgr.execute_async( scope_statement( - sqlalchemy.select(persistence_agent.Agent).where( - persistence_agent.Agent.uuid == target_uuid - ), + sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid), persistence_agent.Agent, context, ) @@ -577,9 +565,7 @@ class BotService: return normalized - async def _prepare_bot_data( - self, context: TenantContext, bot_data: dict, *, include_uuid: bool - ) -> dict: + async def _prepare_bot_data(self, context: TenantContext, bot_data: dict, *, include_uuid: bool) -> dict: """Normalize Bot write payloads to the current event-routing model.""" update_data = bot_data.copy() if not include_uuid: @@ -750,21 +736,19 @@ class BotService: return [log.to_json() for log in logs], total_count - async def list_event_route_statuses( - self, context: TenantContext, bot_uuid: str - ) -> dict[str, typing.Any]: + async def list_event_route_statuses(self, context: TenantContext, bot_uuid: str) -> dict[str, typing.Any]: """Return recent runtime status for Bot event routes from in-memory Bot logs.""" from ....platform.botmgr import RuntimeBot - if await self.get_bot(context, bot_uuid, include_secret=False) is None: + bot = await self.get_bot(context, bot_uuid, include_secret=False) + if bot 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: - raise Exception('Bot not found') latest_by_binding: dict[str, dict[str, typing.Any]] = {} unmatched_events: list[dict[str, typing.Any]] = [] - for log in getattr(runtime_bot.logger, 'logs', []): + runtime_logs = getattr(getattr(runtime_bot, 'logger', None), 'logs', []) + for log in runtime_logs: status = self._event_route_status_from_log(log) if status is None: continue @@ -774,7 +758,10 @@ class BotService: else: unmatched_events.append(status) - raw_bindings = getattr(getattr(runtime_bot, 'bot_entity', None), 'event_bindings', []) + runtime_entity = getattr(runtime_bot, 'bot_entity', None) + raw_bindings = getattr(runtime_entity, 'event_bindings', None) if runtime_entity is not None else None + if raw_bindings is None: + raw_bindings = bot.get('event_bindings') or [] bindings = RuntimeBot._get_event_bindings_from_value(raw_bindings) routes: list[dict[str, typing.Any]] = [] current_binding_ids: set[str] = set() diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index 334ebc7e6..32d4ee7cb 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -62,9 +62,7 @@ def _set_discovered_adapters(ap, *webhook_adapters: str) -> None: ) for adapter_name in webhook_adapters ] - ap.discover = SimpleNamespace( - get_components_by_kind=Mock(return_value=components) - ) + ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=components)) class TestBotServiceGetBots: @@ -583,6 +581,56 @@ class TestBotServiceListEventLogs: assert total == 5 +class TestBotServiceListEventRouteStatuses: + """Tests for event route status when a persisted Bot is not running.""" + + async def test_returns_saved_routes_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', + 'event_bindings': [ + { + 'id': 'binding-1', + 'event_pattern': 'message.received', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'enabled': True, + } + ], + } + ) + + result = await service.list_event_route_statuses(WORKSPACE_UUID, 'bot-uuid') + + assert result['routes'] == [ + { + 'binding_id': 'binding-1', + 'event_pattern': 'message.received', + 'event_type': None, + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'last_status': None, + 'failure_code': None, + 'reason': None, + 'run_id': None, + 'timestamp': None, + 'seq_id': None, + 'level': None, + 'message': '', + 'order': 0, + 'enabled': True, + 'current': True, + } + ] + assert result['unmatched_events'] == [] + assert result['stale_routes'] == [] + + class TestBotServiceSendMessage: """Tests for send_message method.""" diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx index bf0980508..c96ef9e55 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -61,6 +61,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { DropdownMenu, DropdownMenuContent, @@ -1516,8 +1521,8 @@ export default function EventBindingsEditor({ const response = await backendClient.getBotEventRouteStatuses(botId); setRouteStatuses(response.routes || []); } catch (error) { - const err = error as { msg?: string }; - setRouteStatusError(err.msg || t('bots.routeStatusRefreshFailed')); + console.error('Failed to refresh Bot event route status', error); + setRouteStatusError(t('bots.routeStatusRefreshFailed')); } finally { setRouteStatusLoading(false); } @@ -1653,18 +1658,18 @@ export default function EventBindingsEditor({ )} - - - - {catchAllRouteIndex >= 0 - ? t('bots.routeFallbackCatchAll', { - route: t('bots.dryRunRuleIndex', { - index: catchAllRouteIndex + 1, - }), - }) - : t('bots.routeFallbackIgnored')} - - + {catchAllRouteIndex >= 0 && ( + + + + {t('bots.routeFallbackCatchAll', { + route: t('bots.dryRunRuleIndex', { + index: catchAllRouteIndex + 1, + }), + })} + + + )} {/* enabled section */} - + + + + + + {routeStatusError || t('bots.refreshRouteStatus')} + + - {routeStatusError && ( -

{routeStatusError}

- )} {/* disabled section */} {disabledBindings.length > 0 && ( diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 7449a266d..adb7cae89 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -384,8 +384,7 @@ const enUS = { routingConnectionDescription: 'Bind the pipeline that processes messages for this bot', eventRouting: 'Event Routing', - eventRoutingDescription: - 'Choose which processor handles each event received by this bot. Edit the logic in the corresponding Agent or Pipeline configuration. Pipelines only support message events.', + eventRoutingDescription: 'Choose which processor handles each event.', eventBindings: 'Event Routes', addEventBinding: 'Add Route', addBehavior: 'Add behavior', @@ -425,18 +424,16 @@ const enUS = { disable: 'Disable', enable: 'Enable', disabledBindings: 'Disabled', - adapterEventsTitle: 'Events this adapter can receive', - adapterEventsDescription: - '{{count}} event types are available. Routes are matched in order, and unmatched events are not sent to a processor.', + adapterEventsTitle: 'Supported events', + adapterEventsDescription: '{{count}} event types', adapterEventsMore: '{{count}} more', - advancedEventValues: 'Advanced event values', + advancedEventValues: 'View all', eventGroup: 'Group', routeConflictTitle: 'Some routes overlap', routeConflictShadowed: '{{shadowed}} may never run because {{winner}} handles the same events first.', routeConflictMore: '{{count}} more route conflicts need attention.', - routeFallbackCatchAll: - '{{route}} is the catch-all route. Routes with higher priority run first.', + routeFallbackCatchAll: '{{route}} is the catch-all route.', routeFallbackIgnored: 'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.', testRoute: 'Test route', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index ff81e5395..d4634cec2 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -390,8 +390,7 @@ const jaJP = { routingConnectionDescription: 'このボットのメッセージを処理するパイプラインを紐付け', eventRouting: 'イベントルーティング', - eventRoutingDescription: - 'このボットが受信した各イベントをどのプロセッサーに渡すかを選択します。対応する Agent または Pipeline の設定で処理ロジックを編集します。Pipeline はメッセージイベントのみ対応します。', + eventRoutingDescription: 'イベントごとの処理先を設定します。', eventBindings: 'イベントルート', addEventBinding: 'ルートを追加', addBehavior: '動作を追加', @@ -432,18 +431,16 @@ const jaJP = { disable: '無効化', enable: '有効化', disabledBindings: '無効', - adapterEventsTitle: 'このアダプターが受信できるイベント', - adapterEventsDescription: - '{{count}} 種類のイベントを利用できます。ルートは上から順に照合され、未一致のイベントはプロセッサーへ送られません。', + adapterEventsTitle: '対応イベント', + adapterEventsDescription: '{{count}} 種類', adapterEventsMore: 'ほか {{count}} 件', - advancedEventValues: '高度なイベント値', + advancedEventValues: 'すべて表示', eventGroup: 'グループ', routeConflictTitle: '一部のルートが重複しています', routeConflictShadowed: '{{winner}} が同じイベントを先に処理するため、{{shadowed}} は実行されない可能性があります。', routeConflictMore: 'ほか {{count}} 件のルート競合を確認してください。', - routeFallbackCatchAll: - '{{route}} はすべてのイベントを受けるフォールバックです。優先度の高いルートが先に実行されます。', + routeFallbackCatchAll: '{{route}} はフォールバックルートです。', routeFallbackIgnored: 'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。', testRoute: 'ルートをテスト', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index c47a96016..9481e6805 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -367,8 +367,7 @@ const zhHans = { routingConnection: '路由与连接', routingConnectionDescription: '绑定处理此机器人消息的流水线', eventRouting: '事件路由', - eventRoutingDescription: - '选择此机器人收到不同事件时交给哪个处理器。在对应的 Agent 或 Pipeline 配置中编辑处理逻辑;Pipeline 仅支持消息事件。', + eventRoutingDescription: '设置收到事件后交给哪个处理器。', eventBindings: '事件路由', addEventBinding: '添加路由', addBehavior: '添加行为', @@ -404,18 +403,16 @@ const zhHans = { disable: '禁用', enable: '启用', disabledBindings: '已禁用', - adapterEventsTitle: '此适配器可接收的事件', - adapterEventsDescription: - '已识别 {{count}} 类事件。路由会按顺序匹配,未命中时不会交给处理器。', + adapterEventsTitle: '支持的事件', + adapterEventsDescription: '共 {{count}} 类', adapterEventsMore: '另有 {{count}} 类', - advancedEventValues: '高级事件值', + advancedEventValues: '查看全部', eventGroup: '事件组', routeConflictTitle: '部分路由存在覆盖冲突', routeConflictShadowed: '{{shadowed}} 可能永远不会运行,因为 {{winner}} 会先处理相同事件。', routeConflictMore: '另有 {{count}} 个路由冲突需要处理。', - routeFallbackCatchAll: - '{{route}} 是全局兜底路由,优先级更高的路由会先运行。', + routeFallbackCatchAll: '{{route}} 是兜底路由。', routeFallbackIgnored: '未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。', testRoute: '测试路由', diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index aa385f593..814407755 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -335,6 +335,37 @@ test.describe('frontend CRUD smoke flows', () => { }); test.describe('bot advanced flows', () => { + test('keeps event routing compact and hides raw status errors', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route('**/api/v1/platform/bots/*/event-routes/status', (route) => + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ code: -1, msg: 'Internal server error' }), + }), + ); + + await page.goto('/home/bots?id=new'); + await selectPlaywrightAdapter(page); + await page.locator('input[name="name"]').fill('Route Status Bot'); + await submit(page); + + await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); + await expect(page.getByText('Supported events')).toBeVisible(); + await expect(page.getByText('1 event types')).toBeVisible(); + await expect(page.getByText('Internal server error')).toHaveCount(0); + await expect( + page.getByText('Events that match no route are ignored.'), + ).toHaveCount(0); + + await page.getByRole('button', { name: 'Refresh status' }).hover(); + await expect( + page.getByText('Failed to refresh route status.'), + ).toBeVisible(); + }); + test('toggles bot enable/disable state', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true });