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
+1 -1
View File
@@ -78,7 +78,7 @@ test('event data stays in sync across the compact form, JSON and the request', a
await expect(panel.getByText('Done', { exact: true })).toBeVisible();
await page.setViewportSize({ width: 1280, height: 650 });
await panel.getByRole('button', { name: 'Full JSON' }).click();
await panel.locator('summary').filter({ hasText: 'Mock scenario' }).click();
await panel.getByRole('button', { name: 'Mock scenario' }).click();
await expect(
panel.getByRole('button', { name: 'Run test' }),
).toBeInViewport();
+216 -29
View File
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('event processor shows isolated logs, paginates and scrolls expanded payloads', async ({
test('create first, select a plugin in the header, debug beside scrollable logs', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
@@ -12,13 +12,18 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
name: 'Welcome processor',
component_ref: ref,
supported_event_patterns: ['group.member_joined'],
config: { runner: { id: ref }, runner_config: { [ref]: {} } },
config: {
runner: { id: ref },
runner_config: { [ref]: { greeting: 'Hello' } },
},
};
const run = {
run_id: 'run-one',
status: 'completed',
status_reason: 'stop',
created_at: 1788000000,
started_at_ms: 1788000000000,
finished_at_ms: 1788000000500,
metadata: {
event_type: 'group.member_joined',
input_event: { member: { id: 'one' } },
@@ -28,10 +33,63 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
},
};
const cursors: string[] = [];
const debugRequests: unknown[] = [];
const operations: string[] = [];
const creations: unknown[] = [];
await page.route('**/api/v1/agents**', async (route) => {
const url = new URL(route.request().url());
let data: unknown;
if (url.pathname.endsWith('/_/metadata')) {
if (
url.pathname === '/api/v1/agents' &&
route.request().method() === 'POST'
) {
const payload = route.request().postDataJSON();
creations.push(payload);
Object.assign(processor, payload, {
component_ref: null,
config: {},
supported_event_patterns: [],
});
await route.fulfill({
json: { code: 0, data: { uuid: processor.uuid, kind: processor.kind } },
});
return;
} else if (url.pathname.endsWith('/debug/stream')) {
operations.push('debug');
debugRequests.push(route.request().postDataJSON());
const events = [
{
type: 'processor.log',
sequence: 1,
data: { level: 'info', text: 'Debug handler invoked once' },
},
{
type: 'tool.call.started',
sequence: 2,
data: { tool_name: 'event_reply', parameters: { text: 'Welcome' } },
},
{
type: 'tool.call.completed',
sequence: 3,
data: {
tool_name: 'event_reply',
result: { mock: true, ok: true, delivery: 'simulated' },
},
},
{ type: 'run.completed', sequence: 4, data: {} },
];
await route.fulfill({
contentType: 'application/x-ndjson',
body:
[
...events.map((data) => ({ kind: 'result', data })),
{ kind: 'completed', data: { final_text: '' } },
]
.map((frame) => JSON.stringify(frame))
.join('\n') + '\n',
});
return;
} else if (url.pathname.endsWith('/_/metadata')) {
data = {
kinds: [],
event_processors: [
@@ -39,12 +97,38 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
id: ref,
label: { en_US: 'Welcome' },
supported_event_patterns: ['group.member_joined'],
config_schema: [],
config_schema: [
{
name: 'greeting',
type: 'string',
label: { en_US: 'Greeting' },
default: 'Hello',
required: true,
},
],
plugin_author: 'qa',
plugin_name: 'welcome',
},
],
};
} else if (url.pathname.endsWith('/runs/run-two/events')) {
data = {
run: {
...run,
run_id: 'run-two',
metadata: { event_type: 'group.member_left' },
},
items: [
{
sequence: 1,
type: 'processor.log',
created_at_ms: 1788000000000,
data: { text: 'Older run selected', level: 'info' },
},
],
has_more: false,
next_cursor: null,
};
} else if (url.pathname.endsWith('/runs/run-one/events')) {
cursors.push(url.searchParams.get('after_sequence') ?? '');
data = {
@@ -62,6 +146,27 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
sequence: 1,
type: 'processor.log',
data: { level: 'info', text: 'Member received' },
created_at_ms: 1788000000000,
},
{
sequence: 2,
type: 'tool.call.started',
created_at_ms: 1788000000050,
data: {
tool_call_id: 'reply-one',
tool_name: 'event_reply',
parameters: { text: 'Welcome' },
},
},
{
sequence: 3,
type: 'tool.call.completed',
created_at_ms: 1788000000200,
data: {
tool_call_id: 'reply-one',
tool_name: 'event_reply',
result: { ok: true },
},
},
{
sequence: 100,
@@ -78,38 +183,120 @@ test('event processor shows isolated logs, paginates and scrolls expanded payloa
next_cursor: url.searchParams.has('after_sequence') ? null : 100,
};
} else if (url.pathname.endsWith('/runs')) {
data = { items: [run], has_more: false, next_cursor: null, total: 1 };
data = {
items: debugRequests.length
? [
run,
{
...run,
run_id: 'run-two',
created_at: run.created_at - 60,
started_at_ms: 1787999940000,
finished_at_ms: 1787999940200,
metadata: { event_type: 'group.member_left' },
},
]
: [],
has_more: false,
next_cursor: null,
total: debugRequests.length,
};
} else if (url.pathname.endsWith('/processor-qa')) {
if (route.request().method() === 'PUT') {
operations.push('save');
Object.assign(processor, route.request().postDataJSON(), {
supported_event_patterns: ['group.member_joined'],
});
}
data = { agent: processor };
} else {
data = { agents: [processor] };
}
await route.fulfill({ json: { code: 0, data } });
});
await page.goto('/home/agents?id=processor-qa');
await page.goto('/home/agents?id=new');
await page.locator('[data-processor-kind="event_processor"]').click();
await expect(
page.getByRole('heading', { name: 'Welcome processor' }),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Logs and message flow' }),
).toBeVisible();
await expect(page.getByText('stop', { exact: true })).toHaveCount(0);
await page
.getByRole('button')
.filter({ hasText: 'group.member_joined' })
.click();
await expect(
page.getByText('Member received', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Payload line 99', { exact: false }),
).toBeHidden();
await page.locator('summary').filter({ hasText: 'Action result' }).click();
await page.getByRole('button', { name: 'Load more', exact: true }).click();
await page.getByText('Final log after pagination').scrollIntoViewIfNeeded();
await expect(page.getByText('Final log after pagination')).toBeInViewport();
expect(cursors).toEqual(['', '100']);
await expect(
page.getByRole('button', { name: 'Load more', exact: true }),
page.getByRole('combobox', { name: 'Plugin component' }),
).toHaveCount(0);
await page
.getByRole('textbox', { name: 'Name', exact: false })
.fill('Welcome processor');
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await expect(page).toHaveURL(/id=processor-qa/);
expect(creations).toHaveLength(1);
expect(creations[0]).toMatchObject({
kind: 'event_processor',
name: 'Welcome processor',
});
expect(creations[0]).not.toHaveProperty('component_ref');
expect(creations[0]).not.toHaveProperty('config');
const panel = page.getByRole('region', { name: 'Event Debug' });
const logs = page.getByRole('region', { name: 'Logs and message flow' });
await expect(panel).toBeVisible();
await expect(logs).toBeVisible();
await expect(page.getByRole('tab')).toHaveCount(0);
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(panel.getByRole('button', { name: 'Run test' })).toHaveCount(0);
await page.getByRole('combobox', { name: 'Plugin component' }).click();
await page.getByRole('option').filter({ hasText: 'Welcome' }).click();
await expect(
panel.getByRole('combobox', { name: 'Event type' }),
).toContainText('group.member_joined');
await expect(page.getByText('Greeting', { exact: true })).toHaveCount(0);
await page
.getByRole('button', { name: 'Plugin settings', exact: true })
.click();
const settings = page.locator('[data-slot="popover-content"]');
await settings.getByRole('textbox').fill('Welcome');
await page.keyboard.press('Escape');
await panel
.getByRole('textbox', { name: 'Member ID' })
.fill('debug-member-42');
await panel.getByRole('button', { name: 'Save and run' }).click();
await expect(panel.getByText('Debug handler invoked once')).toBeVisible();
expect(operations).toEqual(['save', 'debug']);
expect(debugRequests).toHaveLength(1);
expect(debugRequests[0]).toMatchObject({
event_type: 'group.member_joined',
data: { member: { id: 'debug-member-42' } },
});
await expect(
logs.getByText('Member received', { exact: true }),
).toBeVisible();
await expect(
logs.getByText('Payload line 99', { exact: false }),
).toBeHidden();
const runList = logs.getByRole('group', { name: 'Runs', exact: true });
await expect(logs.getByRole('combobox')).toHaveCount(0);
await expect(runList.getByRole('button')).toHaveCount(2);
await expect(runList.getByText('500 ms', { exact: true })).toBeVisible();
await expect(runList.getByText('200 ms', { exact: true })).toBeVisible();
await expect(runList.getByText('Member received')).toHaveCount(0);
await runList.getByRole('button', { name: /group.member_left/ }).click();
await expect(logs.getByText('Older run selected')).toBeVisible();
await runList.getByRole('button', { name: /group.member_joined/ }).click();
await expect(
logs.getByText('Member received', { exact: true }),
).toBeVisible();
await logs
.getByRole('button', { name: 'Action result', exact: true })
.click();
await logs.getByRole('button', { name: 'Load more' }).click();
await logs.getByText('Final log after pagination').scrollIntoViewIfNeeded();
await expect(logs.getByText('Final log after pagination')).toBeInViewport();
expect(cursors).toContain('100');
await expect(panel.getByText('Debug handler invoked once')).toBeVisible();
const debugBox = await panel.boundingBox();
const logBox = await logs.boundingBox();
expect(debugBox!.x).toBeLessThan(logBox!.x);
await page.setViewportSize({ width: 390, height: 700 });
await panel
.getByRole('button', { name: 'Run test' })
.scrollIntoViewIfNeeded();
await expect(
panel.getByRole('button', { name: 'Run test' }),
).toBeInViewport();
await logs.getByText('Final log after pagination').scrollIntoViewIfNeeded();
await expect(logs.getByText('Final log after pagination')).toBeInViewport();
});
@@ -98,3 +98,64 @@ test('duration and rating remain numbers and reject invalid values', () => {
'rating',
);
});
test('processor common fields edit nested SDK data without losing JSON-only fields', () => {
const { setDebugEventField, getDebugEventField } = module.exports;
const data = createDebugEventData('group.member_joined', samples, true);
assert.deepEqual(data, {
member: { nickname: '测试用户', id: 'debug-user' },
group: { id: 'debug-group' },
});
data.member.username = 'alice';
data.inviter = { id: 'inviter-1' };
const edited = setDebugEventField(data, 'member.id', 'member-42');
assert.equal(edited.member.id, 'member-42');
assert.equal(edited.member.username, 'alice');
assert.deepEqual(edited.inviter, data.inviter);
assert.equal(data.member.id, 'debug-user');
assert.equal(getDebugEventField(edited, 'member.id'), 'member-42');
assert.equal(
invalidDebugEventField('group.member_joined', edited, true),
undefined,
);
});
test('processor message content uses SDK message chains and feedback uses SDK fields', () => {
const { setDebugEventField, processorDebugEventTypes } = module.exports;
for (const [type, field] of [
['message.received', 'message_chain'],
['message.edited', 'new_content'],
]) {
const data = createDebugEventData(type, samples, true);
const edited = setDebugEventField(data, `${field}.0.text`, 'Changed');
assert.deepEqual(edited[field], [{ type: 'Plain', text: 'Changed' }]);
assert.equal(debugEventInputText(type, edited, true), 'Changed');
assert.equal(data[field][0].text, '你好');
}
const feedback = createDebugEventData('feedback.received', samples, true);
assert.equal(feedback.feedback_type, 1);
assert.equal(feedback.feedback_content, '很有帮助');
assert.ok(feedback.feedback_id);
for (const type of processorDebugEventTypes) {
assert.equal(
invalidDebugEventField(
type,
createDebugEventData(type, samples, true),
true,
),
undefined,
type,
);
}
});
test('processor full JSON accepts rich messages without requiring a first text component', () => {
const data = createDebugEventData('message.received', samples, true);
data.message_chain = [
{ type: 'Image', url: 'https://example.com/image.png' },
];
assert.equal(
invalidDebugEventField('message.received', data, true),
undefined,
);
});
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(
new URL(
'../../src/app/home/agents/components/processor-run-timing.ts',
import.meta.url,
),
'utf8',
);
const exports = {};
new Function(
'exports',
ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
)(exports);
const { processorRunDuration } = exports;
test('duration uses precise lifecycle times, excluding queue wait', () => {
assert.equal(
processorRunDuration({
created_at: 1,
started_at_ms: 2100,
finished_at_ms: 2375,
}),
275,
);
});
test('missing or inverted times remain unknown; zero is a valid duration', () => {
assert.equal(
processorRunDuration({ created_at: 1, started_at_ms: 1000 }),
null,
);
assert.equal(
processorRunDuration({ created_at: 1, finished_at_ms: 2000 }),
null,
);
assert.equal(
processorRunDuration({ started_at_ms: 1000, finished_at_ms: 900 }),
null,
);
assert.equal(
processorRunDuration({ started_at_ms: 1000, finished_at_ms: 1000 }),
0,
);
});
test('old server responses fall back to second precision', () => {
assert.equal(processorRunDuration({ started_at: 2, finished_at: 4 }), 2000);
});