fix: resolve master conflicts and align connection mode regression

This commit is contained in:
RockChinQ
2026-10-08 16:19:04 +00:00
150 changed files with 17063 additions and 4067 deletions
@@ -157,12 +157,16 @@ test.describe('bot session request recovery', () => {
.click();
}
}
await expect(page.getByRole('alert')).toBeVisible();
await expect(
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
).toBeVisible();
await expect(
page.getByText('No sessions found', { exact: true }),
).toHaveCount(0);
if (failure === 'analysis') {
await expect(page.getByRole('alert')).toContainText(/Tool/i);
await expect(
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
).toContainText(/Tool/i);
await expect(
page.getByText('Successful message', { exact: true }),
).toBeVisible();
@@ -177,10 +181,13 @@ test.describe('bot session request recovery', () => {
).toHaveCount(0);
failing = false;
await page
.getByRole('tabpanel', { name: /Sessions/ })
.getByRole('alert')
.getByRole('button', { name: 'Retry', exact: true })
.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
).toHaveCount(0);
if (failure === 'initial list' || failure === 'list page') {
await expect(
page.getByRole('button', {
@@ -313,7 +320,9 @@ test.describe('bot session request races', () => {
await expect.poll(() => released).toBe(true);
// Allow the released HTTP response and React's queued update to settle.
await page.waitForTimeout(200);
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(
page.getByRole('tabpanel', { name: /Sessions/ }).getByRole('alert'),
).toHaveCount(0);
await expect(page.getByText('Stale list', { exact: true })).toHaveCount(
0,
);
+2 -1
View File
@@ -203,7 +203,8 @@ test('bot guide starts on the detail page after adapter-only creation', async ({
const adapterCard = page.locator('[data-slot="card"]').filter({
has: page.getByText('Adapter Configuration', { exact: true }),
});
await expect(adapterCard.getByRole('switch')).toBeChecked();
await expect(adapterCard.getByRole('switch')).toHaveCount(0);
await expect(page.getByRole('radio', { name: /^Webhook/ })).toBeChecked();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
@@ -0,0 +1,179 @@
import { test, expect } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
for (const scenario of ['install', 'retry', 'reload'] as const) {
test(`event processor marketplace ${scenario}: filters usage and selects registered component`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const ref = 'plugin:qa/events/default';
let installed = false;
let finish = scenario !== 'reload';
let installs = 0;
let registrations = 0;
const requests: Record<string, unknown>[] = [];
const descriptor = {
id: ref,
plugin_author: 'qa',
plugin_name: 'events',
label: { en_US: 'Event handler' },
supported_event_patterns: ['group.member_joined'],
config_schema: [
{
name: 'greeting',
type: 'string',
label: { en_US: 'Greeting' },
default: 'Welcome',
required: true,
},
],
};
let processor = {
uuid: 'event-market',
kind: 'event_processor',
name: 'Event marketplace',
component_ref: '',
config: {},
supported_event_patterns: [],
};
await page.route('**/api/v1/agents**', async (route) => {
const path = new URL(route.request().url()).pathname;
let data: unknown;
if (path.endsWith('/_/metadata')) {
if (installed) registrations++;
data = {
event_processors: installed && registrations > 1 ? [descriptor] : [],
kinds: [],
platform_tools: [],
};
} else if (path.endsWith('/runs')) data = { items: [], has_more: false };
else if (path.endsWith('/event-market')) {
if (route.request().method() === 'PUT')
processor = { ...processor, ...route.request().postDataJSON() };
data = { agent: processor };
} else return route.fallback();
await route.fulfill({ json: { code: 0, data } });
});
await page.route('**/api/v1/marketplace/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (!path.endsWith('/search')) return route.fallback();
requests.push(route.request().postDataJSON());
const plugin = (
name: string,
usages?: string[],
components = { Runner: 1 },
) => ({
author: 'qa',
name,
label: { en_US: name },
description: { en_US: name + ' description' },
latest_version: '1.0.0',
type: 'plugin',
components,
runner_usages: usages,
install_count: 0,
});
await route.fulfill({
json: {
code: 0,
data: {
total: 5,
[path.includes('/extensions/') ? 'extensions' : 'plugins']: [
plugin('events', ['event']),
plugin('agent-only', ['agent']),
plugin('mixed', ['agent', 'event']),
plugin('unknown'),
plugin('knowledge-only', ['event'], {
KnowledgeEngine: 1,
} as any),
],
},
},
});
});
await page.route('**/api/v1/plugins/install/marketplace', async (route) => {
installs++;
expect(route.request().postDataJSON()).toEqual({
plugin_author: 'qa',
plugin_name: 'events',
plugin_version: '1.0.0',
});
await route.fulfill({ json: { code: 0, data: { task_id: installs } } });
});
await page.route('**/api/v1/system/tasks/*', async (route) => {
const failed = scenario === 'retry' && installs === 1;
installed = finish && !failed;
await route.fulfill({
json: {
code: 0,
data: {
id: installs,
name: 'plugin-install-marketplace',
runtime: {
done: finish,
exception: failed ? 'Download failed' : null,
},
task_context: {
current_action: 'Downloading',
metadata: { progress_percent: 25 },
},
},
},
});
});
await page.goto('/home/agents?id=event-market');
const select = page.getByRole('combobox', {
name: 'Plugin processor',
includeHidden: true,
});
await select.click();
const install = page.getByRole('button', {
name: 'Install events',
exact: true,
});
await expect(install).toBeVisible();
await expect(
page.getByRole('button', { name: 'Install mixed', exact: true }),
).toBeVisible();
for (const excluded of ['agent-only', 'unknown', 'knowledge-only'])
await expect(
page.getByRole('button', { name: `Install ${excluded}`, exact: true }),
).toHaveCount(0);
expect(requests.length).toBeGreaterThan(0);
for (const request of requests)
expect(request).toMatchObject({
component_filter: 'Runner',
runner_usage: 'event',
type_filter: 'plugin',
});
await install.click();
if (scenario === 'retry') {
await expect(
page
.getByRole('alert', { includeHidden: true })
.filter({ hasText: 'Download failed' }),
).toBeVisible();
await install.click();
}
if (scenario === 'reload') {
await expect.poll(() => installs).toBe(1);
await expect
.poll(() =>
page.evaluate(() =>
Object.keys(sessionStorage).some((key) =>
key.includes('event-processor:event-market'),
),
),
)
.toBe(true);
finish = true;
await page.reload();
}
await expect(select).toContainText('Event handler', { timeout: 20000 });
await page.keyboard.press('Escape');
await expect(page.locator('input[name="greeting"]')).toHaveValue('Welcome');
expect(installs).toBe(scenario === 'retry' ? 2 : 1);
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => processor.component_ref).toBe(ref);
});
}
+199 -1
View File
@@ -98,6 +98,7 @@ export interface WorkspaceEntryMock {
interface LangBotApiMockState {
authenticated: boolean;
withAssistant: boolean;
bots: BotMock[];
counters: Record<string, number>;
knowledgeBases: KnowledgeBaseMock[];
@@ -227,6 +228,128 @@ function emptyMonitoringData() {
};
}
type RawMonitoringMessage = Record<string, unknown>;
/** Mirror of the backend execution status groups so fixtures stay realistic. */
function executionStatusGroup(status: string, source: 'agent' | 'pipeline') {
if (source === 'agent') {
if (status === 'failed' || status === 'timeout') return 'failed';
if (status === 'created' || status === 'queued') return 'queued';
if (status === 'claimed' || status === 'running') return 'running';
if (status === 'cancelled') return 'cancelled';
return status;
}
if (status === 'success') return 'completed';
if (status === 'error') return 'failed';
if (status === 'pending') return 'running';
if (status === 'discarded' || status === 'not_matched') return 'ignored';
return status;
}
/** Best-effort text extraction from a serialized message chain (mirrors backend). */
function executionText(raw: unknown): string {
const source = String(raw ?? '');
try {
const parts: string[] = [];
const walk = (node: unknown, depth = 0): void => {
if (depth > 6) return;
if (Array.isArray(node)) {
node.forEach((item) => walk(item, depth + 1));
} else if (node && typeof node === 'object') {
for (const [key, value] of Object.entries(node)) {
if (key === 'text' && typeof value === 'string') parts.push(value);
else walk(value, depth + 1);
}
}
};
walk(JSON.parse(source));
return parts.join(' ').trim() || source;
} catch {
return source;
}
}
/** Derive the unified execution list from a mocked monitoring payload. */
export function buildExecutionList(monitoringData: unknown) {
const payload = monitoringData as
| { messages?: RawMonitoringMessage[] }
| undefined;
const items = (payload?.messages ?? [])
.filter((message) => message.role !== 'assistant')
.map((message) => {
const status = String(message.status ?? 'success');
return {
source: 'pipeline' as const,
id: String(message.id),
event_id: null,
status,
status_group: executionStatusGroup(status, 'pipeline'),
title: executionText(message.message_content),
input_preview: executionText(message.message_content),
target_kind: 'pipeline' as const,
target_id: (message.pipeline_id as string) ?? null,
target_name: (message.pipeline_name as string) ?? null,
bot_id: (message.bot_id as string) ?? null,
bot_name: (message.bot_name as string) ?? null,
pipeline_id: (message.pipeline_id as string) ?? null,
pipeline_name: (message.pipeline_name as string) ?? null,
runner_id: (message.runner_name as string) ?? null,
conversation_id: null,
session_id: (message.session_id as string) ?? null,
created_at_ms: Date.parse(String(message.timestamp ?? '')) || null,
started_at_ms: null,
finished_at_ms: null,
duration_ms: null,
usage: null,
cost: null,
queue_name: null,
debug: false,
has_error: status === 'error',
};
});
const byStatus: Record<string, number> = {};
for (const item of items) {
byStatus[item.status_group] = (byStatus[item.status_group] ?? 0) + 1;
}
const completed = byStatus.completed ?? 0;
const failed = byStatus.failed ?? 0;
const denominator = completed + failed;
return {
items,
total: items.length,
limit: items.length,
offset: 0,
has_more: false,
summary: {
executions: {
total: items.length,
by_source: { agent: 0, pipeline: items.length },
by_status: byStatus,
completed,
failed,
running: byStatus.running ?? 0,
queued: byStatus.queued ?? 0,
cancelled: byStatus.cancelled ?? 0,
ignored: byStatus.ignored ?? 0,
waiting: 0,
success_rate:
denominator > 0
? Math.round((completed / denominator) * 10000) / 100
: null,
denominator,
p50_duration_ms: null,
p95_duration_ms: null,
duration_sample: 0,
debug: 0,
real: items.length,
},
tokens: { total_tokens: 0, calls: 0, calls_with_usage: 0 },
},
};
}
function emptyTokenStatistics() {
return {
summary: {
@@ -610,7 +733,9 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
if (path === '/api/v1/user/info') {
return fulfillJson(route, {
account_uuid: 'account-playwright',
// Most tests exercise an unlinked local user. Assistant tests opt into
// an account identity and mock its additional provider/conversation APIs.
account_uuid: state.withAssistant ? 'account-playwright' : null,
user: '[email protected]',
account_type: 'local',
has_password: true,
@@ -1163,6 +1288,76 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, data.overview || emptyMonitoringData().overview);
}
if (path === '/api/v1/monitoring/executions') {
return fulfillJson(route, buildExecutionList(state.monitoringData));
}
const executionDetail = path.match(
/^\/api\/v1\/monitoring\/executions\/(agent|pipeline|event|auto)\/(.+)$/,
);
if (executionDetail) {
const [, , executionId] = executionDetail;
const payload = state.monitoringData as {
messages?: RawMonitoringMessage[];
llmCalls?: RawMonitoringMessage[];
toolCalls?: RawMonitoringMessage[];
errors?: RawMonitoringMessage[];
};
const row = buildExecutionList(payload).items.find(
(item) => item.id === executionId,
);
if (!row)
return route.fulfill({
status: 404,
json: { code: 404, msg: 'Execution not found' },
});
const root = (payload.messages ?? []).find(
(item) => item.id === executionId,
)!;
const content = (item: RawMonitoringMessage) => ({
...item,
content: item.message_content,
timestamp_ms: Date.parse(String(item.timestamp)),
origin: item.role === 'assistant' ? 'delivery' : 'input',
});
const pages = Object.fromEntries(
Object.entries({
inputs: [content(root)],
outputs: [],
deliveries: (payload.messages ?? [])
.filter((item) => item.parent_message_id === executionId)
.map(content),
conversation: (payload.messages ?? [])
.filter(
(item) =>
item.session_id === root.session_id &&
item.bot_id === root.bot_id,
)
.map(content),
related: [],
events: [],
llm_calls: (payload.llmCalls ?? []).filter(
(item) => item.message_id === executionId,
),
tool_calls: (payload.toolCalls ?? []).filter(
(item) => item.message_id === executionId,
),
errors: (payload.errors ?? []).filter(
(item) => item.message_id === executionId,
),
}).map(([key, items]) => [
key,
{ items, next_offset: items.length, has_more: false },
]),
);
return fulfillJson(route, {
source: row.source,
row,
pages,
legacy_context: true,
});
}
if (path === '/api/v1/monitoring/token-statistics') {
return fulfillJson(route, emptyTokenStatistics());
}
@@ -1267,6 +1462,7 @@ export async function installLangBotApiMocks(
page: Page,
options: {
authenticated?: boolean;
withAssistant?: boolean;
language?: string;
monitoringData?: unknown;
monitoringSessions?: unknown[];
@@ -1280,6 +1476,7 @@ export async function installLangBotApiMocks(
) {
const {
authenticated = false,
withAssistant = false,
language = 'en-US',
monitoringData,
monitoringSessions,
@@ -1292,6 +1489,7 @@ export async function installLangBotApiMocks(
} = options;
const state: LangBotApiMockState = {
authenticated,
withAssistant,
bots: [],
counters: {},
knowledgeBases: [],
@@ -0,0 +1,317 @@
import { expect, test, type Page } from '@playwright/test';
import {
installLangBotApiMocks,
pipelineMetadata,
} from './fixtures/langbot-api';
const runner = 'plugin:langbot-team/LocalAgent/default';
async function setup(page: Page, kind: 'agent' | 'pipeline' = 'agent') {
await installLangBotApiMocks(page, { authenticated: true });
// Exercise the self-hosted form, without the unrelated account assistant overlay.
await page.route('**/api/v1/user/info', (route) =>
route.fulfill({
json: {
code: 0,
data: { account_uuid: null, user_id: 1, username: 'Form tester' },
},
}),
);
const metadata = pipelineMetadata();
let pipelineSaved = false;
page.on('request', (r) => {
if (r.method() === 'PUT' && r.url().endsWith('/pipelines/pipeline-draft'))
pipelineSaved = true;
});
await page.route('**/api/v1/pipelines/pipeline-draft', async (route) => {
if (route.request().method() !== 'GET' || pipelineSaved)
return route.fallback();
await route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'pipeline-draft',
name: 'Pipeline draft',
emoji: '⚙️',
description: '',
config: {
ai: {
runner: { id: runner },
runner_config: {
[runner]: {
model: { primary: 'llm-valid', fallbacks: [] },
instruction: 'Original instruction',
advanced: false,
detail: 'Original detail',
},
},
},
trigger: {},
safety: {},
output: {},
},
},
},
},
});
});
metadata.configs[0].stages[1].config.push(
...([
{
name: 'instruction',
label: { en_US: 'Instruction' },
type: 'text',
required: true,
default: 'Original instruction',
},
{
name: 'advanced',
label: { en_US: 'Advanced' },
type: 'boolean',
default: false,
},
{
name: 'detail',
label: { en_US: 'Detail' },
type: 'text',
default: 'Original detail',
show_if: { field: 'advanced', operator: 'eq', value: true },
},
] as any),
);
await page.route(
`**/api/v1/${kind === 'agent' ? 'agents' : 'pipelines'}/_/metadata`,
(route) =>
route.fulfill({
json: {
code: 0,
data:
kind === 'agent'
? {
runner_config: metadata.configs[0],
kinds: [],
event_processors: [],
}
: metadata,
},
}),
);
await page.goto(
`/home/${kind === 'agent' ? 'agents' : 'pipelines'}?id=${kind}-draft`,
);
if (kind === 'pipeline')
await page.getByRole('tab', { name: /^AI$/ }).click();
const input = page.locator('textarea[name="instruction"]');
await expect(input).toHaveValue('Original instruction');
return {
input,
save: page.getByRole('button', { name: 'Save', exact: true }),
endpoint: `/api/v1/${kind === 'agent' ? 'agents' : 'pipelines'}/${kind}-draft`,
};
}
for (const kind of ['agent', 'pipeline'] as const) {
test(`${kind}: revert, conditional fields, immediate save and reload retain the latest draft`, async ({
page,
}) => {
const { input, save, endpoint } = await setup(page, kind);
await expect(save).toBeDisabled();
await input.fill('Changed');
await expect(save).toBeEnabled();
await input.fill('Original instruction');
await expect(save).toBeDisabled();
await page
.locator('label')
.filter({ hasText: /^Advanced$/ })
.locator('../..')
.getByRole('switch')
.click();
const detail = page.locator('textarea[name="detail"]');
await detail.fill('Retained while hidden');
await page
.locator('label')
.filter({ hasText: /^Advanced$/ })
.locator('../..')
.getByRole('switch')
.click();
await expect(detail).toHaveCount(0);
await input.pressSequentially(' final characters');
const request = page.waitForRequest(
(r) => new URL(r.url()).pathname === endpoint && r.method() === 'PUT',
);
await save.click();
const payload = (await request).postDataJSON();
const config = kind === 'agent' ? payload.config : payload.config.ai;
expect(config.runner_config[runner]).toMatchObject({
instruction: 'Original instruction final characters',
detail: 'Retained while hidden',
advanced: false,
});
await expect(save).toBeDisabled();
await page.reload();
if (kind === 'pipeline')
await page.getByRole('tab', { name: /^AI$/ }).click();
await expect(input).toHaveValue('Original instruction final characters');
await expect(save).toBeDisabled();
await page
.locator('label')
.filter({ hasText: /^Advanced$/ })
.locator('../..')
.getByRole('switch')
.click();
await expect(detail).toHaveValue('Retained while hidden');
});
}
test('agent: save completion does not absorb edits made while the request is pending', async ({
page,
}) => {
const { input, save, endpoint } = await setup(page);
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(`**${endpoint}`, async (route) => {
if (route.request().method() === 'PUT') await gate;
await route.fallback();
});
await input.fill('First draft');
const request = page.waitForRequest(
(r) => r.method() === 'PUT' && r.url().endsWith(endpoint),
);
await save.click();
await request;
await input.fill('Second draft typed during save');
release();
await expect(save).toBeEnabled();
const next = page.waitForRequest(
(r) => r.method() === 'PUT' && r.url().endsWith(endpoint),
);
await save.click();
expect(
(await next).postDataJSON().config.runner_config[runner].instruction,
).toBe('Second draft typed during save');
await expect(save).toBeDisabled();
});
test('agent: failed save keeps the draft, and debugging saves the latest value first', async ({
page,
}) => {
const { input, save, endpoint } = await setup(page);
let fail = true;
const operations: string[] = [];
await page.route(`**${endpoint}`, async (route) => {
if (route.request().method() !== 'PUT') return route.fallback();
if (fail)
return route.fulfill({
status: 500,
json: { code: 500, msg: 'Deliberate save failure' },
});
operations.push('save');
expect(
route.request().postDataJSON().config.runner_config[runner].instruction,
).toBe('Latest before debug');
await route.fallback();
});
await input.fill('Unsaved draft');
await save.click();
await expect(page.getByText(/Deliberate save failure/)).toBeVisible();
await expect(save).toBeEnabled();
await expect(input).toHaveValue('Unsaved draft');
fail = false;
page.on('request', (request) => {
if (request.url().includes('/debug/stream')) operations.push('debug');
});
await page.getByRole('textbox', { name: 'Message content' }).fill('Hello');
await input.fill('Latest before debug');
await page.getByRole('button', { name: 'Save and run', exact: true }).click();
await expect(page.getByText('Mock Agent response')).toBeVisible();
expect(operations).toEqual(['save', 'debug']);
await expect(save).toBeDisabled();
});
test('agent: required-field status still reacts after unrelated keystrokes are isolated', async ({
page,
}) => {
const { input } = await setup(page);
await expect(page.getByText('Runner ready', { exact: true })).toBeVisible();
await input.fill('');
await expect(page.getByText('Runner ready', { exact: true })).toHaveCount(0);
await input.fill('Valid again');
await expect(page.getByText('Runner ready', { exact: true })).toBeVisible();
});
test('knowledge: early edits are not absorbed into initialization, and save uses a submitted snapshot', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const engine = {
plugin_id: 'builtin/minimal-knowledge',
name: { en_US: 'Test engine' },
capabilities: ['text_retrieval'],
creation_schema: [],
retrieval_schema: [
{
name: 'instructions',
type: 'text',
label: { en_US: 'Retrieval instructions' },
default: 'Original',
},
],
};
let base = {
uuid: 'kb-draft',
name: 'Knowledge draft',
description: '',
emoji: '📚',
initialized: true,
knowledge_engine_plugin_id: engine.plugin_id,
knowledge_engine: engine,
creation_settings: {},
retrieval_settings: { instructions: 'Original' },
};
await page.route('**/api/v1/knowledge/engines', (route) =>
route.fulfill({ json: { code: 0, data: { engines: [engine] } } }),
);
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let saving = false;
await page.route('**/api/v1/knowledge/bases/kb-draft', async (route) => {
if (route.request().method() === 'PUT') {
const payload = route.request().postDataJSON();
saving = true;
await gate;
base = { ...base, ...payload };
return route.fulfill({ json: { code: 0, data: { uuid: base.uuid } } });
}
return route.fulfill({ json: { code: 0, data: { base } } });
});
await page.goto('/home/knowledge?id=kb-draft');
const input = page.locator('textarea[name="instructions"]');
const save = page.getByRole('button', { name: 'Save', exact: true });
await expect(input).toHaveValue('Original');
await input.fill('Early edit');
await expect(save).toBeEnabled();
await input.fill('Original');
await expect(save).toBeDisabled();
await input.fill('Submitted value');
await save.click();
await expect.poll(() => saving).toBe(true);
await input.fill('Typed while saving');
release();
await expect
.poll(() => base.retrieval_settings.instructions)
.toBe('Submitted value');
await expect(save).toBeEnabled();
await save.click();
await expect
.poll(() => base.retrieval_settings.instructions)
.toBe('Typed while saving');
await expect(save).toBeDisabled();
await page.reload();
await expect(input).toHaveValue('Typed while saving');
await expect(save).toBeDisabled();
});
+9 -2
View File
@@ -59,10 +59,17 @@ test.describe('authenticated app shell', () => {
await page.goto('/home/monitoring');
await expect(page).toHaveURL(/\/home\/monitoring$/);
await expect(page.getByText('Total Messages').first()).toBeVisible();
// The unified execution view is the default entry point.
await expect(page.getByRole('tab', { name: 'Executions' })).toHaveAttribute(
'data-state',
'active',
);
await expect(
page.getByText('Execution Success Rate').first(),
).toBeVisible();
await expect(
page.getByRole('tab', { name: 'Message Records' }),
).toBeVisible();
).toHaveCount(0);
await expect(
page.getByRole('tab', { name: 'Token Monitoring' }),
).toBeVisible();
+1 -1
View File
@@ -20,7 +20,7 @@ test('local account login reaches the authenticated home shell', async ({
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
await expect(page.getByText('Home').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('Total Messages').first()).toBeVisible();
await expect(page.getByText('Execution Success Rate').first()).toBeVisible();
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
});
+462
View File
@@ -0,0 +1,462 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
buildExecutionList,
} from './fixtures/langbot-api';
const monitoring = {
messages: [
{
id: 'input-one',
role: 'user',
message_content: 'Open this execution',
timestamp: '2026-07-02T10:00:00Z',
bot_id: 'bot',
pipeline_id: 'pipe',
pipeline_name: 'Processor A',
},
],
};
const row = buildExecutionList(monitoring).items[0];
const pageOf = (
items: unknown[],
has_more = false,
next_offset = items.length,
) => ({ items, has_more, next_offset });
test('renders typed and custom events without duplicate text or empty attachment messages', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: monitoring,
});
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
source: 'pipeline',
row,
pages: {
inputs: pageOf([
{
id: 'message',
content: {
text: '你好~',
contents: [{ type: 'text', text: '你好~', image_url: null }],
attachments: [],
},
},
{
id: 'member',
event_type: 'group.member_banned',
content: {
member: { id: 'member-42', nickname: 'Alice' },
group: { id: 'group-9' },
duration: 0,
permanent: false,
custom: { reason: 'Moderation reason' },
},
},
{
id: 'edited',
content: {
message_chain: [{ type: 'Plain', text: 'Typed chain text' }],
editor: { id: 'editor-7' },
new_content: [{ type: 'Plain', text: 'Updated text' }],
},
},
{
id: 'custom',
content: {
type: 'CustomEvent',
text: 'Distinct text',
content: 'Different content',
properties: { nested: ['custom-value'] },
},
},
{
id: 'same-again',
content: {
text: '你好~',
contents: [{ type: 'text', text: '你好~' }],
},
},
{
id: 'image',
content: {
contents: [
{
type: 'image_url',
image_url: {
url: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
},
},
],
},
},
]),
outputs: pageOf([
{
id: 'assistant',
content: {
content: 'Agent answer',
tool_calls: [
{
id: 'tool-1',
function: {
name: 'event_reply',
arguments: '{"text":"sent"}',
},
},
],
},
attachments: [],
},
]),
deliveries: pageOf([]),
},
},
},
});
});
await page.goto('/home/monitoring');
await page
.getByRole('button')
.filter({ hasText: 'Open this execution' })
.click();
const sheet = page.getByRole('dialog');
await expect(sheet.getByText('你好~', { exact: true })).toHaveCount(2);
await expect(sheet.getByText('[Empty message]', { exact: true })).toHaveCount(
0,
);
for (const text of [
'Agent answer',
'member-42',
'Alice',
'group-9',
'Moderation reason',
'0',
'false',
'Typed chain text',
'editor-7',
'Updated text',
'Distinct text',
'Different content',
'custom-value',
'event_reply',
]) {
await expect(sheet.getByText(text, { exact: true })).toBeVisible();
}
await expect(
sheet.getByRole('img', { name: 'Message attachment' }),
).toBeVisible();
});
test('pages a long trace without dropping inputs and opens related executions', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: monitoring,
});
const requests: string[] = [];
await page.route('**/api/v1/monitoring/executions/*/*?*', async (route) => {
const url = new URL(route.request().url());
requests.push(url.search);
const sibling = url.pathname.endsWith('/sibling');
const more = url.searchParams.get('section') === 'events';
await route.fulfill({
json: {
code: 0,
data: {
source: 'pipeline',
row: sibling
? { ...row, id: 'sibling', target_name: 'Processor B' }
: row,
pages: more
? {
events: pageOf(
[
{
id: 101,
sequence: 101,
type: 'run.completed',
data: { final: 'Last trace event' },
},
],
false,
101,
),
}
: {
inputs: pageOf([
{
id: 'in',
content: sibling ? 'Sibling input' : 'Original input',
},
]),
outputs: pageOf([
{ id: 'out', content: 'Generated but not sent' },
]),
deliveries: pageOf([]),
events: pageOf(
[{ id: 1, sequence: 1, type: 'run.started', data: {} }],
true,
100,
),
related: pageOf(
sibling
? []
: [{ ...row, id: 'sibling', target_name: 'Processor B' }],
),
},
},
},
});
});
await page.goto('/home/monitoring');
await page
.getByRole('button')
.filter({ hasText: 'Open this execution' })
.click();
const sheet = page.getByRole('dialog');
await expect(sheet.getByText('Generated but not sent')).toBeVisible();
await expect(sheet.getByText('Delivery records (0)')).toBeVisible();
await sheet.getByText('Execution events (1+)', { exact: true }).click();
await sheet.getByRole('button', { name: 'Load more' }).click();
await expect(sheet.getByText('run.completed', { exact: true })).toBeVisible();
await expect(sheet.getByText('Original input')).toBeVisible();
expect(
requests.some(
(query) =>
query.includes('section=events') && query.includes('offset=100'),
),
).toBe(true);
await sheet.getByText('Related executions (1)', { exact: true }).click();
await sheet.getByRole('button', { name: /Processor B/ }).click();
await expect(sheet.getByText('Sibling input')).toBeVisible();
await expect(sheet.getByText('Original input')).toHaveCount(0);
});
test('shows non-message events without fabricating a reply or a processor', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const event = {
...row,
source: 'event',
id: 'event-only',
event_id: 'event-only',
target_id: null,
target_kind: 'event',
target_name: null,
title: 'group.member.joined',
input_preview: 'Member joined',
status_group: 'ignored',
status: 'ignored',
};
await page.route('**/api/v1/monitoring/executions?*', (route) =>
route.fulfill({
json: {
code: 0,
data: { ...buildExecutionList(monitoring), items: [event] },
},
}),
);
await page.route('**/api/v1/monitoring/executions/event/*?*', (route) =>
route.fulfill({
json: {
code: 0,
data: {
source: 'event',
row: event,
pages: {
inputs: pageOf([
{
id: 'event-only',
event_type: 'group.member.joined',
content: { member_id: 'member-42' },
metadata: {
routes: [
{ status: 'not_matched', reason: 'No matching route' },
],
},
},
]),
outputs: pageOf([]),
deliveries: pageOf([]),
},
},
},
}),
);
await page.goto('/home/monitoring');
await page.getByRole('button').filter({ hasText: 'Member joined' }).click();
const sheet = page.getByRole('dialog');
await expect(
sheet.getByRole('heading', { name: 'No processor run' }),
).toBeVisible();
await expect(sheet.getByText(/member-42/)).toBeVisible();
await expect(sheet.getByText(/No matching route/)).toBeVisible();
await expect(sheet.getByText('Delivery records (0)')).toBeVisible();
});
test('filters all three processor kinds with the sidebar icons', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const processors = [
{ uuid: 'agent-one', name: 'Agent One', kind: 'agent', emoji: '🤖' },
{
uuid: 'pipeline-one',
name: 'Pipeline One',
kind: 'pipeline',
emoji: '⚙️',
},
{
uuid: 'event-one',
name: 'Event One',
kind: 'event_processor',
emoji: '🧩',
},
];
await page.route('**/api/v1/agents', (route) =>
route.fulfill({ json: { code: 0, data: { agents: processors } } }),
);
await page.goto('/home/monitoring');
const filter = page.getByRole('combobox', { name: 'Processor', exact: true });
for (const processor of processors) {
await filter.click();
const option = page.getByRole('option', {
name: processor.name,
exact: true,
});
await expect(
option.getByText(processor.emoji, { exact: true }),
).toBeVisible();
const request = page.waitForRequest((req) => {
const url = new URL(req.url());
return (
url.pathname.endsWith('/monitoring/executions') &&
url.searchParams.get('pipelineId') === processor.uuid
);
});
await option.click();
await request;
await expect(filter).toContainText(processor.name);
await expect(
filter.getByText(processor.emoji, { exact: true }),
).toBeVisible();
}
await filter.click();
await page
.getByRole('option', { name: 'All Processors', exact: true })
.click();
await expect(filter).toContainText('All Processors');
});
test('shares mode and status filters with dashboard charts and records', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/monitoring');
for (const [id, option, parameter, value] of [
['monitoring-filter-mode', 'Debug only', 'mode', 'debug'],
['monitoring-filter-status', 'Failed', 'status', 'failed'],
]) {
await page.locator(`#${id}`).click();
const requests = ['/monitoring/data', '/monitoring/executions'].map(
(path) =>
page.waitForRequest((req) => {
const url = new URL(req.url());
return (
url.pathname.endsWith(path) &&
url.searchParams.get(parameter) === value
);
}),
);
await page.getByRole('option', { name: option, exact: true }).click();
await Promise.all(requests);
}
});
test('remembers all filters on reload and persists resetting them', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/platform/bots', (route) =>
route.fulfill({
json: {
code: 0,
data: {
bots: [{ uuid: 'bot-one', name: 'Saved Bot', adapter: 'aiocqhttp' }],
},
},
}),
);
await page.route('**/api/v1/agents', (route) =>
route.fulfill({
json: {
code: 0,
data: {
agents: [
{
uuid: 'processor-one',
name: 'Saved Processor',
kind: 'agent',
emoji: '🤖',
},
],
},
},
}),
);
await page.goto('/home/monitoring');
const selections = [
['bot', 'Saved Bot'],
['processor', 'Saved Processor'],
['mode', 'Debug only'],
['status', 'Failed'],
['time', 'Last 7 days'],
];
for (const [id, label] of selections) {
await page.locator(`#monitoring-filter-${id}`).click();
await page.getByRole('option', { name: label, exact: true }).click();
}
await page.reload();
for (const [id, label] of selections)
await expect(page.locator(`#monitoring-filter-${id}`)).toContainText(label);
await page
.getByRole('button', { name: 'Reset Filters', exact: true })
.click();
await page.reload();
for (const [id, label] of [
['bot', 'All Bots'],
['processor', 'All Processors'],
['mode', 'Live + debug'],
['status', 'All statuses'],
['time', 'Last 24 hours'],
]) {
await expect(page.locator(`#monitoring-filter-${id}`)).toContainText(label);
}
});
test('reset removes linked resource filters from the URL', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto(
'/home/monitoring?botId=linked-bot&pipelineId=linked-processor',
);
await page
.getByRole('button', { name: 'Reset Filters', exact: true })
.click();
await expect(page).toHaveURL(/\/home\/monitoring$/);
await page.reload();
await expect(page.locator('#monitoring-filter-bot')).toContainText(
'All Bots',
);
await expect(page.locator('#monitoring-filter-processor')).toContainText(
'All Processors',
);
});
+56 -56
View File
@@ -1,6 +1,9 @@
import { expect, test, Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
import {
installLangBotApiMocks,
buildExecutionList,
} from './fixtures/langbot-api';
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
import {
ErrorLog,
@@ -254,7 +257,15 @@ function rawMonitoringData() {
success_rate: 100,
active_sessions: 2,
},
messages: scenario.messages.map(rawMessage),
messages: scenario.messages.map((item) => ({
...rawMessage(item),
parent_message_id:
item.role === 'assistant'
? item.id === 'agent-assistant-4'
? 'agent-user-2'
: 'agent-user-1'
: undefined,
})),
llmCalls: scenario.llmCalls.map(rawLlmCall),
toolCalls: scenario.toolCalls.map(rawToolCall),
embeddingCalls: [],
@@ -273,8 +284,13 @@ function rawMonitoringData() {
async function respond(route: Route, label: string) {
const data = rawMonitoringData();
data.messages = [rawMessage(message(label, 'user', 10, label))];
await route.fulfill({ json: { code: 0, data } });
data.messages = [
{
...rawMessage(message(label, 'user', 10, label)),
parent_message_id: undefined,
},
];
await route.fulfill({ json: { code: 0, data: buildExecutionList(data) } });
}
test.describe('monitoring request contracts', () => {
@@ -283,7 +299,7 @@ test.describe('monitoring request contracts', () => {
}) => {
await installLangBotApiMocks(page, { authenticated: true });
let failing = true;
await page.route('**/api/v1/monitoring/data?*', async (route) => {
await page.route('**/api/v1/monitoring/executions?*', async (route) => {
expect(route.request().headers().authorization).toBe(
'Bearer playwright-token',
);
@@ -298,14 +314,14 @@ test.describe('monitoring request contracts', () => {
else await respond(route, 'Recovered monitoring');
});
await page.goto('/home/monitoring');
await expect(page.getByRole('alert')).toContainText(
'Failed to load monitoring data',
);
await expect(
page.getByText('Failed to load execution trace'),
).toBeVisible();
await expect(page.getByText('No message records')).toHaveCount(0);
failing = false;
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(
page.getByText('Recovered monitoring', { exact: true }),
page.getByText('Recovered monitoring', { exact: true }).first(),
).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
});
@@ -315,7 +331,7 @@ test.describe('monitoring request contracts', () => {
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const pending: Route[] = [];
await page.route('**/api/v1/monitoring/data?*', (route) => {
await page.route('**/api/v1/monitoring/executions?*', (route) => {
pending.push(route);
});
await page.goto('/home/monitoring');
@@ -324,8 +340,9 @@ test.describe('monitoring request contracts', () => {
await page.getByRole('option', { name: /Last 7 days/i }).click();
await expect.poll(() => pending.length).toBe(3);
await respond(pending[2], 'Latest filter data');
// The message-turn view now lives behind its own tab.
await expect(
page.getByText('Latest filter data', { exact: true }),
page.getByText('Latest filter data', { exact: true }).first(),
).toBeVisible();
await respond(pending[0], 'Obsolete filter data');
await respond(pending[1], 'Obsolete filter data');
@@ -336,7 +353,7 @@ test.describe('monitoring request contracts', () => {
),
);
await expect(
page.getByText('Latest filter data', { exact: true }),
page.getByText('Latest filter data', { exact: true }).first(),
).toBeVisible();
await page
.getByRole('button', { name: 'Refresh Data', exact: true })
@@ -350,14 +367,14 @@ test.describe('monitoring request contracts', () => {
await expect.poll(() => pending.length).toBe(5);
await respond(pending[4], 'Current result');
await expect(
page.getByText('Current result', { exact: true }),
page.getByText('Current result', { exact: true }).first(),
).toBeVisible();
await pending[3].fulfill({
status: 500,
json: { code: 500, msg: 'old failure' },
});
await expect(
page.getByText('Current result', { exact: true }),
page.getByText('Current result', { exact: true }).first(),
).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
});
@@ -386,7 +403,7 @@ test.describe('monitoring request contracts', () => {
page.getByText(
'Showing 7 of 125 messages. Conversation traces may be incomplete.',
),
).toBeVisible();
).toHaveCount(0);
await expect(
page.getByText('Traffic range truncated. Choose a shorter time range.'),
).toBeVisible();
@@ -589,56 +606,39 @@ test.describe('monitoring conversation turn grouping', () => {
expect(turns[0].totalToolDuration).toBe(45);
});
test('renders user-only, multi-agent, and multi-turn cases in the monitoring page', async ({
test('replaces the messages tab with full execution inputs, deliveries and calls', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: rawMonitoringData(),
});
await page.goto('/home/monitoring');
await expect(page.getByText('3 conversation turns')).toBeVisible();
await expect(
page.getByText('Standalone question with no reply'),
).toBeVisible();
await expect(page.getByText('No assistant reply recorded')).toBeVisible();
await expect(page.getByText('Need deployment plan')).toBeVisible();
await expect(page.getByText('Agent step 1: inspect repo')).toBeVisible();
await expect(page.getByText('Assistant +2')).toBeVisible();
await expect(page.getByText('3 LLM')).toBeVisible();
await expect(page.getByText('2 tools')).toBeVisible();
await expect(page.getByText('790 tokens')).toBeVisible();
await expect(page.getByText('1 errors')).toBeVisible();
await expect(page.getByText('Continue with rollback plan')).toBeVisible();
await expect(page.getByText('Rollback plan ready')).toBeVisible();
const agentTurn = page
.locator('div[role="button"]')
.filter({ hasText: 'Need deployment plan' });
await expect(agentTurn).toHaveCount(1);
await agentTurn.click();
await expect(page.getByText('Conversation Trace')).toBeVisible();
await expect(page.getByText('Agent step 2: run tests')).toBeVisible();
page.getByRole('tab', { name: 'Message Records' }),
).toHaveCount(0);
await page
.getByRole('button')
.filter({ hasText: 'Need deployment plan' })
.click();
const sheet = page.getByRole('dialog');
await expect(sheet.getByText('Event / input (1)')).toBeVisible();
await expect(
page.getByText('Final answer: deployment plan ready'),
sheet.getByText('Need deployment plan', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText('LLM Calls (3)')).toBeVisible();
await expect(page.getByText('#3 gpt-5.5')).toBeVisible();
await expect(page.getByText('In: 300')).toBeVisible();
await expect(page.getByText('Out: 90')).toBeVisible();
await expect(page.getByText('Total: 390')).toBeVisible();
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
await expect(page.getByText('#1 repo_search')).toBeVisible();
await expect(page.getByText('#2 run_tests')).toBeVisible();
await expect(page.getByText('Arguments')).toHaveCount(0);
await expect(page.getByText('Result')).toHaveCount(0);
await page.getByText('#1 repo_search').click();
await expect(page.getByText('Arguments').first()).toBeVisible();
await expect(page.getByText('Result').first()).toBeVisible();
await expect(page.getByText('Tool retry failed')).toBeVisible();
await expect(sheet.getByText('Delivery records (3)')).toBeVisible();
await expect(
sheet.getByText('Final answer: deployment plan ready').first(),
).toBeVisible();
await sheet.getByText('Model calls (3)', { exact: true }).click();
await expect(sheet.getByText('gpt-5.5').first()).toBeVisible();
await sheet.getByText('Tool calls (2)', { exact: true }).click();
await expect(
sheet.locator('span.break-all').filter({ hasText: /^repo_search$/ }),
).toBeVisible();
await sheet.getByText('Errors (1)', { exact: true }).click();
await expect(sheet.getByText('Tool retry failed').first()).toBeVisible();
await sheet.getByText('Conversation context (6)', { exact: true }).click();
await expect(sheet.getByText('Rollback plan ready')).toBeVisible();
});
});
+804
View File
@@ -0,0 +1,804 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const chat = {
uuid: 'assistant-test',
revision: 0,
status: 'ready',
messages: [],
pending: [],
error: null,
model_uuid: null,
model_name: null,
};
test('resizes from the top handle, keeps height on reopen, and fits small viewports', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Model' } },
}),
);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/home/bots');
const panel = page.getByRole('dialog', { name: 'Workspace assistant' });
const handle = panel.getByRole('separator', {
name: 'Resize assistant height',
});
await expect(handle).toBeVisible();
const initial = (await panel.boundingBox())!;
const grip = (await handle.boundingBox())!;
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(
grip.x + grip.width / 2,
grip.y + grip.height / 2 - 140,
{ steps: 8 },
);
await page.mouse.up();
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBeCloseTo(initial.height + 140, 0);
const expanded = (await panel.boundingBox())!;
expect(expanded.y + expanded.height).toBeCloseTo(
initial.y + initial.height,
0,
);
await panel
.getByRole('button', { name: 'Collapse workspace assistant', exact: true })
.click();
const dock = page.getByRole('button', {
name: 'Expand workspace assistant',
exact: true,
});
await expect(dock).toBeVisible();
expect((await dock.boundingBox())!.height).toBe(21);
await dock.click();
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBeCloseTo(expanded.height, 0);
await page.reload();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBeCloseTo(expanded.height, 0);
await handle.focus();
await page.keyboard.press('Home');
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(280);
await page.keyboard.press('ArrowUp');
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(304);
await page.keyboard.press('End');
const preferredHeight = page.viewportSize()!.height - 48;
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBe(page.viewportSize()!.height - 48);
await page.setViewportSize({ width: 375, height: 580 });
await expect
.poll(async () => (await panel.boundingBox())!.y)
.toBeGreaterThanOrEqual(48);
await expect(panel.getByRole('textbox')).toBeVisible();
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(532);
await page.reload();
await expect(panel).toBeVisible();
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(532);
expect(
await page.evaluate(() =>
localStorage.getItem('langbot-assistant-panel-height'),
),
).toBe(String(preferredHeight));
await page.setViewportSize({ width: 1280, height: 1000 });
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBe(preferredHeight);
const moveHandle = panel.getByRole('slider', {
name: 'Move assistant horizontally',
});
const beforeMove = (await panel.boundingBox())!;
const title = (await moveHandle.boundingBox())!;
await page.mouse.move(title.x + title.width / 2, title.y + title.height / 2);
await page.mouse.down();
await page.mouse.move(
title.x + title.width / 2 + 180,
title.y + title.height / 2,
{ steps: 8 },
);
await page.mouse.up();
await expect
.poll(async () => (await panel.boundingBox())!.x)
.toBeCloseTo(beforeMove.x + 180, 0);
await page.reload();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())!.x)
.toBeCloseTo(beforeMove.x + 180, 0);
await moveHandle.focus();
await page.keyboard.press('End');
await expect
.poll(async () => {
const box = (await panel.boundingBox())!;
return box.x + box.width;
})
.toBe(1268);
await page.keyboard.press('Home');
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
await page.setViewportSize({ width: 375, height: 580 });
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
expect((await panel.boundingBox())!.width).toBe(351);
await page.reload();
await expect(panel).toBeVisible();
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
await page.setViewportSize({ width: 1440, height: 1000 });
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
await panel
.getByRole('button', { name: 'Collapse workspace assistant', exact: true })
.click();
await expect(dock).toBeVisible();
const dockBox = (await dock.boundingBox())!;
const startX = dockBox.x + dockBox.width / 2;
const startY = dockBox.y + dockBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 150, startY, { steps: 8 });
await page.mouse.up();
await expect(dock).toBeVisible();
await expect(panel).toHaveCount(0);
await expect
.poll(async () => (await dock.boundingBox())!.x)
.toBeCloseTo(dockBox.x + 150, 0);
await dock.click();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())!.x)
.toBeCloseTo(162, 0);
await page.reload();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())!.x)
.toBeCloseTo(162, 0);
});
test('sending survives model labels appearing and disappearing in stream snapshots', async ({
page,
}) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (/createPortal|removeChild|createRoot/.test(message.text()))
errors.push(message.text());
});
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
}),
);
let current: Record<string, unknown> = chat;
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({
json: {
code: 0,
data:
route.request().method() === 'POST' ? chat : { conversations: [] },
},
}),
);
await page.route(
'**/api/v1/assistant/conversations/assistant-test',
(route) =>
route.fulfill({
json: { code: 0, data: current },
}),
);
let turn = 0;
await page.route(
'**/api/v1/assistant/conversations/*/turn/stream',
async (route) => {
turn += 1;
current = {
...chat,
revision: turn,
model_uuid: 'llm-valid',
model_name: turn === 2 ? 'Snapshot model' : null,
messages: [{ role: 'assistant', content: `Reply ${turn}` }],
};
await route.fulfill({
contentType: 'application/x-ndjson',
body:
JSON.stringify({ kind: 'snapshot', data: current }) +
'\n' +
JSON.stringify({ kind: 'completed', data: current }) +
'\n',
});
},
);
await page.goto('/home/bots');
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
const model = assistant.getByRole('combobox', { name: 'Select Model' });
await expect(model).toContainText('Recommended model');
for (let i = 1; i <= 3; i++) {
await assistant.getByRole('textbox').fill(`Hello ${i}`);
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
await expect(
assistant.getByText(`Reply ${i}`, { exact: true }),
).toBeVisible();
await expect(model).toContainText(
i === 2 ? 'Snapshot model' : 'Valid Mock Model',
);
}
expect(errors).toEqual([]);
});
test('selects the recommendation on entry and submits the visible model', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
}),
);
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({ json: { code: 0, data: chat } }),
);
await page.route(
'**/api/v1/assistant/conversations/*/turn/stream',
async (route) => {
expect(route.request().postDataJSON().model_uuid).toBe('llm-valid');
const result = {
...chat,
revision: 1,
model_uuid: 'llm-valid',
model_name: 'Recommended model',
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hello back' },
],
};
await route.fulfill({
contentType: 'application/x-ndjson',
body: JSON.stringify({ kind: 'completed', data: result }) + '\n',
});
},
);
await page.goto('/home/bots');
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
await expect(
assistant.getByRole('combobox', { name: 'Select Model' }),
).toContainText('Recommended model');
await assistant.getByRole('textbox').fill('Hello');
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
await expect(
assistant.getByText('Hello back', { exact: true }),
).toBeVisible();
});
test('late recommendation cannot overwrite manual selection, including request payload', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/v1/assistant/recommended-model', async (route) => {
await gate;
await route.fulfill({
json: {
code: 0,
data: { uuid: 'recommended-other', name: 'Other recommendation' },
},
});
});
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({ json: { code: 0, data: chat } }),
);
await page.route(
'**/api/v1/assistant/conversations/*/turn/stream',
async (route) => {
expect(route.request().postDataJSON().model_uuid).toBe('llm-valid');
await route.fulfill({
contentType: 'application/x-ndjson',
body:
JSON.stringify({
kind: 'completed',
data: {
...chat,
revision: 1,
model_uuid: 'llm-valid',
model_name: 'Valid Mock Model',
messages: [{ role: 'assistant', content: 'Manual model used' }],
},
}) + '\n',
});
},
);
await page.goto('/home/bots');
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
await assistant.getByRole('combobox', { name: 'Select Model' }).click();
await page.getByRole('option', { name: /Valid Mock Model/ }).click();
release();
await expect(assistant.getByText('Preparing assistant…')).toHaveCount(0);
await expect(
assistant.getByRole('combobox', { name: 'Select Model' }),
).toContainText('Valid Mock Model');
await assistant.getByRole('textbox').fill('Hello');
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
await expect(
assistant.getByText('Manual model used', { exact: true }),
).toBeVisible();
});
test('renders streaming text and tool progress before completion', async ({
page,
}) => {
const { createServer } = await import('node:http');
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const started = {
...chat,
revision: 1,
status: 'running',
model_uuid: 'llm-valid',
model_name: 'Recommended model',
messages: [{ role: 'user', content: 'Inspect resources' }],
};
let currentConversation: Record<string, unknown> = started;
const server = createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader(
'Access-Control-Allow-Headers',
req.headers['access-control-request-headers'] || '*',
);
if (req.method === 'OPTIONS') {
res.end();
return;
}
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
const emit = (kind: string, data: unknown) =>
res.write(JSON.stringify({ kind, data }) + '\n');
emit('snapshot', started);
emit('phase', { phase: 'thinking', round: 1 });
emit('text', { text: 'Inspecting your resources' });
emit('phase', {
phase: 'tool',
tool: { name: 'list_resources', arguments: { kind: 'models' } },
});
await gate;
currentConversation = {
...started,
status: 'ready',
messages: [
...started.messages,
{
role: 'tool',
content: '{}',
tool: {
name: 'list_resources',
arguments: { kind: 'models' },
result: { total: 1, items: [] },
},
},
{ role: 'assistant', content: 'Found your model' },
],
};
emit('completed', currentConversation);
res.end();
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address() as { port: number };
try {
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
// The panel refreshes the persisted conversation after streaming finishes.
// Return the same snapshot as the stream instead of the generic API fallback.
await page.route(
'**/api/v1/assistant/conversations/assistant-test',
(route) =>
route.fulfill({ json: { code: 0, data: currentConversation } }),
);
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: {
code: 0,
data: { uuid: 'llm-valid', name: 'Recommended model' },
},
}),
);
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({ json: { code: 0, data: chat } }),
);
await page.route(
'**/api/v1/assistant/conversations/*/turn/stream',
(route) =>
route.continue({ url: `http://127.0.0.1:${address.port}/stream` }),
);
await page.goto('/home/bots');
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
await expect(
assistant.getByRole('combobox', { name: 'Select Model' }),
).toContainText('Recommended model');
await assistant.getByRole('textbox').fill('Inspect resources');
await assistant.getByRole('button', { name: 'Send', exact: true }).click();
await expect(
assistant.getByText('Inspecting your resources', { exact: true }),
).toBeVisible();
await expect(
assistant.getByRole('button', { name: /Find chat models Running/ }),
).toBeVisible();
await expect(
assistant.getByRole('button', { name: 'Stop task', exact: true }),
).toBeEnabled();
release();
await expect(
assistant.getByText('Found your model', { exact: true }),
).toBeVisible();
await expect(
assistant.getByText('Inspecting your resources', { exact: true }),
).toHaveCount(0);
const bubble = assistant
.locator('[class*="assistantMessage"]')
.filter({ hasText: 'Found your model' });
await expect(
bubble.getByRole('button', { name: /Find chat models Completed/ }),
).toBeVisible();
} finally {
release();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('without providers shows setup only and rechecks after model settings close', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
await page.route('**/api/v1/provider/requesters', (route) =>
route.fulfill({ json: { code: 0, data: { requesters: [] } } }),
);
let configured = false;
let recommendations = 0;
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: {
code: 0,
data: {
providers: configured
? [
{
uuid: 'provider',
name: 'Custom provider',
requester: 'openai-chat-completions',
config: {},
},
]
: [],
},
},
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) => {
recommendations++;
return route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
});
});
await page.goto('/home/bots');
const setup = page.getByRole('complementary', {
name: 'Workspace assistant',
});
await expect(
setup.getByRole('button', { name: 'Sign in to LangBot Account' }),
).toBeVisible();
await expect(
page.getByRole('dialog', { name: 'Workspace assistant' }),
).toHaveCount(0);
expect(recommendations).toBe(0);
await setup.getByRole('button', { name: 'Configure models' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
configured = true;
await page.keyboard.press('Escape');
await expect(
page
.getByRole('dialog', { name: 'Workspace assistant' })
.getByRole('combobox', { name: 'Select Model' }),
).toContainText('Recommended model');
});
test('setup login starts the existing LangBot Account authorization flow', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [] } },
}),
);
await page.goto('/home/bots');
const request = page.waitForRequest(
(request) =>
request.url().includes('/space/') && request.url().includes('authorize'),
);
await page
.getByRole('complementary', { name: 'Workspace assistant' })
.getByRole('button', { name: 'Sign in to LangBot Account' })
.click();
expect((await request).url()).toContain('authorize');
});
test('switches conversations, restores background progress after reload and stops only the selected task', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Recommended model' } },
}),
);
const running = {
...chat,
uuid: 'running-chat',
revision: 3,
status: 'running',
model_uuid: 'llm-valid',
model_name: 'Recommended model',
messages: [{ role: 'user', content: 'Background request' }],
progress: {
text: 'Working in the background',
phase: 'thinking',
round: 2,
},
};
const ready = {
...chat,
uuid: 'ready-chat',
model_uuid: 'llm-valid',
model_name: 'Recommended model',
messages: [{ role: 'assistant', content: 'Previous answer' }],
};
let stopped = false;
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({
json: {
code: 0,
data: {
conversations: [
{
uuid: running.uuid,
title: 'Background request',
status: stopped ? 'failed' : 'running',
},
{
uuid: ready.uuid,
title: 'Previous conversation',
status: 'ready',
},
],
},
},
}),
);
await page.route('**/api/v1/assistant/conversations/*', (route) => {
const data = route.request().url().endsWith('ready-chat')
? ready
: stopped
? {
...running,
status: 'failed',
error: 'stopped',
progress: undefined,
}
: running;
return route.fulfill({ json: { code: 0, data } });
});
await page.route(
'**/api/v1/assistant/conversations/running-chat/stop',
(route) => {
expect(route.request().postDataJSON()).toEqual({ revision: 3 });
stopped = true;
return route.fulfill({
json: {
code: 0,
data: {
...running,
status: 'failed',
error: 'stopped',
progress: undefined,
},
},
});
},
);
await page.goto('/home/bots');
const assistant = page.getByRole('dialog', { name: 'Workspace assistant' });
await assistant
.getByRole('combobox', { name: 'Conversations', exact: true })
.click();
await page
.getByRole('option', { name: '◌ Background request', exact: true })
.click();
await expect(
assistant.getByText('Working in the background', { exact: true }),
).toBeVisible();
await assistant
.getByRole('combobox', { name: 'Conversations', exact: true })
.click();
await page
.getByRole('option', { name: 'Previous conversation', exact: true })
.click();
await expect(
assistant.getByText('Previous answer', { exact: true }),
).toBeVisible();
expect(stopped).toBe(false);
await assistant
.getByRole('combobox', { name: 'Conversations', exact: true })
.click();
await page
.getByRole('option', { name: '◌ Background request', exact: true })
.click();
await page.reload();
await expect(
assistant.getByText('Working in the background', { exact: true }),
).toBeVisible();
await assistant
.getByRole('button', { name: 'Stop task', exact: true })
.click();
await expect(assistant.getByRole('alert')).toContainText('Stopped.');
await expect(
assistant.getByRole('button', { name: 'Stop task', exact: true }),
).toHaveCount(0);
await assistant
.getByRole('button', { name: 'New conversation', exact: true })
.click();
await expect(assistant.getByRole('textbox')).toBeEmpty();
});
test('keeps the reading position through background updates and resumes following at the bottom', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Model' } },
}),
);
let updates = 0;
await page.route('**/api/v1/assistant/conversations', (route) =>
route.fulfill({
json: {
code: 0,
data: {
conversations: [
{
uuid: 'scroll-chat',
title: 'Long conversation',
status: 'running',
},
],
},
},
}),
);
await page.route('**/api/v1/assistant/conversations/scroll-chat', (route) => {
updates++;
return route.fulfill({
json: {
code: 0,
data: {
...chat,
uuid: 'scroll-chat',
status: 'running',
model_uuid: 'llm-valid',
model_name: 'Model',
messages: [
{
role: 'assistant',
content: Array.from(
{ length: 40 },
(_, i) => `Paragraph ${i}`,
).join('\n\n'),
},
],
progress: {
text: Array.from({ length: updates }, (_, i) => `Update ${i}`).join(
'\n\n',
),
phase: 'thinking',
},
},
},
});
});
await page.goto('/home/bots');
const panel = page.getByRole('dialog', { name: 'Workspace assistant' });
await panel
.getByRole('combobox', { name: 'Conversations', exact: true })
.click();
await page
.getByRole('option', { name: '◌ Long conversation', exact: true })
.click();
const messages = panel.locator('[aria-live="polite"]');
const gap = () =>
messages.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
await expect.poll(gap).toBeLessThan(2);
await messages.hover();
await page.mouse.wheel(0, -600);
await expect.poll(gap).toBeGreaterThan(200);
const position = await messages.evaluate((el) => el.scrollTop);
const previousUpdates = updates;
await expect.poll(() => updates).toBeGreaterThan(previousUpdates + 1);
await expect
.poll(() => messages.evaluate((el) => el.scrollTop))
.toBeCloseTo(position, 0);
await messages.hover();
await page.mouse.wheel(0, 10000);
await expect.poll(gap).toBeLessThan(2);
const beforeFollow = updates;
await expect.poll(() => updates).toBeGreaterThan(beforeFollow + 1);
await expect.poll(gap).toBeLessThan(2);
});
-175
View File
@@ -1,175 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
// The floating assistant button docks against a viewport edge, collapses into a
// short blue rail, and re-expands on hover. The transition rules are pure so
// they can be pinned here: the race this guards against is a button that drops
// under the cursor and never becomes visible again.
const sourcePath = new URL(
'../../src/app/home/components/assistant-dock.ts',
import.meta.url,
);
assert.ok(fs.existsSync(sourcePath), 'Missing assistant dock module');
const compiled = ts.transpileModule(fs.readFileSync(sourcePath, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loaded = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
() => {
throw new Error('assistant-dock must stay dependency free');
},
loaded,
loaded.exports,
);
const {
ASSISTANT_BUTTON_SIZE,
ASSISTANT_SNAP_THRESHOLD,
ASSISTANT_RAIL_WIDTH,
clampAssistantPosition,
resolveAssistantEdge,
shouldCollapseRail,
shouldExpandRail,
restingRailExpanded,
} = loaded.exports;
const viewport = { width: 1280, height: 800 };
const maxX = viewport.width - ASSISTANT_BUTTON_SIZE;
test('clamping keeps the button fully inside the viewport', () => {
assert.deepEqual(clampAssistantPosition(-50, -50, viewport), { x: 0, y: 0 });
assert.deepEqual(clampAssistantPosition(9999, 9999, viewport), {
x: maxX,
y: viewport.height - ASSISTANT_BUTTON_SIZE,
});
assert.deepEqual(clampAssistantPosition(400, 300, viewport), {
x: 400,
y: 300,
});
});
test('a narrow viewport never produces a negative travel range', () => {
assert.deepEqual(clampAssistantPosition(10, 10, { width: 20, height: 20 }), {
x: 0,
y: 0,
});
});
test('positions within the snap threshold dock to the nearest edge', () => {
assert.equal(resolveAssistantEdge(0, viewport), 'left');
assert.equal(
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD, viewport),
'left',
);
assert.equal(resolveAssistantEdge(maxX, viewport), 'right');
assert.equal(
resolveAssistantEdge(maxX - ASSISTANT_SNAP_THRESHOLD, viewport),
'right',
);
});
test('a centred position stays free floating', () => {
assert.equal(resolveAssistantEdge(Math.round(maxX / 2), viewport), null);
assert.equal(
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD + 1, viewport),
null,
);
});
test('the left edge wins ties so a centred drop is deterministic', () => {
// A viewport sized so both gaps are inside the snap threshold at x = 0.
const tight = { width: ASSISTANT_BUTTON_SIZE, height: 400 };
assert.equal(resolveAssistantEdge(0, tight), 'left');
});
test('hovering a docked rail expands it, hovering a free button does not', () => {
assert.equal(
shouldExpandRail({
dockedEdge: 'right',
railExpanded: false,
dragging: false,
}),
true,
);
assert.equal(
shouldExpandRail({
dockedEdge: null,
railExpanded: false,
dragging: false,
}),
false,
);
// Already expanded: nothing to do, so no redundant state churn on every move.
assert.equal(
shouldExpandRail({
dockedEdge: 'left',
railExpanded: true,
dragging: false,
}),
false,
);
});
test('a drag in progress never expands or collapses the rail', () => {
const dragging = { dockedEdge: 'left', railExpanded: false, dragging: true };
assert.equal(shouldExpandRail(dragging), false);
assert.equal(shouldCollapseRail(dragging), false);
});
test('only an *expanded* docked button collapses when the pointer leaves', () => {
// Regression: keying this on `!railExpanded` made a revealed button
// impossible to collapse again, so it stayed open forever after one hover.
assert.equal(
shouldCollapseRail({
dockedEdge: 'left',
railExpanded: true,
dragging: false,
}),
true,
);
assert.equal(
shouldCollapseRail({
dockedEdge: 'left',
railExpanded: false,
dragging: false,
}),
false,
);
assert.equal(
shouldCollapseRail({
dockedEdge: null,
railExpanded: true,
dragging: false,
}),
false,
);
});
test('expand then leave round-trips back to the collapsed rail', () => {
const docked = { dockedEdge: 'right', railExpanded: false, dragging: false };
// Hover: reveal the button.
assert.equal(shouldExpandRail(docked), true);
// Pointer leaves: the revealed button must collapse again.
assert.equal(shouldCollapseRail({ ...docked, railExpanded: true }), true);
// A second hover re-reveals it, so the cycle is repeatable.
assert.equal(shouldExpandRail(docked), true);
});
test('dropping always starts collapsed so the rail is never stuck open', () => {
// The pointer is still over the button on drop; the next pointermove expands.
assert.equal(restingRailExpanded(), false);
assert.equal(
shouldExpandRail({
dockedEdge: 'right',
railExpanded: restingRailExpanded(),
dragging: false,
}),
true,
);
});
test('the rail strip stays thinner than the button it replaces', () => {
assert.ok(ASSISTANT_RAIL_WIDTH > 0);
assert.ok(ASSISTANT_RAIL_WIDTH < ASSISTANT_BUTTON_SIZE);
});
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
currentDirectory,
'../../src/app/infra/hooks/useInstalledPluginIcon.ts',
);
const source = fs.readFileSync(sourcePath, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
// --- minimal React + backend mocks so the hook can run under node:test ---
let stateSlots = [];
let lastCleanup;
const reactMock = {
useState(initial) {
const value = typeof initial === 'function' ? initial() : initial;
const index = stateSlots.length;
stateSlots.push(value);
return [
value,
(next) => {
stateSlots[index] =
typeof next === 'function' ? next(stateSlots[index]) : next;
},
];
},
useEffect(effect) {
const cleanup = effect();
if (typeof cleanup === 'function') lastCleanup = cleanup;
},
};
const httpMock = {
httpClient: {
getAuthenticatedPluginIconURL: () => {
throw new Error('fetch not configured for this test');
},
},
};
const sourceRequire = createRequire(sourcePath);
function loadHook() {
const loadedModule = { exports: {} };
const requireShim = (id) => {
if (id === 'react') return reactMock;
if (id === '@/app/infra/http') return httpMock;
return sourceRequire(id);
};
new Function('require', 'module', 'exports', compiled)(
requireShim,
loadedModule,
loadedModule.exports,
);
return loadedModule.exports.useInstalledPluginIcon;
}
const revoked = [];
globalThis.URL.revokeObjectURL = (url) => revoked.push(url);
function reset() {
stateSlots = [];
lastCleanup = undefined;
revoked.length = 0;
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
test('caches per author/name and revokes once the last subscriber leaves', async () => {
let fetches = 0;
let resolveFetch;
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
fetches += 1;
return new Promise((resolve) => {
resolveFetch = resolve;
});
};
reset();
const useInstalledPluginIcon = loadHook();
const first = useInstalledPluginIcon('acme', 'demo');
const firstCleanup = lastCleanup;
assert.equal(first, null);
assert.equal(fetches, 1);
resolveFetch('blob:acme/demo');
await flush();
// Second subscriber reuses the cached object URL synchronously.
const second = useInstalledPluginIcon('acme', 'demo');
const secondCleanup = lastCleanup;
assert.equal(second, 'blob:acme/demo');
assert.equal(fetches, 1);
firstCleanup();
assert.deepEqual(revoked, []);
secondCleanup();
assert.deepEqual(revoked, ['blob:acme/demo']);
// Cache was dropped with the last subscriber, so a new mount refetches.
useInstalledPluginIcon('acme', 'demo');
assert.equal(fetches, 2);
});
test('de-duplicates concurrent subscribers and revokes a shared icon once', async () => {
let fetches = 0;
let resolveFetch;
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
fetches += 1;
return new Promise((resolve) => {
resolveFetch = resolve;
});
};
reset();
const useInstalledPluginIcon = loadHook();
useInstalledPluginIcon('acme', 'demo');
const firstCleanup = lastCleanup;
useInstalledPluginIcon('acme', 'demo');
const secondCleanup = lastCleanup;
assert.equal(fetches, 1);
resolveFetch('blob:shared');
await flush();
firstCleanup();
assert.deepEqual(revoked, []);
secondCleanup();
assert.deepEqual(revoked, ['blob:shared']);
});
test('caches nothing and revokes nothing when the authenticated fetch fails', async () => {
let fetches = 0;
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
fetches += 1;
return Promise.reject(new Error('not found'));
};
reset();
const useInstalledPluginIcon = loadHook();
const value = useInstalledPluginIcon('acme', 'missing');
const cleanup = lastCleanup;
assert.equal(value, null);
await flush();
assert.equal(useInstalledPluginIcon('acme', 'missing'), null);
assert.equal(fetches, 2); // failures are not negatively cached
cleanup();
assert.deepEqual(revoked, []);
});
test('returns a marketplace URL verbatim without fetching', () => {
let fetches = 0;
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
fetches += 1;
return Promise.resolve('blob:unused');
};
reset();
const useInstalledPluginIcon = loadHook();
const value = useInstalledPluginIcon(
'acme',
'demo',
'https://space.langbot.app/icon',
);
assert.equal(value, 'https://space.langbot.app/icon');
assert.equal(fetches, 0);
assert.equal(lastCleanup, undefined);
});
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
// The resolver is pure: recorded calls carry a time and a session, the execution
// list carries the runs' windows, so the link is decided without any re-query.
const source = fs.readFileSync(
new URL('../../src/app/home/monitoring/utils/callLinks.ts', import.meta.url),
'utf8',
);
const module = { exports: {} };
new Function(
'exports',
ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
)(module.exports);
const { findCallExecution, resolveCallTarget } = module.exports;
const at = (iso) => new Date(iso);
function run(overrides) {
return {
source: 'agent',
id: 'run-1',
session_id: 'session-a',
started_at_ms: at('2026-10-07T10:00:00Z').getTime(),
finished_at_ms: at('2026-10-07T10:00:30Z').getTime(),
...overrides,
};
}
function call(overrides) {
return {
id: 'call-1',
timestamp: at('2026-10-07T10:00:10Z'),
sessionId: 'session-a',
...overrides,
};
}
test('links a pipeline call to the execution carrying its message id', () => {
const execution = run({
source: 'pipeline',
id: 'message-9',
session_id: null,
});
assert.equal(
findCallExecution(call({ messageId: 'message-9', sessionId: undefined }), [
execution,
]),
execution,
);
});
test('links an agent call to the run whose window contains it', () => {
const execution = run({});
assert.equal(findCallExecution(call({}), [execution]), execution);
});
test('prefers the run that was live when a session has consecutive runs', () => {
const first = run({ id: 'run-1' });
const second = run({
id: 'run-2',
started_at_ms: at('2026-10-07T10:05:00Z').getTime(),
finished_at_ms: at('2026-10-07T10:05:30Z').getTime(),
});
assert.equal(
findCallExecution(call({ timestamp: at('2026-10-07T10:05:10Z') }), [
first,
second,
]).id,
'run-2',
);
});
test('an open run owns every later call of its session', () => {
const execution = run({ finished_at_ms: null });
assert.equal(
findCallExecution(call({ timestamp: at('2026-10-07T10:20:00Z') }), [
execution,
]),
execution,
);
});
test('a call outside every window links to nothing', () => {
const execution = run({});
assert.equal(
findCallExecution(call({ timestamp: at('2026-10-07T11:00:00Z') }), [
execution,
]),
null,
);
assert.equal(
findCallExecution(call({ timestamp: at('2026-10-07T09:00:00Z') }), [
execution,
]),
null,
);
assert.equal(
findCallExecution(call({ sessionId: 'other' }), [execution]),
null,
);
});
test('a call keeps its message target when the execution list cannot resolve it', () => {
assert.deepEqual(
resolveCallTarget(
call({ messageId: 'message-9', sessionId: 'other' }),
[run({})],
new Set(['message-9']),
),
{ kind: 'message', id: 'message-9' },
);
// An Agent call carries its run id in the same field: an id the message list
// does not hold must not become a jump to the message tab.
assert.equal(
resolveCallTarget(
call({ messageId: 'run-9', sessionId: 'other' }),
[run({})],
new Set(['message-9']),
),
null,
);
assert.equal(resolveCallTarget(call({}), []), null);
});