feat(agent): add visual event range picker

This commit is contained in:
RockChinQ
2026-08-25 23:51:24 +08:00
parent 114612a984
commit 4d7a333802
13 changed files with 325 additions and 60 deletions
@@ -0,0 +1,195 @@
import { useMemo, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
const FALLBACK_EVENTS = ['message.received'];
interface AgentEventPatternPickerProps {
events: string[];
value: string[];
onChange: (patterns: string[]) => void;
}
function eventNamespaces(events: string[]) {
const counts = new Map<string, number>();
events.forEach((event) => {
if (event === '*' || event.endsWith('.*')) return;
const namespace = event.split('.')[0];
if (namespace) counts.set(namespace, (counts.get(namespace) ?? 0) + 1);
});
return Array.from(counts.entries())
.filter(([, count]) => count >= 2)
.map(([namespace]) => `${namespace}.*`)
.sort();
}
export default function AgentEventPatternPicker({
events,
value,
onChange,
}: AgentEventPatternPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const selectedPatterns = useMemo(
() => (value.length > 0 ? value : ['*']),
[value],
);
const options = useMemo(() => {
const concreteEvents = Array.from(
new Set([
...(events.length > 0 ? events : FALLBACK_EVENTS),
...selectedPatterns.filter(
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
),
]),
).sort();
const namespaces = Array.from(
new Set([
...eventNamespaces(concreteEvents),
...selectedPatterns.filter((pattern) => pattern.endsWith('.*')),
]),
).sort();
return ['*', ...namespaces, ...concreteEvents];
}, [events, selectedPatterns]);
function eventLabel(pattern: string) {
if (pattern === '*') return t('bots.eventWildcard');
if (pattern.endsWith('.*')) {
return t('bots.eventNamespaceWildcard', {
namespace: pattern.replace('.*', ''),
});
}
const key = `bots.eventNames.${pattern.replace(/\./g, '_')}`;
const label = t(key);
return label === key ? pattern : label;
}
function eventDescription(pattern: string) {
if (pattern === '*') return t('bots.eventDescriptions.all');
if (pattern.endsWith('.*')) {
return t('bots.eventDescriptions.namespace');
}
const key = `bots.eventDescriptions.${pattern.replace(/\./g, '_')}`;
const description = t(key);
return description === key
? t('bots.eventDescriptions.custom')
: description;
}
function togglePattern(pattern: string) {
if (pattern === '*') {
onChange(['*']);
return;
}
if (selectedPatterns.includes(pattern)) {
const next = selectedPatterns.filter((item) => item !== pattern);
onChange(next.length > 0 ? next : ['*']);
return;
}
let next = selectedPatterns.filter((item) => item !== '*');
const namespace = pattern.split('.')[0];
if (pattern.endsWith('.*')) {
next = next.filter(
(item) => item.split('.')[0] !== namespace || item.endsWith('.*'),
);
} else {
next = next.filter((item) => item !== `${namespace}.*`);
}
onChange(Array.from(new Set([...next, pattern])));
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={t('agents.supportedEvents')}
className="h-auto min-h-10 w-full min-w-0 justify-between gap-2 px-3 py-2 font-normal"
>
<span className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{selectedPatterns.slice(0, 3).map((pattern) => (
<Badge
key={pattern}
variant="secondary"
className="max-w-full rounded-md font-normal"
>
<span className="truncate">{eventLabel(pattern)}</span>
</Badge>
))}
{selectedPatterns.length > 3 && (
<Badge variant="outline" className="rounded-md font-normal">
+{selectedPatterns.length - 3}
</Badge>
)}
</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<CommandInput placeholder={t('agents.searchEvents')} />
<CommandList>
<CommandEmpty>{t('agents.noEventsFound')}</CommandEmpty>
<CommandGroup heading={t('agents.supportedEvents')}>
{options.map((pattern) => {
const selected = selectedPatterns.includes(pattern);
return (
<CommandItem
key={pattern}
value={`${eventLabel(pattern)} ${pattern}`}
onSelect={() => togglePattern(pattern)}
className="items-start gap-2 py-2"
>
<Check
className={cn(
'mt-0.5 size-4 shrink-0',
selected ? 'opacity-100' : 'opacity-0',
)}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">
{eventLabel(pattern)}
</span>
<code className="shrink-0 text-[10px] text-muted-foreground">
{pattern}
</code>
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{eventDescription(pattern)}
</span>
</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -23,7 +23,6 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { import {
Card, Card,
CardContent, CardContent,
@@ -33,13 +32,12 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { import {
Form, Form,
FormControl,
FormDescription, FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import AgentEventPatternPicker from './AgentEventPatternPicker';
export interface AgentRunnerStatus { export interface AgentRunnerStatus {
label: string; label: string;
@@ -114,8 +112,11 @@ function AgentFormComponent(
useState<ApiRespPluginSystemStatus | null>(null); useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true); const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false); const [pluginStatusError, setPluginStatusError] = useState(false);
const [availableEventTypes, setAvailableEventTypes] = useState<string[]>([
'message.received',
]);
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
useState<AgentConfigSection>('events'); useState<AgentConfigSection>('runner');
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const hasUnsavedChangesRef = useRef(false); const hasUnsavedChangesRef = useRef(false);
@@ -127,7 +128,7 @@ function AgentFormComponent(
}), }),
runner: z.record(z.string(), z.any()), runner: z.record(z.string(), z.any()),
runner_config: z.record(z.string(), z.any()), runner_config: z.record(z.string(), z.any()),
supported_event_patterns_text: z.string(), supported_event_patterns: z.array(z.string()).min(1),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -141,7 +142,7 @@ function AgentFormComponent(
}, },
runner: {}, runner: {},
runner_config: {}, runner_config: {},
supported_event_patterns_text: '*', supported_event_patterns: ['*'],
}, },
}); });
@@ -160,10 +161,22 @@ function AgentFormComponent(
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)]) Promise.all([
.then(([metadata, resp]) => { httpClient.getAgentMetadata(),
httpClient.getAgent(agentId),
httpClient.getAdapters().catch(() => ({ adapters: [] })),
])
.then(([metadata, resp, adaptersResp]) => {
if (cancelled) return; if (cancelled) return;
setRunnerConfigSchema(metadata.runner_config ?? null); setRunnerConfigSchema(metadata.runner_config ?? null);
const adapterEvents = adaptersResp.adapters.flatMap(
(adapter) => adapter.spec.supported_events ?? [],
);
setAvailableEventTypes(
adapterEvents.length > 0
? Array.from(new Set(adapterEvents)).sort()
: ['message.received'],
);
const agent = resp.agent; const agent = resp.agent;
const config = (agent.config ?? {}) as Record<string, any>; const config = (agent.config ?? {}) as Record<string, any>;
const loadedValues: FormValues = { const loadedValues: FormValues = {
@@ -175,10 +188,8 @@ function AgentFormComponent(
runner: (config.runner as Record<string, unknown>) ?? {}, runner: (config.runner as Record<string, unknown>) ?? {},
runner_config: runner_config:
(config.runner_config as Record<string, unknown>) ?? {}, (config.runner_config as Record<string, unknown>) ?? {},
supported_event_patterns_text: ( supported_event_patterns: agent.supported_event_patterns ??
agent.supported_event_patterns ?? agent.capability?.supported_event_patterns ?? ['*'],
agent.capability?.supported_event_patterns ?? ['*']
).join('\n'),
}; };
form.reset(loadedValues); form.reset(loadedValues);
savedSnapshotRef.current = JSON.stringify(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues);
@@ -250,11 +261,6 @@ function AgentFormComponent(
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
}> = [ }> = [
{
name: 'events',
label: t('agents.bindableEvents'),
icon: Zap,
},
{ {
name: 'runner', name: 'runner',
label: t('agents.runnerSettings'), label: t('agents.runnerSettings'),
@@ -267,6 +273,11 @@ function AgentFormComponent(
: t('pipelines.configuration'), : t('pipelines.configuration'),
icon: SlidersHorizontal, icon: SlidersHorizontal,
}, },
{
name: 'events',
label: t('agents.bindableEvents'),
icon: Zap,
},
]; ];
const runnerStatus = useMemo<AgentRunnerStatus>(() => { const runnerStatus = useMemo<AgentRunnerStatus>(() => {
@@ -423,14 +434,6 @@ function AgentFormComponent(
); );
} }
function normalizeEventPatterns(value: string): string[] {
const patterns = value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
return patterns.length > 0 ? patterns : ['*'];
}
const saveValues = useCallback( const saveValues = useCallback(
async (values: FormValues) => { async (values: FormValues) => {
if (isSavingRef.current) return false; if (isSavingRef.current) return false;
@@ -441,9 +444,7 @@ function AgentFormComponent(
description: values.basic.description ?? '', description: values.basic.description ?? '',
emoji: values.basic.emoji, emoji: values.basic.emoji,
component_ref: (runner.id as string) || null, component_ref: (runner.id as string) || null,
supported_event_patterns: normalizeEventPatterns( supported_event_patterns: values.supported_event_patterns,
values.supported_event_patterns_text,
),
config: { config: {
runner, runner,
runner_config: values.runner_config ?? {}, runner_config: values.runner_config ?? {},
@@ -525,14 +526,18 @@ function AgentFormComponent(
setActiveSection(value as AgentConfigSection) setActiveSection(value as AgentConfigSection)
} }
> >
<div className="overflow-x-auto"> <div className="min-w-0">
<TabsList className="grid min-w-[34rem] w-full grid-cols-3"> <TabsList className="grid h-auto w-full min-w-0 grid-cols-3">
{primarySections.map((section) => { {primarySections.map((section) => {
const Icon = section.icon; const Icon = section.icon;
return ( return (
<TabsTrigger key={section.name} value={section.name}> <TabsTrigger
<Icon /> key={section.name}
{section.label} value={section.name}
className="min-w-0 gap-1.5 px-2"
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{section.label}</span>
</TabsTrigger> </TabsTrigger>
); );
})} })}
@@ -588,17 +593,14 @@ function AgentFormComponent(
<CardContent> <CardContent>
<FormField <FormField
control={form.control} control={form.control}
name="supported_event_patterns_text" name="supported_event_patterns"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('agents.supportedEvents')}</FormLabel> <AgentEventPatternPicker
<FormControl> events={availableEventTypes}
<Textarea value={field.value}
{...field} onChange={field.onChange}
className="min-h-32 font-mono text-sm" />
placeholder={'*\nmessage.received\ngroup.*'}
/>
</FormControl>
<FormDescription> <FormDescription>
{t('agents.supportedEventsDescription')} {t('agents.supportedEventsDescription')}
</FormDescription> </FormDescription>
+3 -1
View File
@@ -698,7 +698,9 @@ const enUS = {
'Limit which bot event routes can select this Agent. The default is suitable for most cases.', 'Limit which bot event routes can select this Agent. The default is suitable for most cases.',
supportedEvents: 'Event Range', supportedEvents: 'Event Range',
supportedEventsDescription: supportedEventsDescription:
'Use one event pattern per line, for example *, message.received, group.*. Pipelines are fixed to message.*.', 'Choose all events, an event group, or individual events. Bot routes will only list this Agent for matching events.',
searchEvents: 'Search events…',
noEventsFound: 'No matching events found',
nameRequired: 'Name cannot be empty', nameRequired: 'Name cannot be empty',
createSuccess: 'Created successfully', createSuccess: 'Created successfully',
createError: 'Creation failed: ', createError: 'Creation failed: ',
+3 -1
View File
@@ -533,7 +533,9 @@ const esES = {
'Limita qué rutas de eventos del bot pueden seleccionar este Agent. El valor predeterminado sirve para la mayoría de los casos.', 'Limita qué rutas de eventos del bot pueden seleccionar este Agent. El valor predeterminado sirve para la mayoría de los casos.',
supportedEvents: 'Rango de eventos', supportedEvents: 'Rango de eventos',
supportedEventsDescription: supportedEventsDescription:
'Usa un patrón de evento por línea, por ejemplo *, message.received, group.*. Los Pipelines están fijos en message.*.', 'Elige todos los eventos, un grupo o eventos concretos. Las rutas del bot solo mostrarán este Agent para los eventos compatibles.',
searchEvents: 'Buscar eventos…',
noEventsFound: 'No se encontraron eventos',
nameRequired: 'El nombre no puede estar vacío', nameRequired: 'El nombre no puede estar vacío',
createSuccess: 'Creado correctamente', createSuccess: 'Creado correctamente',
createError: 'Error al crear: ', createError: 'Error al crear: ',
+3 -1
View File
@@ -713,7 +713,9 @@ const jaJP = {
'この Agent を選択できるボットイベントルートの範囲を制限します。通常は既定値のままで問題ありません。', 'この Agent を選択できるボットイベントルートの範囲を制限します。通常は既定値のままで問題ありません。',
supportedEvents: 'イベント範囲', supportedEvents: 'イベント範囲',
supportedEventsDescription: supportedEventsDescription:
'1 行に 1 つのイベントパターンを指定します。例: *、message.received、group.*。Pipeline は message.* 固定です。', 'すべてのイベント、イベントグループ、または個別のイベントを選択します。ボットルートでは一致するイベントにのみこの Agent が表示されます。',
searchEvents: 'イベントを検索…',
noEventsFound: '一致するイベントがありません',
nameRequired: '名前は必須です', nameRequired: '名前は必須です',
createSuccess: '作成に成功しました', createSuccess: '作成に成功しました',
createError: '作成に失敗しました:', createError: '作成に失敗しました:',
+3 -1
View File
@@ -530,7 +530,9 @@ const ruRU = {
'Ограничьте, какие маршруты событий бота могут выбирать этот Agent. Обычно достаточно значения по умолчанию.', 'Ограничьте, какие маршруты событий бота могут выбирать этот Agent. Обычно достаточно значения по умолчанию.',
supportedEvents: 'Диапазон событий', supportedEvents: 'Диапазон событий',
supportedEventsDescription: supportedEventsDescription:
'Один шаблон события в строке, например *, message.received, group.*. Pipeline фиксирован на message.*.', 'Выберите все события, группу или отдельные события. В маршрутах бота этот Agent будет доступен только для подходящих событий.',
searchEvents: 'Поиск событий…',
noEventsFound: 'Подходящие события не найдены',
nameRequired: 'Имя не может быть пустым', nameRequired: 'Имя не может быть пустым',
createSuccess: 'Успешно создано', createSuccess: 'Успешно создано',
createError: 'Ошибка создания: ', createError: 'Ошибка создания: ',
+3 -1
View File
@@ -516,7 +516,9 @@ const thTH = {
'จำกัดว่าเส้นทางเหตุการณ์ของบอทใดสามารถเลือก Agent นี้ได้ ค่าเริ่มต้นเหมาะกับกรณีส่วนใหญ่', 'จำกัดว่าเส้นทางเหตุการณ์ของบอทใดสามารถเลือก Agent นี้ได้ ค่าเริ่มต้นเหมาะกับกรณีส่วนใหญ่',
supportedEvents: 'ช่วงเหตุการณ์', supportedEvents: 'ช่วงเหตุการณ์',
supportedEventsDescription: supportedEventsDescription:
'หนึ่งรูปแบบเหตุการณ์ต่อบรรทัด เช่น *, message.received, group.* Pipeline ถูกกำหนดไว้ที่ message.*', 'เลือกเหตุการณ์ทั้งหมด กลุ่มเหตุการณ์ หรือเหตุการณ์ที่ต้องการ เส้นทางบอทจะแสดง Agent นี้เฉพาะเหตุการณ์ที่ตรงกัน',
searchEvents: 'ค้นหาเหตุการณ์…',
noEventsFound: 'ไม่พบเหตุการณ์ที่ตรงกัน',
nameRequired: 'ชื่อต้องไม่ว่างเปล่า', nameRequired: 'ชื่อต้องไม่ว่างเปล่า',
createSuccess: 'สร้างสำเร็จ', createSuccess: 'สร้างสำเร็จ',
createError: 'สร้างล้มเหลว: ', createError: 'สร้างล้มเหลว: ',
+3 -1
View File
@@ -526,7 +526,9 @@ const viVN = {
'Giới hạn những tuyến sự kiện bot có thể chọn Agent này. Mặc định phù hợp với hầu hết trường hợp.', 'Giới hạn những tuyến sự kiện bot có thể chọn Agent này. Mặc định phù hợp với hầu hết trường hợp.',
supportedEvents: 'Phạm vi sự kiện', supportedEvents: 'Phạm vi sự kiện',
supportedEventsDescription: supportedEventsDescription:
'Mỗi dòng một mẫu sự kiện, ví dụ *, message.received, group.*. Pipeline cố định ở message.*.', 'Chọn tất cả sự kiện, một nhóm hoặc từng sự kiện. Tuyến bot chỉ hiển thị Agent này cho các sự kiện phù hợp.',
searchEvents: 'Tìm sự kiện…',
noEventsFound: 'Không tìm thấy sự kiện phù hợp',
nameRequired: 'Tên không được để trống', nameRequired: 'Tên không được để trống',
createSuccess: 'Tạo thành công', createSuccess: 'Tạo thành công',
createError: 'Tạo thất bại: ', createError: 'Tạo thất bại: ',
+3 -1
View File
@@ -668,7 +668,9 @@ const zhHans = {
'限制此 Agent 可被机器人事件路由选择的事件范围。通常保持默认即可。', '限制此 Agent 可被机器人事件路由选择的事件范围。通常保持默认即可。',
supportedEvents: '事件范围', supportedEvents: '事件范围',
supportedEventsDescription: supportedEventsDescription:
'每行一个事件模式,例如 *、message.received、group.*。Pipeline 固定仅支持 message.*。', '选择全部事件、事件组或具体事件。机器人路由只会在匹配的事件中显示此 Agent。',
searchEvents: '搜索事件…',
noEventsFound: '没有匹配的事件',
nameRequired: '名称不能为空', nameRequired: '名称不能为空',
createSuccess: '创建成功', createSuccess: '创建成功',
createError: '创建失败:', createError: '创建失败:',
+3 -1
View File
@@ -499,7 +499,9 @@ const zhHant = {
'限制此 Agent 可被機器人事件路由選擇的事件範圍。通常保持預設即可。', '限制此 Agent 可被機器人事件路由選擇的事件範圍。通常保持預設即可。',
supportedEvents: '事件範圍', supportedEvents: '事件範圍',
supportedEventsDescription: supportedEventsDescription:
'每行一個事件模式,例如 *、message.received、group.*。Pipeline 固定僅支援 message.*。', '選擇全部事件、事件群組或具體事件。機器人路由只會在符合的事件中顯示此 Agent。',
searchEvents: '搜尋事件…',
noEventsFound: '沒有符合的事件',
nameRequired: '名稱不能為空', nameRequired: '名稱不能為空',
createSuccess: '建立成功', createSuccess: '建立成功',
createError: '建立失敗:', createError: '建立失敗:',
+17 -3
View File
@@ -758,7 +758,10 @@ test.describe('agent and pipeline save concurrency', () => {
test('agent save freezes its payload and keeps later edits dirty', async ({ test('agent save freezes its payload and keeps later edits dirty', async ({
page, page,
}) => { }) => {
await installLangBotApiMocks(page, { authenticated: true }); await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
const delayedSave = await installDelayedFirstSave( const delayedSave = await installDelayedFirstSave(
page, page,
'/api/v1/agents/agent-save-race', '/api/v1/agents/agent-save-race',
@@ -770,12 +773,23 @@ test.describe('agent and pipeline save concurrency', () => {
const eventPatterns = page.getByLabel('Event Range'); const eventPatterns = page.getByLabel('Event Range');
await expect(eventPatterns).toBeVisible(); await expect(eventPatterns).toBeVisible();
await eventPatterns.fill('message.received'); await eventPatterns.click();
await page
.getByRole('option')
.filter({ hasText: 'message.received' })
.click();
await page.keyboard.press('Escape');
await saveButton.click(); await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1); await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(saveButton).toBeDisabled(); await expect(saveButton).toBeDisabled();
await eventPatterns.fill('group.*'); await eventPatterns.click();
await page.getByRole('option').filter({ hasText: 'group.*' }).click();
await page
.getByRole('option')
.filter({ hasText: 'message.received' })
.click();
await page.keyboard.press('Escape');
await forceFormSubmit(page, '#agent-form'); await forceFormSubmit(page, '#agent-form');
expect(delayedSave.payloads).toHaveLength(1); expect(delayedSave.payloads).toHaveLength(1);
await expect(saveButton).toBeDisabled(); await expect(saveButton).toBeDisabled();
+19 -2
View File
@@ -106,6 +106,7 @@ interface LangBotApiMockState {
sessionAnalyses: Record<string, unknown>; sessionAnalyses: Record<string, unknown>;
sessionMessages: Record<string, unknown[]>; sessionMessages: Record<string, unknown[]>;
skills: SkillMock[]; skills: SkillMock[];
withAdapterEvents: boolean;
withRunnerToolSelector: boolean; withRunnerToolSelector: boolean;
workspaces: WorkspaceEntryMock[]; workspaces: WorkspaceEntryMock[];
} }
@@ -514,7 +515,7 @@ function makeBot(
}; };
} }
function mockAdapters() { function mockAdapters(withAdapterEvents = false) {
return [ return [
{ {
name: 'playwright-adapter', name: 'playwright-adapter',
@@ -528,6 +529,17 @@ function mockAdapters() {
}, },
spec: { spec: {
categories: ['testing'], categories: ['testing'],
...(withAdapterEvents
? {
supported_events: [
'message.received',
'message.edited',
'group.member_joined',
'group.member_left',
'feedback.received',
],
}
: {}),
config: [], config: [],
}, },
}, },
@@ -621,7 +633,9 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
} }
if (path === '/api/v1/platform/adapters') { if (path === '/api/v1/platform/adapters') {
return fulfillJson(route, { adapters: mockAdapters() }); return fulfillJson(route, {
adapters: mockAdapters(state.withAdapterEvents),
});
} }
if (path === '/api/v1/platform/bots') { if (path === '/api/v1/platform/bots') {
@@ -1230,6 +1244,7 @@ export async function installLangBotApiMocks(
sessionAnalyses?: Record<string, unknown>; sessionAnalyses?: Record<string, unknown>;
sessionMessages?: Record<string, unknown[]>; sessionMessages?: Record<string, unknown[]>;
storage?: JsonRecord; storage?: JsonRecord;
withAdapterEvents?: boolean;
withRunnerToolSelector?: boolean; withRunnerToolSelector?: boolean;
workspaces?: WorkspaceEntryMock[]; workspaces?: WorkspaceEntryMock[];
} = {}, } = {},
@@ -1241,6 +1256,7 @@ export async function installLangBotApiMocks(
sessionAnalyses, sessionAnalyses,
sessionMessages, sessionMessages,
storage = {}, storage = {},
withAdapterEvents = false,
withRunnerToolSelector = false, withRunnerToolSelector = false,
workspaces = [defaultWorkspaceEntry()], workspaces = [defaultWorkspaceEntry()],
} = options; } = options;
@@ -1256,6 +1272,7 @@ export async function installLangBotApiMocks(
sessionAnalyses: sessionAnalyses || {}, sessionAnalyses: sessionAnalyses || {},
sessionMessages: sessionMessages || {}, sessionMessages: sessionMessages || {},
skills: [], skills: [],
withAdapterEvents,
withRunnerToolSelector, withRunnerToolSelector,
workspaces, workspaces,
}; };
@@ -10,6 +10,7 @@ test.describe('processor detail workbench', () => {
}) => { }) => {
await installLangBotApiMocks(page, { await installLangBotApiMocks(page, {
authenticated: true, authenticated: true,
withAdapterEvents: true,
withRunnerToolSelector: true, withRunnerToolSelector: true,
}); });
@@ -45,14 +46,23 @@ test.describe('processor detail workbench', () => {
expect(debugBox!.y).toBeGreaterThanOrEqual(0); expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist'); const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText( await expect(flow.getByRole('tab').nth(0)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(1)).toContainText('Local Agent');
await expect(flow.getByRole('tab').nth(2)).toContainText(
'Bindable Event Range', 'Bindable Event Range',
); );
await expect(flow.getByRole('tab').nth(1)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(2)).toContainText('Local Agent');
await expect(flow.getByRole('tab')).toHaveCount(3); await expect(flow.getByRole('tab')).toHaveCount(3);
await expect(flow.getByText('Management')).toHaveCount(0); await expect(flow.getByText('Management')).toHaveCount(0);
await page.setViewportSize({ width: 1024, height: 900 });
const tabListMetrics = await flow.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
expect(tabListMetrics.scrollWidth).toBeLessThanOrEqual(
tabListMetrics.clientWidth,
);
await expect( await expect(
page.getByRole('heading', { name: /agent-workbench/ }), page.getByRole('heading', { name: /agent-workbench/ }),
).toBeVisible(); ).toBeVisible();
@@ -83,11 +93,22 @@ test.describe('processor detail workbench', () => {
.last(), .last(),
).toBeVisible(); ).toBeVisible();
await flow.getByRole('tab').nth(0).click(); await flow.getByRole('tab').nth(2).click();
await expect( await expect(
configPanel.getByText('Bindable Event Range', { exact: true }).last(), configPanel.getByText('Bindable Event Range', { exact: true }).last(),
).toBeVisible(); ).toBeVisible();
await flow.getByRole('tab').nth(2).click(); const eventPicker = configPanel.getByRole('combobox', {
name: 'Event Range',
});
await expect(eventPicker).toBeVisible();
await expect(configPanel.getByRole('textbox')).toHaveCount(0);
await eventPicker.click();
await expect(
page.getByRole('option').filter({ hasText: 'message.received' }),
).toBeVisible();
await page.keyboard.press('Escape');
await flow.getByRole('tab').nth(1).click();
await expect( await expect(
configPanel.getByText('Local Agent', { exact: true }).last(), configPanel.getByText('Local Agent', { exact: true }).last(),
).toBeVisible(); ).toBeVisible();