feat(processors): streamline event debugging and run inspection

This commit is contained in:
RockChinQ
2026-09-08 02:30:59 +08:00
parent 237fa6545d
commit ea3e32c904
34 changed files with 1464 additions and 466 deletions
+15 -9
View File
@@ -99,7 +99,7 @@ The activation sequence is explicit:
1. Install a plugin containing an EventProcessor component.
2. Create an Event processor in the Processors area.
3. Select its plugin component and enter any component-defined configuration.
3. Open its detail page, select a plugin component, and save its configuration.
4. Bind a Bot event to that processor instance in the existing event routing UI.
Installation and processor creation alone do not subscribe to Bot events.
@@ -112,20 +112,26 @@ There is no automatic EBA broadcast to installed EventListeners. Keep Pipeline h
plugins must explicitly adopt the new component and be bound by the user; do not
create subscriptions during migration.
Validate component availability, event compatibility, Workspace ownership, and
instance identity at creation/update and again at invocation. A disabled or
An unconfigured instance has no supported events and cannot execute. Validate
component availability, event compatibility, Workspace ownership, and instance
identity when configuring the instance and again at invocation. A disabled or
unavailable plugin leaves the instance visible with an actionable unavailable
status. It must not silently fall back to Agent or Pipeline.
## Compact UI
Creation adds a third type next to Agent and Pipeline, followed by a component
selector and basic instance information. Show configuration fields only when the
component declares them. If no component is installed, show a relevant plugin
installation entry point; installing still does not create a binding.
Creation adds a third type next to Agent and Pipeline and asks only for basic
instance information. Select the plugin component in the detail-page header.
Keep component-defined configuration in the adjacent Plugin settings popover.
If no component is installed, show a relevant plugin installation entry point;
installing still does not create a binding.
The detail page prioritizes a single run list. Selecting a run shows a chronological
trace of the incoming event, handler logs, outgoing actions/messages, and outcome.
The detail page shows event debugging on the left and logs on the right without
view-switching tabs. A compact run list shows event type, time, status and known
processing duration. Selecting a row shows that run's identity, input, logs,
actions and outcome below. There is no shared timeline between unrelated runs.
The additive `created_at_ms`, `started_at_ms`, and `finished_at_ms` fields retain
Host lifecycle precision for elapsed-time display.
Keep payloads and error details collapsed until expanded. Distinguish attempted
delivery from confirmed delivery and display the actual destination.
+1 -1
View File
@@ -232,4 +232,4 @@ line-ending = "auto"
[tool.uv.sources]
# Development contract: update to the matching SDK release before publishing.
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "23011398160cedcd4ef090054692bc2a4b34ee9f" }
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "f82b3ce935f9a33afee389fb39fe8dc29a45b615" }
+8 -3
View File
@@ -116,9 +116,14 @@ already have a default pipeline.
## Event processors
Install the plugin, discover its component with `get_processor_metadata`, then
create a processor with `kind: "event_processor"`, `component_ref` and optional
`parameters`. Bind bot events to this instance with `target_type: "event_processor"`
Create a processor with `kind: "event_processor"` and basic information. Without
a component it supports no events. Discover installed components with
`get_processor_metadata`, then use `update_processor` with `component_ref` and
optional `parameters`. API callers may also supply these when creating an instance. Bind bot events to this instance with `target_type: "event_processor"`
and `target_id` equal to its UUID. Installation alone never activates a handler.
`debug_agent` accepts the complete typed EBA event in `payload.data` for this kind.
Legacy EventListener plugins remain in the Pipeline lifecycle.
`list_processor_runs` includes `created_at_ms`, `started_at_ms`, and
`finished_at_ms`: Host lifecycle times in epoch milliseconds. Use the start and finish times for elapsed processing time; select a run and call `get_processor_run_events` for its
logs and action results. These times are not internal plugin profiling data.
@@ -776,6 +776,9 @@ class RunLedgerStore:
'dispatch_attempts': row.dispatch_attempts,
'last_claimed_at': _datetime_to_epoch(row.last_claimed_at),
'created_at': _datetime_to_epoch(row.created_at),
'created_at_ms': round(_as_utc(row.created_at).timestamp() * 1000) if row.created_at else None,
'started_at_ms': round(_as_utc(row.started_at).timestamp() * 1000) if row.started_at else None,
'finished_at_ms': round(_as_utc(row.finished_at).timestamp() * 1000) if row.finished_at else None,
'started_at': _datetime_to_epoch(row.started_at),
'finished_at': _datetime_to_epoch(row.finished_at),
'updated_at': _datetime_to_epoch(row.updated_at),
@@ -488,6 +488,9 @@ class AgentService:
if not isinstance(config, dict):
raise ValueError('Processor configuration must be an object')
component_ref = data.get('component_ref') or (existing.component_ref if existing is not None else None)
if component_ref is None and not config and not data.get('parameters'):
# An unconfigured instance cannot subscribe to or execute any events.
return {}, None, []
if not isinstance(component_ref, str) or not component_ref.startswith('event_processor:'):
raise ValueError('Select an installed EventProcessor component')
try:
+7 -3
View File
@@ -188,8 +188,9 @@ class LangBotMCPServer:
@mcp.tool(
description=(
'Create an Agent, Pipeline or Event processor. Set `processor_data.kind` to '
'`agent`, `pipeline` or `event_processor`. Event processors require an installed component_ref '
'from get_processor_metadata; optional parameters configure the instance. Returns UUID and kind.'
'`agent`, `pipeline` or `event_processor`. Event processors may be created without a component; '
'then use update_processor with an installed component_ref from get_processor_metadata and optional '
'parameters. Unconfigured instances support no events. Returns UUID and kind.'
)
)
async def create_processor(processor_data: dict) -> str:
@@ -213,7 +214,10 @@ class LangBotMCPServer:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.agent_service.get_agent_metadata(context))
@mcp.tool(description='List one Event processor instance run history; use before_id to page older runs.')
@mcp.tool(
description='List one Event processor instance run history; use before_id to page older runs. '
'created_at_ms, started_at_ms and finished_at_ms are Host lifecycle times in epoch milliseconds.'
)
async def list_processor_runs(processor_uuid: str, before_id: int | None = None) -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.agent_service.get_processor_runs(context, processor_uuid, before_id=before_id))
@@ -455,3 +455,20 @@ async def test_processor_instance_history_filters_count_and_pages(store):
)
assert (total, more) == (2, False)
assert second[0]['run_id'] == 'run-0'
@pytest.mark.asyncio
async def test_run_lifecycle_retains_milliseconds(store, monkeypatch):
started = datetime.datetime(2026, 9, 8, 0, 0, 0, 123000, tzinfo=UTC)
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store._utc_now', lambda: started)
run = await store.create_run(
run_id='run-ms', event_id='evt-ms', binding_id='binding-ms', runner_id='runner-ms', status='running'
)
assert run['created_at_ms'] == round(started.timestamp() * 1000)
assert run['started_at_ms'] == run['created_at_ms']
assert run['finished_at_ms'] is None
finished = started + datetime.timedelta(milliseconds=275)
monkeypatch.setattr('langbot.pkg.agent.runner.run_ledger_store._utc_now', lambda: finished)
await store.finalize_run(run_id='run-ms', status='completed')
saved = await store.get_run('run-ms')
assert saved['finished_at_ms'] - saved['started_at_ms'] == 275
@@ -770,6 +770,20 @@ class TestAgentServiceCreateUpdateDelete:
)
async def test_event_processor_can_be_created_before_selecting_a_plugin():
app = _make_app()
service = AgentService(app)
result = await service.create_agent(
WORKSPACE_UUID,
{'kind': 'event_processor', 'name': 'Unconfigured', 'supported_event_patterns': ['*']},
)
values = _compiled_params(app.persistence_mgr.execute_async.call_args.args[0])
assert result['kind'] == 'event_processor'
assert values['component_ref'] is None
assert values['supported_event_patterns'] == []
assert values['config'] == {}
async def test_event_processor_creation_uses_installed_component_scope():
app = _make_app()
ref = 'event_processor:test/welcome/default'
@@ -797,6 +811,34 @@ async def test_event_processor_creation_uses_installed_component_scope():
assert values['config']['runner_config'][ref] == {'greeting': 'Hi'}
async def test_unconfigured_event_processor_can_select_a_plugin_after_creation():
app = _make_app()
row = _agent_row(config={})
row.kind = 'event_processor'
row.component_ref = None
ref = 'event_processor:test/welcome/default'
app.agent_runner_registry = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
component_kind='EventProcessor',
supported_event_patterns=['group.member_joined'],
config_schema=[{'name': 'greeting', 'required': True}],
)
)
)
service = AgentService(app)
service._get_agent_row = AsyncMock(return_value=row)
await service.update_agent(
WORKSPACE_UUID,
row.uuid,
{'component_ref': ref, 'parameters': {'greeting': 'Hi'}, 'supported_event_patterns': ['*']},
)
values = _compiled_update_values(app.persistence_mgr.execute_async.call_args.args[0])
assert values['component_ref'] == ref
assert values['supported_event_patterns'] == ['group.member_joined']
assert values['config']['runner_config'][ref] == {'greeting': 'Hi'}
async def test_event_processor_rejects_invalid_component_and_missing_parameters():
app = _make_app()
service = AgentService(app)
Generated
+2 -2
View File
@@ -2119,7 +2119,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=23011398160cedcd4ef090054692bc2a4b34ee9f" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=f82b3ce935f9a33afee389fb39fe8dc29a45b615" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2186,7 +2186,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.5"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=23011398160cedcd4ef090054692bc2a4b34ee9f#23011398160cedcd4ef090054692bc2a4b34ee9f" }
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=f82b3ce935f9a33afee389fb39fe8dc29a45b615#f82b3ce935f9a33afee389fb39fe8dc29a45b615" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -174,6 +174,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
id={id}
agent={agent}
canManage={canManage}
canOperate={canOperate}
availableEventTypes={availableEventTypes}
onDelete={() => setDeleteConfirmOpen(true)}
onEdit={() => setBasicInfoOpen(true)}
onSaved={() => {
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { FileCode2, RefreshCw, Settings2, Trash2, Pencil } from 'lucide-react';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import { RefreshCw, Trash2, ScrollText } from 'lucide-react';
import isEqual from 'lodash/isEqual';
import { toast } from 'sonner';
import type {
Agent,
@@ -14,12 +16,23 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
import AgentDebugPanel from './components/AgentDebugPanel';
import EventProcessorTrace, {
ProcessorPayload,
} from './components/EventProcessorTrace';
import ProcessorRunList from './components/ProcessorRunList';
import EventProcessorSettings from './components/EventProcessorSettings';
export default function EventProcessorDetailContent({
agent,
id,
canManage,
canOperate,
availableEventTypes,
onDelete,
onEdit,
onSaved,
@@ -27,6 +40,8 @@ export default function EventProcessorDetailContent({
agent: Agent;
id: string;
canManage: boolean;
canOperate: boolean;
availableEventTypes: string[];
onDelete: () => void;
onEdit: () => void;
onSaved: () => void;
@@ -46,6 +61,13 @@ export default function EventProcessorDetailContent({
>
)[agent.component_ref ?? ''] ?? {};
const [parameters, setParameters] = useState(initialParameters);
const [savedConfig, setSavedConfig] = useState({
componentRef,
parameters: initialParameters,
});
const dirty =
componentRef !== savedConfig.componentRef ||
!isEqual(parameters, savedConfig.parameters);
const [runs, setRuns] = useState<ProcessorRun[]>([]);
const [cursor, setCursor] = useState<number | null>(null);
const [selected, setSelected] = useState<ProcessorRun | null>(null);
@@ -55,11 +77,11 @@ export default function EventProcessorDetailContent({
const [saving, setSaving] = useState(false);
const [pagingRuns, setPagingRuns] = useState(false);
const [pagingEvents, setPagingEvents] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [failed, setFailed] = useState(false);
const validate = useRef<(() => Promise<boolean>) | null>(null);
const requestVersion = useRef(0);
const available = components.some((item) => item.id === agent.component_ref);
const component = components.find((item) => item.id === componentRef);
const available = Boolean(component);
const load = useCallback(async () => {
setFailed(false);
@@ -88,20 +110,38 @@ export default function EventProcessorDetailContent({
[id],
);
async function openRun(run: ProcessorRun) {
const version = ++requestVersion.current;
setSelected(run);
setEvents([]);
setEventCursor(null);
const openRun = useCallback(
async (run: ProcessorRun) => {
const version = ++requestVersion.current;
setSelected(run);
setEvents([]);
setEventCursor(null);
try {
const page = await httpClient.getProcessorRunEvents(id, run.run_id);
if (version !== requestVersion.current) return;
setSelected(page.run);
setEvents(page.items);
setEventCursor(page.has_more ? page.next_cursor : null);
} catch {
if (version === requestVersion.current)
toast.error(t('agents.eventProcessor.loadError'));
}
},
[id, t],
);
useEffect(() => {
if (!selected && runs.length > 0) void openRun(runs[0]);
}, [selected, runs, openRun]);
async function refreshLatestRun() {
try {
const page = await httpClient.getProcessorRunEvents(id, run.run_id);
if (version !== requestVersion.current) return;
setSelected(page.run);
setEvents(page.items);
setEventCursor(page.has_more ? page.next_cursor : null);
const page = await httpClient.getProcessorRuns(id);
setRuns(page.items);
setCursor(page.has_more ? page.next_cursor : null);
if (page.items[0]) await openRun(page.items[0]);
} catch {
if (version === requestVersion.current)
toast.error(t('agents.eventProcessor.loadError'));
toast.error(t('agents.eventProcessor.loadError'));
}
}
@@ -195,7 +235,13 @@ export default function EventProcessorDetailContent({
}, [id, selected, eventCursor, events]);
async function save() {
if (!componentRef || !((await validate.current?.()) ?? true)) return;
if (
!canManage ||
saving ||
!component ||
!((await validate.current?.()) ?? true)
)
return false;
setSaving(true);
try {
await httpClient.updateAgent(id, {
@@ -208,230 +254,213 @@ export default function EventProcessorDetailContent({
});
toast.success(t('agents.saveSuccess'));
onSaved();
setConfigOpen(false);
setSavedConfig({ componentRef, parameters });
await load();
return true;
} catch {
toast.error(t('agents.saveError'));
return false;
} finally {
setSaving(false);
}
}
function payload(value: unknown) {
return (
<pre className="mt-2 whitespace-pre-wrap break-all rounded-md bg-muted/50 p-3 text-xs">
{JSON.stringify(value, null, 2)}
</pre>
);
}
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<FileCode2 className="size-6" />
<h1 className="text-2xl font-semibold">{agent.name}</h1>
{canManage && (
<Button
variant="ghost"
size="icon"
onClick={onEdit}
aria-label={t('common.edit')}
>
<Pencil className="size-4" />
<ProcessorDetailWorkbench
title={`${agent.emoji || '⚡'} ${agent.name}`}
titleAction={
canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
}
titleControls={
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
disabled={!canManage || saving || loading}
onChange={(value) => {
setComponentRef(value);
const descriptor = components.find((item) => item.id === value);
setParameters(
Object.fromEntries(
(descriptor?.config_schema ?? [])
.filter((field) => field.default !== undefined)
.map((field) => [field.name, field.default]),
),
);
validate.current = null;
}}
onParametersChange={setParameters}
onValidate={(fn) => {
validate.current = fn;
}}
/>
}
status={
!loading && componentRef && !available
? { label: t('agents.eventProcessor.unavailable'), tone: 'error' }
: undefined
}
saveLabel={t('common.save')}
saveFormId="event-processor-form"
canSave={canManage && available}
isDirty={dirty}
isSaving={saving}
headerActions={
canManage ? (
<Button variant="destructive" disabled={saving} onClick={onDelete}>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
)}
<Badge variant="outline">{t('agents.eventProcessor.type')}</Badge>
{!loading && !failed && !available && (
<Badge variant="destructive">
{t('agents.eventProcessor.unavailable')}
</Badge>
)}
<div className="ml-auto flex gap-2">
<Button
variant="outline"
onClick={() => {
void load();
if (selected) void openRun(selected);
}}
aria-label={t('agents.eventProcessor.refresh')}
>
<RefreshCw className="size-4" />
</Button>
{canManage && (
<>
<Button
variant="outline"
onClick={() => setConfigOpen(!configOpen)}
>
<Settings2 className="size-4" />
{t('pipelines.configuration')}
</Button>
<Button variant="destructive" onClick={onDelete}>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
</>
)}
</div>
</header>
<p className="shrink-0 break-all text-xs text-muted-foreground">
{agent.component_ref}
</p>
{configOpen && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto rounded-xl border p-4">
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
onChange={(value) => {
setComponentRef(value);
setParameters({});
validate.current = null;
}}
onParametersChange={setParameters}
onValidate={(fn) => {
validate.current = fn;
) : undefined
}
configTitle={t('agents.eventProcessor.trace')}
configIcon={<ScrollText className="size-4" />}
configContent={
<div className="flex h-full min-h-0 flex-col gap-3">
<form
id="event-processor-form"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
/>
<Button
className="mt-4"
disabled={
saving || !components.some((item) => item.id === componentRef)
}
onClick={() => void save()}
>
{t('common.save')}
</Button>
</div>
)}
{failed && (
<p role="alert" className="text-destructive">
{t('agents.eventProcessor.loadError')}
</p>
)}
<div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
<h2 className="mb-3 font-semibold">
{t('agents.eventProcessor.runs')}
</h2>
{loading ? (
<p>{t('common.loading')}</p>
) : runs.length === 0 && !failed ? (
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('agents.eventProcessor.noRuns')}</p>
<Link className="text-primary underline" to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</div>
) : (
runs.map((run) => (
<button
key={run.run_id}
onClick={() => void openRun(run)}
className={`mb-2 block w-full rounded-lg border p-3 text-left text-sm ${selected?.run_id === run.run_id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'}`}
>
<span className="block break-all font-medium">
{run.metadata.event_type}
</span>
<span className="mt-1 flex flex-wrap justify-between gap-1 text-xs text-muted-foreground">
<span>
{new Date(run.created_at * 1000).toLocaleString()}
</span>
<span>
{t(`agents.eventProcessor.status_${run.status}`, {
defaultValue: run.status,
})}
</span>
</span>
</button>
))
{failed && (
<Alert variant="destructive">
<AlertDescription>
{t('agents.eventProcessor.loadError')}
</AlertDescription>
</Alert>
)}
{cursor !== null && (
<div className="flex shrink-0 items-center justify-between gap-2">
<span className="text-sm font-medium">
{t('agents.eventProcessor.runs')}{' '}
<span className="text-muted-foreground">({runs.length})</span>
</span>
<Button
variant="ghost"
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
size="icon"
aria-label={t('agents.eventProcessor.refresh')}
onClick={() => void refreshLatestRun()}
>
{t('agents.eventProcessor.loadMore')}
<RefreshCw className="size-4" />
</Button>
</div>
{runs.length > 0 && (
<ProcessorRunList
runs={runs}
selectedId={selected?.run_id}
onSelect={(run) => void openRun(run)}
footer={
cursor !== null ? (
<Button
className="w-full"
variant="ghost"
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
) : undefined
}
/>
)}
</section>
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
<h2 className="mb-3 font-semibold">
{t('agents.eventProcessor.trace')}
</h2>
{!selected ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.selectRun')}
</p>
) : (
<div className="space-y-3">
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{t('agents.eventProcessor.input')}
</summary>
{payload(selected.metadata.input_event)}
</details>
{selected.metadata.delivery != null && (
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{t('agents.eventProcessor.destination')}
</summary>
{payload(selected.metadata.delivery)}
</details>
)}
{events.map((event) =>
event.type === 'processor.log' ? (
<div
key={event.sequence}
className="rounded-lg bg-muted/40 p-3 text-sm"
>
<span className="mr-2 text-xs text-muted-foreground">
{String(event.data.level)}
</span>
<span className="whitespace-pre-wrap break-words">
{String(event.data.text)}
</span>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 pr-3">
{!selected ? (
<Alert>
<AlertDescription>
{loading
? t('common.loading')
: t('agents.eventProcessor.noRuns')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="border-b pb-2">
<p className="text-sm font-medium">
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(selected.created_at * 1000).toLocaleString()}
</p>
</div>
) : (
<details
key={event.sequence}
className="rounded-lg border p-3"
<Badge
variant={
selected.status === 'failed' ? 'destructive' : 'outline'
}
>
<summary className="cursor-pointer break-all text-sm font-medium">
{t(
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
{ defaultValue: event.type },
)}
{typeof event.data.tool_name === 'string' && (
<span className="ml-2 text-muted-foreground">
{toolLabels[event.data.tool_name] ||
event.data.tool_name}
</span>
)}
</summary>
{payload(event.data)}
</details>
),
)}
{selected.status === 'failed' && selected.status_reason && (
<p className="break-words text-sm text-destructive">
{selected.status_reason}
</p>
)}
{eventCursor !== null && (
<Button
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
{t(`agents.eventProcessor.status_${selected.status}`, {
defaultValue: selected.status,
})}
</Badge>
<ProcessorPayload
title={t('agents.eventProcessor.input')}
value={selected.metadata.input_event}
/>
{selected.metadata.delivery != null && (
<ProcessorPayload
title={t('agents.eventProcessor.destination')}
value={selected.metadata.delivery}
/>
)}
<EventProcessorTrace
events={events}
toolLabels={toolLabels}
/>
{selected.status === 'failed' && selected.status_reason && (
<Alert variant="destructive">
<AlertDescription className="break-words">
{selected.status_reason}
</AlertDescription>
</Alert>
)}
{eventCursor !== null && (
<Button
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</>
)}
</div>
)}
</section>
</div>
</div>
</ScrollArea>
</div>
}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.eventProcessor.debugNotice')}
debugContent={
canOperate ? (
!component ? (
<Alert className="m-3 w-auto">
<AlertDescription>
{t('agents.eventProcessor.selectToDebug')}
</AlertDescription>
</Alert>
) : (
<AgentDebugPanel
agentId={id}
processor
platformTools={platformTools}
hasUnsavedChanges={dirty}
beforeRun={save}
onRunFinished={() => {
void refreshLatestRun();
}}
supportedEventPatterns={component.supported_event_patterns}
availableEventTypes={availableEventTypes}
/>
)
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -6,8 +6,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Bot, Workflow, FileCode2 } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { AgentKind, EventProcessorDescriptor } from '@/app/infra/entities/api';
import EventProcessorSettings from './EventProcessorSettings';
import { AgentKind } from '@/app/infra/entities/api';
import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
@@ -36,23 +35,6 @@ export default function AgentCreateContent({
}) {
const { t } = useTranslation();
const [kind, setKind] = useState<AgentKind>('agent');
const [components, setComponents] = useState<EventProcessorDescriptor[]>([]);
const [componentRef, setComponentRef] = useState('');
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const validateParameters = useRef<(() => Promise<boolean>) | null>(null);
useEffect(() => {
if (kind !== 'event_processor') return;
let cancelled = false;
httpClient
.getAgentMetadata()
.then((metadata) => {
if (!cancelled) setComponents(metadata.event_processors ?? []);
})
.catch(() => toast.error(t('agents.eventProcessor.loadError')));
return () => {
cancelled = true;
};
}, [kind, t]);
const formSchema = z.object({
name: z.string().min(1, { message: t('agents.nameRequired') }),
description: z.string().optional(),
@@ -85,23 +67,9 @@ export default function AgentCreateContent({
}
async function handleSubmit(values: FormValues) {
if (
kind === 'event_processor' &&
(!componentRef || !((await validateParameters.current?.()) ?? true))
)
return;
httpClient
return httpClient
.createAgent({
kind,
...(kind === 'event_processor'
? {
component_ref: componentRef,
config: {
runner: { id: componentRef },
runner_config: { [componentRef]: parameters },
},
}
: {}),
name: values.name,
description: values.description ?? '',
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
@@ -143,10 +111,7 @@ export default function AgentCreateContent({
<Button
type="submit"
form="agent-create-form"
disabled={
form.formState.isSubmitting ||
(kind === 'event_processor' && !componentRef)
}
disabled={form.formState.isSubmitting}
>
{t('common.submit')}
</Button>
@@ -209,22 +174,6 @@ export default function AgentCreateContent({
</ToggleGroup>
</section>
{kind === 'event_processor' && (
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
onChange={(value) => {
setComponentRef(value);
setParameters({});
validateParameters.current = null;
}}
onParametersChange={setParameters}
onValidate={(validate) => {
validateParameters.current = validate;
}}
/>
)}
<Card>
<CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle>
@@ -46,6 +46,7 @@ import {
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
import EventProcessorTrace from './EventProcessorTrace';
import AgentExecutionTrace from './AgentExecutionTrace';
import AgentEventDataEditor from './AgentEventDataEditor';
import {
@@ -53,15 +54,18 @@ import {
debugEventInputText,
invalidDebugEventField,
parseDebugEventData,
processorDebugEventTypes,
} from './debug-event-data';
import { executionSteps, type DebugExecutionEvent } from './debug-execution';
interface AgentDebugPanelProps {
agentId: string;
processor?: boolean;
availableEventTypes: string[];
platformTools?: AgentPlatformTool[];
supportedEventPatterns?: string[];
beforeRun?: () => Promise<boolean>;
onRunFinished?: () => void;
hasUnsavedChanges?: boolean;
onOpenRunnerConfig?: () => void;
}
@@ -89,10 +93,12 @@ function matchesEventPattern(pattern: string, eventType: string) {
export default function AgentDebugPanel({
agentId,
processor = false,
availableEventTypes,
platformTools = [],
supportedEventPatterns = ['*'],
beforeRun,
onRunFinished,
hasUnsavedChanges = false,
onOpenRunnerConfig,
}: AgentDebugPanelProps) {
@@ -105,15 +111,19 @@ export default function AgentDebugPanel({
const newEventData = useCallback(
(type: string) =>
JSON.stringify(
createDebugEventData(type, {
user: t('agents.debugData.sampleUser'),
message: t('agents.debugData.sampleMessage'),
feedback: t('agents.debugData.sampleFeedback'),
}),
createDebugEventData(
type,
{
user: t('agents.debugData.sampleUser'),
message: t('agents.debugData.sampleMessage'),
feedback: t('agents.debugData.sampleFeedback'),
},
processor,
),
null,
2,
),
[t],
[t, processor],
);
const [eventDataText, setEventDataText] = useState(() =>
newEventData('message.received'),
@@ -140,21 +150,28 @@ export default function AgentDebugPanel({
const concretePatterns = supportedEventPatterns.filter(
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
);
return Array.from(new Set([...availableEventTypes, ...concretePatterns]))
return Array.from(
new Set([
...(processor ? processorDebugEventTypes : availableEventTypes),
...concretePatterns,
]),
)
.filter((candidate) =>
supportedEventPatterns.some((pattern) =>
matchesEventPattern(pattern, candidate),
),
)
.sort();
}, [availableEventTypes, supportedEventPatterns]);
}, [availableEventTypes, supportedEventPatterns, processor]);
const eventGroups = useMemo(
() => groupEventPatterns(availableEvents),
[availableEvents],
);
const supportsCustomEvent = supportedEventPatterns.some(
(pattern) => pattern === '*' || pattern.endsWith('.*'),
);
const supportsCustomEvent =
!processor &&
supportedEventPatterns.some(
(pattern) => pattern === '*' || pattern.endsWith('.*'),
);
const selectPreset = useCallback(
(value: string) => {
@@ -193,7 +210,11 @@ export default function AgentDebugPanel({
toast.error(t('agents.debugInvalidPayload'));
return;
}
const invalidField = invalidDebugEventField(eventType, eventData);
const invalidField = invalidDebugEventField(
eventType,
eventData,
processor,
);
if (invalidField) {
toast.error(
t('agents.debugData.invalidField', {
@@ -202,7 +223,7 @@ export default function AgentDebugPanel({
);
return;
}
const inputText = debugEventInputText(eventType, eventData);
const inputText = debugEventInputText(eventType, eventData, processor);
let mockOptions: Record<string, unknown>;
try {
const parsed = JSON.parse(mockOptionsText || '{}');
@@ -275,12 +296,14 @@ export default function AgentDebugPanel({
? {
...entry,
finished: true,
text: executionSteps(entry.events ?? []).some(
(step) =>
step.kind === 'tool' || step.text || step.reasoning,
)
? ''
: result.final_text || t('agents.debugNoTextOutput'),
text:
processor ||
executionSteps(entry.events ?? []).some(
(step) =>
step.kind === 'tool' || step.text || step.reasoning,
)
? ''
: result.final_text || t('agents.debugNoTextOutput'),
}
: entry,
)
@@ -354,6 +377,7 @@ export default function AgentDebugPanel({
if (requestRef.current === controller) {
requestRef.current = null;
setRunning(false);
onRunFinished?.();
}
}
}
@@ -364,15 +388,25 @@ export default function AgentDebugPanel({
<div className="mb-3">
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
<p className="text-xs text-muted-foreground">
{t('agents.debugTranscriptDescription')}
{t(
processor
? 'agents.eventProcessor.debugDescription'
: 'agents.debugTranscriptDescription',
)}
</p>
</div>
{entries.length === 0 ? (
<Alert className="my-4 bg-muted/20">
<CircleHelp className="size-4" />
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
<AlertTitle>
{t(processor ? 'agents.debugTab' : 'agents.debugEmptyTitle')}
</AlertTitle>
<AlertDescription>
{t('agents.debugEmptyTranscript')}
{t(
processor
? 'agents.eventProcessor.debugDescription'
: 'agents.debugEmptyTranscript',
)}
</AlertDescription>
</Alert>
) : (
@@ -382,6 +416,7 @@ export default function AgentDebugPanel({
(entry) =>
entry.direction !== 'output' ||
entry.text ||
processor ||
executionSteps(entry.events ?? []).some(
(step) =>
step.kind === 'tool' || step.text || step.reasoning,
@@ -412,19 +447,29 @@ export default function AgentDebugPanel({
</Badge>
<span className="shrink-0 text-xs text-muted-foreground">
{entry.direction === 'output'
? t('agents.debugAgentOutput')
? t(
processor
? 'agents.eventProcessor.debugOutput'
: 'agents.debugAgentOutput',
)
: entry.direction === 'error'
? t('common.error')
: t('agents.debugTestInput')}
</span>
</div>
{entry.events && (
<AgentExecutionTrace
events={entry.events}
finished={entry.finished}
toolLabels={toolLabels}
/>
)}
{entry.events &&
(processor ? (
<EventProcessorTrace
events={entry.events}
toolLabels={toolLabels}
/>
) : (
<AgentExecutionTrace
events={entry.events}
finished={entry.finished}
toolLabels={toolLabels}
/>
))}
{entry.text && (
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
{entry.text}
@@ -546,26 +591,37 @@ export default function AgentDebugPanel({
<AgentEventDataEditor
key={eventType}
eventType={eventType}
processor={processor}
custom={preset === 'custom'}
value={eventDataText}
onChange={setEventDataText}
/>
<details className="text-muted-foreground">
<summary className="cursor-pointer text-xs font-medium">
{t('agents.debugMockOptions')}
</summary>
<p className="my-2 text-xs text-muted-foreground">
{t('agents.debugMockOptionsHelp')}
</p>
<Textarea
aria-label={t('agents.debugMockOptions')}
value={mockOptionsText}
onChange={(event) => setMockOptionsText(event.target.value)}
className="min-h-24 font-mono text-xs"
spellCheck={false}
/>
</details>
<Collapsible>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="group"
>
<ChevronDown className="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
{t('agents.debugMockOptions')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p className="my-2 text-xs text-muted-foreground">
{t('agents.debugMockOptionsHelp')}
</p>
<Textarea
aria-label={t('agents.debugMockOptions')}
value={mockOptionsText}
onChange={(event) => setMockOptionsText(event.target.value)}
className="min-h-24 font-mono text-xs"
spellCheck={false}
/>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
@@ -5,27 +5,48 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { debugEventDefinition, parseDebugEventData } from './debug-event-data';
import {
debugEventDefinition,
parseDebugEventData,
getDebugEventField,
setDebugEventField,
} from './debug-event-data';
export default function AgentEventDataEditor({
eventType,
value,
onChange,
custom = false,
processor = false,
}: {
eventType: string;
custom?: boolean;
processor?: boolean;
value: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
const [showJson, setShowJson] = useState(false);
const fields = custom ? [] : (debugEventDefinition(eventType)?.fields ?? []);
const fields = custom
? []
: (debugEventDefinition(eventType, processor)?.fields ?? []);
const data = parseDebugEventData(value);
const jsonMode = showJson || !fields.length;
const chainKey =
processor &&
(eventType === 'message.received'
? 'message_chain'
: eventType === 'message.edited'
? 'new_content'
: undefined);
const chain = chainKey && data?.[chainKey];
const richMessage =
chainKey &&
(!Array.isArray(chain) || chain.length !== 1 || chain[0]?.type !== 'Plain');
const jsonMode = showJson || !fields.length || !!richMessage;
function updateField(key: string, next: string | number | undefined) {
if (data) onChange(JSON.stringify({ ...data, [key]: next }, null, 2));
if (data)
onChange(JSON.stringify(setDebugEventField(data, key, next), null, 2));
}
return (
@@ -34,7 +55,7 @@ export default function AgentEventDataEditor({
<span className="text-xs font-medium">
{t('agents.debugData.title')}
</span>
{fields.length > 0 && (
{fields.length > 0 && !richMessage && (
<Button
type="button"
variant="ghost"
@@ -69,7 +90,7 @@ export default function AgentEventDataEditor({
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
{fields.map((field) => {
const id = `agent-debug-data-${field.key}`;
const current = data?.[field.key];
const current = getDebugEventField(data, field.key);
const text =
typeof current === 'string' || typeof current === 'number'
? current
@@ -1,6 +1,14 @@
import { useState } from 'react';
import { Settings2, Puzzle } from 'lucide-react';
import {
Popover,
PopoverTrigger,
PopoverContent,
} from '@/components/ui/popover';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type { EventProcessorDescriptor } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
Select,
@@ -9,8 +17,58 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
function ProcessorComponentContent({
component,
option = false,
}: {
component: EventProcessorDescriptor;
option?: boolean;
}) {
const label = extractI18nObject({
en_US: component.id,
zh_Hans: component.id,
...component.label,
});
const pluginId = `${component.plugin_author}/${component.plugin_name}`;
return (
<span
className={
option
? 'grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)] items-center gap-x-2 text-left'
: 'flex min-w-0 items-center gap-2'
}
>
<img
src={httpClient.getPluginIconURL(
component.plugin_author,
component.plugin_name,
)}
alt=""
className={
option
? 'row-span-2 size-7 shrink-0 rounded-md object-cover'
: 'size-5 shrink-0 rounded object-cover'
}
/>
<span className={option ? 'truncate font-medium leading-5' : 'truncate'}>
{label}
</span>
{option && (
<span
className="truncate text-xs leading-4 text-muted-foreground"
title={pluginId}
>
{pluginId}
</span>
)}
</span>
);
}
export default function EventProcessorSettings({
components,
value,
@@ -18,6 +76,7 @@ export default function EventProcessorSettings({
onChange,
onParametersChange,
onValidate,
disabled = false,
}: {
components: EventProcessorDescriptor[];
value: string;
@@ -25,66 +84,112 @@ export default function EventProcessorSettings({
onChange: (value: string) => void;
onParametersChange: (value: Record<string, unknown>) => void;
onValidate?: (validate: () => Promise<boolean>) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const selected = components.find((item) => item.id === value);
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="space-y-4">
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="event-processor-component"
<div className="flex min-w-0 items-center gap-2">
<Label className="sr-only" htmlFor="event-processor-component">
{t('agents.eventProcessor.component')}
</Label>
<Select
value={value}
disabled={disabled}
onValueChange={(next) => {
onChange(next);
const component = components.find((item) => item.id === next);
setSettingsOpen(
Boolean(
component?.config_schema.some(
(field) =>
field.required &&
(field.default == null || field.default === ''),
),
),
);
}}
>
<SelectTrigger
id="event-processor-component"
className="w-[22rem] max-w-[calc(100vw-8rem)] bg-[#ffffff] dark:bg-[#2a2a2e]"
>
{t('agents.eventProcessor.component')}
</label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id="event-processor-component">
<SelectValue
placeholder={t('agents.eventProcessor.selectComponent')}
/>
</SelectTrigger>
<SelectContent>
{value && !selected && (
<SelectItem value={value}>
{t('agents.eventProcessor.unavailable')}
</SelectItem>
)}
{components.map((component) => (
<SelectItem key={component.id} value={component.id}>
{extractI18nObject({
en_US: component.id,
zh_Hans: component.id,
...component.label,
})}{' '}
· {component.plugin_author}/{component.plugin_name}
</SelectItem>
))}
</SelectContent>
</Select>
{components.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}{' '}
<Link className="text-primary underline" to="/home/plugins">
{t('agents.eventProcessor.installPlugin')}
</Link>
</p>
)}
</div>
{selected && (
<p className="break-words text-xs text-muted-foreground">
{selected.supported_event_patterns.join(' · ')}
</p>
)}
{selected ? (
<ProcessorComponentContent component={selected} />
) : (
<span className="flex min-w-0 items-center gap-2">
<Puzzle className="size-4 shrink-0 text-muted-foreground" />
<SelectValue
placeholder={t('agents.eventProcessor.selectComponent')}
/>
</span>
)}
</SelectTrigger>
<SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
{value && !selected && (
<SelectItem value={value}>
{t('agents.eventProcessor.unavailable')}
</SelectItem>
)}
{components.map((component) => (
<SelectItem
key={component.id}
value={component.id}
className="py-1.5 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
>
<ProcessorComponentContent component={component} option />
</SelectItem>
))}
{components.length === 0 && (
<div className="p-2 text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/plugins">
{t('agents.eventProcessor.installPlugin')}
</Link>
</Button>
</div>
)}
</SelectContent>
</Select>
{selected && selected.config_schema.length > 0 && (
<DynamicFormComponent
key={value}
itemConfigList={selected.config_schema}
initialValues={parameters}
onSubmit={(values) =>
onParametersChange(values as Record<string, unknown>)
}
onValidate={onValidate}
/>
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={t('agents.eventProcessor.pluginSettings')}
title={t('agents.eventProcessor.pluginSettings')}
>
<Settings2 className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="max-h-[70vh] overflow-y-auto space-y-3"
>
<p className="text-sm font-medium">
{t('agents.eventProcessor.pluginSettings')}
</p>
<p className="text-xs text-muted-foreground">
{t('agents.eventProcessor.pluginSettingsDescription')}
</p>
<fieldset disabled={disabled}>
<DynamicFormComponent
key={value}
itemConfigList={selected.config_schema}
initialValues={parameters}
onSubmit={(values) =>
onParametersChange(values as Record<string, unknown>)
}
onValidate={onValidate}
/>
</fieldset>
</PopoverContent>
</Popover>
)}
</div>
);
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import type { DebugExecutionEvent } from './debug-execution';
export function ProcessorPayload({
title,
value,
}: {
title: ReactNode;
value: unknown;
}) {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="h-auto w-full justify-start whitespace-normal text-left group"
>
<ChevronRight className="size-4 shrink-0 transition-transform group-data-[state=open]:rotate-90" />
<span className="min-w-0 break-words">{title}</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="p-3 text-xs whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{JSON.stringify(value, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
export default function EventProcessorTrace({
events,
toolLabels = {},
}: {
events: DebugExecutionEvent[];
toolLabels?: Record<string, string>;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
{events.map((event, index) =>
event.type === 'processor.log' ? (
<Alert
key={event.sequence ?? index}
variant={event.data.level === 'error' ? 'destructive' : 'default'}
>
<AlertDescription className="flex min-w-0 items-start gap-2">
<Badge variant="outline">{String(event.data.level)}</Badge>
<span className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{String(event.data.text)}
</span>
</AlertDescription>
</Alert>
) : (
<ProcessorPayload
key={event.sequence ?? index}
title={
<>
{t(
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
{ defaultValue: event.type },
)}
{typeof event.data.tool_name === 'string' && (
<span className="ml-2 text-muted-foreground">
{toolLabels[event.data.tool_name] || event.data.tool_name}
</span>
)}
</>
}
value={event.data}
/>
),
)}
</div>
);
}
@@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight } from 'lucide-react';
import type { ProcessorRun } from '@/app/infra/entities/api';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
processorRunDuration,
formatRunDuration,
} from './processor-run-timing';
export default function ProcessorRunList({
runs,
selectedId,
onSelect,
footer,
}: {
runs: ProcessorRun[];
selectedId?: string;
onSelect: (run: ProcessorRun) => void;
footer?: ReactNode;
}) {
const { t } = useTranslation();
return (
<div
role="group"
aria-label={t('agents.eventProcessor.runs')}
className="max-h-56 shrink-0 overflow-y-auto rounded-md border"
>
{runs.map((run) => {
const duration = processorRunDuration(run);
const selected = selectedId === run.run_id;
const event = run.metadata.event_type ?? '';
const label = eventPatternLabel(event, t);
return (
<Button
key={run.run_id}
variant="ghost"
aria-pressed={selected}
onClick={() => onSelect(run)}
aria-label={`${label} ${event} ${new Date(run.created_at * 1000).toLocaleString()}`}
title={event}
className="flex h-auto min-h-12 w-full justify-start gap-3 rounded-none border-b px-3 py-2 text-left text-sm font-normal last:border-b-0 aria-pressed:bg-accent"
>
<span className="min-w-0 flex-1 space-y-1">
<span className="block truncate font-medium">{label}</span>
<time
className="block text-xs text-muted-foreground"
dateTime={new Date(run.created_at * 1000).toISOString()}
>
{new Date(run.created_at * 1000).toLocaleString()}
</time>
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<Badge
variant={
run.status === 'failed' || run.status === 'timeout'
? 'destructive'
: 'outline'
}
className="px-1.5 py-0 text-xs"
>
{t(`agents.eventProcessor.status_${run.status}`, {
defaultValue: run.status,
})}
</Badge>
{duration !== null && (
<span className="font-mono text-xs text-muted-foreground">
{formatRunDuration(duration)}
</span>
)}
</span>
<ChevronRight
className={`size-4 shrink-0 ${selected ? 'text-foreground' : 'text-muted-foreground'}`}
/>
</Button>
);
})}
{footer}
</div>
);
}
@@ -155,28 +155,155 @@ const DEBUG_EVENT_DEFINITIONS: Record<string, DebugEventDefinition> = {
},
};
// Event processors receive the SDK's typed EBA payload, rather than Agent debug aliases.
const PROCESSOR_EVENT_DEFINITIONS: Record<string, DebugEventDefinition> =
Object.fromEntries(
Object.entries(DEBUG_EVENT_DEFINITIONS)
.filter(([type]) => type !== 'message.recalled')
.map(([type, definition]) => {
const paths: Record<string, string> = {
group_id: 'group.id',
group_name: 'group.name',
member_id: 'member.id',
member_name: 'member.nickname',
user_id: 'user.id',
user_name: 'user.nickname',
requester_id:
type === 'bot.invited_to_group' ? 'inviter.id' : 'user.id',
requester_name: 'user.nickname',
event_name: 'action',
};
return [
type,
{
...definition,
fields: definition.fields.map((field) => ({
...field,
key: paths[field.key] ?? field.key,
})),
},
];
}),
);
for (const [type, chain, sender] of [
['message.received', 'message_chain', 'sender'],
['message.edited', 'new_content', 'editor'],
]) {
PROCESSOR_EVENT_DEFINITIONS[type] = {
fields: [
{ ...message, key: `${chain}.0.text` },
{ ...userName, key: `${sender}.nickname` },
{ ...user, key: `${sender}.id` },
{ key: 'chat_id', label: 'chatId', value: 'debug-user' },
],
defaults: {
[chain]: [{ type: 'Plain', text: '' }],
chat_type: 'private',
message_id: 'debug-message',
},
messageField: `${chain}.0.text`,
};
}
for (const type of ['message.deleted', 'message.reaction']) {
const definition = PROCESSOR_EVENT_DEFINITIONS[type];
definition.fields = definition.fields.filter(
(field) => field.key !== 'group.id',
);
definition.fields.push({
key: 'chat_id',
label: 'chatId',
value: 'debug-user',
});
definition.defaults = { ...definition.defaults, chat_type: 'private' };
}
PROCESSOR_EVENT_DEFINITIONS['feedback.received'] = {
fields: [
{
key: 'feedback_content',
label: 'feedback',
value: '',
sample: 'feedback',
multiline: true,
},
{
key: 'feedback_type',
label: 'feedbackType',
value: 1,
min: 1,
max: 3,
required: true,
},
user,
messageId,
],
defaults: { feedback_id: 'debug-feedback' },
};
export const processorDebugEventTypes = Object.keys(
PROCESSOR_EVENT_DEFINITIONS,
);
export function getDebugEventField(data: unknown, path: string): unknown {
return path
.split('.')
.reduce<unknown>(
(value, key) =>
value && typeof value === 'object' && Object.hasOwn(value, key)
? (value as Record<string, unknown>)[key]
: undefined,
data,
);
}
export function setDebugEventField(
data: Record<string, unknown>,
path: string,
value: unknown,
): Record<string, unknown> {
const [key, ...rest] = path.split('.');
if (['__proto__', 'constructor', 'prototype'].includes(key)) return data;
const current = data[key];
const next = rest.length
? setDebugEventField(
current && typeof current === 'object'
? (current as Record<string, unknown>)
: {},
rest.join('.'),
value,
)
: value;
return Array.isArray(data)
? Object.assign([...data], { [key]: next })
: { ...data, [key]: next };
}
export function debugEventDefinition(
eventType: string,
processor = false,
): DebugEventDefinition | undefined {
return Object.hasOwn(DEBUG_EVENT_DEFINITIONS, eventType)
? DEBUG_EVENT_DEFINITIONS[eventType]
const definitions = processor
? PROCESSOR_EVENT_DEFINITIONS
: DEBUG_EVENT_DEFINITIONS;
return Object.hasOwn(definitions, eventType)
? definitions[eventType]
: undefined;
}
export function createDebugEventData(
eventType: string,
samples: Record<'user' | 'message' | 'feedback', string>,
processor = false,
) {
const definition = debugEventDefinition(eventType);
return {
...definition?.defaults,
...Object.fromEntries(
(definition?.fields ?? []).map((field) => [
const definition = debugEventDefinition(eventType, processor);
return (definition?.fields ?? []).reduce<Record<string, unknown>>(
(data, field) =>
setDebugEventField(
data,
field.key,
field.sample ? samples[field.sample] : field.value,
]),
),
};
),
{ ...definition?.defaults },
);
}
export function parseDebugEventData(
@@ -195,9 +322,20 @@ export function parseDebugEventData(
export function invalidDebugEventField(
eventType: string,
data: Record<string, unknown>,
processor = false,
) {
return debugEventDefinition(eventType)?.fields.find((field) => {
const value = data[field.key];
return debugEventDefinition(eventType, processor)?.fields.find((field) => {
const value = getDebugEventField(data, field.key);
// Rich messages are edited as full JSON; a first Plain component is not required.
if (processor && field.key.endsWith('.0.text')) {
const chain = data[field.key.split('.')[0]];
if (
Array.isArray(chain) &&
chain.length > 0 &&
(chain.length > 1 || chain[0]?.type !== 'Plain')
)
return false;
}
if (
value === undefined ||
value === null ||
@@ -215,7 +353,10 @@ export function invalidDebugEventField(
}
return (
typeof value !== 'string' &&
!(field.key.endsWith('_id') && typeof value === 'number')
!(
(field.key.endsWith('_id') || field.key.endsWith('.id')) &&
typeof value === 'number'
)
);
});
}
@@ -223,7 +364,9 @@ export function invalidDebugEventField(
export function debugEventInputText(
eventType: string,
data: Record<string, unknown>,
processor = false,
) {
const key = debugEventDefinition(eventType)?.messageField;
return key && typeof data[key] === 'string' ? data[key].trim() : '';
const key = debugEventDefinition(eventType, processor)?.messageField;
const value = key ? getDebugEventField(data, key) : undefined;
return typeof value === 'string' ? value.trim() : '';
}
@@ -0,0 +1,23 @@
import type { ProcessorRun } from '@/app/infra/entities/api';
function milliseconds(
precise: number | null | undefined,
seconds: number | null | undefined,
) {
if (typeof precise === 'number' && Number.isFinite(precise)) return precise;
if (typeof seconds === 'number' && Number.isFinite(seconds))
return seconds * 1000;
return null;
}
export function processorRunDuration(run: ProcessorRun): number | null {
const start = milliseconds(run.started_at_ms, run.started_at);
const end = milliseconds(run.finished_at_ms, run.finished_at);
return start !== null && end !== null && end >= start ? end - start : null;
}
export function formatRunDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(2)} s`;
return `${(ms / 60000).toFixed(1)} min`;
}
@@ -900,7 +900,7 @@ function NavItems({
}
>
{item.kind === 'event_processor' ? (
<span className="text-xs"></span>
<Puzzle className="size-3.5" />
) : item.kind === 'pipeline' ? (
<Workflow className="size-3.5" />
) : (
@@ -8,6 +8,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface ProcessorMonitoringView {
@@ -26,6 +27,7 @@ interface ProcessorDetailWorkbenchProps {
title: string;
titleBadge?: ReactNode;
titleAction?: ReactNode;
titleControls?: ReactNode;
headerActions?: ReactNode;
status?: ProcessorDetailStatus | null;
saveLabel: string;
@@ -34,6 +36,7 @@ interface ProcessorDetailWorkbenchProps {
isDirty: boolean;
isSaving: boolean;
configTitle: string;
configIcon?: ReactNode;
configContent: ReactNode;
debugTitle?: string;
debugDescription?: string;
@@ -49,6 +52,7 @@ export default function ProcessorDetailWorkbench({
title,
titleBadge,
titleAction,
titleControls,
headerActions,
status,
saveLabel,
@@ -57,6 +61,7 @@ export default function ProcessorDetailWorkbench({
isDirty,
isSaving,
configTitle,
configIcon,
configContent,
debugTitle,
debugDescription,
@@ -81,10 +86,11 @@ export default function ProcessorDetailWorkbench({
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="truncate text-xl font-semibold">{title}</h1>
{titleBadge}
{titleAction}
{titleControls}
{monitoring && (
<TabsList
aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`}
@@ -168,18 +174,20 @@ export default function ProcessorDetailWorkbench({
value="monitoring"
className="mt-0 min-h-0 flex-1 overflow-hidden"
>
<section
<Card
role="region"
aria-label={monitoring.label}
className="h-full min-h-0 overflow-y-auto rounded-xl border bg-card p-4"
className="h-full min-h-0 overflow-y-auto gap-0 p-4"
>
{monitoring.content}
</section>
</Card>
</TabsContent>
)}
<TabsContent
value="workbench"
className="mt-0 min-h-0 flex-1 overflow-y-auto lg:overflow-hidden"
forceMount
className="mt-0 min-h-0 flex-1 overflow-y-auto lg:overflow-hidden data-[state=inactive]:hidden"
>
<div
className={cn(
@@ -190,24 +198,27 @@ export default function ProcessorDetailWorkbench({
)}
>
{hasDebug && (
<section
<Card
role="region"
aria-label={debugTitle}
className="flex min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
className="min-h-[32rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
>
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<CardHeader className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4 [.border-b]:pb-0">
<div className="flex min-w-0 items-center gap-2 font-medium">
<Bug className="size-4 shrink-0" />
<span className="truncate">{debugTitle}</span>
{debugDescription && (
<Tooltip>
<TooltipTrigger asChild>
<button
<Button
type="button"
variant="ghost"
size="icon"
aria-label={debugDescription}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
className="size-6 shrink-0 text-muted-foreground"
>
<Info className="size-4" />
</button>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs whitespace-normal leading-relaxed">
{debugDescription}
@@ -228,19 +239,20 @@ export default function ProcessorDetailWorkbench({
: debugDisconnectedLabel}
</span>
)}
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
</CardHeader>
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden px-0">
{debugContent}
</div>
</section>
</CardContent>
</Card>
)}
<section
<Card
role="region"
aria-label={configTitle}
className="flex min-h-[36rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
className="min-h-[36rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
>
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4 font-medium">
<Settings className="size-4" />
<CardHeader className="flex h-12 shrink-0 flex-row items-center gap-2 border-b px-4 font-medium [.border-b]:pb-0">
{configIcon ?? <Settings className="size-4" />}
<span className="truncate">{configTitle}</span>
{isDirty && (
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
@@ -248,11 +260,11 @@ export default function ProcessorDetailWorkbench({
{unsavedLabel}
</span>
)}
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
</CardHeader>
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
{configContent}
</div>
</section>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
+5
View File
@@ -178,6 +178,11 @@ export interface ProcessorRun {
status: string;
status_reason?: string;
created_at: number;
started_at?: number | null;
finished_at?: number | null;
created_at_ms?: number | null;
started_at_ms?: number | null;
finished_at_ms?: number | null;
metadata: { event_type?: string; input_event?: unknown; delivery?: unknown };
}
+13
View File
@@ -704,6 +704,16 @@ const enUS = {
},
agents: {
eventProcessor: {
pluginSettings: 'Plugin settings',
pluginSettingsDescription: 'Parameters declared by this plugin.',
selectToDebug: 'Select a plugin above to start debugging.',
debugOutput: 'Processor output',
debugDescription:
'Input events, plugin logs and action results for this test session.',
debugNotice:
'The plugin processes a test event. Platform actions use Mock and do not send real messages; other tools run as configured.',
type: 'Event processor',
description: 'Process platform events using plugin code.',
component: 'Plugin component',
@@ -734,6 +744,9 @@ const enUS = {
trace_tool_call_completed: 'Action result',
},
debugData: {
chatId: 'Chat ID',
feedbackType: 'Feedback type (1: like, 2: dislike, 3: cancel)',
title: 'Event data',
form: 'Common fields',
json: 'Full JSON',
+14
View File
@@ -507,6 +507,16 @@ const esES = {
},
agents: {
eventProcessor: {
pluginSettings: 'Ajustes del plugin',
pluginSettingsDescription: 'Parámetros definidos por este plugin.',
selectToDebug: 'Selecciona un plugin arriba para iniciar la depuración.',
debugOutput: 'Salida del procesador',
debugDescription:
'Eventos de entrada, registros del plugin y resultados de acciones de esta prueba.',
debugNotice:
'El plugin procesa un evento de prueba. Las acciones de plataforma usan Mock y no envían mensajes reales; las demás herramientas se ejecutan según su configuración.',
type: 'Procesador de eventos',
description: 'Procesa eventos con código del plugin.',
component: 'Componente del plugin',
@@ -538,6 +548,10 @@ const esES = {
trace_tool_call_completed: 'Resultado de la acción',
},
debugData: {
chatId: 'ID del chat',
feedbackType:
'Tipo de valoración (1: positiva, 2: negativa, 3: cancelar)',
title: 'Datos del evento',
form: 'Campos comunes',
json: 'JSON completo',
+13
View File
@@ -717,6 +717,16 @@ const jaJP = {
},
agents: {
eventProcessor: {
pluginSettings: 'プラグイン設定',
pluginSettingsDescription: 'このプラグインが定義するパラメーターです。',
selectToDebug: '上でプラグインを選択してデバッグを開始してください。',
debugOutput: 'プロセッサー出力',
debugDescription:
'このテストの入力イベント、プラグインログ、アクション結果。',
debugNotice:
'プラグインはテストイベントを実際に処理します。返信や送信などは Mock を使用し、実際のメッセージは送信しません。他のツールは設定どおりに実行されます。',
type: 'イベントプロセッサー',
description: 'プラグインのコードでイベントを処理します。',
component: 'プラグインコンポーネント',
@@ -748,6 +758,9 @@ const jaJP = {
trace_tool_call_completed: 'アクション結果',
},
debugData: {
chatId: 'チャット ID',
feedbackType: 'フィードバック種別(1: 高評価、2: 低評価、3: 取消)',
title: 'イベントデータ',
form: '基本項目',
json: '完全な JSON',
+13
View File
@@ -504,6 +504,16 @@ const ruRU = {
},
agents: {
eventProcessor: {
pluginSettings: 'Настройки плагина',
pluginSettingsDescription: 'Параметры, объявленные этим плагином.',
selectToDebug: 'Выберите плагин выше, чтобы начать отладку.',
debugOutput: 'Вывод обработчика',
debugDescription:
'Входные события, журналы плагина и результаты действий этой проверки.',
debugNotice:
'Плагин обрабатывает тестовое событие. Действия платформы используют Mock и не отправляют реальные сообщения; остальные инструменты работают согласно настройкам.',
type: 'Обработчик событий',
description: 'Обрабатывает события кодом плагина.',
component: 'Компонент плагина',
@@ -535,6 +545,9 @@ const ruRU = {
trace_tool_call_completed: 'Результат действия',
},
debugData: {
chatId: 'ID чата',
feedbackType: 'Тип отзыва (1: нравится, 2: не нравится, 3: отмена)',
title: 'Данные события',
form: 'Основные поля',
json: 'Полный JSON',
+13
View File
@@ -491,6 +491,16 @@ const thTH = {
},
agents: {
eventProcessor: {
pluginSettings: 'การตั้งค่าปลั๊กอิน',
pluginSettingsDescription: 'พารามิเตอร์ที่ประกาศโดยปลั๊กอินนี้',
selectToDebug: 'เลือกปลั๊กอินด้านบนเพื่อเริ่มแก้จุดบกพร่อง',
debugOutput: 'ผลลัพธ์ตัวประมวลผล',
debugDescription:
'เหตุการณ์ขาเข้า บันทึกปลั๊กอิน และผลการดำเนินการของการทดสอบนี้',
debugNotice:
'ปลั๊กอินประมวลผลเหตุการณ์ทดสอบจริง การตอบกลับและส่งข้อความใช้ Mock โดยไม่ส่งข้อความจริง เครื่องมืออื่นทำงานตามการตั้งค่า',
type: 'ตัวประมวลผลเหตุการณ์',
description: 'ประมวลผลเหตุการณ์ด้วยโค้ดปลั๊กอิน',
component: 'ส่วนประกอบปลั๊กอิน',
@@ -521,6 +531,9 @@ const thTH = {
trace_tool_call_completed: 'ผลการดำเนินการ',
},
debugData: {
chatId: 'ID แชท',
feedbackType: 'ประเภทข้อเสนอแนะ (1: ชอบ, 2: ไม่ชอบ, 3: ยกเลิก)',
title: 'ข้อมูลเหตุการณ์',
form: 'ฟิลด์ทั่วไป',
json: 'JSON ทั้งหมด',
+13
View File
@@ -500,6 +500,16 @@ const viVN = {
},
agents: {
eventProcessor: {
pluginSettings: 'Cài đặt plugin',
pluginSettingsDescription: 'Tham số do plugin này khai báo.',
selectToDebug: 'Chọn plugin ở trên để bắt đầu gỡ lỗi.',
debugOutput: 'Đầu ra bộ xử lý',
debugDescription:
'Sự kiện đầu vào, nhật ký plugin và kết quả hành động của lần kiểm thử này.',
debugNotice:
'Plugin xử lý sự kiện kiểm thử. Hành động nền tảng dùng Mock và không gửi tin nhắn thật; các công cụ khác chạy theo cấu hình.',
type: 'Bộ xử lý sự kiện',
description: 'Xử lý sự kiện bằng mã plugin.',
component: 'Thành phần plugin',
@@ -530,6 +540,9 @@ const viVN = {
trace_tool_call_completed: 'Kết quả hành động',
},
debugData: {
chatId: 'ID cuộc trò chuyện',
feedbackType: 'Loại phản hồi (1: thích, 2: không thích, 3: hủy)',
title: 'Dữ liệu sự kiện',
form: 'Trường thường dùng',
json: 'JSON đầy đủ',
+12
View File
@@ -669,6 +669,15 @@ const zhHans = {
},
agents: {
eventProcessor: {
pluginSettings: '插件设置',
pluginSettingsDescription: '由当前插件声明的参数。',
selectToDebug: '请先在上方选择插件,再开始调试。',
debugOutput: '处理器输出',
debugDescription: '当前测试的输入事件、插件日志和动作结果。',
debugNotice:
'插件真实处理测试事件;回复、发送等平台动作使用 Mock,不发送真实消息。其他工具仍按实际配置执行。',
type: '事件处理器',
description: '通过插件代码处理平台事件。',
component: '插件组件',
@@ -699,6 +708,9 @@ const zhHans = {
trace_tool_call_completed: '动作结果',
},
debugData: {
chatId: '会话 ID',
feedbackType: '反馈类型(1:赞,2:踩,3:取消)',
title: '事件数据',
form: '常用字段',
json: '完整 JSON',
+12
View File
@@ -475,6 +475,15 @@ const zhHant = {
},
agents: {
eventProcessor: {
pluginSettings: '外掛設定',
pluginSettingsDescription: '由目前外掛宣告的參數。',
selectToDebug: '請先在上方選擇外掛,再開始除錯。',
debugOutput: '處理器輸出',
debugDescription: '目前測試的輸入事件、外掛日誌和動作結果。',
debugNotice:
'外掛實際處理測試事件;回覆、傳送等平台動作使用 Mock,不傳送真實訊息。其他工具仍依實際設定執行。',
type: '事件處理器',
description: '透過外掛程式碼處理平台事件。',
component: '外掛元件',
@@ -505,6 +514,9 @@ const zhHant = {
trace_tool_call_completed: '動作結果',
},
debugData: {
chatId: '會話 ID',
feedbackType: '回饋類型(1:讚,2:踩,3:取消)',
title: '事件資料',
form: '常用欄位',
json: '完整 JSON',
+1 -1
View File
@@ -78,7 +78,7 @@ test('event data stays in sync across the compact form, JSON and the request', a
await expect(panel.getByText('Done', { exact: true })).toBeVisible();
await page.setViewportSize({ width: 1280, height: 650 });
await panel.getByRole('button', { name: 'Full JSON' }).click();
await panel.locator('summary').filter({ hasText: 'Mock scenario' }).click();
await panel.getByRole('button', { name: 'Mock scenario' }).click();
await expect(
panel.getByRole('button', { name: 'Run test' }),
).toBeInViewport();
+216 -29
View File
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('event processor shows isolated logs, paginates and scrolls expanded payloads', async ({
test('create first, select a plugin in the header, debug beside scrollable logs', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
@@ -12,13 +12,18 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
name: 'Welcome processor',
component_ref: ref,
supported_event_patterns: ['group.member_joined'],
config: { runner: { id: ref }, runner_config: { [ref]: {} } },
config: {
runner: { id: ref },
runner_config: { [ref]: { greeting: 'Hello' } },
},
};
const run = {
run_id: 'run-one',
status: 'completed',
status_reason: 'stop',
created_at: 1788000000,
started_at_ms: 1788000000000,
finished_at_ms: 1788000000500,
metadata: {
event_type: 'group.member_joined',
input_event: { member: { id: 'one' } },
@@ -28,10 +33,63 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
},
};
const cursors: string[] = [];
const debugRequests: unknown[] = [];
const operations: string[] = [];
const creations: unknown[] = [];
await page.route('**/api/v1/agents**', async (route) => {
const url = new URL(route.request().url());
let data: unknown;
if (url.pathname.endsWith('/_/metadata')) {
if (
url.pathname === '/api/v1/agents' &&
route.request().method() === 'POST'
) {
const payload = route.request().postDataJSON();
creations.push(payload);
Object.assign(processor, payload, {
component_ref: null,
config: {},
supported_event_patterns: [],
});
await route.fulfill({
json: { code: 0, data: { uuid: processor.uuid, kind: processor.kind } },
});
return;
} else if (url.pathname.endsWith('/debug/stream')) {
operations.push('debug');
debugRequests.push(route.request().postDataJSON());
const events = [
{
type: 'processor.log',
sequence: 1,
data: { level: 'info', text: 'Debug handler invoked once' },
},
{
type: 'tool.call.started',
sequence: 2,
data: { tool_name: 'event_reply', parameters: { text: 'Welcome' } },
},
{
type: 'tool.call.completed',
sequence: 3,
data: {
tool_name: 'event_reply',
result: { mock: true, ok: true, delivery: 'simulated' },
},
},
{ type: 'run.completed', sequence: 4, data: {} },
];
await route.fulfill({
contentType: 'application/x-ndjson',
body:
[
...events.map((data) => ({ kind: 'result', data })),
{ kind: 'completed', data: { final_text: '' } },
]
.map((frame) => JSON.stringify(frame))
.join('\n') + '\n',
});
return;
} else if (url.pathname.endsWith('/_/metadata')) {
data = {
kinds: [],
event_processors: [
@@ -39,12 +97,38 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
id: ref,
label: { en_US: 'Welcome' },
supported_event_patterns: ['group.member_joined'],
config_schema: [],
config_schema: [
{
name: 'greeting',
type: 'string',
label: { en_US: 'Greeting' },
default: 'Hello',
required: true,
},
],
plugin_author: 'qa',
plugin_name: 'welcome',
},
],
};
} else if (url.pathname.endsWith('/runs/run-two/events')) {
data = {
run: {
...run,
run_id: 'run-two',
metadata: { event_type: 'group.member_left' },
},
items: [
{
sequence: 1,
type: 'processor.log',
created_at_ms: 1788000000000,
data: { text: 'Older run selected', level: 'info' },
},
],
has_more: false,
next_cursor: null,
};
} else if (url.pathname.endsWith('/runs/run-one/events')) {
cursors.push(url.searchParams.get('after_sequence') ?? '');
data = {
@@ -62,6 +146,27 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
sequence: 1,
type: 'processor.log',
data: { level: 'info', text: 'Member received' },
created_at_ms: 1788000000000,
},
{
sequence: 2,
type: 'tool.call.started',
created_at_ms: 1788000000050,
data: {
tool_call_id: 'reply-one',
tool_name: 'event_reply',
parameters: { text: 'Welcome' },
},
},
{
sequence: 3,
type: 'tool.call.completed',
created_at_ms: 1788000000200,
data: {
tool_call_id: 'reply-one',
tool_name: 'event_reply',
result: { ok: true },
},
},
{
sequence: 100,
@@ -78,38 +183,120 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
next_cursor: url.searchParams.has('after_sequence') ? null : 100,
};
} else if (url.pathname.endsWith('/runs')) {
data = { items: [run], has_more: false, next_cursor: null, total: 1 };
data = {
items: debugRequests.length
? [
run,
{
...run,
run_id: 'run-two',
created_at: run.created_at - 60,
started_at_ms: 1787999940000,
finished_at_ms: 1787999940200,
metadata: { event_type: 'group.member_left' },
},
]
: [],
has_more: false,
next_cursor: null,
total: debugRequests.length,
};
} else if (url.pathname.endsWith('/processor-qa')) {
if (route.request().method() === 'PUT') {
operations.push('save');
Object.assign(processor, route.request().postDataJSON(), {
supported_event_patterns: ['group.member_joined'],
});
}
data = { agent: processor };
} else {
data = { agents: [processor] };
}
await route.fulfill({ json: { code: 0, data } });
});
await page.goto('/home/agents?id=processor-qa');
await page.goto('/home/agents?id=new');
await page.locator('[data-processor-kind="event_processor"]').click();
await expect(
page.getByRole('heading', { name: 'Welcome processor' }),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Logs and message flow' }),
).toBeVisible();
await expect(page.getByText('stop', { exact: true })).toHaveCount(0);
await page
.getByRole('button')
.filter({ hasText: 'group.member_joined' })
.click();
await expect(
page.getByText('Member received', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Payload line 99', { exact: false }),
).toBeHidden();
await page.locator('summary').filter({ hasText: 'Action result' }).click();
await page.getByRole('button', { name: 'Load more', exact: true }).click();
await page.getByText('Final log after pagination').scrollIntoViewIfNeeded();
await expect(page.getByText('Final log after pagination')).toBeInViewport();
expect(cursors).toEqual(['', '100']);
await expect(
page.getByRole('button', { name: 'Load more', exact: true }),
page.getByRole('combobox', { name: 'Plugin component' }),
).toHaveCount(0);
await page
.getByRole('textbox', { name: 'Name', exact: false })
.fill('Welcome processor');
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await expect(page).toHaveURL(/id=processor-qa/);
expect(creations).toHaveLength(1);
expect(creations[0]).toMatchObject({
kind: 'event_processor',
name: 'Welcome processor',
});
expect(creations[0]).not.toHaveProperty('component_ref');
expect(creations[0]).not.toHaveProperty('config');
const panel = page.getByRole('region', { name: 'Event Debug' });
const logs = page.getByRole('region', { name: 'Logs and message flow' });
await expect(panel).toBeVisible();
await expect(logs).toBeVisible();
await expect(page.getByRole('tab')).toHaveCount(0);
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(panel.getByRole('button', { name: 'Run test' })).toHaveCount(0);
await page.getByRole('combobox', { name: 'Plugin component' }).click();
await page.getByRole('option').filter({ hasText: 'Welcome' }).click();
await expect(
panel.getByRole('combobox', { name: 'Event type' }),
).toContainText('group.member_joined');
await expect(page.getByText('Greeting', { exact: true })).toHaveCount(0);
await page
.getByRole('button', { name: 'Plugin settings', exact: true })
.click();
const settings = page.locator('[data-slot="popover-content"]');
await settings.getByRole('textbox').fill('Welcome');
await page.keyboard.press('Escape');
await panel
.getByRole('textbox', { name: 'Member ID' })
.fill('debug-member-42');
await panel.getByRole('button', { name: 'Save and run' }).click();
await expect(panel.getByText('Debug handler invoked once')).toBeVisible();
expect(operations).toEqual(['save', 'debug']);
expect(debugRequests).toHaveLength(1);
expect(debugRequests[0]).toMatchObject({
event_type: 'group.member_joined',
data: { member: { id: 'debug-member-42' } },
});
await expect(
logs.getByText('Member received', { exact: true }),
).toBeVisible();
await expect(
logs.getByText('Payload line 99', { exact: false }),
).toBeHidden();
const runList = logs.getByRole('group', { name: 'Runs', exact: true });
await expect(logs.getByRole('combobox')).toHaveCount(0);
await expect(runList.getByRole('button')).toHaveCount(2);
await expect(runList.getByText('500 ms', { exact: true })).toBeVisible();
await expect(runList.getByText('200 ms', { exact: true })).toBeVisible();
await expect(runList.getByText('Member received')).toHaveCount(0);
await runList.getByRole('button', { name: /group.member_left/ }).click();
await expect(logs.getByText('Older run selected')).toBeVisible();
await runList.getByRole('button', { name: /group.member_joined/ }).click();
await expect(
logs.getByText('Member received', { exact: true }),
).toBeVisible();
await logs
.getByRole('button', { name: 'Action result', exact: true })
.click();
await logs.getByRole('button', { name: 'Load more' }).click();
await logs.getByText('Final log after pagination').scrollIntoViewIfNeeded();
await expect(logs.getByText('Final log after pagination')).toBeInViewport();
expect(cursors).toContain('100');
await expect(panel.getByText('Debug handler invoked once')).toBeVisible();
const debugBox = await panel.boundingBox();
const logBox = await logs.boundingBox();
expect(debugBox!.x).toBeLessThan(logBox!.x);
await page.setViewportSize({ width: 390, height: 700 });
await panel
.getByRole('button', { name: 'Run test' })
.scrollIntoViewIfNeeded();
await expect(
panel.getByRole('button', { name: 'Run test' }),
).toBeInViewport();
await logs.getByText('Final log after pagination').scrollIntoViewIfNeeded();
await expect(logs.getByText('Final log after pagination')).toBeInViewport();
});
@@ -98,3 +98,64 @@ test('duration and rating remain numbers and reject invalid values', () => {
'rating',
);
});
test('processor common fields edit nested SDK data without losing JSON-only fields', () => {
const { setDebugEventField, getDebugEventField } = module.exports;
const data = createDebugEventData('group.member_joined', samples, true);
assert.deepEqual(data, {
member: { nickname: '测试用户', id: 'debug-user' },
group: { id: 'debug-group' },
});
data.member.username = 'alice';
data.inviter = { id: 'inviter-1' };
const edited = setDebugEventField(data, 'member.id', 'member-42');
assert.equal(edited.member.id, 'member-42');
assert.equal(edited.member.username, 'alice');
assert.deepEqual(edited.inviter, data.inviter);
assert.equal(data.member.id, 'debug-user');
assert.equal(getDebugEventField(edited, 'member.id'), 'member-42');
assert.equal(
invalidDebugEventField('group.member_joined', edited, true),
undefined,
);
});
test('processor message content uses SDK message chains and feedback uses SDK fields', () => {
const { setDebugEventField, processorDebugEventTypes } = module.exports;
for (const [type, field] of [
['message.received', 'message_chain'],
['message.edited', 'new_content'],
]) {
const data = createDebugEventData(type, samples, true);
const edited = setDebugEventField(data, `${field}.0.text`, 'Changed');
assert.deepEqual(edited[field], [{ type: 'Plain', text: 'Changed' }]);
assert.equal(debugEventInputText(type, edited, true), 'Changed');
assert.equal(data[field][0].text, '你好');
}
const feedback = createDebugEventData('feedback.received', samples, true);
assert.equal(feedback.feedback_type, 1);
assert.equal(feedback.feedback_content, '很有帮助');
assert.ok(feedback.feedback_id);
for (const type of processorDebugEventTypes) {
assert.equal(
invalidDebugEventField(
type,
createDebugEventData(type, samples, true),
true,
),
undefined,
type,
);
}
});
test('processor full JSON accepts rich messages without requiring a first text component', () => {
const data = createDebugEventData('message.received', samples, true);
data.message_chain = [
{ type: 'Image', url: 'https://example.com/image.png' },
];
assert.equal(
invalidDebugEventField('message.received', data, true),
undefined,
);
});
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(
new URL(
'../../src/app/home/agents/components/processor-run-timing.ts',
import.meta.url,
),
'utf8',
);
const exports = {};
new Function(
'exports',
ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
)(exports);
const { processorRunDuration } = exports;
test('duration uses precise lifecycle times, excluding queue wait', () => {
assert.equal(
processorRunDuration({
created_at: 1,
started_at_ms: 2100,
finished_at_ms: 2375,
}),
275,
);
});
test('missing or inverted times remain unknown; zero is a valid duration', () => {
assert.equal(
processorRunDuration({ created_at: 1, started_at_ms: 1000 }),
null,
);
assert.equal(
processorRunDuration({ created_at: 1, finished_at_ms: 2000 }),
null,
);
assert.equal(
processorRunDuration({ started_at_ms: 1000, finished_at_ms: 900 }),
null,
);
assert.equal(
processorRunDuration({ started_at_ms: 1000, finished_at_ms: 1000 }),
0,
);
});
test('old server responses fall back to second precision', () => {
assert.equal(processorRunDuration({ started_at: 2, finished_at: 4 }), 2000);
});