mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-27 10:55:21 +08:00
feat: add verified manual pipeline migration for plugin runners
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const contract = JSON.parse(
|
||||
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
|
||||
);
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
const canonical = JSON.parse(
|
||||
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
|
||||
);
|
||||
const outputSchema = contract.output_schema;
|
||||
async function setup(
|
||||
page: Page,
|
||||
plugin = 'weknora-agent',
|
||||
name = 'WeKnoraAgent',
|
||||
agentId: null | 'absent' = null,
|
||||
converted?: typeof canonical,
|
||||
) {
|
||||
const schema = contract.plugins[plugin].component;
|
||||
const id = `plugin:langbot-team/${name}/default`;
|
||||
const raw: Record<string, unknown> = {
|
||||
'api-key': ' fixture credential ',
|
||||
'base-url': ' http://localhost:8080/api/v1 ',
|
||||
'app-type': 'agent',
|
||||
'enable-all-tools': false,
|
||||
tools: ['tool-allowed'],
|
||||
'knowledge-bases': ['kb-allowed'],
|
||||
'mcp-resources': [{ server: 'private', uri: 'secret' }],
|
||||
'mcp-resource-agent-read-enabled': false,
|
||||
};
|
||||
if (agentId === null) raw['agent-id'] = null;
|
||||
if (plugin === 'LocalAgent') {
|
||||
raw['advanced-settings'] = true;
|
||||
raw.model = {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: ['llm-fallback'],
|
||||
reasoning: { 'llm-valid': 'high', 'llm-fallback': 'low' },
|
||||
};
|
||||
raw['remove-think'] = true;
|
||||
}
|
||||
let config = {
|
||||
...structuredClone(canonical),
|
||||
ai: { runner: { id }, runner_config: { [id]: raw } },
|
||||
};
|
||||
if (converted) config = structuredClone(converted);
|
||||
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
|
||||
const writes: any[] = [];
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
|
||||
ws.onMessage((raw) => {
|
||||
if (JSON.parse(String(raw)).type === 'authenticate')
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'connected',
|
||||
connection_id: 'fixture',
|
||||
session_type: 'person',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/preserve', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
const body = route.request().postDataJSON();
|
||||
writes.push(body);
|
||||
if (body.config) config = body.config;
|
||||
}
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'preserve',
|
||||
name: 'Preservation',
|
||||
description: '',
|
||||
emoji: '⚙️',
|
||||
config,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
configs: [
|
||||
{
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI' },
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: { en_US: 'Runner' },
|
||||
config: contract.ai_schema.stages[0].config.map(
|
||||
(field: Record<string, unknown>) =>
|
||||
field.name === 'id'
|
||||
? {
|
||||
...field,
|
||||
default: id,
|
||||
options: [
|
||||
{ name: id, label: schema.metadata.label },
|
||||
],
|
||||
}
|
||||
: field,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: id,
|
||||
label: schema.metadata.label,
|
||||
config: schema.spec.config,
|
||||
},
|
||||
],
|
||||
},
|
||||
outputSchema,
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/provider/models/llm', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
models: ['llm-valid', 'llm-fallback'].map((uuid) => ({
|
||||
uuid,
|
||||
name: uuid,
|
||||
provider_uuid: 'provider-valid',
|
||||
provider: { uuid: 'provider-valid', name: 'Mock Provider' },
|
||||
reasoning: { level: 'high' },
|
||||
reasoning_capabilities: {
|
||||
levels: ['provider_default', 'low', 'high'],
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/pipelines?id=preserve');
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
return { writes, raw, id };
|
||||
}
|
||||
|
||||
test('reasoning edit distinguishes provider default from model inheritance and preserves fallbacks', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { writes, raw, id } = await setup(page, 'LocalAgent', 'LocalAgent');
|
||||
await page
|
||||
.getByRole('button', { name: 'Reasoning level: High', exact: true })
|
||||
.click();
|
||||
await page.getByRole('slider').press('Home');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.ai.runner_config[id]).toEqual({
|
||||
...raw,
|
||||
model: {
|
||||
...(raw.model as object),
|
||||
reasoning: { 'llm-valid': 'provider_default', 'llm-fallback': 'low' },
|
||||
},
|
||||
});
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reasoning level: Provider default',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Use model setting', exact: true })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(2);
|
||||
expect(writes[1].config.ai.runner_config[id]).toEqual({
|
||||
...raw,
|
||||
model: { ...(raw.model as object), reasoning: { 'llm-fallback': 'low' } },
|
||||
});
|
||||
expect(writes[1].config.output).toEqual(writes[0].config.output);
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Reasoning level: Use model setting',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
// The reverse transition must not require moving through another level.
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reasoning level: Use model setting',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Provider default', exact: true })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(3);
|
||||
expect(writes[2].config.ai.runner_config[id]).toEqual(
|
||||
writes[0].config.ai.runner_config[id],
|
||||
);
|
||||
// Clearing a fallback override must not clear the primary override.
|
||||
await page
|
||||
.getByRole('button', { name: 'Reasoning level: Low', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Use model setting', exact: true })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(4);
|
||||
expect(writes[3].config.ai.runner_config[id]).toEqual({
|
||||
...raw,
|
||||
model: {
|
||||
...(raw.model as object),
|
||||
reasoning: { 'llm-valid': 'provider_default' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('standalone picker keeps its original levels and provider default API', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.goto('/home/pipelines');
|
||||
await page.evaluate(async () => {
|
||||
// Vite serves the actual component, not a test replacement.
|
||||
const resources = performance
|
||||
.getEntriesByType('resource')
|
||||
.map((entry) => entry.name);
|
||||
const reactPath = resources.find((url) =>
|
||||
new URL(url).pathname.endsWith('/react.js'),
|
||||
)!;
|
||||
const domPath = resources.find((url) =>
|
||||
new URL(url).pathname.endsWith('/react-dom_client.js'),
|
||||
)!;
|
||||
const pickerPath =
|
||||
'/src/app/home/components/reasoning/ReasoningLevelPicker.tsx';
|
||||
const reactModule = await import(reactPath);
|
||||
const React = reactModule.default || reactModule;
|
||||
const domModule = await import(domPath);
|
||||
const { createRoot } = domModule.default || domModule;
|
||||
const { default: Picker } = await import(pickerPath);
|
||||
const host = document.createElement('div');
|
||||
const app = document.getElementById('root');
|
||||
if (app) app.style.display = 'none';
|
||||
document.body.appendChild(host);
|
||||
function Harness() {
|
||||
const [value, setValue] = React.useState('provider_default');
|
||||
return React.createElement(Picker, {
|
||||
value,
|
||||
levels: ['provider_default', 'high'],
|
||||
onChange: (next: string) => {
|
||||
host.dataset.value = next;
|
||||
setValue(next);
|
||||
},
|
||||
});
|
||||
}
|
||||
host.id = 'standalone-reasoning';
|
||||
createRoot(host).render(React.createElement(Harness));
|
||||
});
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reasoning level: Provider default',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Use model setting', exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole('slider').press('End');
|
||||
await expect(page.locator('#standalone-reasoning')).toHaveAttribute(
|
||||
'data-value',
|
||||
'high',
|
||||
);
|
||||
await page.getByRole('slider').press('Home');
|
||||
await expect(page.locator('#standalone-reasoning')).toHaveAttribute(
|
||||
'data-value',
|
||||
'provider_default',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const contract = JSON.parse(
|
||||
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
|
||||
);
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
const canonical = JSON.parse(
|
||||
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
|
||||
);
|
||||
const outputSchema = contract.output_schema;
|
||||
const switchField = (page: Page, label: string) =>
|
||||
page
|
||||
.locator('label')
|
||||
.filter({ hasText: label })
|
||||
.locator('../..')
|
||||
.getByRole('switch');
|
||||
async function setup(
|
||||
page: Page,
|
||||
plugin = 'weknora-agent',
|
||||
name = 'WeKnoraAgent',
|
||||
agentId: null | 'absent' = null,
|
||||
converted?: typeof canonical,
|
||||
) {
|
||||
const schema = contract.plugins[plugin].component;
|
||||
const id = `plugin:langbot-team/${name}/default`;
|
||||
const raw: Record<string, unknown> = {
|
||||
'api-key': ' fixture credential ',
|
||||
'base-url': ' http://localhost:8080/api/v1 ',
|
||||
'app-type': 'agent',
|
||||
'enable-all-tools': false,
|
||||
tools: ['tool-allowed'],
|
||||
'knowledge-bases': ['kb-allowed'],
|
||||
'mcp-resources': [{ server: 'private', uri: 'secret' }],
|
||||
'mcp-resource-agent-read-enabled': false,
|
||||
};
|
||||
if (agentId === null) raw['agent-id'] = null;
|
||||
if (plugin === 'LocalAgent') {
|
||||
raw['advanced-settings'] = true;
|
||||
raw.model = {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: [],
|
||||
reasoning: { 'llm-valid': 'provider_default' },
|
||||
};
|
||||
raw['remove-think'] = true;
|
||||
}
|
||||
let config = {
|
||||
...structuredClone(canonical),
|
||||
ai: { runner: { id }, runner_config: { [id]: raw } },
|
||||
};
|
||||
if (converted) config = structuredClone(converted);
|
||||
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
|
||||
const writes: any[] = [];
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
|
||||
ws.onMessage((raw) => {
|
||||
if (JSON.parse(String(raw)).type === 'authenticate')
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'connected',
|
||||
connection_id: 'fixture',
|
||||
session_type: 'person',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/preserve', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
const body = route.request().postDataJSON();
|
||||
writes.push(body);
|
||||
if (body.config) config = body.config;
|
||||
}
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'preserve',
|
||||
name: 'Preservation',
|
||||
description: '',
|
||||
emoji: '⚙️',
|
||||
config,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
configs: [
|
||||
{
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI' },
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: { en_US: 'Runner' },
|
||||
config: contract.ai_schema.stages[0].config.map(
|
||||
(field: Record<string, unknown>) =>
|
||||
field.name === 'id'
|
||||
? {
|
||||
...field,
|
||||
default: id,
|
||||
options: [
|
||||
{ name: id, label: schema.metadata.label },
|
||||
],
|
||||
}
|
||||
: field,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: id,
|
||||
label: schema.metadata.label,
|
||||
config: schema.spec.config,
|
||||
},
|
||||
],
|
||||
},
|
||||
outputSchema,
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/pipelines?id=preserve');
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
return { writes, raw, id };
|
||||
}
|
||||
for (const candidate of contract.cases) {
|
||||
test(`actual converter output stays editable and unchanged for ${candidate.legacy_runner}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = candidate.plan.target_plugin.name;
|
||||
const folder = Object.keys(contract.plugins).find(
|
||||
(key) => contract.plugins[key].manifest.metadata.name === name,
|
||||
)!;
|
||||
const { writes } = await setup(
|
||||
page,
|
||||
folder,
|
||||
name,
|
||||
null,
|
||||
candidate.plan.config,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
expect(writes).toHaveLength(0);
|
||||
await page.getByRole('tab', { name: 'Output', exact: true }).click();
|
||||
await switchField(page, 'Quote Origin Message').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.ai).toEqual(candidate.plan.config.ai);
|
||||
expect(writes[0].config.output.misc['quote-origin']).toBe(
|
||||
!candidate.plan.config.output.misc['quote-origin'],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const agentId of [null, 'absent'] as const) {
|
||||
test(`mounted real WeKnora schema preserves ${agentId} agent ID and hidden policy`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const { writes, raw, id } = await setup(
|
||||
page,
|
||||
'weknora-agent',
|
||||
'WeKnoraAgent',
|
||||
agentId,
|
||||
);
|
||||
await expect(page.getByText('Agent ID', { exact: true })).toBeVisible();
|
||||
expect(writes).toEqual([]);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
await switchField(page, 'Enable Web Search').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.ai.runner_config[id]).toEqual({
|
||||
...raw,
|
||||
'web-search-enabled': true,
|
||||
});
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await expect(switchField(page, 'Enable Web Search')).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
test('remove-think is synchronized only after intentional output or runner toggles', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { writes, id } = await setup(page, 'LocalAgent', 'LocalAgent');
|
||||
const runnerThink = switchField(page, 'Remove');
|
||||
await expect(runnerThink).toBeChecked();
|
||||
expect(writes).toEqual([]);
|
||||
await page.getByRole('tab', { name: 'Output', exact: true }).click();
|
||||
const outputThink = switchField(page, 'Remove CoT');
|
||||
await expect(outputThink).not.toBeChecked();
|
||||
await outputThink.click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.output.misc['remove-think']).toBe(true);
|
||||
expect(writes[0].config.ai.runner_config[id]['remove-think']).toBe(true);
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
await runnerThink.click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(2);
|
||||
expect(writes[1].config.output.misc['remove-think']).toBe(false);
|
||||
expect(writes[1].config.ai.runner_config[id]['remove-think']).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const contract = JSON.parse(
|
||||
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
|
||||
);
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
const canonical = contract.cases[0].plan.config;
|
||||
const outputSchema = contract.output_schema;
|
||||
const switchField = (page: Page, label: string) =>
|
||||
page
|
||||
.locator('label')
|
||||
.filter({ hasText: label })
|
||||
.locator('../..')
|
||||
.getByRole('switch');
|
||||
async function setup(
|
||||
page: Page,
|
||||
plugin = 'weknora-agent',
|
||||
name = 'WeKnoraAgent',
|
||||
agentId: null | 'absent' = null,
|
||||
converted?: typeof canonical,
|
||||
) {
|
||||
const schema = contract.plugins[plugin].component;
|
||||
const id = `plugin:langbot-team/${name}/default`;
|
||||
const raw: Record<string, unknown> = {
|
||||
'api-key': ' fixture credential ',
|
||||
'base-url': ' http://localhost:8080/api/v1 ',
|
||||
'app-type': 'agent',
|
||||
'enable-all-tools': false,
|
||||
tools: ['tool-allowed'],
|
||||
'knowledge-bases': ['kb-allowed'],
|
||||
'mcp-resources': [{ server: 'private', uri: 'secret' }],
|
||||
'mcp-resource-agent-read-enabled': false,
|
||||
};
|
||||
if (agentId === null) raw['agent-id'] = null;
|
||||
if (plugin === 'LocalAgent') {
|
||||
raw['advanced-settings'] = true;
|
||||
raw.model = {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: [],
|
||||
reasoning: { 'llm-valid': 'provider_default' },
|
||||
};
|
||||
raw['remove-think'] = true;
|
||||
}
|
||||
let config = {
|
||||
...structuredClone(canonical),
|
||||
ai: { runner: { id }, runner_config: { [id]: raw } },
|
||||
};
|
||||
if (converted) config = structuredClone(converted);
|
||||
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
|
||||
const writes: any[] = [];
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
|
||||
ws.onMessage((raw) => {
|
||||
if (JSON.parse(String(raw)).type === 'authenticate')
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'connected',
|
||||
connection_id: 'fixture',
|
||||
session_type: 'person',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/preserve', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
const body = route.request().postDataJSON();
|
||||
writes.push(body);
|
||||
if (body.config) config = body.config;
|
||||
}
|
||||
await route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
pipeline: {
|
||||
uuid: 'preserve',
|
||||
name: 'Preservation',
|
||||
description: '',
|
||||
emoji: '⚙️',
|
||||
config,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
code: 0,
|
||||
data: {
|
||||
configs: [
|
||||
{
|
||||
name: 'ai',
|
||||
label: { en_US: 'AI' },
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: { en_US: 'Runner' },
|
||||
config: contract.ai_schema.stages[0].config.map(
|
||||
(field: Record<string, unknown>) =>
|
||||
field.name === 'id'
|
||||
? {
|
||||
...field,
|
||||
default: id,
|
||||
options: [
|
||||
{ name: id, label: schema.metadata.label },
|
||||
],
|
||||
}
|
||||
: field,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: id,
|
||||
label: schema.metadata.label,
|
||||
config: schema.spec.config,
|
||||
},
|
||||
],
|
||||
},
|
||||
outputSchema,
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto('/home/pipelines?id=preserve');
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
return { writes, raw, id };
|
||||
}
|
||||
|
||||
for (const kind of ['json', 'prompt', 'prompt-omitted'] as const) {
|
||||
test(`canonical ${kind} edits preserve types and metadata, reject invalid drafts`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const candidate = contract.cases.find(
|
||||
(c: any) =>
|
||||
c.legacy_runner === (kind === 'json' ? 'langflow-api' : 'local-agent'),
|
||||
);
|
||||
const config = structuredClone(candidate.plan.config);
|
||||
const id = candidate.plan.target_runner_id;
|
||||
const field = kind === 'json' ? 'tweaks' : 'prompt';
|
||||
const initial =
|
||||
kind === 'json'
|
||||
? {
|
||||
node: {
|
||||
enabled: false,
|
||||
count: 0,
|
||||
missing: null,
|
||||
list: [false, 0, null],
|
||||
},
|
||||
}
|
||||
: kind === 'prompt-omitted'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [],
|
||||
name: 'original',
|
||||
provider_specific_fields: { cache: false, count: 0 },
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
name: 'alice',
|
||||
metadata: { flag: false, count: 0 },
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call1',
|
||||
type: 'function',
|
||||
function: { name: 'foo', arguments: '{}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', content: [], tool_call_id: 'call1' },
|
||||
];
|
||||
config.ai.runner_config[id][field] = initial;
|
||||
config.ai.runner_config[id]['advanced-settings'] = true;
|
||||
const { writes } = await setup(
|
||||
page,
|
||||
kind === 'json' ? 'langflow-agent' : 'LocalAgent',
|
||||
candidate.plan.target_plugin.name,
|
||||
null,
|
||||
config,
|
||||
);
|
||||
const editor = page.getByTestId(`structured-editor-${field}`);
|
||||
await expect(editor).toBeVisible();
|
||||
expect(JSON.parse(await editor.inputValue())).toEqual(initial);
|
||||
expect(writes).toHaveLength(0);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save', exact: true }),
|
||||
).toBeDisabled();
|
||||
const edited = structuredClone(initial) as any;
|
||||
if (kind === 'json') edited.node.count = 7;
|
||||
else if (kind === 'prompt-omitted') edited[0].name = 'edited';
|
||||
else edited[0].content[0].text = 'edited';
|
||||
await editor.fill(JSON.stringify(edited, null, 2));
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0].config.ai.runner_config[id][field]).toEqual(edited);
|
||||
if (kind === 'prompt-omitted')
|
||||
expect(
|
||||
Object.hasOwn(
|
||||
writes[0].config.ai.runner_config[id][field][0],
|
||||
'content',
|
||||
),
|
||||
).toBe(false);
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
expect(JSON.parse(await editor.inputValue())).toEqual(edited);
|
||||
await editor.fill('{ invalid');
|
||||
await expect(
|
||||
page.getByRole('alert').filter({ hasText: `${field} (JSON)` }),
|
||||
).toBeVisible();
|
||||
if (kind === 'json') await switchField(page, 'Advanced Settings').click();
|
||||
// Make another valid edit so Save remains available: invalid JSON must
|
||||
// block the entire save, not silently persist the previous value.
|
||||
await page.getByRole('tab', { name: 'Output', exact: true }).click();
|
||||
await switchField(page, 'Quote Origin Message').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.waitForTimeout(400);
|
||||
expect(writes).toHaveLength(1);
|
||||
await page.getByRole('tab', { name: 'AI', exact: true }).click();
|
||||
if (kind === 'json') await switchField(page, 'Advanced Settings').click();
|
||||
await expect(editor).toHaveValue('{ invalid');
|
||||
await editor.fill(JSON.stringify(edited));
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect.poll(() => writes.length).toBe(2);
|
||||
expect(writes[1].config.ai.runner_config[id][field]).toEqual(edited);
|
||||
});
|
||||
}
|
||||
@@ -3,20 +3,33 @@ import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
function loadCatalog(url) {
|
||||
const compiled = ts.transpileModule(fs.readFileSync(url, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
const require = (specifier) => {
|
||||
assert.ok(specifier.startsWith('./'), 'Locale imports must be relative');
|
||||
return loadCatalog(new URL(`${specifier}.ts`, url));
|
||||
};
|
||||
new Function('module', 'exports', 'require', compiled)(
|
||||
module,
|
||||
module.exports,
|
||||
require,
|
||||
);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => {
|
||||
const directory = new URL('../../src/i18n/locales/', import.meta.url);
|
||||
const files = fs
|
||||
.readdirSync(directory)
|
||||
.filter((file) => file.endsWith('.ts'));
|
||||
assert.equal(files.length, 8);
|
||||
let expected;
|
||||
for (const file of fs.readdirSync(directory)) {
|
||||
const compiled = ts.transpileModule(
|
||||
fs.readFileSync(new URL(file, directory), 'utf8'),
|
||||
{
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
},
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(module, module.exports);
|
||||
const catalog = (module.exports.default || Object.values(module.exports)[0])
|
||||
.models.codex;
|
||||
for (const file of files) {
|
||||
const exports = loadCatalog(new URL(file, directory));
|
||||
const catalog = (exports.default || Object.values(exports)[0]).models.codex;
|
||||
const keys = Object.keys(catalog).sort();
|
||||
expected ??= keys;
|
||||
assert.deepEqual(keys, expected, file);
|
||||
|
||||
@@ -66,6 +66,7 @@ test('normalizes only single-line text fields in a dynamic form save snapshot',
|
||||
fallbacks: ['fallback-model'],
|
||||
reasoning: {
|
||||
'primary-model': 'high',
|
||||
'fallback-model': 'provider_default',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source = readFileSync(
|
||||
'src/app/home/pipelines/pipeline-config-safety.ts',
|
||||
'utf8',
|
||||
);
|
||||
const context = { exports: {} };
|
||||
vm.runInNewContext(
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText,
|
||||
context,
|
||||
);
|
||||
const { isCurrentPipelineConfig } = context.exports;
|
||||
const plugin = 'plugin:langbot-team/LocalAgent/default';
|
||||
|
||||
test('canonical current empty runner remains editable', () => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(
|
||||
'../src/langbot/templates/default-pipeline-config.json',
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(isCurrentPipelineConfig(config), true);
|
||||
});
|
||||
|
||||
test('configured current binding remains editable', () => {
|
||||
assert.equal(
|
||||
isCurrentPipelineConfig({
|
||||
ai: { runner: { id: plugin }, runner_config: {} },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, ai] of Object.entries({
|
||||
legacy: { runner: { runner: 'local-agent' } },
|
||||
mixedSelector: { runner: { id: plugin, runner: 'local-agent' } },
|
||||
mixedLegacySection: {
|
||||
runner: { id: plugin },
|
||||
runner_config: {},
|
||||
'local-agent': {},
|
||||
},
|
||||
emptyMixedLegacySection: {
|
||||
runner: { id: '' },
|
||||
runner_config: {},
|
||||
'dify-service-api': {},
|
||||
},
|
||||
emptyMissingContainer: { runner: { id: '' } },
|
||||
emptyMalformedContainer: { runner: { id: '' }, runner_config: [] },
|
||||
emptyAmbiguousContainer: {
|
||||
runner: { id: '' },
|
||||
runner_config: { orphan: {} },
|
||||
},
|
||||
emptyAmbiguousSelector: {
|
||||
runner: { id: '', legacy: 'local-agent' },
|
||||
runner_config: {},
|
||||
},
|
||||
missingId: { runner: {}, runner_config: {} },
|
||||
nonPlugin: { runner: { id: 'local-agent' }, runner_config: {} },
|
||||
})) {
|
||||
test(`${name} does not hydrate current editor defaults`, () => {
|
||||
assert.equal(isCurrentPipelineConfig({ ai }), false);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
function load(path) {
|
||||
const context = { exports: {} };
|
||||
vm.runInNewContext(
|
||||
ts.transpileModule(readFileSync(path, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText,
|
||||
context,
|
||||
);
|
||||
return context.exports;
|
||||
}
|
||||
|
||||
const warnings = [
|
||||
...new Set(
|
||||
[
|
||||
...readFileSync(
|
||||
'../src/langbot/pkg/pipeline/legacy_config_migration.py',
|
||||
'utf8',
|
||||
).matchAll(/_warn\(result,\s*'([^']+)'/g),
|
||||
].map((match) => match[1]),
|
||||
),
|
||||
];
|
||||
|
||||
test('every converter warning has a specific explanation in all eight locales', () => {
|
||||
const { migrationIssueKey } = load(
|
||||
'src/app/home/pipelines/pipeline-migration-issues.ts',
|
||||
);
|
||||
const base = 'src/i18n/locales/pipeline-migration';
|
||||
const locales = readdirSync(base).filter((name) => name.endsWith('.ts'));
|
||||
assert.equal(locales.length, 8);
|
||||
assert.ok(
|
||||
warnings.length > 10,
|
||||
'warning discovery must not silently go empty',
|
||||
);
|
||||
for (const file of locales) {
|
||||
const catalog = load(`${base}/${file}`).default;
|
||||
for (const code of warnings) {
|
||||
const key = migrationIssueKey(code);
|
||||
assert.ok(key, `${file}: missing mapping for ${code}`);
|
||||
assert.equal(typeof catalog.notices?.[key], 'string', `${file}: ${code}`);
|
||||
assert.ok(catalog.notices[key].length > 10);
|
||||
}
|
||||
assert.ok(catalog.notices.pluginVersion);
|
||||
assert.ok(catalog.notices.pendingInteraction);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown backend issue codes never become translation keys or displayed messages', () => {
|
||||
const { migrationIssueKey } = load(
|
||||
'src/app/home/pipelines/pipeline-migration-issues.ts',
|
||||
);
|
||||
for (const code of [
|
||||
'unknown',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'secret-token.example',
|
||||
]) {
|
||||
assert.equal(migrationIssueKey(code), undefined);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
const source = fs.readFileSync(
|
||||
'src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx',
|
||||
'utf8',
|
||||
);
|
||||
// Exercise the actual field callback without mocking React's form lifecycle.
|
||||
const callback = source.slice(
|
||||
source.indexOf('const updateModelReasoning ='),
|
||||
source.indexOf('const replaceModel ='),
|
||||
);
|
||||
function edit(reasoning, uuid, level) {
|
||||
let result;
|
||||
const code = ts.transpileModule(
|
||||
callback + '\nupdateModelReasoning(uuid, level);',
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2020 } },
|
||||
).outputText;
|
||||
new Function('modelValue', 'updateValue', 'uuid', 'level', code)(
|
||||
{ reasoning },
|
||||
(value) => {
|
||||
result = value.reasoning;
|
||||
},
|
||||
uuid,
|
||||
level,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
test('provider default is an explicit override, not inheritance', () => {
|
||||
assert.deepEqual(edit({ a: 'high', b: 'low' }, 'a', 'provider_default'), {
|
||||
a: 'provider_default',
|
||||
b: 'low',
|
||||
});
|
||||
});
|
||||
test('inherit deletes only the selected override without mutating input', () => {
|
||||
const input = Object.freeze({ a: 'provider_default', b: 'high' });
|
||||
const output = edit(input, 'a', undefined);
|
||||
assert.equal(Object.hasOwn(output, 'a'), false);
|
||||
assert.deepEqual(output, { b: 'high' });
|
||||
assert.deepEqual(input, { a: 'provider_default', b: 'high' });
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
function load(relative) {
|
||||
const source = fs.readFileSync(new URL(relative, import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
const helper =
|
||||
'../../src/app/home/pipelines/components/pipeline-form/RunnerConfigPreservation.ts';
|
||||
test('mount emission is not a persisted edit', () => {
|
||||
const { preserveRunnerConfig } = load(helper);
|
||||
const raw = {
|
||||
'api-key': ' secret ',
|
||||
'agent-id': null,
|
||||
'enable-all-tools': false,
|
||||
};
|
||||
assert.deepEqual(
|
||||
preserveRunnerConfig(raw, undefined, {
|
||||
'api-key': 'secret',
|
||||
'agent-id': '',
|
||||
mode: 'chat',
|
||||
}),
|
||||
raw,
|
||||
);
|
||||
});
|
||||
test('unrelated edits preserve hidden Host policy, absent/null and credentials', () => {
|
||||
const { preserveRunnerConfig } = load(helper);
|
||||
const raw = {
|
||||
'api-key': ' secret ',
|
||||
'agent-id': null,
|
||||
'enable-all-tools': false,
|
||||
tools: ['one'],
|
||||
'knowledge-bases': ['kb'],
|
||||
'mcp-resources': [{ uri: 'private' }],
|
||||
'mcp-resource-agent-read-enabled': false,
|
||||
};
|
||||
const first = {
|
||||
'api-key': 'secret',
|
||||
'agent-id': '',
|
||||
mode: 'chat',
|
||||
absent: 'default',
|
||||
};
|
||||
assert.deepEqual(
|
||||
preserveRunnerConfig(raw, first, { ...first, mode: 'agent' }),
|
||||
{ ...raw, mode: 'agent' },
|
||||
);
|
||||
});
|
||||
test('intentional edits, removals and nested edits win without normalizing untouched siblings', () => {
|
||||
const { preserveRunnerConfig } = load(helper);
|
||||
const raw = {
|
||||
'api-key': ' secret ',
|
||||
model: {
|
||||
primary: 'one',
|
||||
fallbacks: ['two'],
|
||||
reasoning: { one: 'provider_default', two: 'high' },
|
||||
},
|
||||
};
|
||||
const first = { 'api-key': 'secret', model: raw.model };
|
||||
assert.deepEqual(
|
||||
preserveRunnerConfig(raw, first, {
|
||||
'api-key': 'new',
|
||||
model: {
|
||||
primary: 'one',
|
||||
fallbacks: [],
|
||||
reasoning: { one: 'provider_default' },
|
||||
},
|
||||
}),
|
||||
{
|
||||
'api-key': 'new',
|
||||
model: {
|
||||
primary: 'one',
|
||||
fallbacks: [],
|
||||
reasoning: { one: 'provider_default' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
test('save normalization preserves explicit selected per-model provider_default', () => {
|
||||
const { normalizeDynamicFormValuesForSave } = load(
|
||||
'../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts',
|
||||
);
|
||||
const value = {
|
||||
primary: 'one',
|
||||
fallbacks: ['two'],
|
||||
reasoning: { one: 'provider_default', two: 'high', removed: 'high' },
|
||||
};
|
||||
assert.deepEqual(
|
||||
normalizeDynamicFormValuesForSave(
|
||||
[{ name: 'model', type: 'model-fallback-selector' }],
|
||||
{ model: value },
|
||||
).model.reasoning,
|
||||
{ one: 'provider_default', two: 'high' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
const file = 'src/app/home/components/dynamic-form/StructuredFieldValue.ts';
|
||||
function load() {
|
||||
const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const m = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(m, m.exports);
|
||||
return m.exports;
|
||||
}
|
||||
test('JSON parsing preserves nested false, zero, null and arrays as typed values', () => {
|
||||
const { parseStructuredDraft } = load();
|
||||
const value = { n: { a: false, b: 0, c: null, d: [false, 0, null] } };
|
||||
assert.deepEqual(parseStructuredDraft(JSON.stringify(value), false), value);
|
||||
});
|
||||
test('prompt parsing preserves structured content, null, empty arrays, metadata and first role', () => {
|
||||
const { parseStructuredDraft } = load();
|
||||
const value = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'x' }], name: 'alice' },
|
||||
{ role: 'assistant', content: null, tool_calls: [] },
|
||||
{ role: 'tool', content: [], tool_call_id: 'x' },
|
||||
];
|
||||
assert.deepEqual(parseStructuredDraft(JSON.stringify(value), true), value);
|
||||
});
|
||||
test('prompt metadata edits preserve omitted content without inserting null', () => {
|
||||
const { parseStructuredDraft, isPromptValue } = load();
|
||||
const value = [
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [],
|
||||
name: 'edited',
|
||||
provider_specific_fields: { cache: false },
|
||||
},
|
||||
];
|
||||
assert.equal(isPromptValue(value), true);
|
||||
const result = parseStructuredDraft(JSON.stringify(value), true);
|
||||
assert.deepEqual(result, value);
|
||||
assert.equal(Object.hasOwn(result[0], 'content'), false);
|
||||
for (const content of [false, 0, {}, undefined])
|
||||
assert.equal(isPromptValue([{ ...value[0], content }]), false);
|
||||
});
|
||||
test('invalid JSON and non-message prompt shapes reject rather than silently retain old values', () => {
|
||||
const { parseStructuredDraft } = load();
|
||||
for (const raw of ['{broken', '', 'undefined'])
|
||||
assert.throws(() => parseStructuredDraft(raw, false));
|
||||
for (const raw of [
|
||||
'{}',
|
||||
'null',
|
||||
'[{"role":0,"content":"x"}]',
|
||||
'[{"role":"user","content":false}]',
|
||||
])
|
||||
assert.throws(() => parseStructuredDraft(raw, true));
|
||||
});
|
||||
test('friendly prompt editor is restricted to losslessly represented roles and string content', () => {
|
||||
const { isSimplePrompt } = load();
|
||||
assert.equal(
|
||||
isSimplePrompt([
|
||||
{ role: 'system', content: '' },
|
||||
{ role: 'user', content: 'x' },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
for (const value of [
|
||||
[{ role: 'user', content: 'x' }],
|
||||
[{ role: 'system', content: null }],
|
||||
[{ role: 'system', content: [] }],
|
||||
[{ role: 'system', content: 'x', name: 'a' }],
|
||||
[],
|
||||
])
|
||||
assert.equal(isSimplePrompt(value), false);
|
||||
});
|
||||
Reference in New Issue
Block a user