From 4b7a817eb52b69d0fda25245067ac20d09183e31 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 13:27:30 -0700 Subject: [PATCH] Keep hidden harness defaults in sync Signed-off-by: kenny lopez --- desktop/src/features/agents/AGENTS.md | 4 ++- .../lib/runtimeVisibilityPreference.test.mjs | 16 +++++++++++ .../agents/lib/runtimeVisibilityPreference.ts | 22 +++++++++++++++ .../agents/ui/AgentDefaultsEditor.tsx | 25 +++++++++-------- .../agents/ui/agentConfigOptions.test.mjs | 18 +++++++++++++ .../features/agents/ui/agentConfigOptions.tsx | 19 +++++++++++++ .../features/agents/useGlobalAgentConfig.ts | 17 +++++++++++- desktop/tests/e2e/doctor-states.spec.ts | 27 +++++++++++++++++++ 8 files changed, 135 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 1dece1ef6..146ff34e8 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -91,7 +91,9 @@ with a TypeScript lookup table or an id comparison in a component. from new choices while the raw Rust catalog remains authoritative for installation, capabilities, and existing agent configuration. Turning a runtime off must not uninstall it, stop it, or invalidate an existing - agent that already uses it. + agent that already uses it. If the disabled runtime was the saved global + default, consumers immediately ignore that preference and the defaults + editor persists its visible fallback on the next save. 10. **The defaults modal is progressively disclosed.** An unset global config starts on the Buzz Agent-first deployment fallback and carries that visible harness into the next saved edit. The `progressive-defaults` disclosure diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs index 1f96cb55e..fcf1755ea 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { ACP_RUNTIME_VISIBILITY_STORAGE_KEY, filterEnabledAcpRuntimes, + maskDisabledAcpRuntimePreference, nextDisabledAcpRuntimeIds, parseDisabledAcpRuntimeIds, readDisabledAcpRuntimeIds, @@ -51,3 +52,18 @@ test("stored runtime visibility is read from the versioned device key", () => { assert.deepEqual(readDisabledAcpRuntimeIds(storage), ["claude"]); }); + +test("a disabled saved runtime is removed from the effective preference", () => { + const config = { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: "Goose", + }; + + assert.deepEqual(maskDisabledAcpRuntimePreference(config, ["goose"]), { + ...config, + preferred_runtime: null, + }); + assert.equal(maskDisabledAcpRuntimePreference(config, ["claude"]), config); +}); diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts index f21f86671..c364c8b0f 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -95,6 +95,28 @@ export function filterEnabledAcpRuntimes( ); } +/** + * Prevent a disabled runtime from remaining the effective global preference. + * + * The persisted config is left untouched until the user next saves defaults; + * consumers immediately fall back through the normal runtime selection path. + */ +export function maskDisabledAcpRuntimePreference< + T extends { preferred_runtime: string | null }, +>(config: T, disabledRuntimeIds: readonly string[]): T { + const preferredRuntime = config.preferred_runtime; + if ( + !preferredRuntime || + !disabledRuntimeIds + .map(normalizeRuntimeId) + .includes(normalizeRuntimeId(preferredRuntime)) + ) { + return config; + } + + return { ...config, preferred_runtime: null }; +} + function getDisabledRuntimeIdsSnapshot(): readonly string[] { const storage = getLocalStorage(); if (!storage) return EMPTY_DISABLED_RUNTIME_IDS; diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 341c1e1b1..e5325d6fa 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -27,6 +27,7 @@ import { import { formatRuntimeOptionLabel, getDefaultPersonaRuntime, + reconcilePreferredRuntimeFallback, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, resetConfigForHarnessChange, @@ -154,16 +155,14 @@ export function AgentDefaultsEditor({ sortedRuntimes[0] ); }, [config.preferred_runtime, sortedRuntimes]); - const renderedConfig = React.useMemo( - () => - config.preferred_runtime || !selectedRuntime - ? config - : { ...config, preferred_runtime: selectedRuntime.id }, - [config, selectedRuntime], - ); const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( selectedRuntime?.id ?? "", ); + const effectiveConfig = React.useMemo( + () => + reconcilePreferredRuntimeFallback(config, selectedRuntime?.id ?? null), + [config, selectedRuntime], + ); const harnessOptions = React.useMemo( () => sortedRuntimes.map((runtime) => ({ @@ -187,7 +186,7 @@ export function AgentDefaultsEditor({ } function handleHarnessChange(runtimeId: string) { - handleConfigChange(resetConfigForHarnessChange(config, runtimeId)); + handleConfigChange(resetConfigForHarnessChange(effectiveConfig, runtimeId)); setConfigIsValid(false); setIsCustomModelEditing(false); setIsCustomProvider(false); @@ -196,7 +195,11 @@ export function AgentDefaultsEditor({ async function handleSave() { // Snapshot the config being submitted so we can detect edits that arrive // during the IPC round-trip and avoid clobbering the user's newer input. - const submittedConfig = config; + const draftAtSubmit = configRef.current; + const submittedConfig = reconcilePreferredRuntimeFallback( + draftAtSubmit, + selectedRuntime?.id ?? null, + ); onSavingChange?.(true); setSaveState("saving"); setSaveError(null); @@ -206,7 +209,7 @@ export function AgentDefaultsEditor({ // IPC window. If the user edited, keep their newer value and leave dirty=true // so they can save again. setDirty(false) runs inside the updater so both // state updates batch into the same render (React 18 automatic batching). - const savedCurrentDraft = configRef.current === submittedConfig; + const savedCurrentDraft = configRef.current === draftAtSubmit; setConfig((current) => { if (!savedCurrentDraft) { // Mid-flight edit detected — do not overwrite newer user input. @@ -242,7 +245,7 @@ export function AgentDefaultsEditor({ { assert.equal(resetConfigForHarnessChange(config, "goose").provider, null); }); +test("reconcilePreferredRuntimeFallback updates a hidden saved default", () => { + const config = { + env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high", SHARED: "kept" }, + provider: "anthropic", + model: "claude-opus", + preferred_runtime: "goose", + }; + + assert.deepEqual(reconcilePreferredRuntimeFallback(config, "buzz-agent"), { + env_vars: { SHARED: "kept" }, + provider: "anthropic", + model: null, + preferred_runtime: "buzz-agent", + }); + assert.equal(reconcilePreferredRuntimeFallback(config, "goose"), config); +}); + // ── getPersonaModelOptions — codex/claude do not use global provider ────────── // // The discovery call in AgentDefinitionDialog passes diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6c..120098c16 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -198,6 +198,25 @@ export function resetConfigForHarnessChange( }; } +/** + * Align a stale or hidden saved preference with the fallback shown by a + * runtime selector, clearing values that are not portable across harnesses. + */ +export function reconcilePreferredRuntimeFallback( + config: GlobalAgentConfig, + fallbackRuntimeId: string | null, +): GlobalAgentConfig { + if ( + !config.preferred_runtime || + !fallbackRuntimeId || + config.preferred_runtime === fallbackRuntimeId + ) { + return config; + } + + return resetConfigForHarnessChange(config, fallbackRuntimeId); +} + function effectiveModelProviderForOptions( runtimeId: string, providerId: string | null | undefined, diff --git a/desktop/src/features/agents/useGlobalAgentConfig.ts b/desktop/src/features/agents/useGlobalAgentConfig.ts index 4b90beb43..054bfb7c2 100644 --- a/desktop/src/features/agents/useGlobalAgentConfig.ts +++ b/desktop/src/features/agents/useGlobalAgentConfig.ts @@ -9,8 +9,14 @@ * On fetch error the query falls back to EMPTY_CONFIG (safe — the absence of * a global config is never an error state for callers). */ +import * as React from "react"; + import { useQuery } from "@tanstack/react-query"; +import { + maskDisabledAcpRuntimePreference, + useDisabledAcpRuntimeIds, +} from "@/features/agents/lib/runtimeVisibilityPreference"; import { getGlobalAgentConfig } from "@/shared/api/tauriGlobalAgentConfig"; import type { GlobalAgentConfig } from "@/shared/api/types"; @@ -27,6 +33,7 @@ export function useGlobalAgentConfig(): { globalConfig: GlobalAgentConfig; isLoading: boolean; } { + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); const { data, isPending } = useQuery({ queryKey: globalAgentConfigQueryKey, queryFn: getGlobalAgentConfig, @@ -36,9 +43,17 @@ export function useGlobalAgentConfig(): { // Never show a stale empty flash while a background refetch runs. placeholderData: EMPTY_CONFIG, }); + const globalConfig = React.useMemo( + () => + maskDisabledAcpRuntimePreference( + data ?? EMPTY_CONFIG, + disabledRuntimeIds, + ), + [data, disabledRuntimeIds], + ); return { - globalConfig: data ?? EMPTY_CONFIG, + globalConfig, isLoading: isPending, }; } diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 681748c0d..7371bca7b 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -240,6 +240,12 @@ test.describe("Doctor panel state screenshots", () => { CLAUDE_AVAILABLE_LOGGED_IN, BUZZ_AGENT_AVAILABLE, ], + globalAgentConfig: { + env_vars: {}, + provider: "anthropic", + model: "claude-opus", + preferred_runtime: "goose", + }, }); await page.goto("/", { waitUntil: "domcontentloaded" }); @@ -251,6 +257,27 @@ test.describe("Doctor panel state screenshots", () => { await gooseToggle.click(); await expect(gooseToggle).not.toBeChecked(); + const defaultHarness = page.getByTestId("global-agent-default-harness"); + await expect(defaultHarness).toHaveText("Buzz Agent"); + const provider = page.getByTestId("global-agent-provider"); + await provider.click(); + await page.getByTestId("global-agent-provider-option-openai").click(); + await page.getByRole("button", { name: "Save defaults" }).click(); + const savedConfig = await page.evaluate(async () => + ( + window as typeof window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null), + ); + expect(savedConfig).toMatchObject({ + provider: "openai", + preferred_runtime: "buzz-agent", + }); + await page.getByRole("button", { name: "Back to app" }).click(); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click();