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. 1. Install a plugin containing an EventProcessor component.
2. Create an Event processor in the Processors area. 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. 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. 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 plugins must explicitly adopt the new component and be bound by the user; do not
create subscriptions during migration. create subscriptions during migration.
Validate component availability, event compatibility, Workspace ownership, and An unconfigured instance has no supported events and cannot execute. Validate
instance identity at creation/update and again at invocation. A disabled or 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 unavailable plugin leaves the instance visible with an actionable unavailable
status. It must not silently fall back to Agent or Pipeline. status. It must not silently fall back to Agent or Pipeline.
## Compact UI ## Compact UI
Creation adds a third type next to Agent and Pipeline, followed by a component Creation adds a third type next to Agent and Pipeline and asks only for basic
selector and basic instance information. Show configuration fields only when the instance information. Select the plugin component in the detail-page header.
component declares them. If no component is installed, show a relevant plugin Keep component-defined configuration in the adjacent Plugin settings popover.
installation entry point; installing still does not create a binding. 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 The detail page shows event debugging on the left and logs on the right without
trace of the incoming event, handler logs, outgoing actions/messages, and outcome. 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 Keep payloads and error details collapsed until expanded. Distinguish attempted
delivery from confirmed delivery and display the actual destination. delivery from confirmed delivery and display the actual destination.
+1 -1
View File
@@ -232,4 +232,4 @@ line-ending = "auto"
[tool.uv.sources] [tool.uv.sources]
# Development contract: update to the matching SDK release before publishing. # 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 ## Event processors
Install the plugin, discover its component with `get_processor_metadata`, then Create a processor with `kind: "event_processor"` and basic information. Without
create a processor with `kind: "event_processor"`, `component_ref` and optional a component it supports no events. Discover installed components with
`parameters`. Bind bot events to this instance with `target_type: "event_processor"` `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. 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. `debug_agent` accepts the complete typed EBA event in `payload.data` for this kind.
Legacy EventListener plugins remain in the Pipeline lifecycle. 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, 'dispatch_attempts': row.dispatch_attempts,
'last_claimed_at': _datetime_to_epoch(row.last_claimed_at), 'last_claimed_at': _datetime_to_epoch(row.last_claimed_at),
'created_at': _datetime_to_epoch(row.created_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), 'started_at': _datetime_to_epoch(row.started_at),
'finished_at': _datetime_to_epoch(row.finished_at), 'finished_at': _datetime_to_epoch(row.finished_at),
'updated_at': _datetime_to_epoch(row.updated_at), 'updated_at': _datetime_to_epoch(row.updated_at),
@@ -488,6 +488,9 @@ class AgentService:
if not isinstance(config, dict): if not isinstance(config, dict):
raise ValueError('Processor configuration must be an object') 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) 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:'): if not isinstance(component_ref, str) or not component_ref.startswith('event_processor:'):
raise ValueError('Select an installed EventProcessor component') raise ValueError('Select an installed EventProcessor component')
try: try:
+7 -3
View File
@@ -188,8 +188,9 @@ class LangBotMCPServer:
@mcp.tool( @mcp.tool(
description=( description=(
'Create an Agent, Pipeline or Event processor. Set `processor_data.kind` to ' 'Create an Agent, Pipeline or Event processor. Set `processor_data.kind` to '
'`agent`, `pipeline` or `event_processor`. Event processors require an installed component_ref ' '`agent`, `pipeline` or `event_processor`. Event processors may be created without a component; '
'from get_processor_metadata; optional parameters configure the instance. Returns UUID and kind.' '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: async def create_processor(processor_data: dict) -> str:
@@ -213,7 +214,10 @@ class LangBotMCPServer:
context = _authorized(Permission.RESOURCE_VIEW) context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.agent_service.get_agent_metadata(context)) 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: async def list_processor_runs(processor_uuid: str, before_id: int | None = None) -> str:
context = _authorized(Permission.RESOURCE_VIEW) context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.agent_service.get_processor_runs(context, processor_uuid, before_id=before_id)) 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 (total, more) == (2, False)
assert second[0]['run_id'] == 'run-0' 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(): async def test_event_processor_creation_uses_installed_component_scope():
app = _make_app() app = _make_app()
ref = 'event_processor:test/welcome/default' 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'} 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(): async def test_event_processor_rejects_invalid_component_and_missing_parameters():
app = _make_app() app = _make_app()
service = AgentService(app) service = AgentService(app)
Generated
+2 -2
View File
@@ -2119,7 +2119,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { 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", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2186,7 +2186,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.5.5" 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 = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -174,6 +174,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
id={id} id={id}
agent={agent} agent={agent}
canManage={canManage} canManage={canManage}
canOperate={canOperate}
availableEventTypes={availableEventTypes}
onDelete={() => setDeleteConfirmOpen(true)} onDelete={() => setDeleteConfirmOpen(true)}
onEdit={() => setBasicInfoOpen(true)} onEdit={() => setBasicInfoOpen(true)}
onSaved={() => { onSaved={() => {
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; 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 { toast } from 'sonner';
import type { import type {
Agent, Agent,
@@ -14,12 +16,23 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { Badge } from '@/components/ui/badge'; 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'; import EventProcessorSettings from './components/EventProcessorSettings';
export default function EventProcessorDetailContent({ export default function EventProcessorDetailContent({
agent, agent,
id, id,
canManage, canManage,
canOperate,
availableEventTypes,
onDelete, onDelete,
onEdit, onEdit,
onSaved, onSaved,
@@ -27,6 +40,8 @@ export default function EventProcessorDetailContent({
agent: Agent; agent: Agent;
id: string; id: string;
canManage: boolean; canManage: boolean;
canOperate: boolean;
availableEventTypes: string[];
onDelete: () => void; onDelete: () => void;
onEdit: () => void; onEdit: () => void;
onSaved: () => void; onSaved: () => void;
@@ -46,6 +61,13 @@ export default function EventProcessorDetailContent({
> >
)[agent.component_ref ?? ''] ?? {}; )[agent.component_ref ?? ''] ?? {};
const [parameters, setParameters] = useState(initialParameters); 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 [runs, setRuns] = useState<ProcessorRun[]>([]);
const [cursor, setCursor] = useState<number | null>(null); const [cursor, setCursor] = useState<number | null>(null);
const [selected, setSelected] = useState<ProcessorRun | null>(null); const [selected, setSelected] = useState<ProcessorRun | null>(null);
@@ -55,11 +77,11 @@ export default function EventProcessorDetailContent({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [pagingRuns, setPagingRuns] = useState(false); const [pagingRuns, setPagingRuns] = useState(false);
const [pagingEvents, setPagingEvents] = useState(false); const [pagingEvents, setPagingEvents] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const validate = useRef<(() => Promise<boolean>) | null>(null); const validate = useRef<(() => Promise<boolean>) | null>(null);
const requestVersion = useRef(0); 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 () => { const load = useCallback(async () => {
setFailed(false); setFailed(false);
@@ -88,20 +110,38 @@ export default function EventProcessorDetailContent({
[id], [id],
); );
async function openRun(run: ProcessorRun) { const openRun = useCallback(
const version = ++requestVersion.current; async (run: ProcessorRun) => {
setSelected(run); const version = ++requestVersion.current;
setEvents([]); setSelected(run);
setEventCursor(null); 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 { try {
const page = await httpClient.getProcessorRunEvents(id, run.run_id); const page = await httpClient.getProcessorRuns(id);
if (version !== requestVersion.current) return; setRuns(page.items);
setSelected(page.run); setCursor(page.has_more ? page.next_cursor : null);
setEvents(page.items); if (page.items[0]) await openRun(page.items[0]);
setEventCursor(page.has_more ? page.next_cursor : null);
} catch { } 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]); }, [id, selected, eventCursor, events]);
async function save() { async function save() {
if (!componentRef || !((await validate.current?.()) ?? true)) return; if (
!canManage ||
saving ||
!component ||
!((await validate.current?.()) ?? true)
)
return false;
setSaving(true); setSaving(true);
try { try {
await httpClient.updateAgent(id, { await httpClient.updateAgent(id, {
@@ -208,230 +254,213 @@ export default function EventProcessorDetailContent({
}); });
toast.success(t('agents.saveSuccess')); toast.success(t('agents.saveSuccess'));
onSaved(); onSaved();
setConfigOpen(false); setSavedConfig({ componentRef, parameters });
await load(); await load();
return true;
} catch { } catch {
toast.error(t('agents.saveError')); toast.error(t('agents.saveError'));
return false;
} finally { } finally {
setSaving(false); 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 ( return (
<div className="flex h-full min-h-0 flex-col gap-4"> <ProcessorDetailWorkbench
<header className="flex flex-wrap items-center gap-3"> title={`${agent.emoji || '⚡'} ${agent.name}`}
<FileCode2 className="size-6" /> titleAction={
<h1 className="text-2xl font-semibold">{agent.name}</h1> canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
{canManage && ( }
<Button titleControls={
variant="ghost" <EventProcessorSettings
size="icon" components={components}
onClick={onEdit} value={componentRef}
aria-label={t('common.edit')} parameters={parameters}
> disabled={!canManage || saving || loading}
<Pencil className="size-4" /> 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> </Button>
)} ) : undefined
<Badge variant="outline">{t('agents.eventProcessor.type')}</Badge> }
{!loading && !failed && !available && ( configTitle={t('agents.eventProcessor.trace')}
<Badge variant="destructive"> configIcon={<ScrollText className="size-4" />}
{t('agents.eventProcessor.unavailable')} configContent={
</Badge> <div className="flex h-full min-h-0 flex-col gap-3">
)} <form
<div className="ml-auto flex gap-2"> id="event-processor-form"
<Button onSubmit={(event) => {
variant="outline" event.preventDefault();
onClick={() => { void save();
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;
}} }}
/> />
<Button {failed && (
className="mt-4" <Alert variant="destructive">
disabled={ <AlertDescription>
saving || !components.some((item) => item.id === componentRef) {t('agents.eventProcessor.loadError')}
} </AlertDescription>
onClick={() => void save()} </Alert>
>
{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>
))
)} )}
{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 <Button
variant="ghost" variant="ghost"
disabled={pagingRuns} size="icon"
onClick={() => void loadMoreRuns()} aria-label={t('agents.eventProcessor.refresh')}
onClick={() => void refreshLatestRun()}
> >
{t('agents.eventProcessor.loadMore')} <RefreshCw className="size-4" />
</Button> </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> <ScrollArea className="min-h-0 flex-1">
<section className="min-h-0 overflow-y-auto rounded-xl border p-4"> <div className="space-y-2 pr-3">
<h2 className="mb-3 font-semibold"> {!selected ? (
{t('agents.eventProcessor.trace')} <Alert>
</h2> <AlertDescription>
{!selected ? ( {loading
<p className="text-sm text-muted-foreground"> ? t('common.loading')
{t('agents.eventProcessor.selectRun')} : t('agents.eventProcessor.noRuns')}
</p> <Button asChild variant="link" className="h-auto px-0">
) : ( <Link to="/home/bots">
<div className="space-y-3"> {t('agents.eventProcessor.bindBot')}
<details className="rounded-lg border p-3"> </Link>
<summary className="cursor-pointer text-sm font-medium"> </Button>
{t('agents.eventProcessor.input')} </AlertDescription>
</summary> </Alert>
{payload(selected.metadata.input_event)} ) : (
</details> <>
{selected.metadata.delivery != null && ( <div className="border-b pb-2">
<details className="rounded-lg border p-3"> <p className="text-sm font-medium">
<summary className="cursor-pointer text-sm font-medium"> {eventPatternLabel(selected.metadata.event_type ?? '', t)}
{t('agents.eventProcessor.destination')} </p>
</summary> <p className="text-xs text-muted-foreground">
{payload(selected.metadata.delivery)} {new Date(selected.created_at * 1000).toLocaleString()}
</details> </p>
)}
{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>
</div> </div>
) : ( <Badge
<details variant={
key={event.sequence} selected.status === 'failed' ? 'destructive' : 'outline'
className="rounded-lg border p-3" }
> >
<summary className="cursor-pointer break-all text-sm font-medium"> {t(`agents.eventProcessor.status_${selected.status}`, {
{t( defaultValue: selected.status,
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`, })}
{ defaultValue: event.type }, </Badge>
)} <ProcessorPayload
{typeof event.data.tool_name === 'string' && ( title={t('agents.eventProcessor.input')}
<span className="ml-2 text-muted-foreground"> value={selected.metadata.input_event}
{toolLabels[event.data.tool_name] || />
event.data.tool_name} {selected.metadata.delivery != null && (
</span> <ProcessorPayload
)} title={t('agents.eventProcessor.destination')}
</summary> value={selected.metadata.delivery}
{payload(event.data)} />
</details> )}
), <EventProcessorTrace
)} events={events}
{selected.status === 'failed' && selected.status_reason && ( toolLabels={toolLabels}
<p className="break-words text-sm text-destructive"> />
{selected.status_reason} {selected.status === 'failed' && selected.status_reason && (
</p> <Alert variant="destructive">
)} <AlertDescription className="break-words">
{eventCursor !== null && ( {selected.status_reason}
<Button </AlertDescription>
variant="ghost" </Alert>
disabled={pagingEvents} )}
onClick={() => void loadMoreEvents()} {eventCursor !== null && (
> <Button
{t('agents.eventProcessor.loadMore')} variant="ghost"
</Button> disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</>
)} )}
</div> </div>
)} </ScrollArea>
</section> </div>
</div> }
</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 { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@@ -6,8 +6,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Bot, Workflow, FileCode2 } from 'lucide-react'; import { Bot, Workflow, FileCode2 } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { AgentKind, EventProcessorDescriptor } from '@/app/infra/entities/api'; import { AgentKind } from '@/app/infra/entities/api';
import EventProcessorSettings from './EventProcessorSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { import {
@@ -36,23 +35,6 @@ export default function AgentCreateContent({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [kind, setKind] = useState<AgentKind>('agent'); 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({ const formSchema = z.object({
name: z.string().min(1, { message: t('agents.nameRequired') }), name: z.string().min(1, { message: t('agents.nameRequired') }),
description: z.string().optional(), description: z.string().optional(),
@@ -85,23 +67,9 @@ export default function AgentCreateContent({
} }
async function handleSubmit(values: FormValues) { async function handleSubmit(values: FormValues) {
if ( return httpClient
kind === 'event_processor' &&
(!componentRef || !((await validateParameters.current?.()) ?? true))
)
return;
httpClient
.createAgent({ .createAgent({
kind, kind,
...(kind === 'event_processor'
? {
component_ref: componentRef,
config: {
runner: { id: componentRef },
runner_config: { [componentRef]: parameters },
},
}
: {}),
name: values.name, name: values.name,
description: values.description ?? '', description: values.description ?? '',
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'), emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
@@ -143,10 +111,7 @@ export default function AgentCreateContent({
<Button <Button
type="submit" type="submit"
form="agent-create-form" form="agent-create-form"
disabled={ disabled={form.formState.isSubmitting}
form.formState.isSubmitting ||
(kind === 'event_processor' && !componentRef)
}
> >
{t('common.submit')} {t('common.submit')}
</Button> </Button>
@@ -209,22 +174,6 @@ export default function AgentCreateContent({
</ToggleGroup> </ToggleGroup>
</section> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle> <CardTitle>{t('agents.basicInfo')}</CardTitle>
@@ -46,6 +46,7 @@ import {
groupEventPatterns, groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups'; } from '@/app/home/components/event-patterns/event-pattern-groups';
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent'; import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
import EventProcessorTrace from './EventProcessorTrace';
import AgentExecutionTrace from './AgentExecutionTrace'; import AgentExecutionTrace from './AgentExecutionTrace';
import AgentEventDataEditor from './AgentEventDataEditor'; import AgentEventDataEditor from './AgentEventDataEditor';
import { import {
@@ -53,15 +54,18 @@ import {
debugEventInputText, debugEventInputText,
invalidDebugEventField, invalidDebugEventField,
parseDebugEventData, parseDebugEventData,
processorDebugEventTypes,
} from './debug-event-data'; } from './debug-event-data';
import { executionSteps, type DebugExecutionEvent } from './debug-execution'; import { executionSteps, type DebugExecutionEvent } from './debug-execution';
interface AgentDebugPanelProps { interface AgentDebugPanelProps {
agentId: string; agentId: string;
processor?: boolean;
availableEventTypes: string[]; availableEventTypes: string[];
platformTools?: AgentPlatformTool[]; platformTools?: AgentPlatformTool[];
supportedEventPatterns?: string[]; supportedEventPatterns?: string[];
beforeRun?: () => Promise<boolean>; beforeRun?: () => Promise<boolean>;
onRunFinished?: () => void;
hasUnsavedChanges?: boolean; hasUnsavedChanges?: boolean;
onOpenRunnerConfig?: () => void; onOpenRunnerConfig?: () => void;
} }
@@ -89,10 +93,12 @@ function matchesEventPattern(pattern: string, eventType: string) {
export default function AgentDebugPanel({ export default function AgentDebugPanel({
agentId, agentId,
processor = false,
availableEventTypes, availableEventTypes,
platformTools = [], platformTools = [],
supportedEventPatterns = ['*'], supportedEventPatterns = ['*'],
beforeRun, beforeRun,
onRunFinished,
hasUnsavedChanges = false, hasUnsavedChanges = false,
onOpenRunnerConfig, onOpenRunnerConfig,
}: AgentDebugPanelProps) { }: AgentDebugPanelProps) {
@@ -105,15 +111,19 @@ export default function AgentDebugPanel({
const newEventData = useCallback( const newEventData = useCallback(
(type: string) => (type: string) =>
JSON.stringify( JSON.stringify(
createDebugEventData(type, { createDebugEventData(
user: t('agents.debugData.sampleUser'), type,
message: t('agents.debugData.sampleMessage'), {
feedback: t('agents.debugData.sampleFeedback'), user: t('agents.debugData.sampleUser'),
}), message: t('agents.debugData.sampleMessage'),
feedback: t('agents.debugData.sampleFeedback'),
},
processor,
),
null, null,
2, 2,
), ),
[t], [t, processor],
); );
const [eventDataText, setEventDataText] = useState(() => const [eventDataText, setEventDataText] = useState(() =>
newEventData('message.received'), newEventData('message.received'),
@@ -140,21 +150,28 @@ export default function AgentDebugPanel({
const concretePatterns = supportedEventPatterns.filter( const concretePatterns = supportedEventPatterns.filter(
(pattern) => pattern !== '*' && !pattern.endsWith('.*'), (pattern) => pattern !== '*' && !pattern.endsWith('.*'),
); );
return Array.from(new Set([...availableEventTypes, ...concretePatterns])) return Array.from(
new Set([
...(processor ? processorDebugEventTypes : availableEventTypes),
...concretePatterns,
]),
)
.filter((candidate) => .filter((candidate) =>
supportedEventPatterns.some((pattern) => supportedEventPatterns.some((pattern) =>
matchesEventPattern(pattern, candidate), matchesEventPattern(pattern, candidate),
), ),
) )
.sort(); .sort();
}, [availableEventTypes, supportedEventPatterns]); }, [availableEventTypes, supportedEventPatterns, processor]);
const eventGroups = useMemo( const eventGroups = useMemo(
() => groupEventPatterns(availableEvents), () => groupEventPatterns(availableEvents),
[availableEvents], [availableEvents],
); );
const supportsCustomEvent = supportedEventPatterns.some( const supportsCustomEvent =
(pattern) => pattern === '*' || pattern.endsWith('.*'), !processor &&
); supportedEventPatterns.some(
(pattern) => pattern === '*' || pattern.endsWith('.*'),
);
const selectPreset = useCallback( const selectPreset = useCallback(
(value: string) => { (value: string) => {
@@ -193,7 +210,11 @@ export default function AgentDebugPanel({
toast.error(t('agents.debugInvalidPayload')); toast.error(t('agents.debugInvalidPayload'));
return; return;
} }
const invalidField = invalidDebugEventField(eventType, eventData); const invalidField = invalidDebugEventField(
eventType,
eventData,
processor,
);
if (invalidField) { if (invalidField) {
toast.error( toast.error(
t('agents.debugData.invalidField', { t('agents.debugData.invalidField', {
@@ -202,7 +223,7 @@ export default function AgentDebugPanel({
); );
return; return;
} }
const inputText = debugEventInputText(eventType, eventData); const inputText = debugEventInputText(eventType, eventData, processor);
let mockOptions: Record<string, unknown>; let mockOptions: Record<string, unknown>;
try { try {
const parsed = JSON.parse(mockOptionsText || '{}'); const parsed = JSON.parse(mockOptionsText || '{}');
@@ -275,12 +296,14 @@ export default function AgentDebugPanel({
? { ? {
...entry, ...entry,
finished: true, finished: true,
text: executionSteps(entry.events ?? []).some( text:
(step) => processor ||
step.kind === 'tool' || step.text || step.reasoning, executionSteps(entry.events ?? []).some(
) (step) =>
? '' step.kind === 'tool' || step.text || step.reasoning,
: result.final_text || t('agents.debugNoTextOutput'), )
? ''
: result.final_text || t('agents.debugNoTextOutput'),
} }
: entry, : entry,
) )
@@ -354,6 +377,7 @@ export default function AgentDebugPanel({
if (requestRef.current === controller) { if (requestRef.current === controller) {
requestRef.current = null; requestRef.current = null;
setRunning(false); setRunning(false);
onRunFinished?.();
} }
} }
} }
@@ -364,15 +388,25 @@ export default function AgentDebugPanel({
<div className="mb-3"> <div className="mb-3">
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p> <p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('agents.debugTranscriptDescription')} {t(
processor
? 'agents.eventProcessor.debugDescription'
: 'agents.debugTranscriptDescription',
)}
</p> </p>
</div> </div>
{entries.length === 0 ? ( {entries.length === 0 ? (
<Alert className="my-4 bg-muted/20"> <Alert className="my-4 bg-muted/20">
<CircleHelp className="size-4" /> <CircleHelp className="size-4" />
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle> <AlertTitle>
{t(processor ? 'agents.debugTab' : 'agents.debugEmptyTitle')}
</AlertTitle>
<AlertDescription> <AlertDescription>
{t('agents.debugEmptyTranscript')} {t(
processor
? 'agents.eventProcessor.debugDescription'
: 'agents.debugEmptyTranscript',
)}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : ( ) : (
@@ -382,6 +416,7 @@ export default function AgentDebugPanel({
(entry) => (entry) =>
entry.direction !== 'output' || entry.direction !== 'output' ||
entry.text || entry.text ||
processor ||
executionSteps(entry.events ?? []).some( executionSteps(entry.events ?? []).some(
(step) => (step) =>
step.kind === 'tool' || step.text || step.reasoning, step.kind === 'tool' || step.text || step.reasoning,
@@ -412,19 +447,29 @@ export default function AgentDebugPanel({
</Badge> </Badge>
<span className="shrink-0 text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
{entry.direction === 'output' {entry.direction === 'output'
? t('agents.debugAgentOutput') ? t(
processor
? 'agents.eventProcessor.debugOutput'
: 'agents.debugAgentOutput',
)
: entry.direction === 'error' : entry.direction === 'error'
? t('common.error') ? t('common.error')
: t('agents.debugTestInput')} : t('agents.debugTestInput')}
</span> </span>
</div> </div>
{entry.events && ( {entry.events &&
<AgentExecutionTrace (processor ? (
events={entry.events} <EventProcessorTrace
finished={entry.finished} events={entry.events}
toolLabels={toolLabels} toolLabels={toolLabels}
/> />
)} ) : (
<AgentExecutionTrace
events={entry.events}
finished={entry.finished}
toolLabels={toolLabels}
/>
))}
{entry.text && ( {entry.text && (
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed"> <pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
{entry.text} {entry.text}
@@ -546,26 +591,37 @@ export default function AgentDebugPanel({
<AgentEventDataEditor <AgentEventDataEditor
key={eventType} key={eventType}
eventType={eventType} eventType={eventType}
processor={processor}
custom={preset === 'custom'} custom={preset === 'custom'}
value={eventDataText} value={eventDataText}
onChange={setEventDataText} onChange={setEventDataText}
/> />
<details className="text-muted-foreground"> <Collapsible>
<summary className="cursor-pointer text-xs font-medium"> <CollapsibleTrigger asChild>
{t('agents.debugMockOptions')} <Button
</summary> type="button"
<p className="my-2 text-xs text-muted-foreground"> variant="ghost"
{t('agents.debugMockOptionsHelp')} size="sm"
</p> className="group"
<Textarea >
aria-label={t('agents.debugMockOptions')} <ChevronDown className="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
value={mockOptionsText} {t('agents.debugMockOptions')}
onChange={(event) => setMockOptionsText(event.target.value)} </Button>
className="min-h-24 font-mono text-xs" </CollapsibleTrigger>
spellCheck={false} <CollapsibleContent>
/> <p className="my-2 text-xs text-muted-foreground">
</details> {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> </div>
@@ -5,27 +5,48 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; 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({ export default function AgentEventDataEditor({
eventType, eventType,
value, value,
onChange, onChange,
custom = false, custom = false,
processor = false,
}: { }: {
eventType: string; eventType: string;
custom?: boolean; custom?: boolean;
processor?: boolean;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showJson, setShowJson] = useState(false); const [showJson, setShowJson] = useState(false);
const fields = custom ? [] : (debugEventDefinition(eventType)?.fields ?? []); const fields = custom
? []
: (debugEventDefinition(eventType, processor)?.fields ?? []);
const data = parseDebugEventData(value); 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) { 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 ( return (
@@ -34,7 +55,7 @@ export default function AgentEventDataEditor({
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{t('agents.debugData.title')} {t('agents.debugData.title')}
</span> </span>
{fields.length > 0 && ( {fields.length > 0 && !richMessage && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -69,7 +90,7 @@ export default function AgentEventDataEditor({
<div className="grid grid-cols-2 gap-x-3 gap-y-2"> <div className="grid grid-cols-2 gap-x-3 gap-y-2">
{fields.map((field) => { {fields.map((field) => {
const id = `agent-debug-data-${field.key}`; const id = `agent-debug-data-${field.key}`;
const current = data?.[field.key]; const current = getDebugEventField(data, field.key);
const text = const text =
typeof current === 'string' || typeof current === 'number' typeof current === 'string' || typeof current === 'number'
? current ? 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 { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { EventProcessorDescriptor } from '@/app/infra/entities/api'; import type { EventProcessorDescriptor } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { import {
Select, Select,
@@ -9,8 +17,58 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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'; 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({ export default function EventProcessorSettings({
components, components,
value, value,
@@ -18,6 +76,7 @@ export default function EventProcessorSettings({
onChange, onChange,
onParametersChange, onParametersChange,
onValidate, onValidate,
disabled = false,
}: { }: {
components: EventProcessorDescriptor[]; components: EventProcessorDescriptor[];
value: string; value: string;
@@ -25,66 +84,112 @@ export default function EventProcessorSettings({
onChange: (value: string) => void; onChange: (value: string) => void;
onParametersChange: (value: Record<string, unknown>) => void; onParametersChange: (value: Record<string, unknown>) => void;
onValidate?: (validate: () => Promise<boolean>) => void; onValidate?: (validate: () => Promise<boolean>) => void;
disabled?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const selected = components.find((item) => item.id === value); const selected = components.find((item) => item.id === value);
const [settingsOpen, setSettingsOpen] = useState(false);
return ( return (
<div className="space-y-4"> <div className="flex min-w-0 items-center gap-2">
<div className="space-y-2"> <Label className="sr-only" htmlFor="event-processor-component">
<label {t('agents.eventProcessor.component')}
className="text-sm font-medium" </Label>
htmlFor="event-processor-component" <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')} {selected ? (
</label> <ProcessorComponentContent component={selected} />
<Select value={value} onValueChange={onChange}> ) : (
<SelectTrigger id="event-processor-component"> <span className="flex min-w-0 items-center gap-2">
<SelectValue <Puzzle className="size-4 shrink-0 text-muted-foreground" />
placeholder={t('agents.eventProcessor.selectComponent')} <SelectValue
/> placeholder={t('agents.eventProcessor.selectComponent')}
</SelectTrigger> />
<SelectContent> </span>
{value && !selected && ( )}
<SelectItem value={value}> </SelectTrigger>
{t('agents.eventProcessor.unavailable')} <SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
</SelectItem> {value && !selected && (
)} <SelectItem value={value}>
{components.map((component) => ( {t('agents.eventProcessor.unavailable')}
<SelectItem key={component.id} value={component.id}> </SelectItem>
{extractI18nObject({ )}
en_US: component.id, {components.map((component) => (
zh_Hans: component.id, <SelectItem
...component.label, key={component.id}
})}{' '} value={component.id}
· {component.plugin_author}/{component.plugin_name} className="py-1.5 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
</SelectItem> >
))} <ProcessorComponentContent component={component} option />
</SelectContent> </SelectItem>
</Select> ))}
{components.length === 0 && ( {components.length === 0 && (
<p className="text-sm text-muted-foreground"> <div className="p-2 text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}{' '} {t('agents.eventProcessor.noComponents')}
<Link className="text-primary underline" to="/home/plugins"> <Button asChild variant="link" className="h-auto px-0">
{t('agents.eventProcessor.installPlugin')} <Link to="/home/plugins">
</Link> {t('agents.eventProcessor.installPlugin')}
</p> </Link>
)} </Button>
</div> </div>
{selected && ( )}
<p className="break-words text-xs text-muted-foreground"> </SelectContent>
{selected.supported_event_patterns.join(' · ')} </Select>
</p>
)}
{selected && selected.config_schema.length > 0 && ( {selected && selected.config_schema.length > 0 && (
<DynamicFormComponent <Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
key={value} <PopoverTrigger asChild>
itemConfigList={selected.config_schema} <Button
initialValues={parameters} type="button"
onSubmit={(values) => variant="ghost"
onParametersChange(values as Record<string, unknown>) size="icon"
} disabled={disabled}
onValidate={onValidate} 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> </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( export function debugEventDefinition(
eventType: string, eventType: string,
processor = false,
): DebugEventDefinition | undefined { ): DebugEventDefinition | undefined {
return Object.hasOwn(DEBUG_EVENT_DEFINITIONS, eventType) const definitions = processor
? DEBUG_EVENT_DEFINITIONS[eventType] ? PROCESSOR_EVENT_DEFINITIONS
: DEBUG_EVENT_DEFINITIONS;
return Object.hasOwn(definitions, eventType)
? definitions[eventType]
: undefined; : undefined;
} }
export function createDebugEventData( export function createDebugEventData(
eventType: string, eventType: string,
samples: Record<'user' | 'message' | 'feedback', string>, samples: Record<'user' | 'message' | 'feedback', string>,
processor = false,
) { ) {
const definition = debugEventDefinition(eventType); const definition = debugEventDefinition(eventType, processor);
return { return (definition?.fields ?? []).reduce<Record<string, unknown>>(
...definition?.defaults, (data, field) =>
...Object.fromEntries( setDebugEventField(
(definition?.fields ?? []).map((field) => [ data,
field.key, field.key,
field.sample ? samples[field.sample] : field.value, field.sample ? samples[field.sample] : field.value,
]), ),
), { ...definition?.defaults },
}; );
} }
export function parseDebugEventData( export function parseDebugEventData(
@@ -195,9 +322,20 @@ export function parseDebugEventData(
export function invalidDebugEventField( export function invalidDebugEventField(
eventType: string, eventType: string,
data: Record<string, unknown>, data: Record<string, unknown>,
processor = false,
) { ) {
return debugEventDefinition(eventType)?.fields.find((field) => { return debugEventDefinition(eventType, processor)?.fields.find((field) => {
const value = data[field.key]; 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 ( if (
value === undefined || value === undefined ||
value === null || value === null ||
@@ -215,7 +353,10 @@ export function invalidDebugEventField(
} }
return ( return (
typeof value !== 'string' && 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( export function debugEventInputText(
eventType: string, eventType: string,
data: Record<string, unknown>, data: Record<string, unknown>,
processor = false,
) { ) {
const key = debugEventDefinition(eventType)?.messageField; const key = debugEventDefinition(eventType, processor)?.messageField;
return key && typeof data[key] === 'string' ? data[key].trim() : ''; 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' ? ( {item.kind === 'event_processor' ? (
<span className="text-xs"></span> <Puzzle className="size-3.5" />
) : item.kind === 'pipeline' ? ( ) : item.kind === 'pipeline' ? (
<Workflow className="size-3.5" /> <Workflow className="size-3.5" />
) : ( ) : (
@@ -8,6 +8,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ProcessorMonitoringView { interface ProcessorMonitoringView {
@@ -26,6 +27,7 @@ interface ProcessorDetailWorkbenchProps {
title: string; title: string;
titleBadge?: ReactNode; titleBadge?: ReactNode;
titleAction?: ReactNode; titleAction?: ReactNode;
titleControls?: ReactNode;
headerActions?: ReactNode; headerActions?: ReactNode;
status?: ProcessorDetailStatus | null; status?: ProcessorDetailStatus | null;
saveLabel: string; saveLabel: string;
@@ -34,6 +36,7 @@ interface ProcessorDetailWorkbenchProps {
isDirty: boolean; isDirty: boolean;
isSaving: boolean; isSaving: boolean;
configTitle: string; configTitle: string;
configIcon?: ReactNode;
configContent: ReactNode; configContent: ReactNode;
debugTitle?: string; debugTitle?: string;
debugDescription?: string; debugDescription?: string;
@@ -49,6 +52,7 @@ export default function ProcessorDetailWorkbench({
title, title,
titleBadge, titleBadge,
titleAction, titleAction,
titleControls,
headerActions, headerActions,
status, status,
saveLabel, saveLabel,
@@ -57,6 +61,7 @@ export default function ProcessorDetailWorkbench({
isDirty, isDirty,
isSaving, isSaving,
configTitle, configTitle,
configIcon,
configContent, configContent,
debugTitle, debugTitle,
debugDescription, debugDescription,
@@ -81,10 +86,11 @@ export default function ProcessorDetailWorkbench({
className="flex h-full min-h-0 min-w-0 flex-col gap-0" 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 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> <h1 className="truncate text-xl font-semibold">{title}</h1>
{titleBadge} {titleBadge}
{titleAction} {titleAction}
{titleControls}
{monitoring && ( {monitoring && (
<TabsList <TabsList
aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`} aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`}
@@ -168,18 +174,20 @@ export default function ProcessorDetailWorkbench({
value="monitoring" value="monitoring"
className="mt-0 min-h-0 flex-1 overflow-hidden" className="mt-0 min-h-0 flex-1 overflow-hidden"
> >
<section <Card
role="region"
aria-label={monitoring.label} 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} {monitoring.content}
</section> </Card>
</TabsContent> </TabsContent>
)} )}
<TabsContent <TabsContent
value="workbench" 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 <div
className={cn( className={cn(
@@ -190,24 +198,27 @@ export default function ProcessorDetailWorkbench({
)} )}
> >
{hasDebug && ( {hasDebug && (
<section <Card
role="region"
aria-label={debugTitle} 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"> <div className="flex min-w-0 items-center gap-2 font-medium">
<Bug className="size-4 shrink-0" /> <Bug className="size-4 shrink-0" />
<span className="truncate">{debugTitle}</span> <span className="truncate">{debugTitle}</span>
{debugDescription && ( {debugDescription && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
aria-label={debugDescription} 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" /> <Info className="size-4" />
</button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-xs whitespace-normal leading-relaxed"> <TooltipContent className="max-w-xs whitespace-normal leading-relaxed">
{debugDescription} {debugDescription}
@@ -228,19 +239,20 @@ export default function ProcessorDetailWorkbench({
: debugDisconnectedLabel} : debugDisconnectedLabel}
</span> </span>
)} )}
</div> </CardHeader>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden"> <CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden px-0">
{debugContent} {debugContent}
</div> </CardContent>
</section> </Card>
)} )}
<section <Card
role="region"
aria-label={configTitle} 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"> <CardHeader className="flex h-12 shrink-0 flex-row items-center gap-2 border-b px-4 font-medium [.border-b]:pb-0">
<Settings className="size-4" /> {configIcon ?? <Settings className="size-4" />}
<span className="truncate">{configTitle}</span> <span className="truncate">{configTitle}</span>
{isDirty && ( {isDirty && (
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400"> <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} {unsavedLabel}
</span> </span>
)} )}
</div> </CardHeader>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4"> <CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
{configContent} {configContent}
</div> </CardContent>
</section> </Card>
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
+5
View File
@@ -178,6 +178,11 @@ export interface ProcessorRun {
status: string; status: string;
status_reason?: string; status_reason?: string;
created_at: number; 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 }; metadata: { event_type?: string; input_event?: unknown; delivery?: unknown };
} }
+13
View File
@@ -704,6 +704,16 @@ const enUS = {
}, },
agents: { agents: {
eventProcessor: { 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', type: 'Event processor',
description: 'Process platform events using plugin code.', description: 'Process platform events using plugin code.',
component: 'Plugin component', component: 'Plugin component',
@@ -734,6 +744,9 @@ const enUS = {
trace_tool_call_completed: 'Action result', trace_tool_call_completed: 'Action result',
}, },
debugData: { debugData: {
chatId: 'Chat ID',
feedbackType: 'Feedback type (1: like, 2: dislike, 3: cancel)',
title: 'Event data', title: 'Event data',
form: 'Common fields', form: 'Common fields',
json: 'Full JSON', json: 'Full JSON',
+14
View File
@@ -507,6 +507,16 @@ const esES = {
}, },
agents: { agents: {
eventProcessor: { 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', type: 'Procesador de eventos',
description: 'Procesa eventos con código del plugin.', description: 'Procesa eventos con código del plugin.',
component: 'Componente del plugin', component: 'Componente del plugin',
@@ -538,6 +548,10 @@ const esES = {
trace_tool_call_completed: 'Resultado de la acción', trace_tool_call_completed: 'Resultado de la acción',
}, },
debugData: { debugData: {
chatId: 'ID del chat',
feedbackType:
'Tipo de valoración (1: positiva, 2: negativa, 3: cancelar)',
title: 'Datos del evento', title: 'Datos del evento',
form: 'Campos comunes', form: 'Campos comunes',
json: 'JSON completo', json: 'JSON completo',
+13
View File
@@ -717,6 +717,16 @@ const jaJP = {
}, },
agents: { agents: {
eventProcessor: { eventProcessor: {
pluginSettings: 'プラグイン設定',
pluginSettingsDescription: 'このプラグインが定義するパラメーターです。',
selectToDebug: '上でプラグインを選択してデバッグを開始してください。',
debugOutput: 'プロセッサー出力',
debugDescription:
'このテストの入力イベント、プラグインログ、アクション結果。',
debugNotice:
'プラグインはテストイベントを実際に処理します。返信や送信などは Mock を使用し、実際のメッセージは送信しません。他のツールは設定どおりに実行されます。',
type: 'イベントプロセッサー', type: 'イベントプロセッサー',
description: 'プラグインのコードでイベントを処理します。', description: 'プラグインのコードでイベントを処理します。',
component: 'プラグインコンポーネント', component: 'プラグインコンポーネント',
@@ -748,6 +758,9 @@ const jaJP = {
trace_tool_call_completed: 'アクション結果', trace_tool_call_completed: 'アクション結果',
}, },
debugData: { debugData: {
chatId: 'チャット ID',
feedbackType: 'フィードバック種別(1: 高評価、2: 低評価、3: 取消)',
title: 'イベントデータ', title: 'イベントデータ',
form: '基本項目', form: '基本項目',
json: '完全な JSON', json: '完全な JSON',
+13
View File
@@ -504,6 +504,16 @@ const ruRU = {
}, },
agents: { agents: {
eventProcessor: { eventProcessor: {
pluginSettings: 'Настройки плагина',
pluginSettingsDescription: 'Параметры, объявленные этим плагином.',
selectToDebug: 'Выберите плагин выше, чтобы начать отладку.',
debugOutput: 'Вывод обработчика',
debugDescription:
'Входные события, журналы плагина и результаты действий этой проверки.',
debugNotice:
'Плагин обрабатывает тестовое событие. Действия платформы используют Mock и не отправляют реальные сообщения; остальные инструменты работают согласно настройкам.',
type: 'Обработчик событий', type: 'Обработчик событий',
description: 'Обрабатывает события кодом плагина.', description: 'Обрабатывает события кодом плагина.',
component: 'Компонент плагина', component: 'Компонент плагина',
@@ -535,6 +545,9 @@ const ruRU = {
trace_tool_call_completed: 'Результат действия', trace_tool_call_completed: 'Результат действия',
}, },
debugData: { debugData: {
chatId: 'ID чата',
feedbackType: 'Тип отзыва (1: нравится, 2: не нравится, 3: отмена)',
title: 'Данные события', title: 'Данные события',
form: 'Основные поля', form: 'Основные поля',
json: 'Полный JSON', json: 'Полный JSON',
+13
View File
@@ -491,6 +491,16 @@ const thTH = {
}, },
agents: { agents: {
eventProcessor: { eventProcessor: {
pluginSettings: 'การตั้งค่าปลั๊กอิน',
pluginSettingsDescription: 'พารามิเตอร์ที่ประกาศโดยปลั๊กอินนี้',
selectToDebug: 'เลือกปลั๊กอินด้านบนเพื่อเริ่มแก้จุดบกพร่อง',
debugOutput: 'ผลลัพธ์ตัวประมวลผล',
debugDescription:
'เหตุการณ์ขาเข้า บันทึกปลั๊กอิน และผลการดำเนินการของการทดสอบนี้',
debugNotice:
'ปลั๊กอินประมวลผลเหตุการณ์ทดสอบจริง การตอบกลับและส่งข้อความใช้ Mock โดยไม่ส่งข้อความจริง เครื่องมืออื่นทำงานตามการตั้งค่า',
type: 'ตัวประมวลผลเหตุการณ์', type: 'ตัวประมวลผลเหตุการณ์',
description: 'ประมวลผลเหตุการณ์ด้วยโค้ดปลั๊กอิน', description: 'ประมวลผลเหตุการณ์ด้วยโค้ดปลั๊กอิน',
component: 'ส่วนประกอบปลั๊กอิน', component: 'ส่วนประกอบปลั๊กอิน',
@@ -521,6 +531,9 @@ const thTH = {
trace_tool_call_completed: 'ผลการดำเนินการ', trace_tool_call_completed: 'ผลการดำเนินการ',
}, },
debugData: { debugData: {
chatId: 'ID แชท',
feedbackType: 'ประเภทข้อเสนอแนะ (1: ชอบ, 2: ไม่ชอบ, 3: ยกเลิก)',
title: 'ข้อมูลเหตุการณ์', title: 'ข้อมูลเหตุการณ์',
form: 'ฟิลด์ทั่วไป', form: 'ฟิลด์ทั่วไป',
json: 'JSON ทั้งหมด', json: 'JSON ทั้งหมด',
+13
View File
@@ -500,6 +500,16 @@ const viVN = {
}, },
agents: { agents: {
eventProcessor: { 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', type: 'Bộ xử lý sự kiện',
description: 'Xử lý sự kiện bằng mã plugin.', description: 'Xử lý sự kiện bằng mã plugin.',
component: 'Thành phần plugin', component: 'Thành phần plugin',
@@ -530,6 +540,9 @@ const viVN = {
trace_tool_call_completed: 'Kết quả hành động', trace_tool_call_completed: 'Kết quả hành động',
}, },
debugData: { 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', title: 'Dữ liệu sự kiện',
form: 'Trường thường dùng', form: 'Trường thường dùng',
json: 'JSON đầy đủ', json: 'JSON đầy đủ',
+12
View File
@@ -669,6 +669,15 @@ const zhHans = {
}, },
agents: { agents: {
eventProcessor: { eventProcessor: {
pluginSettings: '插件设置',
pluginSettingsDescription: '由当前插件声明的参数。',
selectToDebug: '请先在上方选择插件,再开始调试。',
debugOutput: '处理器输出',
debugDescription: '当前测试的输入事件、插件日志和动作结果。',
debugNotice:
'插件真实处理测试事件;回复、发送等平台动作使用 Mock,不发送真实消息。其他工具仍按实际配置执行。',
type: '事件处理器', type: '事件处理器',
description: '通过插件代码处理平台事件。', description: '通过插件代码处理平台事件。',
component: '插件组件', component: '插件组件',
@@ -699,6 +708,9 @@ const zhHans = {
trace_tool_call_completed: '动作结果', trace_tool_call_completed: '动作结果',
}, },
debugData: { debugData: {
chatId: '会话 ID',
feedbackType: '反馈类型(1:赞,2:踩,3:取消)',
title: '事件数据', title: '事件数据',
form: '常用字段', form: '常用字段',
json: '完整 JSON', json: '完整 JSON',
+12
View File
@@ -475,6 +475,15 @@ const zhHant = {
}, },
agents: { agents: {
eventProcessor: { eventProcessor: {
pluginSettings: '外掛設定',
pluginSettingsDescription: '由目前外掛宣告的參數。',
selectToDebug: '請先在上方選擇外掛,再開始除錯。',
debugOutput: '處理器輸出',
debugDescription: '目前測試的輸入事件、外掛日誌和動作結果。',
debugNotice:
'外掛實際處理測試事件;回覆、傳送等平台動作使用 Mock,不傳送真實訊息。其他工具仍依實際設定執行。',
type: '事件處理器', type: '事件處理器',
description: '透過外掛程式碼處理平台事件。', description: '透過外掛程式碼處理平台事件。',
component: '外掛元件', component: '外掛元件',
@@ -505,6 +514,9 @@ const zhHant = {
trace_tool_call_completed: '動作結果', trace_tool_call_completed: '動作結果',
}, },
debugData: { debugData: {
chatId: '會話 ID',
feedbackType: '回饋類型(1:讚,2:踩,3:取消)',
title: '事件資料', title: '事件資料',
form: '常用欄位', form: '常用欄位',
json: '完整 JSON', 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 expect(panel.getByText('Done', { exact: true })).toBeVisible();
await page.setViewportSize({ width: 1280, height: 650 }); await page.setViewportSize({ width: 1280, height: 650 });
await panel.getByRole('button', { name: 'Full JSON' }).click(); 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( await expect(
panel.getByRole('button', { name: 'Run test' }), panel.getByRole('button', { name: 'Run test' }),
).toBeInViewport(); ).toBeInViewport();
+216 -29
View File
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api'; 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, page,
}) => { }) => {
await installLangBotApiMocks(page, { authenticated: true }); await installLangBotApiMocks(page, { authenticated: true });
@@ -12,13 +12,18 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
name: 'Welcome processor', name: 'Welcome processor',
component_ref: ref, component_ref: ref,
supported_event_patterns: ['group.member_joined'], supported_event_patterns: ['group.member_joined'],
config: { runner: { id: ref }, runner_config: { [ref]: {} } }, config: {
runner: { id: ref },
runner_config: { [ref]: { greeting: 'Hello' } },
},
}; };
const run = { const run = {
run_id: 'run-one', run_id: 'run-one',
status: 'completed', status: 'completed',
status_reason: 'stop', status_reason: 'stop',
created_at: 1788000000, created_at: 1788000000,
started_at_ms: 1788000000000,
finished_at_ms: 1788000000500,
metadata: { metadata: {
event_type: 'group.member_joined', event_type: 'group.member_joined',
input_event: { member: { id: 'one' } }, input_event: { member: { id: 'one' } },
@@ -28,10 +33,63 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
}, },
}; };
const cursors: string[] = []; const cursors: string[] = [];
const debugRequests: unknown[] = [];
const operations: string[] = [];
const creations: unknown[] = [];
await page.route('**/api/v1/agents**', async (route) => { await page.route('**/api/v1/agents**', async (route) => {
const url = new URL(route.request().url()); const url = new URL(route.request().url());
let data: unknown; 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 = { data = {
kinds: [], kinds: [],
event_processors: [ event_processors: [
@@ -39,12 +97,38 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
id: ref, id: ref,
label: { en_US: 'Welcome' }, label: { en_US: 'Welcome' },
supported_event_patterns: ['group.member_joined'], 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_author: 'qa',
plugin_name: 'welcome', 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')) { } else if (url.pathname.endsWith('/runs/run-one/events')) {
cursors.push(url.searchParams.get('after_sequence') ?? ''); cursors.push(url.searchParams.get('after_sequence') ?? '');
data = { data = {
@@ -62,6 +146,27 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
sequence: 1, sequence: 1,
type: 'processor.log', type: 'processor.log',
data: { level: 'info', text: 'Member received' }, 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, 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, next_cursor: url.searchParams.has('after_sequence') ? null : 100,
}; };
} else if (url.pathname.endsWith('/runs')) { } 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')) { } 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 }; data = { agent: processor };
} else { } else {
data = { agents: [processor] }; data = { agents: [processor] };
} }
await route.fulfill({ json: { code: 0, data } }); 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( await expect(
page.getByRole('heading', { name: 'Welcome processor' }), page.getByRole('combobox', { name: 'Plugin component' }),
).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 }),
).toHaveCount(0); ).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', '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);
});