From 3d692fa8dbc7c41bea6127d8347c5f6e369e0337 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Wed, 26 Aug 2026 19:46:59 +0800 Subject: [PATCH] feat(bots): add platform event debugger --- .../e2e/bot-event-routing-product-flow.mjs | 59 ++- .../cases/bot-event-routing-product-flow.yaml | 15 +- src/langbot/pkg/platform/botmgr.py | 24 +- .../unit_tests/platform/test_routing_rules.py | 30 ++ .../bot-form/AdapterEventDebugDialog.tsx | 335 ++++++++++++++++++ .../home/bots/components/bot-form/BotForm.tsx | 5 + .../bot-form/EventBindingsEditor.tsx | 4 + .../requestParam/bots/GetBotLogsResponse.ts | 3 +- web/src/i18n/locales/en-US.ts | 20 ++ web/src/i18n/locales/ja-JP.ts | 20 ++ web/src/i18n/locales/zh-Hans.ts | 18 + web/tests/e2e/crud-smoke.spec.ts | 67 ++++ 12 files changed, 586 insertions(+), 14 deletions(-) create mode 100644 web/src/app/home/bots/components/bot-form/AdapterEventDebugDialog.tsx diff --git a/skills/scripts/e2e/bot-event-routing-product-flow.mjs b/skills/scripts/e2e/bot-event-routing-product-flow.mjs index e200e361a..d90aeeec1 100644 --- a/skills/scripts/e2e/bot-event-routing-product-flow.mjs +++ b/skills/scripts/e2e/bot-event-routing-product-flow.mjs @@ -188,11 +188,7 @@ try { .getByText(/Event Routing|事件路由|イベントルーティング/) .first() .waitFor({ timeout: 15_000 }); - await page - .getByText( - /Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/, - ) - .waitFor(); + await page.getByText(/Supported events|支持的事件|対応イベント/).waitFor(); await page .getByText(/Message received|收到消息|メッセージを受信/) .first() @@ -237,6 +233,57 @@ try { .click(); await page.getByRole("dialog").waitFor({ state: "hidden" }); + await page + .getByRole("button", { + name: /Listen for platform events|监听平台事件|プラットフォームイベントを監視/, + }) + .click(); + const adapterDialog = page.getByRole("dialog"); + await adapterDialog.waitFor(); + await adapterDialog + .getByText(/Listening|正在监听|監視中/, { exact: true }) + .waitFor({ timeout: 15_000 }); + + const inboundText = `adapter event ${paths.runId}`; + const inbound = await apiJson( + backendUrl, + `/bots/${encodeURIComponent(botId)}`, + { + method: "POST", + token, + body: { + session_id: `adapter-debug-${paths.runId}`, + session_type: "person", + sender: { id: "adapter-debug-user", name: "Adapter QA" }, + message: [{ type: "Plain", text: inboundText }], + }, + }, + ); + result.api.adapter_event_webhook = { + http_status: inbound.status, + code: inbound.json.code ?? null, + }; + if (inbound.status >= 400 || inbound.json.code !== 0) { + throw new Error( + inbound.json.msg || "The HTTP Bot adapter rejected the inbound event.", + ); + } + + await adapterDialog + .getByText(/Message received|收到消息|メッセージ受信/, { exact: true }) + .waitFor({ timeout: 15_000 }); + await adapterDialog.getByText("message.received", { exact: true }).waitFor(); + await adapterDialog.getByText(inboundText, { exact: true }).waitFor(); + result.visible_signals.push( + "adapter-event-listening", + "adapter-event-received", + "adapter-event-raw-code", + ); + await adapterDialog + .getByRole("button", { name: /Close|关闭|閉じる/ }) + .click(); + await adapterDialog.waitFor({ state: "hidden" }); + const text = await bodyText(page); if (/\bEBA event\b/.test(text)) { throw new Error( @@ -291,7 +338,7 @@ try { } result.status = "pass"; result.reason = - "Bot event routing, dry-run, synthetic dispatch, and visible route status passed in the WebUI."; + "Bot event routing, dry-run, real adapter input, and visible route status passed in the WebUI."; } catch (error) { if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; result.reason = result.reason || error.message; diff --git a/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml b/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml index 4e8a6dbab..e19c4802b 100644 --- a/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml +++ b/skills/skills/langbot-testing/cases/bot-event-routing-product-flow.yaml @@ -1,5 +1,5 @@ id: bot-event-routing-product-flow -title: "Bot event routing can be configured and tested from the WebUI" +title: "Bot event routing and adapter input can be inspected from the WebUI" mode: agent-browser area: bot type: feature @@ -33,16 +33,18 @@ steps: - "Confirm the adapter capability summary, friendly event name, target, and route status are visible." - "Confirm overlapping routes and unmatched-event fallback behavior are explained before save." - "Open Test event route and run a dry-run against the current form." - - "Run the saved runtime route with a synthetic event." - - "Close the dialog and confirm the route card shows the latest discarded status." + - "Open Platform event debugging and send a real inbound event through the HTTP Bot adapter." + - "Confirm the normalized event, raw event code, payload, and latest discarded route status are visible." checks: - "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation." - "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card." - "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs." - "UI: Dry-run visibly reports that the route matched the discard processor." - - "UI: Saved-route execution visibly succeeds, explains its side-effect boundary, and updates route status to discarded." + - "UI: Adapter event debugging is clearly separate from route preview and starts listening only after the dialog opens." + - "UI: A real adapter event shows its friendly name, raw code, and normalized event data." + - "UI: The route card updates to discarded after the real inbound event is handled." - "Console: No unexpected frontend errors appear during the flow." - - "Network: Bot, dry-run, route-status, and test-event requests return without 5xx responses." + - "Network: Bot, dry-run, route-status, log, and HTTP Bot webhook requests return without 5xx responses." - "Cleanup: The temporary Bot is deleted after evidence is collected." evidence_required: - ui @@ -51,7 +53,8 @@ evidence_required: - api_diagnostic diagnostics: - "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback." - - "A passing API call without the visible matched and discarded UI states is not a pass." + - "The adapter dialog observes normalized platform events; it does not simulate route matching." + - "A passing webhook call without the visible adapter event and discarded UI states is not a pass." troubleshooting: - backend-not-listening - proxy-env-mismatch diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 1e7373470..434cd37b4 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -444,6 +444,26 @@ class RuntimeBot: compact[key] = value return compact + async def _record_adapter_event( + self, + event: platform_events.EBAEvent, + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> dict[str, typing.Any]: + """Record a normalized adapter event for the platform debugging surface.""" + event_type = getattr(event, 'type', None) or event.__class__.__name__ + metadata = { + 'kind': 'adapter_event_received', + 'event_type': event_type, + 'event_data': self._compact_event_data(event), + 'adapter': getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__, + 'bot_uuid': self.bot_entity.uuid, + } + await self.logger.info( + f'Platform adapter received {event_type}', + metadata=metadata, + ) + return metadata + @staticmethod def _get_entity_id(entity: typing.Any) -> str | None: entity_id = getattr(entity, 'id', None) @@ -864,11 +884,13 @@ class RuntimeBot: event: platform_events.EBAEvent, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, ) -> None: + event.bot_uuid = self.bot_entity.uuid + await self._record_adapter_event(event, adapter) + if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted': await self._handle_interaction_submission(event, adapter) return - event.bot_uuid = self.bot_entity.uuid plugin_event = self._eba_event_to_plugin_event(event) if plugin_event is not None: diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index 7cda0ae66..82f9bc9fc 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -95,6 +95,36 @@ class TestEventRouteTrace: assert metadata['target_uuid'] == 'agent-1' assert metadata['status'] == 'failed' + @pytest.mark.asyncio + async def test_adapter_event_log_exposes_normalized_input_without_platform_object(self): + """Adapter debugging records the shared event shape without opaque SDK data.""" + from langbot_plugin.api.entities.builtin.platform import entities, events, message + + bot = self._make_bot([]) + bot.bot_entity.adapter = 'test-adapter' + event = events.MessageReceivedEvent( + message_id='message-1', + message_chain=message.MessageChain([message.Plain(text='hello')]), + sender=entities.User(id='user-1', nickname='QA User'), + chat_type=entities.ChatType.PRIVATE, + chat_id='user-1', + source_platform_object={'access_token': 'must-not-be-logged'}, + ) + + metadata = await bot._record_adapter_event(event, SimpleNamespace()) + + assert metadata['kind'] == 'adapter_event_received' + assert metadata['event_type'] == 'message.received' + assert metadata['adapter'] == 'test-adapter' + assert metadata['bot_uuid'] == 'bot-1' + assert metadata['event_data']['message_chain'] == [{'type': 'Plain', 'text': 'hello'}] + assert metadata['event_data']['sender']['id'] == 'user-1' + assert 'source_platform_object' not in metadata['event_data'] + bot.logger.info.assert_awaited_once_with( + 'Platform adapter received message.received', + metadata=metadata, + ) + @pytest.mark.asyncio 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.""" diff --git a/web/src/app/home/bots/components/bot-form/AdapterEventDebugDialog.tsx b/web/src/app/home/bots/components/bot-form/AdapterEventDebugDialog.tsx new file mode 100644 index 000000000..17c362c16 --- /dev/null +++ b/web/src/app/home/bots/components/bot-form/AdapterEventDebugDialog.tsx @@ -0,0 +1,335 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Activity, + AlertCircle, + ChevronDown, + RadioTower, + Trash2, +} from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { backendClient } from '@/app/infra/http'; +import type { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; +import { + eventPatternDescription, + eventPatternLabel, +} from '@/app/home/components/event-patterns/event-pattern-groups'; + +const POLL_INTERVAL_MS = 1200; +const MAX_VISIBLE_EVENTS = 50; + +interface ObservedAdapterEvent { + seqId: number; + timestamp: number; + eventType: string; + eventData: Record; +} + +type ListenerState = 'preparing' | 'listening' | 'error'; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function observedEventFromLog(log: BotLog): ObservedAdapterEvent | null { + const metadata = log.metadata; + if (!isRecord(metadata) || metadata.kind !== 'adapter_event_received') { + return null; + } + + const eventType = metadata.event_type; + if (typeof eventType !== 'string' || !eventType) return null; + + return { + seqId: log.seq_id, + timestamp: log.timestamp, + eventType, + eventData: isRecord(metadata.event_data) ? metadata.event_data : {}, + }; +} + +function findEventPreview(value: unknown, depth = 0): string | null { + if (depth > 4 || value === null || value === undefined) return null; + if (Array.isArray(value)) { + for (const item of value) { + const preview = findEventPreview(item, depth + 1); + if (preview) return preview; + } + return null; + } + if (!isRecord(value)) return null; + + for (const key of ['message_text', 'text', 'action']) { + const candidate = value[key]; + if (typeof candidate === 'string' && candidate.trim()) { + const trimmed = candidate.trim(); + return trimmed.length > 160 ? `${trimmed.slice(0, 160)}…` : trimmed; + } + } + for (const child of Object.values(value)) { + const preview = findEventPreview(child, depth + 1); + if (preview) return preview; + } + return null; +} + +export default function AdapterEventDebugDialog({ + botId, + adapterLabel, +}: { + botId?: string; + adapterLabel: string; +}) { + const { t, i18n } = useTranslation(); + const [open, setOpen] = useState(false); + const [listenerState, setListenerState] = + useState('preparing'); + const [events, setEvents] = useState([]); + const baselineSeqRef = useRef(null); + const pollInFlightRef = useRef(false); + + const platformName = adapterLabel || t('bots.adapterEventCurrentPlatform'); + + const pollLogs = useCallback(async () => { + if (!botId || pollInFlightRef.current) return; + pollInFlightRef.current = true; + try { + const response = await backendClient.getBotLogs(botId, { + from_index: -1, + max_count: 100, + }); + const latestSeq = response.logs.reduce( + (maximum, log) => Math.max(maximum, log.seq_id), + -1, + ); + + if (baselineSeqRef.current === null) { + baselineSeqRef.current = latestSeq; + setListenerState('listening'); + return; + } + + const baseline = baselineSeqRef.current; + const newlyObserved = response.logs + .filter((log) => log.seq_id > baseline) + .map(observedEventFromLog) + .filter((event): event is ObservedAdapterEvent => event !== null); + + if (newlyObserved.length > 0) { + setEvents((current) => { + const bySeqId = new Map( + [...newlyObserved, ...current].map((event) => [event.seqId, event]), + ); + return Array.from(bySeqId.values()) + .sort((left, right) => right.seqId - left.seqId) + .slice(0, MAX_VISIBLE_EVENTS); + }); + } + baselineSeqRef.current = Math.max(baseline, latestSeq); + setListenerState('listening'); + } catch { + setListenerState('error'); + } finally { + pollInFlightRef.current = false; + } + }, [botId]); + + useEffect(() => { + if (!open || !botId) return; + + baselineSeqRef.current = null; + pollInFlightRef.current = false; + setEvents([]); + setListenerState('preparing'); + void pollLogs(); + const interval = window.setInterval( + () => void pollLogs(), + POLL_INTERVAL_MS, + ); + return () => window.clearInterval(interval); + }, [botId, open, pollLogs]); + + const status = useMemo(() => { + if (listenerState === 'error') { + return { + text: t('bots.adapterEventListenerUnavailable'), + dot: 'bg-destructive', + }; + } + if (listenerState === 'listening') { + return { + text: t('bots.adapterEventListening'), + dot: 'bg-emerald-500', + }; + } + return { + text: t('bots.adapterEventPreparing'), + dot: 'bg-amber-500', + }; + }, [listenerState, t]); + + return ( + <> + + + + + +
+ {t('bots.adapterEventDebugTitle')} + + + {status.text} + +
+ + {t('bots.adapterEventDebugDescription', { + platform: platformName, + })} + +
+ + + + + {t('bots.adapterEventObserveOnly')} + + + + {listenerState === 'error' && ( + + + + {t('bots.adapterEventLoadFailed')} + + + )} + +
+

+ {t('bots.adapterEventReceivedCount', { count: events.length })} +

+ +
+ + + {events.length === 0 ? ( +
+ +

+ {t('bots.adapterEventEmptyTitle')} +

+

+ {t('bots.adapterEventEmptyDescription', { + platform: platformName, + })} +

+
+ ) : ( +
+ {events.map((event) => { + const preview = findEventPreview(event.eventData); + return ( + + +
+
+

+ {eventPatternLabel(event.eventType, t)} +

+ + {event.eventType} + +
+ +
+

+ {preview || + eventPatternDescription(event.eventType, t)} +

+ + + + + +
+                              {JSON.stringify(event.eventData, null, 2)}
+                            
+
+
+
+
+ ); + })} +
+ )} +
+ + + + +
+
+ + ); +} diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 4fff685ba..9c715b7da 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -703,6 +703,11 @@ const BotForm = forwardRef(function BotForm( adapter.value === currentAdapter, + )?.label ?? currentAdapter + } supportedEvents={adapterSupportedEvents[currentAdapter] || []} agentOptions={agentNameList} /> 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 2b01df6c0..cc25691a8 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -120,6 +120,7 @@ import { groupEventPatterns, } from '@/app/home/components/event-patterns/event-pattern-groups'; import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent'; +import AdapterEventDebugDialog from './AdapterEventDebugDialog'; export const PIPELINE_DISCARD = '__discard__'; @@ -128,6 +129,7 @@ export const PIPELINE_DISCARD = '__discard__'; interface EventBindingsEditorProps { form: UseFormReturn; botId?: string; + adapterLabel: string; supportedEvents: string[]; agentOptions: Agent[]; } @@ -1426,6 +1428,7 @@ function SortableBindingCard({ export default function EventBindingsEditor({ form, botId, + adapterLabel, supportedEvents, agentOptions, }: EventBindingsEditorProps) { @@ -1797,6 +1800,7 @@ export default function EventBindingsEditor({ eventOptions={dryRunEventOptions} agentOptions={agentOptions} /> +