From f15f004680dea28f01ad2b23f282a2c02b3293b0 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:59:58 +0800 Subject: [PATCH] feat(agent-platform): explain route conflicts and fallback --- .../e2e/bot-event-routing-product-flow.mjs | 42 ++++ .../cases/bot-event-routing-product-flow.yaml | 2 + .../bot-form/EventBindingsEditor.tsx | 219 ++++++++++++++---- web/src/i18n/locales/en-US.ts | 8 + web/src/i18n/locales/ja-JP.ts | 8 + web/src/i18n/locales/zh-Hans.ts | 8 + 6 files changed, 242 insertions(+), 45 deletions(-) diff --git a/skills/scripts/e2e/bot-event-routing-product-flow.mjs b/skills/scripts/e2e/bot-event-routing-product-flow.mjs index b8fd59481..fe971cbd1 100644 --- a/skills/scripts/e2e/bot-event-routing-product-flow.mjs +++ b/skills/scripts/e2e/bot-event-routing-product-flow.mjs @@ -19,6 +19,7 @@ const caseId = "bot-event-routing-product-flow"; await loadEnvFiles(); const paths = evidencePaths(caseId); await ensureEvidence(paths); +const mobileScreenshot = paths.screenshot.replace(/\.png$/, "-mobile.png"); const startedAt = new Date(); const frontendUrl = process.env.LANGBOT_FRONTEND_URL || ""; @@ -48,6 +49,7 @@ const result = { console_log: paths.consoleLog, network_log: paths.networkLog, screenshot: paths.screenshot, + mobile_screenshot: mobileScreenshot, automation_result_json: paths.automationResultJson, result_json: paths.resultJson, }, @@ -125,6 +127,17 @@ try { description: "Discard the deterministic QA event", order: 0, }, + { + id: "qa-message-discard-shadowed", + event_pattern: "message.received", + target_type: "discard", + target_uuid: "", + filters: [], + priority: 0, + enabled: true, + description: "Verify visible route conflict guidance", + order: 1, + }, ], }, }); @@ -158,10 +171,22 @@ try { .getByText(/Message received|收到消息|メッセージを受信/) .first() .waitFor(); + await page + .getByText( + /Some routes overlap|部分路由存在覆盖冲突|一部のルートが重複しています/, + ) + .waitFor(); + await page + .getByText( + /Events that match no route are ignored|未命中任何路由的事件会被忽略|どのルートにも一致しないイベントは無視されます/, + ) + .waitFor(); result.visible_signals.push( "event-routing", "adapter-capabilities", "friendly-event-name", + "route-conflict-guidance", + "fallback-guidance", ); await page @@ -234,6 +259,23 @@ try { } await safeScreenshot(page, paths.screenshot); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(250); + const horizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + if (horizontalOverflow > 1) { + throw new Error( + `The mobile route editor overflows horizontally by ${horizontalOverflow}px.`, + ); + } + await page + .getByText( + /Some routes overlap|部分路由存在覆盖冲突|一部のルートが重複しています/, + ) + .waitFor(); + await safeScreenshot(page, mobileScreenshot); + result.visible_signals.push("mobile-layout"); result.diagnostics = await scanBrowserDiagnostics(paths); if (result.diagnostics.status !== "pass") { throw new Error(result.diagnostics.reason); 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 6a72e3336..00373b454 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 @@ -31,12 +31,14 @@ steps: - "Create a temporary HTTP Bot with a saved message.received route to the discard processor." - "Open the Bot configuration in the WebUI." - "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." checks: - "UI: A user can choose a channel and add event routes 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." - "Console: No unexpected frontend errors appear during the flow." 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 15866b721..df5f02a11 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -16,6 +16,7 @@ import { ChevronRight, ChevronsUpDown, GripVertical, + Info, ListChecks, Plus, Play, @@ -161,6 +162,90 @@ function eventPatternCovers(sup: string, bind: string) { return false; } +interface RouteConflict { + winnerIndex: number; + shadowedIndex: number; +} + +function bindingFilters(binding: EventBinding) { + return binding.filters ?? []; +} + +function filtersEqual(a: EventBinding, b: EventBinding) { + return ( + JSON.stringify(bindingFilters(a)) === JSON.stringify(bindingFilters(b)) + ); +} + +function routePrecedes( + a: EventBinding, + aIndex: number, + b: EventBinding, + bIndex: number, +) { + const aPriority = Number.isFinite(a.priority) ? a.priority : 0; + const bPriority = Number.isFinite(b.priority) ? b.priority : 0; + return aPriority === bPriority ? aIndex < bIndex : aPriority > bPriority; +} + +function findRouteConflicts(bindings: EventBinding[]): RouteConflict[] { + const enabled = bindings + .map((binding, index) => ({ binding, index })) + .filter(({ binding }) => binding.enabled ?? true); + const conflicts: RouteConflict[] = []; + + for (let left = 0; left < enabled.length; left += 1) { + for (let right = left + 1; right < enabled.length; right += 1) { + const a = enabled[left]; + const b = enabled[right]; + const [winner, shadowed] = routePrecedes( + a.binding, + a.index, + b.binding, + b.index, + ) + ? [a, b] + : [b, a]; + + if ( + !eventPatternCovers( + winner.binding.event_pattern, + shadowed.binding.event_pattern, + ) + ) { + continue; + } + + if ( + bindingFilters(winner.binding).length === 0 || + filtersEqual(winner.binding, shadowed.binding) + ) { + conflicts.push({ + winnerIndex: winner.index, + shadowedIndex: shadowed.index, + }); + } + } + } + + return conflicts; +} + +function findCatchAllRouteIndex(bindings: EventBinding[]) { + const candidates = bindings + .map((binding, index) => ({ binding, index })) + .filter( + ({ binding }) => + (binding.enabled ?? true) && + binding.event_pattern === '*' && + bindingFilters(binding).length === 0, + ); + candidates.sort((a, b) => + routePrecedes(a.binding, a.index, b.binding, b.index) ? -1 : 1, + ); + return candidates[0]?.index ?? -1; +} + function agentSupportsEventPattern(agent: Agent, pattern: string) { const patterns = agent.supported_event_patterns ?? agent.capability?.supported_event_patterns ?? ['*']; @@ -1135,7 +1220,7 @@ function BindingCardContent({ return (
{t('bots.routeConflictTitle')}
++ {t('bots.routeConflictMore', { + count: routeConflicts.length - 3, + })} +
+ )} +