diff --git a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.presentation.test.ts b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.presentation.test.ts index ed77f69f9b..9e980721d6 100644 --- a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.presentation.test.ts +++ b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.presentation.test.ts @@ -10,6 +10,20 @@ function readSource(relativePath: string): string { } describe('Runtime settings information architecture', () => { + it('keeps concurrency input limits aligned with the host option schema', () => { + const source = readSource('./RuntimeSettingsPages.tsx'); + const registry = readSource('../../../../../../src/crates/contracts/product-domains/src/product_control_owner_registry.rs'); + for (const [option, constant] of [ + ['subagent-max-concurrency', 'SUBAGENT_MAX_CONCURRENCY_LIMIT'], + ['swarm-max-concurrency', 'SWARM_MAX_CONCURRENCY_LIMIT'], + ]) { + const maximum = registry.match(new RegExp(`"${option}",\\s*integer_range\\(1\\.0, (\\d+)\\.0\\)`))?.[1]; + expect(maximum).toBeDefined(); + expect(source).toContain(`const ${constant} = ${maximum};`); + expect(source).toContain(`max={${constant}}`); + } + }); + it('keeps execution and permissions unified and stacks browser and desktop control in one owner', () => { const source = readSource('./RuntimeSettingsPages.tsx'); const appearance = readSource('./RuntimeSettingsPages.appearance.ts'); diff --git a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.test.tsx b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.test.tsx index 65c058bf3a..a093106505 100644 --- a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.test.tsx @@ -2,12 +2,12 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { BrowserDesktopControlSettingsPage } from './RuntimeSettingsPages'; +import { BrowserDesktopControlSettingsPage, ExecutionSettingsPage } from './RuntimeSettingsPages'; const mocks = vi.hoisted(() => { Object.defineProperty(window, '__TAURI__', { configurable: true, value: {} }); return { - invoke: vi.fn(), setConfig: vi.fn(), setEnabled: vi.fn(), + invoke: vi.fn(), setConfig: vi.fn(), setEnabled: vi.fn(), getConfig: vi.fn(), error: vi.fn(), t: (key: string) => key, }; }); @@ -16,15 +16,18 @@ vi.mock('@/infrastructure/i18n', () => ({ i18nService: { formatNumber: String } vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { invoke: mocks.invoke } })); vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ systemAPI: { getSystemInfo: async () => ({ platform: 'macos' }) } })); vi.mock('../services/ConfigManager', () => ({ configManager: { - getConfig: async () => false, setConfig: mocks.setConfig, + getConfig: mocks.getConfig, getOptionalConfig: async () => true, setConfig: mocks.setConfig, } })); vi.mock('../hooks/useComputerUseEnabled', () => ({ useComputerUseEnabled: () => ({ computerUseEnabled: false, setComputerUseEnabled: mocks.setEnabled }) })); vi.mock('../services/AIExperienceConfigService', () => ({ aiExperienceConfigService: {} })); vi.mock('../services/AgentCompanionPetService', () => ({ DEFAULT_AGENT_COMPANION_PET: 'default' })); -vi.mock('../services/PermissionConfigService', () => ({ DEFAULT_TOOL_PERMISSION_CONFIG: {}, permissionConfigService: {} })); +vi.mock('../services/PermissionConfigService', async (original) => ({ + ...await original(), + permissionConfigService: { getConfig: async () => ({}) }, +})); vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceModeOptional: () => null })); vi.mock('@/infrastructure/confirm-dialog', () => ({ confirmDanger: vi.fn() })); -vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({}), notificationService: { dismiss: vi.fn(), success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } })); +vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({}), notificationService: { dismiss: vi.fn(), success: vi.fn(), error: mocks.error, info: vi.fn(), warning: vi.fn() } })); vi.mock('./GlobalPermissionRulesDialog', () => ({ GlobalPermissionRulesDialog: () => null })); vi.mock('./SessionTitleConfig', () => ({ default: () => null })); vi.mock('./ReviewCapacitySection', () => ({ default: () => null })); @@ -46,6 +49,9 @@ const render = async () => { await act(async () => root.render( { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; status = { ...readyStatus }; + mocks.getConfig.mockReset().mockResolvedValue(null); + mocks.setConfig.mockReset().mockResolvedValue(undefined); + mocks.error.mockReset(); mocks.invoke.mockReset().mockImplementation(async (command: string) => { if (command === 'computer_use_get_status') return { computerUseEnabled: false, accessibilityGranted: true, screenCaptureGranted: false, platformNote: null }; if (command === 'browser_control_get_status') return { ...status }; @@ -56,6 +62,54 @@ beforeEach(() => { document.body.append(container); root = createRoot(container); }); + +describe('Tool execution concurrency settings', () => { + const concurrencyInput = (label: string) => Array.from(container.querySelectorAll('[data-openbitfun-part="row"]')) + .find(row => row.textContent?.includes(label))!.querySelector('input')!; + + async function editAndBlur(input: HTMLInputElement, value: string) { + await act(async () => input.focus()); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => input.blur()); + } + + it.each([ + ['config.subagentMaxConcurrency', 'ai.subagent_max_concurrency', '50', 32], + ['config.swarmMaxConcurrency', 'ai.swarm_max_concurrency', '100', 64], + ['config.subagentMaxConcurrency', 'ai.subagent_max_concurrency', '20', 20], + ['config.subagentMaxConcurrency', 'ai.subagent_max_concurrency', '5.5', 6], + ['config.swarmMaxConcurrency', 'ai.swarm_max_concurrency', '0', 1], + ])('saves a supported integer for %s when entering %s / %s', async (label, path, draft, expected) => { + await act(async () => root.render()); + const input = concurrencyInput(label); + await editAndBlur(input, draft); + expect(mocks.setConfig).toHaveBeenCalledWith(path, expected); + expect(input.value).toBe(String(expected)); + expect(mocks.error).not.toHaveBeenCalled(); + }); + + it('preserves an existing value outside the current save range until it is edited', async () => { + mocks.getConfig.mockImplementation(async (path: string) => path === 'ai.subagent_max_concurrency' ? 50 : null); + await act(async () => root.render()); + expect(concurrencyInput('config.subagentMaxConcurrency').value).toBe('50'); + expect(mocks.setConfig).not.toHaveBeenCalled(); + }); + + it.each([ + ['config.subagentMaxConcurrency', 5], + ['config.swarmMaxConcurrency', 16], + ])('restores the saved value and exposes the host error for %s', async (label, previous) => { + mocks.setConfig.mockRejectedValue(new Error('Host rejected the concurrency setting')); + await act(async () => root.render()); + const input = concurrencyInput(label); + await editAndBlur(input, '20'); + expect(input.value).toBe(String(previous)); + expect(mocks.error).toHaveBeenCalledWith('messages.saveFailed: Host rejected the concurrency setting'); + }); +}); afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); describe('Browser and desktop control settings', () => { diff --git a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.tsx b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.tsx index 32d866ce2a..bfd2a747c9 100644 --- a/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.tsx +++ b/src/web-ui/src/infrastructure/config/components/RuntimeSettingsPages.tsx @@ -118,6 +118,9 @@ type ToolPermissionMode = 'ask' | 'auto' | 'full_access'; const DEFAULT_SUBAGENT_BATCH_EXECUTION_POLICY: SubagentBatchExecutionPolicy = 'force_parallel'; const DEFAULT_SUBAGENT_MAX_CONCURRENCY = 5; const DEFAULT_SWARM_MAX_CONCURRENCY = 16; +// Match the setting.tools.execution integer ranges in the product control registry. +const SUBAGENT_MAX_CONCURRENCY_LIMIT = 32; +const SWARM_MAX_CONCURRENCY_LIMIT = 64; const SHOW_PERMISSION_MODE_CONTROL_CONFIG_PATH = 'app.flow_chat.show_permission_mode_control'; function normalizeSubagentBatchExecutionPolicy(value: unknown): SubagentBatchExecutionPolicy { @@ -623,8 +626,9 @@ const RuntimeSettingsPage: React.FC = ({ } }; - const handleSwarmMaxConcurrencyChange = async (value: number) => { - if (Number.isNaN(value) || value < 1) return; + const handleSwarmMaxConcurrencyChange = async (input: number) => { + if (!Number.isFinite(input)) return; + const value = Math.min(SWARM_MAX_CONCURRENCY_LIMIT, Math.max(1, Math.round(input))); const previous = swarmMaxConcurrency; setSwarmMaxConcurrency(value); setToolExecConfigLoading(true); @@ -634,14 +638,17 @@ const RuntimeSettingsPage: React.FC = ({ } catch (error) { log.error('Failed to save swarm_max_concurrency', error); setSwarmMaxConcurrency(previous); - notificationService.error(tTools('messages.saveFailed')); + notificationService.error( + `${tTools('messages.saveFailed')}: ${error instanceof Error ? error.message : String(error)}` + ); } finally { setToolExecConfigLoading(false); } }; - const handleSubagentMaxConcurrencyChange = async (value: number) => { - if (Number.isNaN(value) || value < 1) return; + const handleSubagentMaxConcurrencyChange = async (input: number) => { + if (!Number.isFinite(input)) return; + const value = Math.min(SUBAGENT_MAX_CONCURRENCY_LIMIT, Math.max(1, Math.round(input))); const previous = subagentMaxConcurrency; setSubagentMaxConcurrency(value); setToolExecConfigLoading(true); @@ -651,7 +658,9 @@ const RuntimeSettingsPage: React.FC = ({ } catch (error) { log.error('Failed to save subagent_max_concurrency', error); setSubagentMaxConcurrency(previous); - notificationService.error(tTools('messages.saveFailed')); + notificationService.error( + `${tTools('messages.saveFailed')}: ${error instanceof Error ? error.message : String(error)}` + ); } finally { setToolExecConfigLoading(false); } @@ -1256,7 +1265,7 @@ const RuntimeSettingsPage: React.FC = ({ value={subagentMaxConcurrency} onValueChange={(val) => void handleSubagentMaxConcurrencyChange(val)} min={1} - max={100} + max={SUBAGENT_MAX_CONCURRENCY_LIMIT} step={1} size="sm" variant="compact" @@ -1274,7 +1283,7 @@ const RuntimeSettingsPage: React.FC = ({ value={swarmMaxConcurrency} onValueChange={(val) => void handleSwarmMaxConcurrencyChange(val)} min={1} - max={100} + max={SWARM_MAX_CONCURRENCY_LIMIT} step={1} size="sm" variant="compact"