fix(wizard): prepare default Local Agent and refine Runner setup

This commit is contained in:
RockChinQ
2026-09-18 19:27:56 +08:00
parent bece2aa554
commit da4b0d6ca2
18 changed files with 1424 additions and 359 deletions
+1 -1
View File
@@ -301,7 +301,7 @@ function makePipeline(
};
}
function pipelineMetadata(withRunnerToolSelector = false) {
export function pipelineMetadata(withRunnerToolSelector = false) {
const runnerId = 'plugin:langbot-team/LocalAgent/default';
return {
configs: [
+542
View File
@@ -0,0 +1,542 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
pipelineMetadata,
} from './fixtures/langbot-api';
const runnerId = 'plugin:langbot-team/LocalAgent/default';
const ok = (data: unknown) => JSON.stringify({ code: 0, message: 'ok', data });
for (const failsFirst of [false, true]) {
test(`prepares Local Agent before enabling the bot${failsFirst ? ' after an installation retry' : ''}`, async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
let installed = false;
let installCount = 0;
let modelCount = 0;
let messageReceived = false;
const order: string[] = [];
const configs: Record<string, any>[] = [];
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
const metadata = pipelineMetadata();
if (!installed) metadata.configs[0].stages = [];
else {
const model = metadata.configs[0].stages.find(
(stage) => stage.name === runnerId,
)!.config[0];
model.default = { primary: '', fallbacks: [] } as never;
}
await route.fulfill({
contentType: 'application/json',
body: ok(metadata),
});
});
await page.route(
'**/api/v1/marketplace/plugins/langbot-team/LocalAgent',
async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
plugin: {
author: 'langbot-team',
name: 'LocalAgent',
label: { en_US: 'Local Agent' },
latest_version: '0.1.7',
},
}),
});
},
);
await page.route('**/api/v1/plugins/install/marketplace', async (route) => {
installCount += 1;
expect(route.request().postDataJSON()).toEqual({
plugin_author: 'langbot-team',
plugin_name: 'LocalAgent',
plugin_version: '0.1.7',
});
order.push('install');
await route.fulfill({
contentType: 'application/json',
body: ok({ task_id: installCount }),
});
});
await page.route('**/api/v1/system/tasks/*', async (route) => {
const failed = failsFirst && installCount === 1;
installed = !failed;
await route.fulfill({
contentType: 'application/json',
body: ok({
runtime: {
done: true,
exception: failed ? 'Download failed: connection reset' : null,
},
}),
});
});
await page.route(
'**/api/v1/system/wizard/recommended-model',
async (route) => {
modelCount += 1;
order.push('model');
await route.fulfill({
contentType: 'application/json',
body: ok({ uuid: 'recommended-model', name: 'Recommended' }),
});
},
);
await page.route('**/api/v1/platform/bots/*/logs', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: ok({
logs: messageReceived
? [
{
seq_id: 1,
timestamp: Date.now() / 1000,
level: 'info',
text: 'Received message',
images: [],
message_session_id: 'person_123',
},
]
: [],
total_count: messageReceived ? 1 : 0,
}),
});
});
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (
request.method() === 'PUT' &&
path === '/api/v1/pipelines/pipeline-1'
) {
configs.push(request.postDataJSON().config);
order.push('configure');
}
if (
request.method() === 'PUT' &&
path === '/api/v1/platform/bots/bot-1' &&
request.postDataJSON().enable
)
order.push('enable');
});
await page.goto('/wizard');
await page.getByText('Playwright Adapter', { exact: true }).click();
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
await page.getByRole('button', { name: 'Save & Enable Bot' }).click();
if (failsFirst) {
await expect(
page.getByText(/Download failed: connection reset/),
).toBeVisible();
expect(order).toEqual(['install']);
await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled();
await page.getByRole('button', { name: 'Save & Enable Bot' }).click();
}
await expect(
page.getByRole('button', { name: 'Re-save Configuration' }),
).toBeVisible();
expect(order.slice(-4)).toEqual([
'install',
'model',
'configure',
'enable',
]);
expect(configs[0].ai.runner.id).toBe(runnerId);
expect(configs[0].ai.runner_config[runnerId].model.primary).toBe(
'recommended-model',
);
await page.getByRole('button', { name: 'Re-save Configuration' }).click();
await expect.poll(() => configs.length).toBe(2);
expect(installCount).toBe(failsFirst ? 2 : 1);
expect(modelCount).toBe(1);
messageReceived = true;
await page.getByRole('button', { name: 'Next' }).click();
await expect(
page.getByText('Use the default setup', { exact: true }),
).toBeVisible();
await expect(page.getByText('Local Agent', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Done', exact: true }).click();
await expect(
page.getByRole('button', { name: 'Back to Workbench' }),
).toBeVisible();
expect(configs.length).toBe(2);
});
}
test('uses a custom model in the existing Local Agent pipeline', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
await page.route('**/api/v1/platform/bots/*/logs', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
logs: [
{
seq_id: 1,
timestamp: Date.now() / 1000,
level: 'info',
text: 'Received message',
images: [],
message_session_id: 'person_123',
},
],
total_count: 1,
},
},
});
});
await page.route('**/api/v1/provider/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith('/requesters'))
return route.fulfill({
json: {
code: 0,
data: {
requesters: [
{
name: 'openai',
label: { en_US: 'OpenAI API' },
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm'],
config: [
{ name: 'base_url', default: 'https://api.openai.com/v1' },
],
},
},
],
},
},
});
if (path.endsWith('/providers') && route.request().method() === 'POST')
return route.fulfill({
json: { code: 0, data: { uuid: 'own-provider' } },
});
if (path.endsWith('/scan-models'))
return route.fulfill({ json: { code: 0, data: { models: [] } } });
if (path.endsWith('/models/llm') && route.request().method() === 'POST')
return route.fulfill({ json: { code: 0, data: { uuid: 'own-model' } } });
await route.fallback();
});
await page.goto('/wizard');
await page.getByText('Playwright Adapter', { exact: true }).click();
await page.getByRole('button', { name: 'Confirm, Create Bot' }).click();
await page.getByRole('button', { name: 'Save & Enable Bot' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByText('Use My Own Model', { exact: true }).click();
await expect(
page.getByRole('button', { name: 'Use Selected Model & Finish' }),
).toBeDisabled();
await page.locator('input[name="name"]').fill('Wizard custom provider');
await page
.getByRole('button', { name: 'Select Provider Type', exact: true })
.click();
await page.getByRole('button', { name: /OpenAI API/ }).click();
await page.locator('input[name="api_key"]').fill('fixture-key');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByLabel('Model ID', { exact: false }).fill('test-model');
const update = page.waitForRequest(
(request) =>
request.method() === 'PUT' &&
new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1',
);
await page
.getByRole('button', { name: 'Use Selected Model & Finish' })
.click();
const config = (await update).postDataJSON().config;
expect(config.ai.runner.id).toBe(runnerId);
expect(config.ai.runner_config[runnerId].model.primary).toBe('own-model');
await expect(
page.getByRole('button', { name: 'Back to Workbench' }),
).toBeVisible();
});
test('only labels debugging Runners even when the marketplace is offline', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/system/info', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
version: 'fixture',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
wizard_status: 'none',
wizard_progress: {
step: 2,
created_bot_uuid: 'bot-1',
created_pipeline_uuid: 'pipeline-1',
selected_adapter: 'test',
bot_saved: true,
message_received: true,
},
},
},
});
});
const plugins = [
{ name: 'local', label: 'Local Runner', debug: false },
{ name: 'debug', label: 'Debug Runner', debug: true },
];
await page.route('**/api/v1/plugins', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
plugins: plugins.map((plugin) => ({
manifest: {
manifest: {
metadata: {
author: 'qa',
name: plugin.name,
description: { en_US: `${plugin.label} description` },
},
},
},
debug: plugin.debug,
})),
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
const metadata = pipelineMetadata();
const selector = metadata.configs[0].stages[0].config[0];
if (!('options' in selector))
throw new Error('Missing Runner options in fixture');
const options = selector.options;
for (const plugin of plugins) {
options.push({
name: `plugin:qa/${plugin.name}/default`,
label: { en_US: plugin.label, zh_Hans: plugin.label },
});
}
await route.fulfill({ json: { code: 0, data: metadata } });
});
await page.route('**/api/v1/marketplace/**', async (route) => {
await route.fulfill({
status: 503,
json: { code: 503, msg: 'Marketplace offline' },
});
});
await page.goto('/wizard');
await page
.getByRole('radio', { name: 'Connect an External Agent', exact: true })
.click();
const local = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Local Runner' });
const debug = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Debug Runner' });
await expect(local.getByText('Installed', { exact: true })).toHaveCount(0);
await expect(debug.getByText('Debugging', { exact: true })).toBeVisible();
await expect(debug.getByText('Installed', { exact: true })).toHaveCount(0);
await debug.getByRole('button', { name: 'Use This Runner' }).click();
await expect(
page
.locator('[data-slot="card"]')
.filter({ hasText: 'Debug Runner' })
.getByText('Debugging', { exact: true }),
).toBeVisible();
});
test('shows installation progress inside the matching card button and recovers after failure', async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/system/info', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
version: 'fixture',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
wizard_status: 'none',
wizard_progress: {
step: 2,
created_bot_uuid: 'bot-1',
created_pipeline_uuid: 'pipeline-1',
selected_adapter: 'test',
bot_saved: true,
message_received: true,
},
},
},
});
});
const plugin = (name: string) => ({
name,
author: 'qa',
label: { en_US: name },
description: { en_US: 'Test Runner' },
components: { Runner: 1 },
runner_usages: ['agent'],
latest_version: '1.0.0',
install_count: 0,
type: 'plugin',
});
await page.route(
'**/api/v1/marketplace/extensions/search**',
async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
extensions: [plugin('ProgressRunner'), plugin('OtherRunner')],
total: 2,
},
},
});
},
);
let attempt = 0;
let phase: 'download' | 'deps' | 'failed' | 'done' = 'download';
let registered = false;
await page.route('**/api/v1/plugins/install/marketplace', async (route) => {
attempt += 1;
phase = 'download';
await route.fulfill({ json: { code: 0, data: { task_id: attempt } } });
});
await page.route('**/api/v1/system/tasks/*', async (route) => {
await route.fulfill({
json: {
code: 0,
data: {
id: attempt,
name: 'plugin-install-marketplace',
label: 'ProgressRunner',
runtime: {
done: phase === 'done' || phase === 'failed',
exception:
phase === 'failed' ? 'Dependency installation failed' : null,
},
task_context: {
current_action:
phase === 'download'
? 'Downloading package'
: 'Installing dependencies',
metadata: { progress_percent: phase === 'download' ? 24 : 64 },
},
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', async (route) => {
const metadata = pipelineMetadata();
const selector = metadata.configs[0].stages[0].config[0];
if ('options' in selector)
selector.options.push({
name: 'plugin:qa/ExistingRunner/default',
label: { en_US: 'ExistingRunner', zh_Hans: 'ExistingRunner' },
});
metadata.configs[0].stages.push({
name: 'plugin:qa/ExistingRunner/default',
label: { en_US: 'ExistingRunner', zh_Hans: 'ExistingRunner' },
config: [
{
name: 'api-key',
label: { en_US: 'API Key', zh_Hans: 'API Key' },
type: 'string',
required: true,
default: '',
},
],
} as (typeof metadata.configs)[0]['stages'][number]);
if (registered && 'options' in selector)
selector.options.push({
name: 'plugin:qa/ProgressRunner/default',
label: { en_US: 'ProgressRunner', zh_Hans: 'ProgressRunner' },
});
await route.fulfill({ json: { code: 0, data: metadata } });
});
await page.goto('/wizard');
await page
.getByRole('radio', { name: 'Connect an External Agent', exact: true })
.click();
const card = page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('ProgressRunner', { exact: true }) });
const other = page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('OtherRunner', { exact: true }) });
const back = page.getByRole('button', {
name: /Back to (options|list)/,
exact: true,
});
await expect(back).toBeVisible();
const pickerBounds = await back.boundingBox();
await page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('ExistingRunner', { exact: true }) })
.getByRole('button', { name: 'Use This Runner' })
.click();
await expect(page.getByText('ExistingRunner Configuration')).toBeVisible();
await expect
.poll(async () => (await back.boundingBox())?.x)
.toBeCloseTo(pickerBounds!.x, 0);
await expect
.poll(async () => (await back.boundingBox())?.y)
.toBeCloseTo(pickerBounds!.y, 0);
await page.getByRole('textbox').fill('unsaved-test-key');
await page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('ExistingRunner', { exact: true }) })
.getByRole('button', { name: 'Use This Runner' })
.click();
await expect(page.getByRole('textbox')).toHaveValue('unsaved-test-key');
await card
.getByRole('button', { name: 'Install & Continue', exact: true })
.click();
await expect(
card.getByRole('button', { name: /Downloading.*24%/ }),
).toBeDisabled();
await expect(page.getByRole('progressbar')).toHaveCount(0);
await expect(
other.getByRole('button', { name: 'Install & Continue', exact: true }),
).toBeDisabled();
phase = 'deps';
await expect(
card.getByRole('button', { name: /Installing Dependencies.*64%/ }),
).toBeDisabled();
phase = 'failed';
await card
.getByRole('button', { name: 'Failed · Retry', exact: true })
.click();
await expect(
card.getByRole('button', { name: /Downloading.*24%/ }),
).toBeDisabled();
phase = 'done';
await expect(
card.getByRole('button', {
name: /Starting and refreshing components.*95%/,
}),
).toBeDisabled();
registered = true;
await expect(
card.getByRole('button', { name: 'Use This Runner' }),
).toBeEnabled();
await expect(
card.getByRole('button', { name: 'Use This Runner' }),
).toHaveAttribute('aria-pressed', 'true');
await expect(
other.getByRole('button', { name: 'Install & Continue' }),
).toBeEnabled();
expect(attempt).toBe(2);
});
@@ -509,6 +509,17 @@ test.describe('wizard and QR platform regressions', () => {
name: 'ai',
label: { en_US: 'AI Feature', zh_Hans: 'AI 能力' },
stages: [
{
name: 'plugin:langbot-team/LocalAgent/default',
config: [
{
name: 'model',
type: 'model-fallback-selector',
required: true,
default: { primary: 'llm-valid', fallbacks: [] },
},
],
},
{
name: 'runner',
label: { en_US: 'Runtime', zh_Hans: '运行方式' },
@@ -562,7 +573,15 @@ test.describe('wizard and QR platform regressions', () => {
await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled();
messageReceived = true;
await page.getByRole('button', { name: 'Next' }).click();
await page.getByText('External Runner', { exact: true }).click();
await expect(
page.getByText('Use the default setup', { exact: true }),
).toBeVisible();
await page.getByText('Connect an External Agent', { exact: true }).click();
await page
.locator('[data-slot="card"]')
.filter({ has: page.getByText('External Runner', { exact: true }) })
.getByRole('button', { name: 'Use This Runner' })
.click();
const deployButton = page.getByRole('button', { name: 'Create & Deploy' });
await expect(deployButton).toBeDisabled();
@@ -571,21 +590,14 @@ test.describe('wizard and QR platform regressions', () => {
const bindingRequest = page.waitForRequest(
(request) =>
request.method() === 'PUT' &&
new URL(request.url()).pathname === '/api/v1/platform/bots/bot-1',
new URL(request.url()).pathname === '/api/v1/pipelines/pipeline-1',
);
await deployButton.click();
const body = (await bindingRequest).postDataJSON();
expect(body.event_bindings).toEqual([
{
event_pattern: 'message.received',
target_type: 'pipeline',
target_uuid: 'pipeline-1',
filters: [],
priority: 0,
enabled: true,
description: '',
},
]);
expect(body.config.ai.runner.id).toBe('external-runner');
expect(body.config.ai.runner_config['external-runner']['api-key']).toBe(
'app-real-api-key',
);
await expect(
page.getByRole('button', { name: 'Back to Workbench' }),
).toBeVisible();
+19 -10
View File
@@ -28,6 +28,7 @@ function loadWizardUtils() {
}
const {
LOCAL_AGENT_RUNNER_ID,
configureLocalAgentPrimaryModel,
ensureHttpBotSigningSecret,
findDefaultPipeline,
@@ -81,23 +82,31 @@ test('configures the selected model as the Local Agent primary model', () => {
const config = {
trigger: { prefix: '!' },
ai: {
runner: { runner: 'plugin:external', timeout: 30 },
'local-agent': {
model: { primary: 'old-model', fallbacks: ['fallback-model'] },
tools: { enabled: true },
runner: { id: 'plugin:external', timeout: 30 },
runner_config: {
[LOCAL_AGENT_RUNNER_ID]: {
model: { primary: 'old-model', fallbacks: ['fallback-model'] },
tools: { enabled: true },
},
},
},
};
const updated = configureLocalAgentPrimaryModel(config, 'selected-model');
assert.equal(updated.ai.runner.runner, 'local-agent');
assert.equal(updated.ai.runner.id, LOCAL_AGENT_RUNNER_ID);
assert.equal(updated.ai.runner.timeout, 30);
assert.equal(updated.ai['local-agent'].model.primary, 'selected-model');
assert.deepEqual(updated.ai['local-agent'].model.fallbacks, [
'fallback-model',
]);
assert.deepEqual(updated.ai['local-agent'].tools, { enabled: true });
assert.equal(
updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].model.primary,
'selected-model',
);
assert.deepEqual(
updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].model.fallbacks,
['fallback-model'],
);
assert.deepEqual(updated.ai.runner_config[LOCAL_AGENT_RUNNER_ID].tools, {
enabled: true,
});
assert.deepEqual(updated.trigger, { prefix: '!' });
});
+2 -5
View File
@@ -89,13 +89,10 @@ test('requires the selected Runner mandatory configuration before finishing', ()
wizardSource,
/isRequiredRunnerConfigComplete\(selectedRunnerConfigItems, runnerConfig\)/,
);
assert.match(wizardSource, /aiChoice === 'external'/);
assert.match(
wizardSource,
/return selectedRunner !== null && isRunnerConfigComplete/,
);
assert.match(
wizardSource,
/!selectedRunner \|\|[\s\S]*?!isRunnerConfigComplete \|\|[\s\S]*?!createdBotUuid/,
/!createdBotUuid \|\| !createdPipelineUuid \|\| !canProceed\(\)/,
);
});