diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index c7a421ef23..00be6114b3 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -180,6 +180,28 @@ test('wide settings gutters scroll the whole main pane', async ({ window: page } await expect(content).toBeVisible(); }); +test('temporary subagent policy can be enabled and persisted', async ({ window: page }) => { + await ensureSidebarExpanded(page); + await page.getByRole('button', { name: '设置', exact: true }).click(); + await page.getByRole('button', { name: '子 Agent', exact: true }).click(); + + const main = page.getByRole('main', { name: '设置内容' }); + await expect(main.getByRole('heading', { name: '临时子 Agent' })).toBeVisible(); + const enabled = main.getByRole('switch', { name: '允许临时子 Agent' }); + await enabled.click(); + await main.getByRole('button', { name: '保存临时策略' }).click(); + + await expect + .poll(async () => (await page.evaluate(() => window.maka.settings.get())).subagents.adHoc) + .toMatchObject({ + enabled: true, + maxProfile: 'local_read', + connectionSlug: 'e2e', + model: 'claude-sonnet-4-5-20250929', + }); + await expect(enabled).toBeChecked(); +}); + test('appearance choice content stays vertically centered in stretched grid rows', async ({ window: page }) => { await page.evaluate(async () => { await window.maka.settings.update({ personalization: { uiLocale: 'en' } }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts index 63bd715226..0b59371dd8 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts @@ -135,12 +135,17 @@ function createModuleFixture(options: { async updateRuntimePolicy( createMutation: (value: RuntimePolicy) => { kind: string; - value: RuntimePolicy["networkProxy"]; + value: unknown; }, ) { const mutation = createMutation(policy); if (mutation.kind === "set_network_proxy") { - policy = { ...policy, networkProxy: mutation.value }; + policy = { ...policy, networkProxy: mutation.value as RuntimePolicy["networkProxy"] }; + } else if (mutation.kind === "set_subagents") { + policy = { + ...policy, + subagents: mutation.value as RuntimePolicy["subagents"], + }; } policyRevision += 1; return { revision: policyRevision, policy }; @@ -265,6 +270,22 @@ test("runtime settings project credential status without a password value", asyn assert.equal("password" in settings.network.proxy, false); }); +test("subagent preset updates preserve the existing ad-hoc policy", async () => { + const fixture = createModuleFixture(); + const current = fixture.policy(); + const adHoc = { + enabled: true, + maxProfile: "local_read" as const, + connectionSlug: "worker-provider", + model: "gpt-5-mini", + }; + // Seed the host policy through the same mutation seam used by the module. + await fixture.module.update({ subagents: { presets: [], adHoc } }); + await fixture.module.update({ subagents: { presets: [] } }); + assert.deepEqual(fixture.policy().subagents, { presets: [], adHoc }); + assert.notDeepEqual(fixture.policy(), current); +}); + test("spread-back derived and legacy password fields never enter Runtime policy", async () => { const fixture = createModuleFixture({ configured: true }); diff --git a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts index 369ec810e0..f6dff080be 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -405,9 +405,12 @@ async function applyHostPatchWithoutLane( } } if (patch.subagents) { - await client.updateRuntimePolicy(() => ({ + await client.updateRuntimePolicy((policy) => ({ kind: "set_subagents", - value: patch.subagents!, + value: { + ...policy.subagents, + ...patch.subagents, + }, })); } return skippedCredentials; diff --git a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts index a77ccfae23..29b39f7d49 100644 --- a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts @@ -29,6 +29,21 @@ type ProfileCopy = { }; export type SubagentSettingsCopy = { + adHoc: { + title: string; + description: string; + enabled: string; + enabledDescription: string; + profile: string; + profileDescription: string; + connection: string; + model: string; + thinking: string; + noConnection: string; + noModel: string; + save: string; + saveFailed: string; + }; section: { title: string; count(total: number): string; @@ -101,6 +116,21 @@ export type SubagentSettingsCopy = { const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { 'zh-CN': { + adHoc: { + title: '临时子 Agent', + description: '明确启用后,主 Agent 才能创建一次性的任务角色。这里固定它可用的最高能力、连接和模型。', + enabled: '允许临时子 Agent', + enabledDescription: '关闭后,临时角色不会出现在 agent_list 中,也无法通过 agent_spawn 创建。', + profile: '最高能力 Profile', + profileDescription: '临时角色只能使用不高于此 Profile 的固定能力边界。', + connection: '模型连接', + model: '模型', + thinking: '思考级别', + noConnection: '请先在“模型”页启用一个模型连接。', + noModel: '所选连接没有已启用的模型。', + save: '保存临时策略', + saveFailed: '保存临时子 Agent 策略失败', + }, section: { title: '已批准的子 Agent', count: (total) => `共 ${total} 个配置`, @@ -183,6 +213,21 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { }, }, 'zh-TW': { + adHoc: { + title: '臨時子 Agent', + description: '明確啟用後,主 Agent 才能建立一次性的任務角色。這裡固定它可用的最高能力、連線和模型。', + enabled: '允許臨時子 Agent', + enabledDescription: '關閉後,臨時角色不會出現在 agent_list 中,也無法透過 agent_spawn 建立。', + profile: '最高能力 Profile', + profileDescription: '臨時角色只能使用不高於此 Profile 的固定能力邊界。', + connection: '模型連線', + model: '模型', + thinking: '思考級別', + noConnection: '請先在「模型」頁啟用一個模型連線。', + noModel: '所選連線沒有已啟用的模型。', + save: '儲存臨時策略', + saveFailed: '儲存臨時子 Agent 策略失敗', + }, section: { title: '已批准的子 Agent', count: (total) => `共 ${total} 個設定`, @@ -265,6 +310,21 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { }, }, en: { + adHoc: { + title: 'Temporary subagent', + description: 'When explicitly enabled, the main agent may create one-off task roles. These settings fix their maximum capability, connection, and model.', + enabled: 'Allow temporary subagents', + enabledDescription: 'When off, the route is omitted from agent_list and agent_spawn cannot create it.', + profile: 'Maximum capability profile', + profileDescription: 'Temporary roles cannot exceed this fixed capability boundary.', + connection: 'Model connection', + model: 'Model', + thinking: 'Thinking level', + noConnection: 'Enable a model connection on the Models page first.', + noModel: 'The selected connection has no enabled models.', + save: 'Save temporary policy', + saveFailed: 'Failed to save temporary subagent policy', + }, section: { title: 'Approved subagents', count: (total) => `${total} presets`, diff --git a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx index abffa4fd13..c68de1fce1 100644 --- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx @@ -38,6 +38,7 @@ import { SUBAGENT_PRESET_DESCRIPTION_MAX_CHARS, SUBAGENT_PRESET_ID_MAX_CHARS, SUBAGENT_PRESET_NAME_MAX_CHARS, + type AdHocSubagentPolicy, type SubagentPreset, type SubagentProfile, } from '@maka/core/subagent-settings'; @@ -94,6 +95,28 @@ type SubagentEditorDraft = Omit & { thinkingLevel: ThinkingLevel | ''; }; +type AdHocSubagentPolicyDraft = Omit & { + thinkingLevel: ThinkingLevel | ''; +}; + +function initialAdHocPolicyDraft( + policy: AdHocSubagentPolicy | undefined, + connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[], +): AdHocSubagentPolicyDraft { + const usableConnections = connections.filter(isSelectableSubagentConnection); + const initialConnection = policy + ? connections.find((connection) => connection.slug === policy.connectionSlug) + : usableConnections[0]; + const initialModels = initialConnection ? offerableCatalogEntries(initialConnection) : []; + return { + enabled: policy?.enabled ?? false, + maxProfile: policy?.maxProfile ?? 'local_read', + connectionSlug: policy?.connectionSlug ?? usableConnections[0]?.slug ?? '', + model: policy?.model ?? initialModels[0]?.id ?? '', + thinkingLevel: policy?.thinkingLevel ?? '', + }; +} + export function SubagentSettingsPage(props: { settings: AppSettings; connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[]; @@ -105,7 +128,16 @@ export function SubagentSettingsPage(props: { const locale = useUiLocale(); const copy = getSubagentSettingsCopy(locale); const toast = useToast(); - const [route, setRoute] = useState({ kind: 'list' }); + const [pageState, setPageState] = useState(() => ({ + route: { kind: 'list' } as SubagentPageRoute, + adHocDraft: initialAdHocPolicyDraft( + props.settings.subagents.adHoc, + props.connections, + ), + })); + const route = pageState.route; + const setRoute = (nextRoute: SubagentPageRoute): void => + setPageState((current) => ({ ...current, route: nextRoute })); const [saving, setSaving] = useState(false); const presets = props.settings.subagents.presets; const { level, preset: editorPreset } = resolveSubagentRoute(route, presets); @@ -158,7 +190,14 @@ export function SubagentSettingsPage(props: { ): Promise { setSaving(true); try { - const result = await props.onUpdate({ subagents: { presets: nextPresets } }); + const result = await props.onUpdate({ + subagents: { + presets: nextPresets, + ...(props.settings.subagents.adHoc + ? { adHoc: props.settings.subagents.adHoc } + : {}), + }, + }); if ( expectPresent !== undefined && !result.settings.subagents.presets.some((candidate) => candidate.id === expectPresent) @@ -236,6 +275,29 @@ export function SubagentSettingsPage(props: { return ( + + setPageState((current) => ({ + ...current, + adHocDraft: update(current.adHocDraft), + })) + } + onSave={async (adHoc) => { + setSaving(true); + try { + await props.onUpdate({ subagents: { presets, adHoc } }); + } catch (error) { + reportHostError(copy.adHoc.saveFailed, settingsActionErrorMessage(error, locale)); + } finally { + setSaving(false); + } + }} + /> ; + connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[]; + isSaving: boolean; + onDraftChange(update: (current: AdHocSubagentPolicyDraft) => AdHocSubagentPolicyDraft): void; + onSave(policy: AdHocSubagentPolicy): Promise; +}) { + const copy = props.copy; + const usableConnections = useMemo( + () => props.connections.filter(isSelectableSubagentConnection), + [props.connections], + ); + const draft = props.draft; + const setDraft = props.onDraftChange; + const selectedConnection = props.connections.find( + (connection) => connection.slug === draft.connectionSlug, + ); + const offerableModels = selectedConnection ? offerableCatalogEntries(selectedConnection) : []; + const thinkingLevels = + selectedConnection?.catalogEntries.find((entry) => entry.id === draft.model)?.thinkingLevels ?? + []; + const validRoute = Boolean( + selectedConnection && + isSelectableSubagentConnection(selectedConnection) && + offerableModels.some((entry) => entry.id === draft.model), + ); + const canSave = validRoute || (props.policy !== undefined && !draft.enabled); + + function selectConnection(connectionSlug: string): void { + const connection = usableConnections.find((candidate) => candidate.slug === connectionSlug); + const models = connection ? offerableCatalogEntries(connection) : []; + setDraft((current) => ({ + ...current, + connectionSlug, + model: models[0]?.id ?? '', + thinkingLevel: '', + })); + } + + function policyFromDraft(next: AdHocSubagentPolicyDraft): AdHocSubagentPolicy { + return { + enabled: next.enabled, + maxProfile: next.maxProfile, + connectionSlug: next.connectionSlug, + model: next.model, + ...(next.thinkingLevel ? { thinkingLevel: next.thinkingLevel } : {}), + }; + } + + return ( + + setDraft((current) => ({ ...current, enabled }))} + /> + )} + /> + ({ + value: profile, + label: copy.profiles[profile].label, + }))} + width="100%" + isDisabled={props.isSaving} + onChange={(maxProfile) => setDraft((current) => ({ + ...current, + maxProfile: maxProfile as SubagentProfile, + }))} + /> + )} + /> + ({ + value: connection.slug, + label: connection.name, + }))} + width="100%" + isDisabled={props.isSaving || usableConnections.length === 0} + disabledMessage={usableConnections.length === 0 ? copy.adHoc.noConnection : undefined} + onChange={selectConnection} + /> + )} + /> + ({ + value: entry.id, + label: entry.displayName?.trim() || entry.id, + }))} + width="100%" + isDisabled={props.isSaving || offerableModels.length === 0} + disabledMessage={offerableModels.length === 0 ? copy.adHoc.noModel : undefined} + onChange={(model) => setDraft((current) => ({ + ...current, + model, + thinkingLevel: '', + }))} + /> + )} + /> + {thinkingLevels.length > 0 ? ( + ({ value: level, label: copy.thinking[level] })), + ]} + width="100%" + isDisabled={props.isSaving} + onChange={(thinkingLevel) => setDraft((current) => ({ + ...current, + thinkingLevel: thinkingLevel as ThinkingLevel | '', + }))} + /> + )} + /> + ) : null} + +