mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(processors): streamline event debugging and run inspection
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user