diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx
index 95813ce8d..abe7cbe2d 100644
--- a/web/src/app/home/agents/AgentDetailContent.tsx
+++ b/web/src/app/home/agents/AgentDetailContent.tsx
@@ -261,7 +261,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
hasUnsavedChanges={formDirty}
beforeRun={async () => agentFormRef.current?.save() ?? false}
onOpenRunnerConfig={() =>
- agentFormRef.current?.openSection('runner_config')
+ agentFormRef.current?.openSection('runner')
}
supportedEventPatterns={supportedEventPatterns}
availableEventTypes={availableEventTypes}
diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx
index 25e6bcb76..8584662ea 100644
--- a/web/src/app/home/agents/components/AgentFormComponent.tsx
+++ b/web/src/app/home/agents/components/AgentFormComponent.tsx
@@ -14,7 +14,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
-import { Bot, Loader2, SlidersHorizontal, Wrench } from 'lucide-react';
+import { Bot, Loader2, Wrench } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Agent,
@@ -72,10 +72,7 @@ interface AgentFormComponentProps {
onPlatformToolsChange?: (tools: AgentPlatformTool[]) => void;
}
-export type AgentConfigSection =
- | 'runner'
- | 'runner_config'
- | 'events_and_tools';
+export type AgentConfigSection = 'runner' | 'events_and_tools';
export interface AgentFormHandle {
openSection: (section: AgentConfigSection) => void;
@@ -382,13 +379,6 @@ function AgentFormComponent(
label: t('agents.runnerSettings'),
icon: Bot,
},
- {
- name: 'runner_config',
- label: selectedRunnerOption
- ? extractI18nObject(selectedRunnerOption.label)
- : t('pipelines.configuration'),
- icon: SlidersHorizontal,
- },
{
name: 'events_and_tools',
label: t('agents.eventsAndTools'),
@@ -678,7 +668,7 @@ function AgentFormComponent(
}
>
-
+
{primarySections.map((section) => {
const Icon = section.icon;
return (
@@ -713,11 +703,6 @@ function AgentFormComponent(
)}
-
- )}
-
- {activeSection === 'runner_config' && (
-
{runnerInstallRecovering ? (
@@ -730,7 +715,7 @@ function AgentFormComponent(
) : activeRunnerStage ? (
renderDynamicStage(activeRunnerStage)
- ) : (
+ ) : runnerConfigSchema ? (
{t('agents.runnerSettings')}
@@ -739,7 +724,7 @@ function AgentFormComponent(
- )}
+ ) : null}
)}
diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts
index 59c7e46e8..fd7a4e0c3 100644
--- a/web/tests/e2e/crud-smoke.spec.ts
+++ b/web/tests/e2e/crud-smoke.spec.ts
@@ -1187,7 +1187,6 @@ test.describe('agent runner resource selectors', () => {
await page.goto('/home/agents?id=agent-scope');
await page.getByRole('tab', { name: /^Runner$/ }).click();
- await page.getByRole('tab', { name: 'Local Agent' }).click();
await page.getByRole('button', { name: 'Edit tools' }).click();
const dialog = page.getByRole('dialog');
diff --git a/web/tests/e2e/processor-detail-workbench.spec.ts b/web/tests/e2e/processor-detail-workbench.spec.ts
index 1f81c2cf8..6bdb5b45e 100644
--- a/web/tests/e2e/processor-detail-workbench.spec.ts
+++ b/web/tests/e2e/processor-detail-workbench.spec.ts
@@ -78,9 +78,24 @@ test.describe('processor detail workbench', () => {
const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText('Runner');
- await expect(flow.getByRole('tab').nth(1)).toContainText('Local Agent');
- await expect(flow.getByRole('tab').nth(2)).toContainText('Events & tools');
- await expect(flow.getByRole('tab')).toHaveCount(3);
+ await expect(flow.getByRole('tab').nth(1)).toContainText('Events & tools');
+ await expect(flow.getByRole('tab')).toHaveCount(2);
+ const selectorCard = configPanel.locator('[data-slot="card"]').filter({
+ has: page.getByRole('combobox', { name: 'Runner', exact: true }),
+ });
+ const configCard = configPanel.locator('[data-slot="card"]').filter({
+ has: page
+ .locator('[data-slot="card-title"]')
+ .getByText('Local Agent', { exact: true }),
+ });
+ await expect(selectorCard).toBeVisible();
+ await expect(configCard).toBeVisible();
+ const selectorCardBox = await selectorCard.boundingBox();
+ const configCardBox = await configCard.boundingBox();
+ expect(configCardBox!.y).toBeGreaterThan(
+ selectorCardBox!.y + selectorCardBox!.height,
+ );
+
await expect(flow.getByText('Management')).toHaveCount(0);
await page.setViewportSize({ width: 1024, height: 900 });
@@ -122,7 +137,7 @@ test.describe('processor detail workbench', () => {
.last(),
).toBeVisible();
- await flow.getByRole('tab').nth(2).click();
+ await flow.getByRole('tab').nth(1).click();
await expect(
configPanel.getByText('Events & tools', { exact: true }).last(),
).toBeVisible();
@@ -171,7 +186,7 @@ test.describe('processor detail workbench', () => {
await page.getByRole('option').filter({ hasText: 'All events' }).click();
await page.keyboard.press('Escape');
- await flow.getByRole('tab').nth(1).click();
+ await flow.getByRole('tab').nth(0).click();
await expect(
configPanel.getByText('Local Agent', { exact: true }).last(),
).toBeVisible();
@@ -277,7 +292,7 @@ test.describe('processor detail workbench', () => {
.getByRole('button', { name: 'Review runner configuration' })
.click();
await expect(
- page.getByRole('tab', { name: 'Local Agent', exact: true }),
+ page.getByRole('tab', { name: 'Runner', exact: true }),
).toHaveAttribute('data-state', 'active');
});
@@ -485,3 +500,117 @@ test.describe('processor detail workbench', () => {
).toBeVisible();
});
});
+
+test('merged runner page preserves both runner configurations when switching and saving', async ({
+ page,
+}) => {
+ await installLangBotApiMocks(page, { authenticated: true });
+ const first = 'plugin:qa/first/default';
+ const second = 'plugin:qa/second/default';
+ const agent = {
+ uuid: 'agent-switch',
+ kind: 'agent',
+ name: 'Runner switch',
+ supported_event_patterns: ['*'],
+ config: {
+ runner: { id: first },
+ runner_config: {
+ [first]: { greeting: 'First saved value' },
+ [second]: { greeting: 'Second saved value' },
+ },
+ },
+ };
+ let saved: Record | undefined;
+ await page.route('**/api/v1/agents/agent-switch', async (route) => {
+ if (route.request().method() === 'PUT') {
+ saved = route.request().postDataJSON();
+ await route.fulfill({ json: { code: 0, data: {} } });
+ } else await route.fulfill({ json: { code: 0, data: { agent } } });
+ });
+ await page.route('**/api/v1/agents/_/metadata', (route) =>
+ route.fulfill({
+ json: {
+ code: 0,
+ data: {
+ platform_tools: [],
+ host_tools: [],
+ runner_config: {
+ name: 'ai',
+ label: { en_US: 'AI' },
+ stages: [
+ {
+ name: 'runner',
+ label: { en_US: 'Execution mode' },
+ config: [
+ {
+ name: 'id',
+ label: { en_US: 'Runner' },
+ type: 'select',
+ required: true,
+ options: [
+ { name: first, label: { en_US: 'First runner' } },
+ { name: second, label: { en_US: 'Second runner' } },
+ ],
+ },
+ ],
+ },
+ ...[first, second].map((name, index) => ({
+ name,
+ label: {
+ en_US: index === 0 ? 'First settings' : 'Second settings',
+ },
+ config: [
+ {
+ name: 'greeting',
+ label: { en_US: 'Greeting' },
+ type: 'string',
+ required: true,
+ },
+ ],
+ })),
+ ],
+ },
+ },
+ },
+ }),
+ );
+ await page.goto('/home/agents?id=agent-switch');
+ const config = page.getByRole('region', {
+ name: 'Configuration',
+ exact: true,
+ });
+ const runner = config.getByRole('combobox', { name: 'Runner', exact: true });
+ const greeting = config.getByRole('textbox');
+ await expect(greeting).toHaveValue('First saved value');
+ await greeting.fill('First edited value');
+ await runner.click();
+ await page
+ .getByRole('option', { name: `Second runner ${second}`, exact: true })
+ .click();
+ await expect(greeting).toHaveValue('Second saved value');
+ await greeting.fill('Second edited value');
+ await runner.click();
+ await page
+ .getByRole('option', { name: `First runner ${first}`, exact: true })
+ .click();
+ await expect(greeting).toHaveValue('First edited value');
+ await expect(
+ config.getByRole('tab', { name: 'Runner', exact: true }),
+ ).toHaveAttribute('data-state', 'active');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect
+ .poll(() => saved)
+ .toMatchObject({
+ config: {
+ runner: { id: first },
+ runner_config: {
+ [first]: { greeting: 'First edited value' },
+ [second]: { greeting: 'Second edited value' },
+ },
+ },
+ });
+ await expect(
+ page.getByRole('button', { name: 'Save', exact: true }),
+ ).toBeDisabled();
+ await page.screenshot({ path: '../../.codex-run/agent-merged-config.png' });
+});