From 4b365349c4fd42bc7b020a8051c08791003d3f6e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 11:52:41 -0700 Subject: [PATCH 01/22] Allow hiding agent harness options --- desktop/src/features/agents/AGENTS.md | 7 + .../lib/runtimeVisibilityPreference.test.mjs | 53 +++++ .../agents/lib/runtimeVisibilityPreference.ts | 185 ++++++++++++++++++ .../agents/ui/AgentDefaultsEditor.tsx | 6 +- .../agents/ui/AgentDefinitionDialog.tsx | 18 +- .../agents/ui/AgentInstanceEditDialog.tsx | 6 +- .../settings/ui/DoctorSettingsPanel.tsx | 18 +- desktop/tests/e2e/doctor-states.spec.ts | 48 ++++- 8 files changed, 318 insertions(+), 23 deletions(-) create mode 100644 desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs create mode 100644 desktop/src/features/agents/lib/runtimeVisibilityPreference.ts diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index da230bdf45..d520b57f59 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -79,6 +79,13 @@ with a TypeScript lookup table or an id comparison in a component. harnesses always keep the field. Gate: `defaults hides model when optional harness has empty discovery` (and the failed-discovery counterpart) in `onboarding-agent-defaults.spec.ts`. +9. **Runtime enablement is a device-local menu preference, not a capability or + lifecycle fact.** Settings persists disabled runtime IDs through + `lib/runtimeVisibilityPreference.ts`; harness dropdowns filter those IDs + 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. ## The tests that enforce this diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs new file mode 100644 index 0000000000..1f96cb55e2 --- /dev/null +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ACP_RUNTIME_VISIBILITY_STORAGE_KEY, + filterEnabledAcpRuntimes, + nextDisabledAcpRuntimeIds, + parseDisabledAcpRuntimeIds, + readDisabledAcpRuntimeIds, +} from "./runtimeVisibilityPreference.ts"; + +test("runtime visibility parsing is normalized and corruption tolerant", () => { + assert.deepEqual( + parseDisabledAcpRuntimeIds('[" Goose ","codex","goose",4]'), + ["codex", "goose"], + ); + assert.deepEqual(parseDisabledAcpRuntimeIds("{not-json"), []); + assert.deepEqual(parseDisabledAcpRuntimeIds('{"goose":false}'), []); +}); + +test("enabling and disabling runtimes preserves the other choices", () => { + const disabled = nextDisabledAcpRuntimeIds([], " Goose ", false); + assert.deepEqual(disabled, ["goose"]); + assert.deepEqual(nextDisabledAcpRuntimeIds(disabled, "codex", false), [ + "codex", + "goose", + ]); + assert.deepEqual(nextDisabledAcpRuntimeIds(disabled, "GOOSE", true), []); +}); + +test("disabled runtimes are removed from selectable catalog entries", () => { + const runtimes = [ + { id: "buzz-agent", label: "Buzz Agent" }, + { id: "goose", label: "Goose" }, + { id: "codex", label: "Codex" }, + ]; + + assert.deepEqual(filterEnabledAcpRuntimes(runtimes, ["Goose"]), [ + runtimes[0], + runtimes[2], + ]); +}); + +test("stored runtime visibility is read from the versioned device key", () => { + const storage = { + getItem(key) { + assert.equal(key, ACP_RUNTIME_VISIBILITY_STORAGE_KEY); + return '["claude"]'; + }, + }; + + assert.deepEqual(readDisabledAcpRuntimeIds(storage), ["claude"]); +}); diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts new file mode 100644 index 0000000000..f21f86671e --- /dev/null +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -0,0 +1,185 @@ +import * as React from "react"; + +export const ACP_RUNTIME_VISIBILITY_STORAGE_KEY = + "buzz-agent-runtime-visibility.v1"; + +type RuntimeVisibilityStorage = Pick; + +const EMPTY_DISABLED_RUNTIME_IDS: readonly string[] = Object.freeze([]); +const listeners = new Set<() => void>(); + +let cachedSerializedValue: string | null | undefined; +let cachedDisabledRuntimeIds = EMPTY_DISABLED_RUNTIME_IDS; + +function normalizeRuntimeId(runtimeId: string): string { + return runtimeId.trim().toLowerCase(); +} + +function getLocalStorage(): RuntimeVisibilityStorage | null { + if (typeof window === "undefined") return null; + + try { + return window.localStorage; + } catch { + return null; + } +} + +export function parseDisabledAcpRuntimeIds( + serialized: string | null, +): readonly string[] { + if (!serialized) return EMPTY_DISABLED_RUNTIME_IDS; + + try { + const parsed: unknown = JSON.parse(serialized); + if (!Array.isArray(parsed)) return EMPTY_DISABLED_RUNTIME_IDS; + + const runtimeIds = [ + ...new Set( + parsed + .filter((value): value is string => typeof value === "string") + .map(normalizeRuntimeId) + .filter(Boolean), + ), + ].sort(); + return runtimeIds.length > 0 + ? Object.freeze(runtimeIds) + : EMPTY_DISABLED_RUNTIME_IDS; + } catch { + return EMPTY_DISABLED_RUNTIME_IDS; + } +} + +export function readDisabledAcpRuntimeIds( + storage: Pick, +): readonly string[] { + try { + return parseDisabledAcpRuntimeIds( + storage.getItem(ACP_RUNTIME_VISIBILITY_STORAGE_KEY), + ); + } catch { + return EMPTY_DISABLED_RUNTIME_IDS; + } +} + +export function nextDisabledAcpRuntimeIds( + current: readonly string[], + runtimeId: string, + enabled: boolean, +): readonly string[] { + const normalizedRuntimeId = normalizeRuntimeId(runtimeId); + if (!normalizedRuntimeId) return current; + + const next = new Set(current.map(normalizeRuntimeId).filter(Boolean)); + if (enabled) { + next.delete(normalizedRuntimeId); + } else { + next.add(normalizedRuntimeId); + } + + const runtimeIds = [...next].sort(); + return runtimeIds.length > 0 + ? Object.freeze(runtimeIds) + : EMPTY_DISABLED_RUNTIME_IDS; +} + +export function filterEnabledAcpRuntimes( + runtimes: readonly T[], + disabledRuntimeIds: readonly string[], +): T[] { + if (disabledRuntimeIds.length === 0) return [...runtimes]; + + const disabled = new Set(disabledRuntimeIds.map(normalizeRuntimeId)); + return runtimes.filter( + (runtime) => !disabled.has(normalizeRuntimeId(runtime.id)), + ); +} + +function getDisabledRuntimeIdsSnapshot(): readonly string[] { + const storage = getLocalStorage(); + if (!storage) return EMPTY_DISABLED_RUNTIME_IDS; + + let serialized: string | null; + try { + serialized = storage.getItem(ACP_RUNTIME_VISIBILITY_STORAGE_KEY); + } catch { + return EMPTY_DISABLED_RUNTIME_IDS; + } + + if (serialized === cachedSerializedValue) { + return cachedDisabledRuntimeIds; + } + + cachedSerializedValue = serialized; + cachedDisabledRuntimeIds = parseDisabledAcpRuntimeIds(serialized); + return cachedDisabledRuntimeIds; +} + +function subscribeToRuntimeVisibility(onStoreChange: () => void): () => void { + listeners.add(onStoreChange); + + const handleStorage = (event: StorageEvent) => { + if ( + event.key !== null && + event.key !== ACP_RUNTIME_VISIBILITY_STORAGE_KEY + ) { + return; + } + cachedSerializedValue = undefined; + onStoreChange(); + }; + window.addEventListener("storage", handleStorage); + + return () => { + listeners.delete(onStoreChange); + window.removeEventListener("storage", handleStorage); + }; +} + +export function setAcpRuntimeEnabled( + runtimeId: string, + enabled: boolean, +): boolean { + const storage = getLocalStorage(); + if (!storage) return false; + + const current = getDisabledRuntimeIdsSnapshot(); + const next = nextDisabledAcpRuntimeIds(current, runtimeId, enabled); + if (next === current) return true; + + const serialized = JSON.stringify(next); + try { + storage.setItem(ACP_RUNTIME_VISIBILITY_STORAGE_KEY, serialized); + } catch { + return false; + } + + cachedSerializedValue = serialized; + cachedDisabledRuntimeIds = next; + for (const listener of listeners) listener(); + return true; +} + +export function useDisabledAcpRuntimeIds(): readonly string[] { + return React.useSyncExternalStore( + subscribeToRuntimeVisibility, + getDisabledRuntimeIdsSnapshot, + () => EMPTY_DISABLED_RUNTIME_IDS, + ); +} + +export function useSelectableAcpRuntimes( + runtimes: readonly T[], +): T[] { + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); + return React.useMemo( + () => filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds), + [disabledRuntimeIds, runtimes], + ); +} + +export function useAcpRuntimeEnabled(runtimeId: string): boolean { + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); + const normalizedRuntimeId = normalizeRuntimeId(runtimeId); + return !disabledRuntimeIds.includes(normalizedRuntimeId); +} diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 52cd01dd3a..109acfcf42 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -32,6 +32,7 @@ import { EMPTY_GLOBAL_CONFIG, } from "@/features/agents/ui/AgentConfigFields"; import { Button } from "@/shared/ui/button"; +import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference"; type SaveState = "idle" | "saving" | "saved" | "error"; @@ -113,9 +114,10 @@ export function AgentDefaultsEditor({ }, []); const runtimesQuery = useAcpRuntimesQuery(); + const selectableRuntimes = useSelectableAcpRuntimes(runtimesQuery.data ?? []); const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimesQuery.data ?? []), - [runtimesQuery.data], + () => sortPersonaRuntimes(selectableRuntimes), + [selectableRuntimes], ); // A missing/stale preference displays the same effective fallback the backend // would use; it is persisted only after the user edits and saves this form. diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index d69028cfd1..f496496b8d 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -83,6 +83,7 @@ import { } from "./agentAiConfigurationPolicy"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; +import { useSelectableAcpRuntimes } from "../lib/runtimeVisibilityPreference"; type AgentDefinitionDialogProps = { open: boolean; @@ -150,10 +151,8 @@ export function AgentDefinitionDialog({ // the seeded runtime from the submit payload for builtin definitions whose // canonical runtime is null — the sync would revert it anyway. const isRuntimeAutoSeededRef = React.useRef(false); - // Guards the seeding effect so it fires at most once per dialog-open. - // Without this, clearing runtime back to "" via "No preference" would re- - // trigger the effect (the `runtime` dep would pass the length guard) and - // snap the dropdown back to the default — an edit-mode regression. + // Seed once per open so choosing "No preference" cannot snap the dropdown + // back to the default. const hasSeededForOpenRef = React.useRef(false); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [isAvatarUploadPending, setIsAvatarUploadPending] = @@ -166,9 +165,10 @@ export function AgentDefinitionDialog({ }, inheritedEnvVars: inheritedEnvVarsForAdvanced, } = useAgentDialogDefaults({ open }); - const defaultRuntime = React.useMemo( - () => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime), - [globalConfig.preferred_runtime, runtimes], + const selectableRuntimes = useSelectableAcpRuntimes(runtimes); + const defaultRuntime = getDefaultPersonaRuntime( + selectableRuntimes, + globalConfig.preferred_runtime, ); const shouldReduceMotion = useReducedMotion(); const initialModelProviderEditableWithoutRuntime = Boolean( @@ -553,8 +553,8 @@ export function AgentDefinitionDialog({ llmProviderFieldVisible && isCustomProviderEditing; const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE; const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimes), - [runtimes], + () => sortPersonaRuntimes(selectableRuntimes), + [selectableRuntimes], ); const blankRuntimeOptionLabel = runtimesLoading ? "Loading harnesses..." diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index c2cc5de18b..fa115387a9 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -81,6 +81,7 @@ import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; +import { useSelectableAcpRuntimes } from "../lib/runtimeVisibilityPreference"; const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, @@ -110,6 +111,7 @@ export function AgentInstanceEditDialog({ const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; + const selectableRuntimes = useSelectableAcpRuntimes(runtimes); const [name, setName] = React.useState(agent.name); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); @@ -214,8 +216,8 @@ export function AgentInstanceEditDialog({ // Build the sorted runtime catalog for the dropdown. const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimes), - [runtimes], + () => sortPersonaRuntimes(selectableRuntimes), + [selectableRuntimes], ); const selectedRuntime = React.useMemo( diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index cee156ece7..05112a5105 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -9,6 +9,10 @@ import { useGitBashPrerequisiteQuery, useInstallAcpRuntimeMutation, } from "@/features/agents/hooks"; +import { + setAcpRuntimeEnabled, + useAcpRuntimeEnabled, +} from "@/features/agents/lib/runtimeVisibilityPreference"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { getInstallErrorMessage } from "@/shared/lib/installError"; @@ -160,7 +164,9 @@ function RuntimeActions({ }) { const isAvailable = runtime.availability === "available"; const canInstall = runtime.canAutoInstall && !runtime.nodeRequired; - const isOn = isAvailable || installSuccess; + const isEnabled = useAcpRuntimeEnabled(runtime.id); + const isInstalled = isAvailable || installSuccess; + const isOn = isInstalled && isEnabled; const isWorking = isInstalling || isConnecting; return ( @@ -182,13 +188,13 @@ function RuntimeActions({ ) : ( { - if (checked) { + setAcpRuntimeEnabled(runtime.id, checked); + if (checked && !isInstalled) { onInstall(); } }} @@ -553,7 +559,7 @@ export function DoctorSettingsPanel() { { }); await page.keyboard.press("Escape"); await expect(page.getByTestId("doctor-runtime-toggle-goose")).toBeChecked(); - await expect( - page.getByTestId("doctor-runtime-toggle-goose"), - ).toBeDisabled(); + await expect(page.getByTestId("doctor-runtime-toggle-goose")).toBeEnabled(); await expect(page.getByTestId("doctor-runtime-codex")).not.toContainText( "Not installed", ); @@ -220,6 +218,48 @@ test.describe("Doctor panel state screenshots", () => { }); }); + test("available runtimes can be hidden from agent harness menus", async ({ + page, + }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + BUZZ_AGENT_AVAILABLE, + ], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + const gooseToggle = page.getByTestId("doctor-runtime-toggle-goose"); + await expect(gooseToggle).toBeChecked(); + await expect(gooseToggle).toBeEnabled(); + await gooseToggle.click(); + await expect(gooseToggle).not.toBeChecked(); + + await page.getByRole("button", { name: "Back to app" }).click(); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("new-agent-card").click(); + await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + + const harnessDropdown = page.locator("#persona-runtime"); + await expect(harnessDropdown).toContainText("Buzz Agent"); + await harnessDropdown.press("Enter"); + await expect( + page.getByRole("menuitemradio", { name: /^Goose/ }), + ).toHaveCount(0); + await expect( + page.getByRole("menuitemradio", { name: /^Buzz Agent/ }), + ).toBeVisible(); + + await page.reload({ waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + await expect( + page.getByTestId("doctor-runtime-toggle-goose"), + ).not.toBeChecked(); + }); + /** 01 — a ready runtime stays compact without redundant status copy. */ test("01-auth-logged-in", async ({ page }) => { await installMockBridge(page, { @@ -484,7 +524,7 @@ test.describe("Doctor panel state screenshots", () => { timeout: 10_000, }); await expect(toggle).toBeChecked(); - await expect(toggle).toBeDisabled(); + await expect(toggle).toBeEnabled(); await row.scrollIntoViewIfNeeded(); await waitForAnimations(page); From b16014102af0ffaaaa7b2b0bdc7d0f99aa86ae94 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 13:27:30 -0700 Subject: [PATCH 02/22] Keep hidden harness defaults in sync --- desktop/src/features/agents/AGENTS.md | 4 ++- .../lib/runtimeVisibilityPreference.test.mjs | 16 +++++++++++ .../agents/lib/runtimeVisibilityPreference.ts | 22 +++++++++++++++ .../agents/ui/AgentDefaultsEditor.tsx | 18 ++++++++++--- .../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(+), 6 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d520b57f59..d567ccd962 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -85,7 +85,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. ## The tests that enforce this diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs index 1f96cb55e2..fcf1755eac 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 f21f86671e..c364c8b0f5 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 109acfcf42..36bb5ce662 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -23,6 +23,7 @@ import { useAcpRuntimesQuery } from "@/features/agents/hooks"; import { formatRuntimeOptionLabel, getDefaultPersonaRuntime, + reconcilePreferredRuntimeFallback, resetConfigForHarnessChange, sortPersonaRuntimes, } from "@/features/agents/ui/agentConfigOptions"; @@ -131,6 +132,11 @@ export function AgentDefaultsEditor({ sortedRuntimes[0], [config.preferred_runtime, sortedRuntimes], ); + const effectiveConfig = React.useMemo( + () => + reconcilePreferredRuntimeFallback(config, selectedRuntime?.id ?? null), + [config, selectedRuntime], + ); const harnessOptions = React.useMemo( () => sortedRuntimes.map((runtime) => ({ @@ -154,7 +160,7 @@ export function AgentDefaultsEditor({ } function handleHarnessChange(runtimeId: string) { - handleConfigChange(resetConfigForHarnessChange(config, runtimeId)); + handleConfigChange(resetConfigForHarnessChange(effectiveConfig, runtimeId)); setIsCustomModelEditing(false); setIsCustomProvider(false); } @@ -162,7 +168,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); @@ -172,7 +182,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. @@ -237,7 +247,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 6ae81ff6cb..120098c16e 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 4b90beb43d..054bfb7c23 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 ce119d1209..5563d8fa96 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -227,6 +227,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" }); @@ -238,6 +244,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(); From 4a049a3383d9162b4d37952b3e3834f720e3e45a Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 13:39:09 -0700 Subject: [PATCH 03/22] Honor harness visibility for implicit starts --- desktop/src/features/agents/AGENTS.md | 4 +++- .../lib/runtimeVisibilityPreference.test.mjs | 13 +++++++++++++ .../agents/lib/runtimeVisibilityPreference.ts | 14 ++++++++++++++ .../features/agents/ui/AddTeamToChannelDialog.tsx | 6 ++++-- .../features/agents/ui/useManagedAgentActions.ts | 11 ++++++++++- 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d567ccd962..fdce8e5bf5 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -87,7 +87,9 @@ with a TypeScript lookup table or an id comparison in a component. runtime off must not uninstall it, stop it, or invalidate an existing 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. + editor persists its visible fallback on the next save. Runtime-less agent + starts and team deploys must filter through the same visible-runtime set; + definitions already pinned to a hidden runtime remain runnable. ## The tests that enforce this diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs index fcf1755eac..629f28559e 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs @@ -8,6 +8,7 @@ import { nextDisabledAcpRuntimeIds, parseDisabledAcpRuntimeIds, readDisabledAcpRuntimeIds, + runtimesForImplicitAcpSelection, } from "./runtimeVisibilityPreference.ts"; test("runtime visibility parsing is normalized and corruption tolerant", () => { @@ -40,6 +41,18 @@ test("disabled runtimes are removed from selectable catalog entries", () => { runtimes[0], runtimes[2], ]); + assert.deepEqual(filterEnabledAcpRuntimes(runtimes, ["buzz-agent"]), [ + runtimes[1], + runtimes[2], + ]); + assert.deepEqual( + runtimesForImplicitAcpSelection(runtimes, ["buzz-agent"], null), + [runtimes[1], runtimes[2]], + ); + assert.deepEqual( + runtimesForImplicitAcpSelection(runtimes, ["buzz-agent"], "buzz-agent"), + runtimes, + ); }); test("stored runtime visibility is read from the versioned device key", () => { diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts index c364c8b0f5..dcda74a413 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -95,6 +95,20 @@ export function filterEnabledAcpRuntimes( ); } +/** + * Apply visibility only when the app is choosing a runtime implicitly. + * Existing definitions pinned to a runtime remain runnable. + */ +export function runtimesForImplicitAcpSelection( + runtimes: readonly T[], + disabledRuntimeIds: readonly string[], + explicitRuntimeId?: string | null, +): T[] { + return explicitRuntimeId?.trim() + ? [...runtimes] + : filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds); +} + /** * Prevent a disabled runtime from remaining the effective global preference. * diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 99d3167d00..8919666a3c 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -11,6 +11,7 @@ import { emptyResolvedTeamPersonas, resolveTeamPersonas, } from "@/features/agents/lib/teamPersonas"; +import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference"; import { collectRuntimeWarnings, getDefaultPersonaRuntime, @@ -68,11 +69,12 @@ export function AddTeamToChannelDialog({ [channelsQuery.data], ); - const runtimes = providersQuery.data ?? []; + const runtimes = providersQuery.data; + const selectableRuntimes = useSelectableAcpRuntimes(runtimes); // Use the buzz-agent-first preference so the team-deploy fallback mirrors the // single-agent start path (buzz-agent → goose → first available). const defaultProvider = getDefaultPersonaRuntime( - runtimes, + selectableRuntimes, globalConfig.preferred_runtime, ); diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 4eb9b6ed03..73739060c8 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -34,9 +34,14 @@ import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, } from "../lib/instanceInputForDefinition"; +import { + runtimesForImplicitAcpSelection, + useDisabledAcpRuntimeIds, +} from "../lib/runtimeVisibilityPreference"; export function useManagedAgentActions() { const { globalConfig } = useGlobalAgentConfig(); + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false); @@ -191,7 +196,11 @@ export function useManagedAgentActions() { setPersonaStartPending(persona.id, true); clearFeedback(); try { - const runtimes = await availableRuntimesForStart(availableRuntimesQuery); + const runtimes = runtimesForImplicitAcpSelection( + await availableRuntimesForStart(availableRuntimesQuery), + disabledRuntimeIds, + persona.runtime, + ); const { runtime, warnings } = resolveStartRuntimeForDefinition( persona, runtimes, From b48a8c2af15b0dc32f691a8d602634301ff3d6e2 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:22:30 -0700 Subject: [PATCH 04/22] Scope defaults to visible harness fallbacks --- .../src-tauri/src/commands/agents_deploy.rs | 6 +- .../src-tauri/src/commands/agents_tests.rs | 18 +++++ .../src/managed_agents/global_config/mod.rs | 74 ++++++++++++++----- .../src/managed_agents/global_config/tests.rs | 57 ++++++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 4 +- desktop/src/features/agents/AGENTS.md | 9 ++- .../lib/instanceInputForDefinition.test.mjs | 4 + .../agents/lib/instanceInputForDefinition.ts | 15 +++- .../lib/runtimeVisibilityPreference.test.mjs | 8 +- .../agents/lib/runtimeVisibilityPreference.ts | 20 ++++- .../agents/ui/AddTeamToChannelDialog.tsx | 5 ++ 11 files changed, 187 insertions(+), 33 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index be132225ea..514cfe66d3 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -31,14 +31,16 @@ pub(crate) fn resolve_deploy_model_provider<'a>( .persona_id .as_deref() .and_then(|pid| personas.iter().find(|p| p.id == pid)); + let (global_model, global_provider) = + crate::managed_agents::global_model_provider_for_record(record, personas, global); let model = live_persona .and_then(|p| p.model.as_deref()) .or(record.model.as_deref()) - .or(global.model.as_deref()); + .or(global_model); let provider = live_persona .and_then(|p| p.provider.as_deref()) .or(record.provider.as_deref()) - .or(global.provider.as_deref()); + .or(global_provider); (model, provider) } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 1b2e0ed179..9fb66138ae 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -191,6 +191,24 @@ fn deploy_resolver_falls_back_to_global_when_persona_and_record_have_none() { assert_eq!(provider, Some("global-prov")); } +#[test] +fn deploy_resolver_ignores_defaults_from_a_different_implicit_runtime() { + let mut record = bare_agent_record(Some("p1"), None, None); + record.agent_command_override = Some("goose".to_string()); + let personas = vec![persona_record("p1", None, None)]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("auto".to_string()), + provider: Some("relay-mesh".to_string()), + preferred_runtime: Some("buzz-agent".to_string()), + ..Default::default() + }; + + assert_eq!( + resolve_deploy_model_provider(&record, &personas, &global), + (None, None) + ); +} + #[test] fn normalize_relay_mesh_rejects_empty_model_ref() { let config = RelayMeshConfig { diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 57993fc040..554245ff71 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -204,20 +204,60 @@ pub fn save_global_agent_config(app: &AppHandle, config: &GlobalAgentConfig) -> atomic_write_json_restricted(&path, &payload) } -/// Resolve the effective model and provider for an agent using the -/// precedence chain: `agent record → linked persona → global defaults → None`. -/// -/// This is the single source of truth used by readiness evaluation, spawn, -/// and deploy-payload construction. All three paths must use this function so -/// they agree on what model/provider the agent will actually run with. +/// Return the global provider/model values that are safe for this record. /// -/// # Arguments -/// * `record` — the `ManagedAgentRecord` (may have `None` for model/provider) -/// * `personas` — all current persona records (looked up by `record.persona_id`) -/// * `global` — global agent config defaults +/// A runtime-less definition can be started on a fallback runtime when its +/// saved global preference is hidden or unavailable. In that case the selected +/// command is pinned on the record, and provider/model defaults belonging to a +/// different preferred runtime must not cross the harness boundary. Explicitly +/// configured definitions and standalone agents keep normal global inheritance. +pub(crate) fn global_model_provider_for_record<'a>( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &'a GlobalAgentConfig, +) -> (Option<&'a str>, Option<&'a str>) { + let global_values = (global.model.as_deref(), global.provider.as_deref()); + if record.persona_id.is_none() { + return global_values; + } + + let definition_runtime = record.runtime.as_deref().or_else(|| { + record + .persona_id + .as_deref() + .and_then(|id| personas.iter().find(|persona| persona.id == id)) + .and_then(|persona| persona.runtime.as_deref()) + }); + if definition_runtime.is_some_and(|runtime| !runtime.trim().is_empty()) { + return global_values; + } + + let Some(selected_runtime) = record + .agent_command_override + .as_deref() + .and_then(crate::managed_agents::known_acp_runtime) + else { + return global_values; + }; + let preferred_runtime = global + .preferred_runtime + .as_deref() + .and_then(crate::managed_agents::known_acp_runtime); + + if preferred_runtime.is_some_and(|preferred| std::ptr::eq(preferred, selected_runtime)) { + global_values + } else { + (None, None) + } +} + +/// Resolve the effective model and provider for an agent using the +/// precedence chain: `agent record → linked persona → applicable global +/// defaults → None`. /// -/// # Returns -/// `(effective_model, effective_provider)` — both `Option<&str>`. +/// This is the single source of truth used by readiness evaluation and spawn. +/// Deploy uses the same global applicability helper with its live-persona-first +/// precedence. pub(crate) fn resolve_effective_model_provider<'a>( record: &'a ManagedAgentRecord, personas: &'a [AgentDefinition], @@ -229,17 +269,15 @@ pub(crate) fn resolve_effective_model_provider<'a>( .and_then(|pid| personas.iter().find(|p| p.id == pid)) .map(|p| (p.model.as_deref(), p.provider.as_deref())) .unwrap_or((None, None)); + let (global_model, global_provider) = + global_model_provider_for_record(record, personas, global); - let effective_model = record - .model - .as_deref() - .or(persona_model) - .or(global.model.as_deref()); + let effective_model = record.model.as_deref().or(persona_model).or(global_model); let effective_provider = record .provider .as_deref() .or(persona_provider) - .or(global.provider.as_deref()); + .or(global_provider); (effective_model, effective_provider) } diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 6eb4338034..61a8a169d6 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -469,6 +469,63 @@ fn resolve_global_fallback_when_record_and_persona_have_none() { ); } +#[test] +fn runtime_fallback_does_not_inherit_defaults_from_a_different_preferred_runtime() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + record.agent_command_override = Some("goose".to_string()); + let personas = vec![persona("p1", None, None)]; + let global = GlobalAgentConfig { + model: Some("auto".to_string()), + provider: Some("relay-mesh".to_string()), + preferred_runtime: Some("buzz-agent".to_string()), + ..Default::default() + }; + + assert_eq!( + resolve_effective_model_provider(&record, &personas, &global), + (None, None) + ); +} + +#[test] +fn runtime_fallback_inherits_defaults_when_it_matches_the_preferred_runtime() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + record.agent_command_override = Some("goose".to_string()); + let personas = vec![persona("p1", None, None)]; + let global = GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + preferred_runtime: Some("goose".to_string()), + ..Default::default() + }; + + assert_eq!( + resolve_effective_model_provider(&record, &personas, &global), + (Some("global-model"), Some("global-provider")) + ); +} + +#[test] +fn explicit_runtime_keeps_global_defaults_when_another_runtime_is_preferred() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + record.runtime = Some("goose".to_string()); + let personas = vec![persona("p1", None, None)]; + let global = GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + preferred_runtime: Some("buzz-agent".to_string()), + ..Default::default() + }; + + assert_eq!( + resolve_effective_model_provider(&record, &personas, &global), + (Some("global-model"), Some("global-provider")) + ); +} + /// Tier 4 — no persona linked: record.persona_id is None, record has no /// model/provider; global defaults must still fill in (persona lookup skipped). #[cfg(feature = "mesh-llm")] diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 66f3d882b9..020b3326ba 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -50,8 +50,8 @@ pub use env_vars::*; pub(crate) use git_bash::git_bash_available; pub(crate) use git_bash::{discover_git_bash, GitBashPrerequisite}; pub(crate) use global_config::{ - load_global_agent_config, resolve_effective_model_provider, save_global_agent_config, - validate_global_config, GlobalAgentConfig, + global_model_provider_for_record, load_global_agent_config, resolve_effective_model_provider, + save_global_agent_config, validate_global_config, GlobalAgentConfig, }; pub(crate) use managed_node_paths::*; pub use nest::*; diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index fdce8e5bf5..493d29707a 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -87,9 +87,12 @@ with a TypeScript lookup table or an id comparison in a component. runtime off must not uninstall it, stop it, or invalidate an existing 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. Runtime-less agent - starts and team deploys must filter through the same visible-runtime set; - definitions already pinned to a hidden runtime remain runnable. + editor persists its visible fallback on the next save. Its dependent + provider/model defaults are also ignored for new implicit fallback agents, + without changing the persisted configuration used by existing agents. + Runtime-less agent starts and team deploys must filter through the same + visible-runtime set; definitions already pinned to a hidden runtime remain + runnable. ## The tests that enforce this diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index d4ba32e603..266d39f07f 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -5,6 +5,7 @@ import { availableRuntimesForStart, buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, + shouldPinSelectedRuntimeForDefinition, } from "./instanceInputForDefinition.ts"; // ── Phase 1B.3.5: the single definition→instance mapping ──────────────────── @@ -74,6 +75,9 @@ test("row 4: create input never contains definition env vars", async () => { }); test("row 2: harnessOverride follows the backend-aligned formula", async () => { + assert.equal(shouldPinSelectedRuntimeForDefinition(undefined, "goose"), true); + assert.equal(shouldPinSelectedRuntimeForDefinition("claude", "goose"), false); + const match = await buildInstanceInputForDefinition( persona({ runtime: "goose" }), gooseRuntime, diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 7db36b7241..9efe619fd3 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -88,6 +88,16 @@ export type BackendIntent = { config: Record; }; +/** Keep every definition-start surface aligned on runtime pinning semantics. */ +export function shouldPinSelectedRuntimeForDefinition( + definitionRuntimeId: string | null | undefined, + selectedRuntimeId: string, +): boolean { + return ( + !definitionRuntimeId || definitionRuntimeId.trim() === selectedRuntimeId + ); +} + /** * The single definition→instance mapping (Phase 1B.3.5 rows 2–4). Every * surface that creates a running instance from a definition builds its @@ -145,7 +155,10 @@ export async function buildInstanceInputForDefinition( agentCommand: runtime.command, agentArgs: runtime.defaultArgs, mcpCommand: runtime.mcpCommand ?? "", - harnessOverride: !persona.runtime || persona.runtime === runtime.id, + harnessOverride: shouldPinSelectedRuntimeForDefinition( + persona.runtime, + runtime.id, + ), model: persona.model ?? undefined, provider: persona.provider ?? undefined, spawnAfterCreate: true, diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs index 629f28559e..662fbeb2d0 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs @@ -66,16 +66,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", () => { +test("a disabled saved runtime and its dependent defaults are masked", () => { const config = { env_vars: {}, - provider: null, - model: null, + provider: "relay-mesh", + model: "auto", preferred_runtime: "Goose", }; assert.deepEqual(maskDisabledAcpRuntimePreference(config, ["goose"]), { ...config, + provider: null, + model: null, 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 dcda74a413..361259b39c 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -110,13 +110,20 @@ export function runtimesForImplicitAcpSelection( } /** - * Prevent a disabled runtime from remaining the effective global preference. + * Prevent a disabled runtime and its dependent defaults from remaining + * effective for new implicit selections. * * The persisted config is left untouched until the user next saves defaults; - * consumers immediately fall back through the normal runtime selection path. + * existing agents keep their configuration while new consumers immediately + * fall back through the normal runtime selection path without carrying a + * provider or model selected for the hidden harness. */ export function maskDisabledAcpRuntimePreference< - T extends { preferred_runtime: string | null }, + T extends { + model: string | null; + preferred_runtime: string | null; + provider: string | null; + }, >(config: T, disabledRuntimeIds: readonly string[]): T { const preferredRuntime = config.preferred_runtime; if ( @@ -128,7 +135,12 @@ export function maskDisabledAcpRuntimePreference< return config; } - return { ...config, preferred_runtime: null }; + return { + ...config, + model: null, + preferred_runtime: null, + provider: null, + }; } function getDisabledRuntimeIdsSnapshot(): readonly string[] { diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 8919666a3c..c105389ae3 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -11,6 +11,7 @@ import { emptyResolvedTeamPersonas, resolveTeamPersonas, } from "@/features/agents/lib/teamPersonas"; +import { shouldPinSelectedRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference"; import { collectRuntimeWarnings, @@ -147,6 +148,10 @@ export function AddTeamToChannelDialog({ name: persona.displayName, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, + harnessOverride: shouldPinSelectedRuntimeForDefinition( + persona.runtime, + runtimeToUse.id, + ), model: persona.model ?? undefined, personaId: persona.id, teamId: team.id, From c0f26020cc7a48467b76de0bd28985953833f38c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:38:31 -0700 Subject: [PATCH 05/22] Apply harness visibility to every agent start --- desktop/src/features/agents/AGENTS.md | 8 ++++--- .../lib/instanceInputForDefinition.test.mjs | 22 +++++++++++++++++++ .../agents/lib/instanceInputForDefinition.ts | 17 ++++++++++++-- .../agents/lib/runtimeVisibilityPreference.ts | 6 ++--- .../agents/ui/useManagedAgentActions.ts | 11 +--------- 5 files changed, 46 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 493d29707a..4e87cbacd0 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -90,9 +90,11 @@ with a TypeScript lookup table or an id comparison in a component. editor persists its visible fallback on the next save. Its dependent provider/model defaults are also ignored for new implicit fallback agents, without changing the persisted configuration used by existing agents. - Runtime-less agent starts and team deploys must filter through the same - visible-runtime set; definitions already pinned to a hidden runtime remain - runnable. + `resolveStartRuntimeForDefinition` is the shared boundary that filters every + runtime-less start through the device's visible-runtime set; call it instead + of duplicating default selection in a start surface. Team deploys use the + same visible-runtime set. Definitions already pinned to a hidden runtime + remain runnable. ## The tests that enforce this diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index 266d39f07f..90d85f526b 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -325,6 +325,28 @@ test("item-13: goose-only available — persona with no runtime resolves goose", assert.deepEqual(warnings, []); }); +test("runtime-less starts exclude disabled runtimes at the shared resolver", () => { + const { runtime, warnings } = resolveStartRuntimeForDefinition( + persona({ runtime: undefined }), + [gooseRuntime, buzzAgentRuntime], + "buzz-agent", + ["buzz-agent"], + ); + assert.equal(runtime.id, "goose"); + assert.deepEqual(warnings, []); +}); + +test("explicit definitions can still start on a hidden runtime", () => { + const { runtime, warnings } = resolveStartRuntimeForDefinition( + persona({ runtime: "buzz-agent" }), + [gooseRuntime, buzzAgentRuntime], + null, + ["buzz-agent"], + ); + assert.equal(runtime.id, "buzz-agent"); + assert.deepEqual(warnings, []); +}); + test("item-13: no runtimes available — refuses with actionable error", () => { assert.throws( () => resolveStartRuntimeForDefinition(persona({ runtime: undefined }), []), diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 9efe619fd3..dda66ae69a 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -9,6 +9,10 @@ import { resolvePersonaRuntime, type ResolvePersonaRuntimeResult, } from "./resolvePersonaRuntime"; +import { + getDisabledAcpRuntimeIdsSnapshot, + runtimesForImplicitAcpSelection, +} from "./runtimeVisibilityPreference"; import { resolveManagedAgentAvatarUrl, type UploadMediaBytes, @@ -46,13 +50,22 @@ export function resolveStartRuntimeForDefinition( persona: AgentPersona, runtimes: readonly AcpRuntime[], preferredRuntimeId?: string | null, + disabledRuntimeIds: readonly string[] = getDisabledAcpRuntimeIdsSnapshot(), ): { runtime: AcpRuntime; warnings: string[] } { + const selectableRuntimes = runtimesForImplicitAcpSelection( + runtimes, + disabledRuntimeIds, + persona.runtime, + ); // Use the buzz-agent-first preference (buzz-agent → goose → first available) // so a freshly installed goose never beats the bundled buzz-agent sidecar // for runtime-less personas (item 13 regression guard). - const defaultRuntime = getDefaultPersonaRuntime(runtimes, preferredRuntimeId); + const defaultRuntime = getDefaultPersonaRuntime( + selectableRuntimes, + preferredRuntimeId, + ); const { runtime, warnings, isOverridden }: ResolvePersonaRuntimeResult = - resolvePersonaRuntime(persona.runtime, runtimes, defaultRuntime); + resolvePersonaRuntime(persona.runtime, selectableRuntimes, defaultRuntime); if (!runtime) { throw new Error("No available runtime found for this agent."); diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts index 361259b39c..071176ead1 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -143,7 +143,7 @@ export function maskDisabledAcpRuntimePreference< }; } -function getDisabledRuntimeIdsSnapshot(): readonly string[] { +export function getDisabledAcpRuntimeIdsSnapshot(): readonly string[] { const storage = getLocalStorage(); if (!storage) return EMPTY_DISABLED_RUNTIME_IDS; @@ -191,7 +191,7 @@ export function setAcpRuntimeEnabled( const storage = getLocalStorage(); if (!storage) return false; - const current = getDisabledRuntimeIdsSnapshot(); + const current = getDisabledAcpRuntimeIdsSnapshot(); const next = nextDisabledAcpRuntimeIds(current, runtimeId, enabled); if (next === current) return true; @@ -211,7 +211,7 @@ export function setAcpRuntimeEnabled( export function useDisabledAcpRuntimeIds(): readonly string[] { return React.useSyncExternalStore( subscribeToRuntimeVisibility, - getDisabledRuntimeIdsSnapshot, + getDisabledAcpRuntimeIdsSnapshot, () => EMPTY_DISABLED_RUNTIME_IDS, ); } diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 73739060c8..4eb9b6ed03 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -34,14 +34,9 @@ import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, } from "../lib/instanceInputForDefinition"; -import { - runtimesForImplicitAcpSelection, - useDisabledAcpRuntimeIds, -} from "../lib/runtimeVisibilityPreference"; export function useManagedAgentActions() { const { globalConfig } = useGlobalAgentConfig(); - const disabledRuntimeIds = useDisabledAcpRuntimeIds(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false); @@ -196,11 +191,7 @@ export function useManagedAgentActions() { setPersonaStartPending(persona.id, true); clearFeedback(); try { - const runtimes = runtimesForImplicitAcpSelection( - await availableRuntimesForStart(availableRuntimesQuery), - disabledRuntimeIds, - persona.runtime, - ); + const runtimes = await availableRuntimesForStart(availableRuntimesQuery); const { runtime, warnings } = resolveStartRuntimeForDefinition( persona, runtimes, From d2de49380973f836f09b60044c37da391cbf6375 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:57:30 -0700 Subject: [PATCH 06/22] Preserve explicit hidden harness starts --- .../src-tauri/src/commands/agents_tests.rs | 19 +++++++++++ .../src/managed_agents/global_config/mod.rs | 12 +++---- .../src/managed_agents/global_config/tests.rs | 20 +++++++++++ desktop/src/features/agents/AGENTS.md | 5 +-- .../agents/lib/resolvePersonaRuntime.test.mjs | 23 +++++++++++++ .../agents/lib/resolvePersonaRuntime.ts | 13 ++++++++ .../agents/ui/AddTeamToChannelDialog.tsx | 33 ++++++++++++------- 7 files changed, 104 insertions(+), 21 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 9fb66138ae..fcc99c1038 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -209,6 +209,25 @@ fn deploy_resolver_ignores_defaults_from_a_different_implicit_runtime() { ); } +#[test] +fn deploy_resolver_ignores_mismatched_defaults_without_a_stored_override() { + let mut record = bare_agent_record(Some("p1"), None, None); + record.agent_command = "buzz-agent".to_string(); + record.agent_command_override = None; + let personas = vec![persona_record("p1", None, None)]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + preferred_runtime: Some("goose".to_string()), + ..Default::default() + }; + + assert_eq!( + resolve_deploy_model_provider(&record, &personas, &global), + (None, None) + ); +} + #[test] fn normalize_relay_mesh_rejects_empty_model_ref() { let config = RelayMeshConfig { diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 554245ff71..69bc39b58e 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -232,19 +232,17 @@ pub(crate) fn global_model_provider_for_record<'a>( return global_values; } - let Some(selected_runtime) = record - .agent_command_override + let Some(preferred_runtime) = global + .preferred_runtime .as_deref() .and_then(crate::managed_agents::known_acp_runtime) else { return global_values; }; - let preferred_runtime = global - .preferred_runtime - .as_deref() - .and_then(crate::managed_agents::known_acp_runtime); + let selected_command = crate::managed_agents::record_agent_command(record, personas); + let selected_runtime = crate::managed_agents::known_acp_runtime(&selected_command); - if preferred_runtime.is_some_and(|preferred| std::ptr::eq(preferred, selected_runtime)) { + if selected_runtime.is_some_and(|selected| std::ptr::eq(preferred_runtime, selected)) { global_values } else { (None, None) diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 61a8a169d6..a79870566e 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -488,6 +488,26 @@ fn runtime_fallback_does_not_inherit_defaults_from_a_different_preferred_runtime ); } +#[test] +fn default_command_fallback_does_not_inherit_defaults_from_another_preferred_runtime() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + record.agent_command = "buzz-agent".to_string(); + record.agent_command_override = None; + let personas = vec![persona("p1", None, None)]; + let global = GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-provider".to_string()), + preferred_runtime: Some("goose".to_string()), + ..Default::default() + }; + + assert_eq!( + resolve_effective_model_provider(&record, &personas, &global), + (None, None) + ); +} + #[test] fn runtime_fallback_inherits_defaults_when_it_matches_the_preferred_runtime() { let mut record = bare_record(); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 4e87cbacd0..23f645b9b7 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -93,8 +93,9 @@ with a TypeScript lookup table or an id comparison in a component. `resolveStartRuntimeForDefinition` is the shared boundary that filters every runtime-less start through the device's visible-runtime set; call it instead of duplicating default selection in a start surface. Team deploys use the - same visible-runtime set. Definitions already pinned to a hidden runtime - remain runnable. + same visible-runtime set only for members that need an implicit fallback, so + a fully pinned team remains deployable even when every installed runtime is + hidden. Definitions already pinned to a hidden runtime remain runnable. ## The tests that enforce this diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs index 37796b8bba..f77f056d64 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + canResolveAllPersonaRuntimes, collectRuntimeWarnings, resolvePersonaRuntime, } from "./resolvePersonaRuntime.ts"; @@ -177,3 +178,25 @@ test("collectRuntimeWarnings — override=false behaves identically to no overri test("collectRuntimeWarnings — empty personas array always returns empty", () => { assert.deepEqual(collectRuntimeWarnings([], runtimes, goose, true), []); }); + +test("team resolution allows explicit runtimes without a fallback", () => { + assert.equal( + canResolveAllPersonaRuntimes( + [{ runtime: "goose" }, { runtime: "claude" }], + runtimes, + null, + ), + true, + ); +}); + +test("team resolution requires a fallback for runtime-less definitions", () => { + assert.equal( + canResolveAllPersonaRuntimes([{ runtime: null }], runtimes, null), + false, + ); + assert.equal( + canResolveAllPersonaRuntimes([{ runtime: null }], runtimes, goose), + true, + ); +}); diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts index f833d7113d..474c218f66 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts @@ -142,3 +142,16 @@ export function collectRuntimeWarnings( } return warnings; } + +/** Whether every definition can resolve with the supplied optional fallback. */ +export function canResolveAllPersonaRuntimes( + personas: readonly { runtime: string | null }[], + runtimes: readonly AcpRuntime[], + fallbackRuntime: AcpRuntime | null, +): boolean { + return personas.every( + (persona) => + resolvePersonaRuntime(persona.runtime, runtimes, fallbackRuntime) + .runtime !== null, + ); +} diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index c105389ae3..62475819a9 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -14,6 +14,7 @@ import { import { shouldPinSelectedRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference"; import { + canResolveAllPersonaRuntimes, collectRuntimeWarnings, getDefaultPersonaRuntime, resolvePersonaRuntime, @@ -86,6 +87,11 @@ export function AddTeamToChannelDialog({ ); const resolved = teamPersonaResolution.resolvedPersonas; const missingPersonaCount = teamPersonaResolution.missingPersonaCount; + const canResolveTeamRuntimes = canResolveAllPersonaRuntimes( + resolved, + runtimes, + defaultProvider, + ); // Surface warnings when a persona's preferred runtime is unavailable. // This dialog has no runtime selector, so the fallback is always @@ -121,7 +127,7 @@ export function AddTeamToChannelDialog({ channels.find((channel) => channel.id === channelId) ?? null; async function handleDeploy() { - if (!team || !selectedChannel || !defaultProvider) { + if (!team || !selectedChannel || !canResolveTeamRuntimes) { return; } @@ -136,21 +142,23 @@ export function AddTeamToChannelDialog({ runtimes, defaultProvider, ); - const runtimeToUse = personaRuntime ?? defaultProvider; + if (!personaRuntime) { + throw new Error("No runtime is available for this team member."); + } return { runtime: { - id: runtimeToUse.id, - label: runtimeToUse.label, - command: runtimeToUse.command, - defaultArgs: runtimeToUse.defaultArgs, - mcpCommand: runtimeToUse.mcpCommand, + id: personaRuntime.id, + label: personaRuntime.label, + command: personaRuntime.command, + defaultArgs: personaRuntime.defaultArgs, + mcpCommand: personaRuntime.mcpCommand, }, name: persona.displayName, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, harnessOverride: shouldPinSelectedRuntimeForDefinition( persona.runtime, - runtimeToUse.id, + personaRuntime.id, ), model: persona.model ?? undefined, personaId: persona.id, @@ -262,10 +270,11 @@ export function AddTeamToChannelDialog({

) : null} - {!defaultProvider && !providersQuery.isLoading ? ( + {!canResolveTeamRuntimes && !providersQuery.isLoading ? (

- No ACP runtimes found. Make sure an agent runtime (e.g. Goose) - is installed. + {runtimes.length === 0 + ? "No ACP runtimes found. Make sure an agent runtime (e.g. Goose) is installed." + : "No enabled fallback runtime is available. Turn on a harness to deploy runtime-less team members."}

) : null} @@ -307,7 +316,7 @@ export function AddTeamToChannelDialog({ disabled={ !team || !selectedChannel || - !defaultProvider || + !canResolveTeamRuntimes || resolved.length === 0 || missingPersonaCount > 0 || channelsQuery.isLoading || From 33d285e543e8df47069d0cba45b84772e0f0c8cd Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 15:14:28 -0700 Subject: [PATCH 07/22] Preserve hidden harness defaults when editing --- .../agents/ui/AddTeamToChannelDialog.tsx | 4 +-- .../agents/ui/useManagedAgentActions.ts | 4 +-- .../features/agents/useGlobalAgentConfig.ts | 35 +++++++++++++------ .../src/features/onboarding/welcomeKickoff.ts | 5 +-- .../features/profile/ui/UserProfilePanel.tsx | 4 +-- 5 files changed, 34 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 62475819a9..552373743f 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -5,7 +5,7 @@ import { useAvailableAcpRuntimes, useCreateChannelManagedAgentsMutation, } from "@/features/agents/hooks"; -import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; +import { useImplicitGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents"; import { emptyResolvedTeamPersonas, @@ -54,7 +54,7 @@ export function AddTeamToChannelDialog({ onOpenChange, onDeployed, }: AddTeamToChannelDialogProps) { - const { globalConfig } = useGlobalAgentConfig(); + const { globalConfig } = useImplicitGlobalAgentConfig(); const channelsQuery = useChannelsQuery(); const providersQuery = useAvailableAcpRuntimes(); const [channelId, setChannelId] = React.useState(""); diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 4eb9b6ed03..31fff91cee 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -12,7 +12,7 @@ import { useStopManagedAgentMutation, useDeleteManagedAgentMutation, } from "@/features/agents/hooks"; -import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; +import { useImplicitGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import type { @@ -36,7 +36,7 @@ import { } from "../lib/instanceInputForDefinition"; export function useManagedAgentActions() { - const { globalConfig } = useGlobalAgentConfig(); + const { globalConfig } = useImplicitGlobalAgentConfig(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false); diff --git a/desktop/src/features/agents/useGlobalAgentConfig.ts b/desktop/src/features/agents/useGlobalAgentConfig.ts index 054bfb7c23..7b5e09191c 100644 --- a/desktop/src/features/agents/useGlobalAgentConfig.ts +++ b/desktop/src/features/agents/useGlobalAgentConfig.ts @@ -33,7 +33,6 @@ export function useGlobalAgentConfig(): { globalConfig: GlobalAgentConfig; isLoading: boolean; } { - const disabledRuntimeIds = useDisabledAcpRuntimeIds(); const { data, isPending } = useQuery({ queryKey: globalAgentConfigQueryKey, queryFn: getGlobalAgentConfig, @@ -43,17 +42,33 @@ 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, + globalConfig: data ?? EMPTY_CONFIG, isLoading: isPending, }; } + +/** + * Load global defaults for a new implicit runtime choice. + * + * Existing agent edit surfaces must use useGlobalAgentConfig so a hidden + * harness can still inherit its persisted provider and model. Start paths use + * this hook to ignore a hidden preferred harness and its dependent defaults. + */ +export function useImplicitGlobalAgentConfig(): { + globalConfig: GlobalAgentConfig; + isLoading: boolean; +} { + const { globalConfig, isLoading } = useGlobalAgentConfig(); + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); + const implicitGlobalConfig = React.useMemo( + () => maskDisabledAcpRuntimePreference(globalConfig, disabledRuntimeIds), + [disabledRuntimeIds, globalConfig], + ); + + return { + globalConfig: implicitGlobalConfig, + isLoading, + }; +} diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index fd5dac3947..5d40fbb858 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -5,7 +5,7 @@ import { useAcpRuntimesQuery, useManagedAgentsQuery, } from "@/features/agents/hooks"; -import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; +import { useImplicitGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { useCommunities } from "@/features/communities/useCommunities"; import { welcomeKickoffMarker } from "@/features/onboarding/devFreshOnboarding"; import { resolveAgentReadiness } from "@/features/onboarding/ui/agentReadiness"; @@ -493,7 +493,8 @@ export function useWelcomeKickoff( const { activeCommunity } = useCommunities(); const runtimesQuery = useAcpRuntimesQuery(); const managedAgentsQuery = useManagedAgentsQuery(); - const { globalConfig, isLoading: configLoading } = useGlobalAgentConfig(); + const { globalConfig, isLoading: configLoading } = + useImplicitGlobalAgentConfig(); const channelId = activeChannel?.id ?? null; const isActiveWelcome = isWelcomeChannel(activeChannel); const focusedWelcomeChannelRef = React.useRef(null); diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index c1f713d230..4dfdaaafc9 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -25,7 +25,7 @@ import { useUpdateManagedAgentMutation, useUpdatePersonaMutation, } from "@/features/agents/hooks"; -import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; +import { useImplicitGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { AddAgentToChannelDialog } from "@/features/agents/ui/AddAgentToChannelDialog"; import { availableRuntimesForStart, @@ -122,7 +122,7 @@ export function UserProfilePanel({ widthPx, transparentChrome = false, }: UserProfilePanelProps) { - const { globalConfig } = useGlobalAgentConfig(); + const { globalConfig } = useImplicitGlobalAgentConfig(); const isOverlay = useIsThreadPanelOverlay(); const isSplitLayout = layout === "split"; useEscapeKey(onClose, isOverlay || isSinglePanelView); From 4e82da3615c82d8ca7d2ba1146104eb70e42b4a5 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 15:28:28 -0700 Subject: [PATCH 08/22] Filter implicit runtime fallbacks centrally --- desktop/src/features/agents/AGENTS.md | 14 +++-- .../agents/lib/resolvePersonaRuntime.test.mjs | 40 +++++++++++++ .../agents/lib/resolvePersonaRuntime.ts | 60 ++++++++++++++++--- 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 23f645b9b7..99e2cdd79d 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -90,12 +90,14 @@ with a TypeScript lookup table or an id comparison in a component. editor persists its visible fallback on the next save. Its dependent provider/model defaults are also ignored for new implicit fallback agents, without changing the persisted configuration used by existing agents. - `resolveStartRuntimeForDefinition` is the shared boundary that filters every - runtime-less start through the device's visible-runtime set; call it instead - of duplicating default selection in a start surface. Team deploys use the - same visible-runtime set only for members that need an implicit fallback, so - a fully pinned team remains deployable even when every installed runtime is - hidden. Definitions already pinned to a hidden runtime remain runnable. + `resolvePersonaRuntime` is the shared visibility boundary for every + runtime-less deployment or provisioning path. Definition-to-instance starts + use the stricter `resolveStartRuntimeForDefinition` wrapper; call one of + these shared resolvers instead of duplicating default selection in a start + surface. Team deploys use the same visible-runtime set only for members that + need an implicit fallback, so a fully pinned team remains deployable even + when every installed runtime is hidden. Definitions already pinned to a + hidden runtime remain runnable. ## The tests that enforce this diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs index f77f056d64..6a5b66a5e5 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs @@ -33,6 +33,46 @@ test("resolvePersonaRuntime — undefined personaRuntimeId also returns defaultR }); }); +test("resolvePersonaRuntime — hidden defaults are skipped for runtime-less personas", () => { + const result = resolvePersonaRuntime(null, runtimes, goose, false, ["goose"]); + assert.deepEqual(result, { + runtime: claude, + warnings: [], + isOverridden: false, + }); +}); + +test("resolvePersonaRuntime — explicitly pinned hidden runtimes remain available", () => { + const result = resolvePersonaRuntime("goose", runtimes, claude, false, [ + "goose", + ]); + assert.deepEqual(result, { + runtime: goose, + warnings: [], + isOverridden: false, + }); +}); + +test("resolvePersonaRuntime — hidden fallback is replaced when a pinned runtime is unavailable", () => { + const result = resolvePersonaRuntime("unknown-rt", runtimes, goose, false, [ + "goose", + ]); + assert.equal(result.runtime, claude); + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /Claude/); + assert.equal(result.isOverridden, true); +}); + +test("resolvePersonaRuntime — no implicit fallback remains when every runtime is hidden", () => { + const result = resolvePersonaRuntime(null, runtimes, goose, false, [ + "goose", + "claude", + ]); + assert.equal(result.runtime, null); + assert.equal(result.warnings.length, 1); + assert.equal(result.isOverridden, false); +}); + test("resolvePersonaRuntime — no personaRuntimeId and no defaultRuntime returns null with warning", () => { const result = resolvePersonaRuntime(null, runtimes, null); assert.equal(result.runtime, null); diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts index 474c218f66..3b09e5b4f7 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts @@ -1,4 +1,8 @@ import type { AcpRuntime, AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { + filterEnabledAcpRuntimes, + getDisabledAcpRuntimeIdsSnapshot, +} from "./runtimeVisibilityPreference"; /** * Select the best default runtime from a catalog, using the same preference @@ -55,18 +59,31 @@ export type ResolvePersonaRuntimeResult = { * fall back to `defaultRuntime` and emit a warning. * 4. If there is no `defaultRuntime` either → return `null` with an error * warning so the UI can block deployment. + * + * Hidden runtimes remain eligible when explicitly pinned by a persona. They + * are removed only from the implicit fallback set, at this shared boundary, + * so every provisioning surface observes the device visibility preference. */ export function resolvePersonaRuntime( personaRuntimeId: string | undefined | null, runtimes: readonly AcpRuntime[], defaultRuntime: AcpRuntime | null, forceOverride?: boolean, + disabledRuntimeIds: readonly string[] = getDisabledAcpRuntimeIdsSnapshot(), ): ResolvePersonaRuntimeResult { + const implicitDefaultRuntime = forceOverride + ? defaultRuntime + : resolveVisibleDefaultRuntime( + runtimes, + defaultRuntime, + disabledRuntimeIds, + ); + // Case 1: Persona has no runtime preference — use the default. if (!personaRuntimeId) { return { - runtime: defaultRuntime, - warnings: defaultRuntime + runtime: implicitDefaultRuntime, + warnings: implicitDefaultRuntime ? [] : [ "No agent runtimes are available. Install a runtime (e.g. Goose) to deploy agents.", @@ -78,28 +95,35 @@ export function resolvePersonaRuntime( // Case 2: Persona's preferred runtime is available. const matched = runtimes.find((p) => p.id === personaRuntimeId); if (matched) { - if (forceOverride && defaultRuntime && matched.id !== defaultRuntime.id) { + if ( + forceOverride && + implicitDefaultRuntime && + matched.id !== implicitDefaultRuntime.id + ) { return { - runtime: defaultRuntime, + runtime: implicitDefaultRuntime, warnings: [ - `Runtime override: using ${defaultRuntime.label} instead of ${matched.label}.`, + `Runtime override: using ${implicitDefaultRuntime.label} instead of ${matched.label}.`, ], isOverridden: true, }; } return { - runtime: forceOverride && defaultRuntime ? defaultRuntime : matched, + runtime: + forceOverride && implicitDefaultRuntime + ? implicitDefaultRuntime + : matched, warnings: [], isOverridden: false, }; } // Case 3 & 4: Persona's runtime is not available — fall back. - if (defaultRuntime) { + if (implicitDefaultRuntime) { return { - runtime: defaultRuntime, + runtime: implicitDefaultRuntime, warnings: [ - `This agent is configured for runtime "${personaRuntimeId}" but it is not available. Using ${defaultRuntime.label} instead.`, + `This agent is configured for runtime "${personaRuntimeId}" but it is not available. Using ${implicitDefaultRuntime.label} instead.`, ], isOverridden: true, }; @@ -114,6 +138,24 @@ export function resolvePersonaRuntime( }; } +function resolveVisibleDefaultRuntime( + runtimes: readonly AcpRuntime[], + defaultRuntime: AcpRuntime | null, + disabledRuntimeIds: readonly string[], +): AcpRuntime | null { + if (!defaultRuntime) return null; + + const visibleRuntimes = filterEnabledAcpRuntimes( + runtimes, + disabledRuntimeIds, + ); + return ( + visibleRuntimes.find((runtime) => runtime.id === defaultRuntime.id) ?? + visibleRuntimes[0] ?? + null + ); +} + /** * Collect runtime-resolution warnings for a list of personas. * From 93cf670e77787f2f1d27d4eef552c2cffcb39f41 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 15:42:01 -0700 Subject: [PATCH 09/22] Scope hidden harness defaults by dialog mode --- desktop/src/features/agents/AGENTS.md | 2 + .../agents/ui/AgentDefinitionDialog.tsx | 4 +- .../agents/ui/AgentInstanceEditDialog.tsx | 6 ++- .../agents/ui/agentConfigOptions.test.mjs | 14 +++++ .../features/agents/ui/agentConfigOptions.tsx | 18 ++++--- .../agents/ui/useAgentDialogDefaults.test.mjs | 42 +++++++++++++++ .../agents/ui/useAgentDialogDefaults.ts | 51 ++++++++++++++++++- 7 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 desktop/src/features/agents/ui/useAgentDialogDefaults.test.mjs diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 99e2cdd79d..544981cb91 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -90,6 +90,8 @@ with a TypeScript lookup table or an id comparison in a component. editor persists its visible fallback on the next save. Its dependent provider/model defaults are also ignored for new implicit fallback agents, without changing the persisted configuration used by existing agents. + Create-mode dialogs use the implicit masked config; existing definition and + instance edit dialogs use the raw persisted config. `resolvePersonaRuntime` is the shared visibility boundary for every runtime-less deployment or provisioning path. Definition-to-instance starts use the stricter `resolveStartRuntimeForDefinition` wrapper; call one of diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index f496496b8d..b054409ade 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -68,7 +68,7 @@ import { usePersonaModelDiscovery, } from "./usePersonaModelDiscovery"; import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks"; -import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; +import { useDefinitionAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { @@ -164,7 +164,7 @@ export function AgentDefinitionDialog({ model: inheritedModelDefault, }, inheritedEnvVars: inheritedEnvVarsForAdvanced, - } = useAgentDialogDefaults({ open }); + } = useDefinitionAgentDialogDefaults(initialValues, open); const selectableRuntimes = useSelectableAcpRuntimes(runtimes); const defaultRuntime = getDefaultPersonaRuntime( selectableRuntimes, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index fa115387a9..ca7731b18d 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -366,7 +366,11 @@ export function AgentInstanceEditDialog({ model: inheritedModelDefault, }, inheritedEnvVars: inheritedEnvVarsForAdvanced, - } = useAgentDialogDefaults({ inheritedEnvVars, open }); + } = useAgentDialogDefaults({ + configScope: "existing", + inheritedEnvVars, + open, + }); // Runtime/provider-required credential state, derived from the PROSPECTIVE // post-submit runtime — see the hook for the inherit-transition and diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index ffa680006d..ed71f6477d 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -207,6 +207,20 @@ test("reconcilePreferredRuntimeFallback updates a hidden saved default", () => { assert.equal(reconcilePreferredRuntimeFallback(config, "goose"), config); }); +test("reconcilePreferredRuntimeFallback persists an unsaved displayed fallback", () => { + const config = { + env_vars: { SHARED: "kept" }, + provider: "relay-mesh", + model: "auto", + preferred_runtime: null, + }; + + assert.deepEqual(reconcilePreferredRuntimeFallback(config, "buzz-agent"), { + ...config, + preferred_runtime: "buzz-agent", + }); +}); + // ── 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 120098c16e..2eecb36715 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -199,21 +199,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. + * Align a missing, stale, or hidden saved preference with the shown fallback. + * A missing preference adopts the current context without clearing its draft; + * switching away from a saved harness clears values that are not portable. */ export function reconcilePreferredRuntimeFallback( config: GlobalAgentConfig, fallbackRuntimeId: string | null, ): GlobalAgentConfig { - if ( - !config.preferred_runtime || - !fallbackRuntimeId || - config.preferred_runtime === fallbackRuntimeId - ) { + if (!fallbackRuntimeId || config.preferred_runtime === fallbackRuntimeId) { return config; } + if (!config.preferred_runtime) { + return { + ...config, + preferred_runtime: fallbackRuntimeId, + }; + } + return resetConfigForHarnessChange(config, fallbackRuntimeId); } diff --git a/desktop/src/features/agents/ui/useAgentDialogDefaults.test.mjs b/desktop/src/features/agents/ui/useAgentDialogDefaults.test.mjs new file mode 100644 index 0000000000..12e0a02b2d --- /dev/null +++ b/desktop/src/features/agents/ui/useAgentDialogDefaults.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentDefinitionConfigScope, + resolveAgentDialogGlobalConfig, +} from "./useAgentDialogDefaults.ts"; + +const persistedConfig = { + env_vars: { SHARED: "kept" }, + provider: "relay-mesh", + model: "auto", + preferred_runtime: "buzz-agent", +}; + +test("create-mode defaults mask values owned by a hidden harness", () => { + assert.deepEqual( + resolveAgentDialogGlobalConfig(persistedConfig, "implicit", ["buzz-agent"]), + { + env_vars: { SHARED: "kept" }, + provider: null, + model: null, + preferred_runtime: null, + }, + ); +}); + +test("existing edit defaults preserve values owned by a hidden harness", () => { + assert.equal( + resolveAgentDialogGlobalConfig(persistedConfig, "existing", ["buzz-agent"]), + persistedConfig, + ); +}); + +test("definition dialogs select config scope from create versus edit values", () => { + assert.equal(agentDefinitionConfigScope(null), "implicit"); + assert.equal(agentDefinitionConfigScope({ displayName: "New" }), "implicit"); + assert.equal( + agentDefinitionConfigScope({ id: "existing", displayName: "Existing" }), + "existing", + ); +}); diff --git a/desktop/src/features/agents/ui/useAgentDialogDefaults.ts b/desktop/src/features/agents/ui/useAgentDialogDefaults.ts index 5ede9558fe..f9fbbd31c7 100644 --- a/desktop/src/features/agents/ui/useAgentDialogDefaults.ts +++ b/desktop/src/features/agents/ui/useAgentDialogDefaults.ts @@ -1,18 +1,57 @@ import * as React from "react"; +import type { + CreatePersonaInput, + GlobalAgentConfig, + UpdatePersonaInput, +} from "@/shared/api/types"; import { useBakedBuildEnvQuery } from "../hooks"; +import { + maskDisabledAcpRuntimePreference, + useDisabledAcpRuntimeIds, +} from "../lib/runtimeVisibilityPreference"; import { useGlobalAgentConfig } from "../useGlobalAgentConfig"; import { BUZZ_AGENT_THINKING_EFFORT } from "./buzzAgentConfig"; import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; +export type AgentDialogConfigScope = "existing" | "implicit"; + +export function agentDefinitionConfigScope( + initialValues: CreatePersonaInput | UpdatePersonaInput | null, +): AgentDialogConfigScope { + return initialValues && "id" in initialValues ? "existing" : "implicit"; +} + +export function resolveAgentDialogGlobalConfig( + persistedConfig: GlobalAgentConfig, + configScope: AgentDialogConfigScope, + disabledRuntimeIds: readonly string[], +): GlobalAgentConfig { + return configScope === "implicit" + ? maskDisabledAcpRuntimePreference(persistedConfig, disabledRuntimeIds) + : persistedConfig; +} + export function useAgentDialogDefaults({ + configScope, inheritedEnvVars = {}, open, }: { + configScope: AgentDialogConfigScope; inheritedEnvVars?: Record; open: boolean; }) { - const { globalConfig } = useGlobalAgentConfig(); + const { globalConfig: persistedConfig } = useGlobalAgentConfig(); + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); + const globalConfig = React.useMemo( + () => + resolveAgentDialogGlobalConfig( + persistedConfig, + configScope, + disabledRuntimeIds, + ), + [configScope, disabledRuntimeIds, persistedConfig], + ); const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: open }); const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv); const effectiveInheritedEnvVars = React.useMemo( @@ -31,3 +70,13 @@ export function useAgentDialogDefaults({ inheritedEnvVars: effectiveInheritedEnvVars, }; } + +export function useDefinitionAgentDialogDefaults( + initialValues: CreatePersonaInput | UpdatePersonaInput | null, + open: boolean, +) { + return useAgentDialogDefaults({ + configScope: agentDefinitionConfigScope(initialValues), + open, + }); +} From d9b164fbf4ee0b6b78e1c2f60a2d1b744e04e2a9 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 15:58:37 -0700 Subject: [PATCH 10/22] Fix hidden agent harness fallbacks --- .../agents/lib/resolvePersonaRuntime.test.mjs | 14 +++++++++- .../agents/lib/resolvePersonaRuntime.ts | 6 +---- .../agents/ui/AgentDefinitionDialog.tsx | 19 +++++-------- .../agents/ui/AgentInstanceEditDialog.tsx | 17 ++++++------ .../agents/ui/agentConfigOptions.test.mjs | 27 +++++++++++++++++++ .../features/agents/ui/agentConfigOptions.tsx | 27 ++++++++++++++++--- 6 files changed, 79 insertions(+), 31 deletions(-) diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs index 6a5b66a5e5..89991a705d 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs @@ -8,9 +8,10 @@ import { } from "./resolvePersonaRuntime.ts"; function makeRuntime(id, label = `${id} label`) { - return { id, label, command: id, avatarUrl: "" }; + return { id, label, command: id, avatarUrl: "", availability: "available" }; } +const buzzAgent = makeRuntime("buzz-agent", "Buzz Agent"); const goose = makeRuntime("goose", "Goose"); const claude = makeRuntime("claude", "Claude"); const runtimes = [goose, claude]; @@ -42,6 +43,17 @@ test("resolvePersonaRuntime — hidden defaults are skipped for runtime-less per }); }); +test("resolvePersonaRuntime — hidden defaults preserve product fallback order", () => { + const result = resolvePersonaRuntime( + null, + [goose, claude, buzzAgent], + goose, + false, + ["goose"], + ); + assert.equal(result.runtime, buzzAgent); +}); + test("resolvePersonaRuntime — explicitly pinned hidden runtimes remain available", () => { const result = resolvePersonaRuntime("goose", runtimes, claude, false, [ "goose", diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts index 3b09e5b4f7..69f484d1eb 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts @@ -149,11 +149,7 @@ function resolveVisibleDefaultRuntime( runtimes, disabledRuntimeIds, ); - return ( - visibleRuntimes.find((runtime) => runtime.id === defaultRuntime.id) ?? - visibleRuntimes[0] ?? - null - ); + return getDefaultPersonaRuntime(visibleRuntimes, defaultRuntime.id); } /** diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index b054409ade..c690b8032b 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -35,11 +35,11 @@ import { personaSubmitBlock } from "./personaSubmitBlock"; import { AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, - BLOCK_BUILD_HIDDEN_PROVIDER_IDS, CUSTOM_PROVIDER_DROPDOWN_VALUE, computeLocalModeGate, formatRuntimeOptionLabel, getDefaultPersonaRuntime, + getPersonaHiddenProviderIds, getPersonaModelOptions, getPersonaProviderOptions, getRuntimePersonaModelOptions, @@ -527,17 +527,12 @@ export function AgentDefinitionDialog({ modelFieldVisible, provider: effectiveProvider, }); - // On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot - // migration rewrites any persisted Databricks v1 values → v2. Hide the v1 - // option there so it is not offered for new selections. OSS builds have no - // baked provider, so v1 remains visible. - const hideProviderIds = React.useMemo( - () => - (bakedEnvKeys ?? []).includes("BUZZ_AGENT_PROVIDER") - ? BLOCK_BUILD_HIDDEN_PROVIDER_IDS - : new Set(), - [bakedEnvKeys], - ); + const hideProviderIds = getPersonaHiddenProviderIds({ + bakedEnvKeys: bakedEnvKeys ?? [], + selectableRuntimes, + currentRuntimeId: runtime, + preserveCurrentRuntime: !isCreateMode, + }); const providerOptions = getPersonaProviderOptions( trimmedProvider, runtime, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index ca7731b18d..9a54f987be 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -28,12 +28,13 @@ import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents"; import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields"; import { AUTO_PROVIDER_DROPDOWN_VALUE, - BLOCK_BUILD_HIDDEN_PROVIDER_IDS, CUSTOM_PROVIDER_DROPDOWN_VALUE, formatRuntimeOptionLabel, getDefaultLlmModelLabel, getDefaultPersonaRuntime, + getPersonaHiddenProviderIds, getPersonaProviderOptions, + getProviderApiKeyEnvVar, isMissingRequiredDropdownField, NO_RUNTIME_DROPDOWN_VALUE, PERSONA_FIELD_CONTROL_CLASS, @@ -76,7 +77,6 @@ import { getBakedModelInheritLabel, getBakedProviderInheritLabel, } from "./bakedEnvHelpers"; -import { getProviderApiKeyEnvVar } from "./agentConfigOptions"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -797,13 +797,12 @@ export function AgentInstanceEditDialog({ // Provider field derived state const trimmedProvider = provider.trim(); - const hideProviderIds = React.useMemo( - () => - (bakedEnvKeys ?? []).includes("BUZZ_AGENT_PROVIDER") - ? BLOCK_BUILD_HIDDEN_PROVIDER_IDS - : new Set(), - [bakedEnvKeys], - ); + const hideProviderIds = getPersonaHiddenProviderIds({ + bakedEnvKeys: bakedEnvKeys ?? [], + selectableRuntimes, + currentRuntimeId: selectedRuntimeId, + preserveCurrentRuntime: true, + }); const providerOptions = getPersonaProviderOptions( trimmedProvider, selectedRuntime?.id ?? "", diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index ed71f6477d..ae078f2fc2 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { getDefaultPersonaRuntime, + getPersonaHiddenProviderIds, getPersonaModelOptions, getPersonaProviderOptions, reconcilePreferredRuntimeFallback, @@ -75,6 +76,32 @@ test("getPersonaProviderOptions appends (current) tail for an unknown saved prov assert.equal(tail?.label, "my-custom-llm (current)"); }); +test("hidden Buzz Agent suppresses shared compute for new selections", () => { + const hidden = getPersonaHiddenProviderIds({ + bakedEnvKeys: [], + selectableRuntimes: [makeRuntime("goose")], + currentRuntimeId: "goose", + preserveCurrentRuntime: false, + }); + const ids = getPersonaProviderOptions("", "goose", "", hidden).map( + (option) => option.id, + ); + assert.ok(!ids.includes("relay-mesh")); +}); + +test("an existing hidden Buzz Agent keeps its shared compute provider", () => { + const hidden = getPersonaHiddenProviderIds({ + bakedEnvKeys: [], + selectableRuntimes: [makeRuntime("goose")], + currentRuntimeId: "buzz-agent", + preserveCurrentRuntime: true, + }); + const ids = getPersonaProviderOptions("", "buzz-agent", "", hidden).map( + (option) => option.id, + ); + assert.ok(ids.includes("relay-mesh")); +}); + // ── getDefaultPersonaRuntime — buzz-agent first ─────────────────────────────── test("getDefaultPersonaRuntime honors an available global preference", () => { diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 2eecb36715..fda3825e11 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -14,15 +14,34 @@ export { getDefaultPersonaRuntime } from "../lib/resolvePersonaRuntime"; * offering it for new selections would create a regression path. * OSS builds pass an empty `Set` so v1 remains visible. * - * All three dialog sites that show a provider picker import this constant — - * `AgentDefinitionDialog`, `AgentInstanceEditDialog`, and - * `AgentDefaultsSettingsCard` — making it the single source of truth for - * which provider ids to suppress on Block builds. + * Provider pickers consume this directly or through + * `getPersonaHiddenProviderIds`, keeping one source of truth for Block builds. */ export const BLOCK_BUILD_HIDDEN_PROVIDER_IDS: ReadonlySet = new Set([ "databricks", ]); +export function getPersonaHiddenProviderIds({ + bakedEnvKeys, + selectableRuntimes, + currentRuntimeId, + preserveCurrentRuntime, +}: { + bakedEnvKeys: readonly string[]; + selectableRuntimes: readonly Pick[]; + currentRuntimeId: string; + preserveCurrentRuntime: boolean; +}): ReadonlySet { + const hidden = bakedEnvKeys.includes("BUZZ_AGENT_PROVIDER") + ? new Set(BLOCK_BUILD_HIDDEN_PROVIDER_IDS) + : new Set(); + const buzzAgentSelectable = + selectableRuntimes.some((runtime) => runtime.id === "buzz-agent") || + (preserveCurrentRuntime && currentRuntimeId.trim() === "buzz-agent"); + if (!buzzAgentSelectable) hidden.add("relay-mesh"); + return hidden; +} + export const PERSONA_FIELD_SHELL_CLASS = "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; export const PERSONA_FIELD_CONTROL_CLASS = From 642fedcff099748bc138e6ba2a312cb05f50121c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 16:29:59 -0700 Subject: [PATCH 11/22] Block hidden harness provisioning bypasses --- .../agents/lib/resolvePersonaRuntime.test.mjs | 17 ++++ .../agents/lib/resolvePersonaRuntime.ts | 10 ++- .../lib/runtimeVisibilityPreference.test.mjs | 14 +++ .../agents/lib/runtimeVisibilityPreference.ts | 18 ++++ .../agents/ui/AgentDefinitionDialog.tsx | 86 ++++++++++--------- .../channel-templates/useApplyTemplate.ts | 6 +- .../channels/ui/AddChannelBotDialog.tsx | 46 +++++++--- 7 files changed, 138 insertions(+), 59 deletions(-) diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs index 89991a705d..ffe75d69b3 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs @@ -252,3 +252,20 @@ test("team resolution requires a fallback for runtime-less definitions", () => { true, ); }); + +test("team resolution blocks runtime-less definitions when all fallbacks are hidden", () => { + assert.equal( + canResolveAllPersonaRuntimes([{ runtime: null }], runtimes, goose, [ + "goose", + "claude", + ]), + false, + ); + assert.equal( + canResolveAllPersonaRuntimes([{ runtime: "goose" }], runtimes, goose, [ + "goose", + "claude", + ]), + true, + ); +}); diff --git a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts index 69f484d1eb..6e1b9977aa 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.ts +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.ts @@ -186,10 +186,16 @@ export function canResolveAllPersonaRuntimes( personas: readonly { runtime: string | null }[], runtimes: readonly AcpRuntime[], fallbackRuntime: AcpRuntime | null, + disabledRuntimeIds: readonly string[] = getDisabledAcpRuntimeIdsSnapshot(), ): boolean { return personas.every( (persona) => - resolvePersonaRuntime(persona.runtime, runtimes, fallbackRuntime) - .runtime !== null, + resolvePersonaRuntime( + persona.runtime, + runtimes, + fallbackRuntime, + false, + disabledRuntimeIds, + ).runtime !== null, ); } diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs index 662fbeb2d0..7230d49424 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs @@ -9,6 +9,7 @@ import { parseDisabledAcpRuntimeIds, readDisabledAcpRuntimeIds, runtimesForImplicitAcpSelection, + visibleAcpRuntimeSeedForCreate, } from "./runtimeVisibilityPreference.ts"; test("runtime visibility parsing is normalized and corruption tolerant", () => { @@ -55,6 +56,19 @@ test("disabled runtimes are removed from selectable catalog entries", () => { ); }); +test("create seeds replace hidden runtimes but preserve selectable ones", () => { + const runtimes = [{ id: "buzz-agent" }, { id: "goose" }]; + assert.equal( + visibleAcpRuntimeSeedForCreate("claude", runtimes, "buzz-agent"), + "buzz-agent", + ); + assert.equal(visibleAcpRuntimeSeedForCreate("claude", [], null), ""); + assert.equal( + visibleAcpRuntimeSeedForCreate("goose", runtimes, "buzz-agent"), + "goose", + ); +}); + test("stored runtime visibility is read from the versioned device key", () => { const storage = { getItem(key) { diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts index 071176ead1..89932e6030 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -109,6 +109,24 @@ export function runtimesForImplicitAcpSelection( : filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds); } +/** Replace a hidden create-mode seed without changing edit-mode behavior. */ +export function visibleAcpRuntimeSeedForCreate( + runtimeId: string, + selectableRuntimes: readonly T[], + fallbackRuntimeId: string | null | undefined, +): string { + const normalizedRuntimeId = normalizeRuntimeId(runtimeId); + if ( + !normalizedRuntimeId || + selectableRuntimes.some( + (runtime) => normalizeRuntimeId(runtime.id) === normalizedRuntimeId, + ) + ) { + return runtimeId.trim(); + } + return fallbackRuntimeId?.trim() ?? ""; +} + /** * Prevent a disabled runtime and its dependent defaults from remaining * effective for new implicit selections. diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index c690b8032b..bb2ef3ff90 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -83,7 +83,10 @@ import { } from "./agentAiConfigurationPolicy"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; -import { useSelectableAcpRuntimes } from "../lib/runtimeVisibilityPreference"; +import { + useSelectableAcpRuntimes, + visibleAcpRuntimeSeedForCreate, +} from "../lib/runtimeVisibilityPreference"; type AgentDefinitionDialogProps = { open: boolean; @@ -143,16 +146,11 @@ export function AgentDefinitionDialog({ const [behaviorDraft, setBehaviorDraft] = React.useState( emptyPersonaBehaviorDraft, ); - // The seed the draft is diffed against at submit: an untouched quad - // submits no behavior group, keeping unrelated edits hash-quiet. + // Untouched behavior fields submit no group, keeping edits hash-quiet. const behaviorSeedRef = React.useRef(emptyPersonaBehaviorDraft); - // Tracks when the runtime was auto-seeded by the default-runtime effect in - // edit mode (i.e. the user never explicitly chose a runtime). Used to omit - // the seeded runtime from the submit payload for builtin definitions whose - // canonical runtime is null — the sync would revert it anyway. + // Lets edit-mode builtin definitions omit an untouched auto-seeded runtime. const isRuntimeAutoSeededRef = React.useRef(false); - // Seed once per open so choosing "No preference" cannot snap the dropdown - // back to the default. + // Prevent "No preference" from snapping back to the default. const hasSeededForOpenRef = React.useRef(false); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [isAvatarUploadPending, setIsAvatarUploadPending] = @@ -208,15 +206,44 @@ export function AgentDefinitionDialog({ setBehaviorDraft(nextBehaviorDraft); setNamePoolText(nextNamePoolText); setEnvVars(nextEnvVars); - // Item 5: collapsed by default in edit mode — only expand if a non-default - // behavior value demands attention. Having env vars or a name pool is not - // sufficient reason to auto-open. setShowAdvancedFields(false); setIsAvatarUploadPending(false); isRuntimeAutoSeededRef.current = false; hasSeededForOpenRef.current = false; }, [initialValues, open]); + React.useEffect(() => { + if (!open || !initialValues || "id" in initialValues || runtimesLoading) { + return; + } + const seededRuntime = initialValues.runtime?.trim() ?? ""; + const nextRuntime = visibleAcpRuntimeSeedForCreate( + seededRuntime, + selectableRuntimes, + defaultRuntime?.id, + ); + if ( + !seededRuntime || + runtime.trim() !== seededRuntime || + nextRuntime === seededRuntime + ) { + return; + } + setRuntime(nextRuntime); + setModel(""); + setProvider(""); + setAiConfigurationMode("defaults"); + setIsCustomModelEditing(false); + setIsCustomProviderEditing(false); + }, [ + defaultRuntime?.id, + initialValues, + open, + runtime, + runtimesLoading, + selectableRuntimes, + ]); + React.useEffect(() => { if ( !open || @@ -233,10 +260,7 @@ export function AgentDefinitionDialog({ setRuntime(defaultRuntime.id); hasSeededForOpenRef.current = true; if ("id" in initialValues) { - // Edit mode: record that this runtime was auto-seeded so the submit path - // can omit it from the payload for builtin definitions (canonical runtime - // null; sync would revert the value anyway). Explicit user changes via - // the dropdown clear this flag. + // Builtin definitions omit this untouched inferred runtime on submit. isRuntimeAutoSeededRef.current = true; } }, [defaultRuntime, initialValues, open, runtime, runtimesLoading]); @@ -258,16 +282,14 @@ export function AgentDefinitionDialog({ behaviorSeedRef.current = emptyPersonaBehaviorDraft; setShowAdvancedFields(false); setIsAvatarUploadPending(false); - // isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the - // [initialValues, open] effect resets both when the dialog re-opens. + // The open-seeding effect resets both refs on the next open. } onOpenChange(next); } async function handleSubmit() { - // D1: the same localModeSatisfied gate as canSubmit prevents form-submit - // (Enter) from bypassing a missing credential. + // Keep Enter submission on the same credential gate as the button. if (!initialValues || !localModeSatisfied || !canSubmit) return; const { @@ -334,11 +356,7 @@ export function AgentDefinitionDialog({ (runtime.trim().length > 0 && runtimeCanChooseLlmProvider) || blankRuntimeModelProviderEditable; const trimmedProvider = provider.trim(); - // Required credential env keys for this runtime + provider combination. - // Used to show required markers on the LLM provider label and amber - // locked rows in the env vars editor. - // File-layer config for the selected runtime (e.g. goose config.yaml). - // Used to silence requirements already satisfied there. + // File config satisfies credentials before the readiness gate renders them. const { data: runtimeFileConfig, isLoading: fileConfigLoading } = useRuntimeFileConfigQuery(runtime, { enabled: open }); function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) { @@ -420,25 +438,11 @@ export function AgentDefinitionDialog({ secretEnvVar: topLevelSecretEnvVar, value: apiKeyValue, } = apiKeyFieldState; - // Provider required-ness is a static property of the field's visibility — it - // does not change based on whether the field is currently filled. Using the - // dynamic missingNormalizedFields check would flip the asterisk off once a - // value is selected, which is incoherent (required means required, not - // "required until satisfied"). runtimeCanChooseLlmProvider is the authoritative - // gate: it tracks exactly when the provider picker is shown (Buzz Agent/Goose, - // plus runtime-less legacy/builtin definitions), so the required marker never - // drifts from whether Save actually needs a provider. const providerIsRequired = aiConfigurationMode === "custom" && runtimeCanChooseLlmProvider; const modelFieldVisible = runtime.trim().length > 0 || blankRuntimeModelProviderEditable; const isExplicitModelRequired = aiConfigurationMode === "custom"; - // Gate the provider requirement on the field's actual visibility, not the raw - // runtime capability. Codex/Claude hide the provider picker (they drive their - // own provider), so Customize must not require a provider there. But a - // runtime-less legacy/builtin definition still exposes the picker via - // blankRuntimeModelProviderEditable, so it must keep requiring a provider — - // otherwise Save could persist `provider: undefined` despite the visible field. const customAiPairSatisfied = agentAiConfigurationModeSatisfied( aiConfigurationMode, { provider, model }, @@ -448,8 +452,7 @@ export function AgentDefinitionDialog({ const selectedRuntimeIsAvailable = runtime.trim().length === 0 || selectedRuntime?.availability === "available"; - // Gate model/provider validity through missingNormalizedFields — single - // source of truth with the readiness gate so display and Save can't drift. + // Keep model/provider validity aligned with the readiness gate. const canSubmit = canSubmitPersonaDialog({ displayName, isPending }) && (!isCreateMode || runtime.trim().length > 0) && @@ -574,6 +577,7 @@ export function AgentDefinitionDialog({ })), ]; if ( + !isCreateMode && runtime.trim().length > 0 && !runtimeDropdownOptions.some((option) => option.value === runtime) ) { diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts index 1f7d066fad..474767acf4 100644 --- a/desktop/src/features/channel-templates/useApplyTemplate.ts +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -91,8 +91,9 @@ export function useApplyTemplate() { runtimes, defaultProvider, ); + if (!resolved.runtime) continue; inputs.push({ - runtime: resolved.runtime ?? defaultProvider, + runtime: resolved.runtime, name: persona.displayName, personaId: persona.id, systemPrompt: persona.systemPrompt, @@ -116,8 +117,9 @@ export function useApplyTemplate() { runtimes, defaultProvider, ); + if (!resolved.runtime) continue; inputs.push({ - runtime: resolved.runtime ?? defaultProvider, + runtime: resolved.runtime, name: persona.displayName, personaId: persona.id, systemPrompt: persona.systemPrompt, diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index fb27c659d5..2c5a7515cb 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -8,7 +8,10 @@ import { type CreateChannelManagedAgentResult, } from "@/features/agents/hooks"; import { getActivePersonas } from "@/features/agents/lib/catalog"; -import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { + canResolveAllPersonaRuntimes, + resolvePersonaRuntime, +} from "@/features/agents/lib/resolvePersonaRuntime"; import { getUsableTeams } from "@/features/agents/lib/teamPersonas"; import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection"; import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection"; @@ -135,26 +138,35 @@ export function AddChannelBotDialog({ } async function handleSubmit() { - if (providers.length === 0 || selectedPersonas.length === 0) return; + if ( + providers.length === 0 || + selectedPersonas.length === 0 || + !selectedPersonasResolvable + ) { + return; + } - const inputs = selectedPersonas.map((persona) => { + const inputs = selectedPersonas.flatMap((persona) => { const resolved = resolvePersonaRuntime( persona.runtime, providers, providers[0] ?? null, false, ); - return { - runtime: resolved.runtime ?? providers[0], - name: persona.displayName, - personaId: persona.id, - harnessOverride: false, - systemPrompt: persona.systemPrompt, - avatarUrl: persona.avatarUrl ?? undefined, - model: persona.model ?? undefined, - role: "bot" as const, - backend: { type: "local" as const }, - }; + if (!resolved.runtime) return []; + return [ + { + runtime: resolved.runtime, + name: persona.displayName, + personaId: persona.id, + harnessOverride: false, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + role: "bot" as const, + backend: { type: "local" as const }, + }, + ]; }); setSubmissionNotice(null); @@ -189,9 +201,15 @@ export function AddChannelBotDialog({ } } + const selectedPersonasResolvable = canResolveAllPersonaRuntimes( + selectedPersonas, + providers, + providers[0] ?? null, + ); const canSubmit = providers.length > 0 && selectedPersonas.length > 0 && + selectedPersonasResolvable && !providersLoading && !createBotsMutation.isPending; const addButtonLabel = createBotsMutation.isPending From 616e9d76258427d6163e12093b2fef8c73010ede Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 17:12:14 -0700 Subject: [PATCH 12/22] Preserve visible harness fallbacks --- desktop/src/features/agents/channelAgents.ts | 7 +++---- .../channel-templates/useApplyTemplate.ts | 15 +++++++++++++-- .../features/channels/ui/AddChannelBotDialog.tsx | 6 +++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index edbb106817..7897bdcb6a 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -61,10 +61,9 @@ export type CreateChannelManagedAgentInput = { /** Team this instance is deployed from; prevents cross-team reuse. */ teamId?: string | null; /** - * True when `runtime` is a runtime the user deliberately picked to override - * the persona (a deploy-dialog runtime selector), as opposed to a - * missing-runtime fallback. Forwarded to the backend so a persona-backed - * create only pins the harness for a deliberate override. + * Pins `runtime` when the persona has no harness or the selection matches + * its harness. A fallback away from an unavailable configured harness stays + * unpinned so the persona remains authoritative. */ harnessOverride?: boolean; /** Preferred model ID from the persona. Passed to createManagedAgent. */ diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts index 474767acf4..dbb91ce675 100644 --- a/desktop/src/features/channel-templates/useApplyTemplate.ts +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -9,6 +9,7 @@ import { usePersonasQuery, useTeamsQuery, } from "@/features/agents/hooks"; +import { shouldPinSelectedRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; @@ -86,8 +87,9 @@ export function useApplyTemplate() { if (!persona) continue; if (seenPersonaIds.has(persona.id)) continue; seenPersonaIds.add(persona.id); + const requestedRuntimeId = entry.runtime ?? persona.runtime; const resolved = resolvePersonaRuntime( - entry.runtime ?? persona.runtime, + requestedRuntimeId, runtimes, defaultProvider, ); @@ -96,6 +98,10 @@ export function useApplyTemplate() { runtime: resolved.runtime, name: persona.displayName, personaId: persona.id, + harnessOverride: shouldPinSelectedRuntimeForDefinition( + requestedRuntimeId, + resolved.runtime.id, + ), systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: entry.model ?? persona.model ?? undefined, @@ -112,8 +118,9 @@ export function useApplyTemplate() { for (const persona of resolvedPersonas) { if (seenPersonaIds.has(persona.id)) continue; seenPersonaIds.add(persona.id); + const requestedRuntimeId = teamEntry.runtime ?? persona.runtime; const resolved = resolvePersonaRuntime( - teamEntry.runtime ?? persona.runtime, + requestedRuntimeId, runtimes, defaultProvider, ); @@ -122,6 +129,10 @@ export function useApplyTemplate() { runtime: resolved.runtime, name: persona.displayName, personaId: persona.id, + harnessOverride: shouldPinSelectedRuntimeForDefinition( + requestedRuntimeId, + resolved.runtime.id, + ), systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: teamEntry.model ?? persona.model ?? undefined, diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 2c5a7515cb..63bf6f4382 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -12,6 +12,7 @@ import { canResolveAllPersonaRuntimes, resolvePersonaRuntime, } from "@/features/agents/lib/resolvePersonaRuntime"; +import { shouldPinSelectedRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; import { getUsableTeams } from "@/features/agents/lib/teamPersonas"; import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection"; import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection"; @@ -159,7 +160,10 @@ export function AddChannelBotDialog({ runtime: resolved.runtime, name: persona.displayName, personaId: persona.id, - harnessOverride: false, + harnessOverride: shouldPinSelectedRuntimeForDefinition( + persona.runtime, + resolved.runtime.id, + ), systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: persona.model ?? undefined, From bb5b0330f405818d4dace9bcaec77bd8d9bc3edf Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 17:26:40 -0700 Subject: [PATCH 13/22] Complete hidden harness provisioning policy --- .../src/managed_agents/discovery/overrides.rs | 10 +++---- .../src/managed_agents/discovery/tests.rs | 16 +--------- .../discovery/tests/provisioning_overrides.rs | 19 ++++++++++++ .../src/managed_agents/global_config/mod.rs | 2 +- .../src/managed_agents/types/requests.rs | 7 ++--- desktop/src/features/agents/AGENTS.md | 12 +++++--- .../lib/instanceInputForDefinition.test.mjs | 28 ++++++++++++++++++ .../agents/lib/instanceInputForDefinition.ts | 29 +++++++++++++++++++ .../features/agents/ui/AgentConfigFields.tsx | 27 +++++++---------- .../channel-templates/useApplyTemplate.ts | 26 +++++------------ .../channels/ui/AddChannelBotDialog.tsx | 16 +++------- .../features/channels/ui/useQuickBotDrop.ts | 13 ++++----- .../messages/ui/useMentionSendFlow.ts | 11 +++---- 13 files changed, 125 insertions(+), 91 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/provisioning_overrides.rs diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 5140bb2cdd..7f9ded5a18 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -113,10 +113,10 @@ pub fn apply_agent_command_update( /// `resolvePersonaRuntime` (frontend), which produces a divergent command in two /// distinct cases that the backend MUST tell apart: /// -/// - DELIBERATE OVERRIDE (`harness_override` true): the user explicitly picked a -/// runtime command in UI that exposes a runtime selector. This is a real pin -/// and is preserved when it differs from the command inheritance would spawn, -/// including installed aliases such as `claude-code-acp`. +/// - PINNED SELECTION (`harness_override` true): the frontend selected a runtime +/// that must survive persona inheritance. This includes explicit user choices, +/// installed aliases such as `claude-code-acp`, and visible implicit fallbacks +/// for runtime-less personas. /// - MISSING-RUNTIME FALLBACK (`harness_override` false): the persona's runtime /// isn't installed locally, so `resolvePersonaRuntime` substitutes a fallback /// default. This is NOT a pin — baking it would freeze the agent on the fallback @@ -125,7 +125,7 @@ pub fn apply_agent_command_update( /// so the persona stays authoritative. /// /// `isOverridden` from `resolvePersonaRuntime` cannot distinguish these — it is -/// `true` for BOTH — so the caller must thread the explicit user-intent bit. +/// `true` for BOTH — so the caller must thread the pinning decision. /// /// Persona-less creates (`persona_id` is `None`, e.g. the standalone /// CreateAgentDialog) have no persona to inherit, so the picked command is always a diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..7e758456a0 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -419,21 +419,6 @@ fn create_time_override_none_when_persona_runtime_not_installed() { ); } -#[test] -fn create_time_override_some_when_user_deliberately_overrides_installed_runtime() { - // Case 2 + deliberate override: the persona's `claude` runtime IS - // available, but the user explicitly picked `codex` in a deploy dialog's - // runtime selector ("overriding persona preferences"), so the frontend - // sends `codex-acp` with `harness_override` true. This is a real pin and - // MUST be preserved — returning `None` would silently swallow the - // deliberate override and inherit `claude` on spawn. - let personas = vec![persona_with_runtime("p1", Some("claude"))]; - assert_eq!( - create_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), true), - Some("codex-acp".to_string()) - ); -} - #[test] fn create_time_override_none_when_persona_runtime_installed() { // Case 2: the persona's runtime is available, so `resolvePersonaRuntime` @@ -610,6 +595,7 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { // ── probe_codex_acp_major_version ───────────────────────────────────────────── mod managed_path_resolution; +mod provisioning_overrides; #[cfg(unix)] #[test] diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/provisioning_overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/provisioning_overrides.rs new file mode 100644 index 0000000000..972d05fdf2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/provisioning_overrides.rs @@ -0,0 +1,19 @@ +use super::{create_time_agent_command_override, persona_with_runtime}; + +#[test] +fn preserves_deliberate_override_of_installed_runtime() { + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + create_time_agent_command_override(Some("p1"), &personas, Some("codex-acp"), true), + Some("codex-acp".to_string()) + ); +} + +#[test] +fn pins_visible_fallback_for_runtime_less_persona() { + let personas = vec![persona_with_runtime("p1", None)]; + assert_eq!( + create_time_agent_command_override(Some("p1"), &personas, Some("goose"), true), + Some("goose".to_string()) + ); +} diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 69bc39b58e..7ff2773474 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -242,7 +242,7 @@ pub(crate) fn global_model_provider_for_record<'a>( let selected_command = crate::managed_agents::record_agent_command(record, personas); let selected_runtime = crate::managed_agents::known_acp_runtime(&selected_command); - if selected_runtime.is_some_and(|selected| std::ptr::eq(preferred_runtime, selected)) { + if selected_runtime.is_some_and(|selected| preferred_runtime.id == selected.id) { global_values } else { (None, None) diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 58d60218a1..6d882de266 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -135,10 +135,9 @@ pub struct CreateManagedAgentRequest { pub relay_url: Option, pub acp_command: Option, pub agent_command: Option, - /// True when `agent_command` is a runtime command the user deliberately - /// picked for a linked persona. Distinguishes a real selection, including an - /// installed alias, from a missing-runtime fallback so a persona-backed - /// create only stores an `agent_command_override` for the former. + /// True when `agent_command` must survive linked-persona inheritance. + /// Includes explicit selections, installed aliases, and the visible + /// implicit fallback for a runtime-less persona. #[serde(default)] pub harness_override: bool, #[serde(default)] diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 544981cb91..4ba4827efb 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -96,10 +96,14 @@ with a TypeScript lookup table or an id comparison in a component. runtime-less deployment or provisioning path. Definition-to-instance starts use the stricter `resolveStartRuntimeForDefinition` wrapper; call one of these shared resolvers instead of duplicating default selection in a start - surface. Team deploys use the same visible-runtime set only for members that - need an implicit fallback, so a fully pinned team remains deployable even - when every installed runtime is hidden. Definitions already pinned to a - hidden runtime remain runnable. + surface. Persona-backed provisioning uses + `resolveProvisioningRuntimeForDefinition` so the resolved runtime and + backend pinning bit cannot drift apart. Team deploys use the same + visible-runtime set only for members that need an implicit fallback, so a + fully pinned team remains deployable even when every installed runtime is + hidden. Definitions already pinned to a hidden runtime remain runnable. + Provider pickers use `getPersonaHiddenProviderIds` as the shared + relay-mesh visibility policy. ## The tests that enforce this diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index 90d85f526b..58c9d9a312 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { availableRuntimesForStart, buildInstanceInputForDefinition, + resolveProvisioningRuntimeForDefinition, resolveStartRuntimeForDefinition, shouldPinSelectedRuntimeForDefinition, } from "./instanceInputForDefinition.ts"; @@ -101,6 +102,33 @@ test("row 2: harnessOverride follows the backend-aligned formula", async () => { ); }); +test("provisioning pins a visible fallback for a runtime-less definition", () => { + const resolved = resolveProvisioningRuntimeForDefinition( + null, + [buzzAgentRuntime, gooseRuntime], + null, + ["buzz-agent"], + ); + assert.equal(resolved.runtime, gooseRuntime); + assert.equal(resolved.harnessOverride, true); +}); + +test("provisioning uses product ordering and leaves unavailable configured fallbacks unpinned", () => { + const ordered = resolveProvisioningRuntimeForDefinition(null, [ + gooseRuntime, + claudeRuntime, + buzzAgentRuntime, + ]); + assert.equal(ordered.runtime, buzzAgentRuntime); + assert.equal(ordered.harnessOverride, true); + + const unavailable = resolveProvisioningRuntimeForDefinition("missing", [ + gooseRuntime, + ]); + assert.equal(unavailable.runtime, gooseRuntime); + assert.equal(unavailable.harnessOverride, false); +}); + test("row 3: plain avatar URLs pass through; base64 data URIs upload via the injectable", async () => { const plain = await buildInstanceInputForDefinition(persona(), gooseRuntime); assert.equal(plain.avatarUrl, "https://example.com/a.png"); diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index dda66ae69a..0da53bf2ef 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -111,6 +111,35 @@ export function shouldPinSelectedRuntimeForDefinition( ); } +/** + * Resolve the runtime and backend pinning bit together so persona-backed + * provisioning cannot discard a visible implicit fallback during handoff. + */ +export function resolveProvisioningRuntimeForDefinition( + definitionRuntimeId: string | null | undefined, + runtimes: readonly AcpRuntime[], + preferredRuntimeId?: string | null, + disabledRuntimeIds: readonly string[] = getDisabledAcpRuntimeIdsSnapshot(), +): ResolvePersonaRuntimeResult & { harnessOverride: boolean } { + const defaultRuntime = getDefaultPersonaRuntime(runtimes, preferredRuntimeId); + const resolved = resolvePersonaRuntime( + definitionRuntimeId, + runtimes, + defaultRuntime, + false, + disabledRuntimeIds, + ); + return { + ...resolved, + harnessOverride: + resolved.runtime !== null && + shouldPinSelectedRuntimeForDefinition( + definitionRuntimeId, + resolved.runtime.id, + ), + }; +} + /** * The single definition→instance mapping (Phase 1B.3.5 rows 2–4). Every * surface that creates a running instance from a definition builds its diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 4e5ba17607..77eb9560de 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -27,8 +27,8 @@ import { } from "@/features/agents/ui/bakedEnvHelpers"; import { AUTO_PROVIDER_DROPDOWN_VALUE, - BLOCK_BUILD_HIDDEN_PROVIDER_IDS, CUSTOM_PROVIDER_DROPDOWN_VALUE, + getPersonaHiddenProviderIds, getPersonaProviderOptions, getProviderApiKeyEnvVar, requiredCredentialEnvKeys, @@ -543,21 +543,16 @@ export function AgentConfigFields({ onConfigChange({ ...config, env_vars: merged }); } - // On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot - // migration rewrites v1→v2. Hide the legacy v1 option so it is not offered - // for new selections; OSS builds show it. - const hideProviderIds = React.useMemo(() => { - const hidden = new Set(); - if (bakedEnvKeys.includes("BUZZ_AGENT_PROVIDER")) { - for (const providerId of BLOCK_BUILD_HIDDEN_PROVIDER_IDS) { - hidden.add(providerId); - } - } - if (selectedRuntimeId !== "buzz-agent") { - hidden.add("relay-mesh"); - } - return hidden; - }, [bakedEnvKeys, selectedRuntimeId]); + const hideProviderIds = React.useMemo( + () => + getPersonaHiddenProviderIds({ + bakedEnvKeys, + selectableRuntimes: selectedRuntime ? [selectedRuntime] : [], + currentRuntimeId: selectedRuntimeId, + preserveCurrentRuntime: false, + }), + [bakedEnvKeys, selectedRuntime, selectedRuntimeId], + ); const providerOptions = getPersonaProviderOptions( providerValue, credentialRuntimeId, diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts index dbb91ce675..537ca66f7b 100644 --- a/desktop/src/features/channel-templates/useApplyTemplate.ts +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -9,8 +9,7 @@ import { usePersonasQuery, useTeamsQuery, } from "@/features/agents/hooks"; -import { shouldPinSelectedRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; -import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { resolveProvisioningRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; @@ -73,11 +72,6 @@ export function useApplyTemplate() { const runtimes = acpRuntimesQuery.data ?? []; if (runtimes.length === 0) return; // No runtimes — skip silently - // Resolve default provider: user's last-used preference, or first available - const defaultProvider = - runtimes.find((p) => p.id === lastRuntimeId) ?? runtimes[0] ?? null; - if (!defaultProvider) return; - const seenPersonaIds = new Set(); const inputs: CreateChannelManagedAgentInput[] = []; @@ -88,20 +82,17 @@ export function useApplyTemplate() { if (seenPersonaIds.has(persona.id)) continue; seenPersonaIds.add(persona.id); const requestedRuntimeId = entry.runtime ?? persona.runtime; - const resolved = resolvePersonaRuntime( + const resolved = resolveProvisioningRuntimeForDefinition( requestedRuntimeId, runtimes, - defaultProvider, + lastRuntimeId, ); if (!resolved.runtime) continue; inputs.push({ runtime: resolved.runtime, name: persona.displayName, personaId: persona.id, - harnessOverride: shouldPinSelectedRuntimeForDefinition( - requestedRuntimeId, - resolved.runtime.id, - ), + harnessOverride: resolved.harnessOverride, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: entry.model ?? persona.model ?? undefined, @@ -119,20 +110,17 @@ export function useApplyTemplate() { if (seenPersonaIds.has(persona.id)) continue; seenPersonaIds.add(persona.id); const requestedRuntimeId = teamEntry.runtime ?? persona.runtime; - const resolved = resolvePersonaRuntime( + const resolved = resolveProvisioningRuntimeForDefinition( requestedRuntimeId, runtimes, - defaultProvider, + lastRuntimeId, ); if (!resolved.runtime) continue; inputs.push({ runtime: resolved.runtime, name: persona.displayName, personaId: persona.id, - harnessOverride: shouldPinSelectedRuntimeForDefinition( - requestedRuntimeId, - resolved.runtime.id, - ), + harnessOverride: resolved.harnessOverride, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: teamEntry.model ?? persona.model ?? undefined, diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 63bf6f4382..cd8e8beb80 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -8,11 +8,8 @@ import { type CreateChannelManagedAgentResult, } from "@/features/agents/hooks"; import { getActivePersonas } from "@/features/agents/lib/catalog"; -import { - canResolveAllPersonaRuntimes, - resolvePersonaRuntime, -} from "@/features/agents/lib/resolvePersonaRuntime"; -import { shouldPinSelectedRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; +import { resolveProvisioningRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; +import { canResolveAllPersonaRuntimes } from "@/features/agents/lib/resolvePersonaRuntime"; import { getUsableTeams } from "@/features/agents/lib/teamPersonas"; import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection"; import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection"; @@ -148,11 +145,9 @@ export function AddChannelBotDialog({ } const inputs = selectedPersonas.flatMap((persona) => { - const resolved = resolvePersonaRuntime( + const resolved = resolveProvisioningRuntimeForDefinition( persona.runtime, providers, - providers[0] ?? null, - false, ); if (!resolved.runtime) return []; return [ @@ -160,10 +155,7 @@ export function AddChannelBotDialog({ runtime: resolved.runtime, name: persona.displayName, personaId: persona.id, - harnessOverride: shouldPinSelectedRuntimeForDefinition( - persona.runtime, - resolved.runtime.id, - ), + harnessOverride: resolved.harnessOverride, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: persona.model ?? undefined, diff --git a/desktop/src/features/channels/ui/useQuickBotDrop.ts b/desktop/src/features/channels/ui/useQuickBotDrop.ts index f141ca4381..5283a402fb 100644 --- a/desktop/src/features/channels/ui/useQuickBotDrop.ts +++ b/desktop/src/features/channels/ui/useQuickBotDrop.ts @@ -4,7 +4,7 @@ import { useAvailableAcpRuntimes, useCreateChannelManagedAgentMutation, } from "@/features/agents/hooks"; -import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { resolveProvisioningRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; import type { AgentPersona } from "@/shared/api/types"; type QuickBotDropState = { @@ -24,7 +24,6 @@ export function useQuickBotDrop(channelId: string | null) { }); const providers = providersQuery.data ?? []; - const defaultProvider = providers[0] ?? null; const addBot = React.useCallback( async (persona: AgentPersona, instanceName: string) => { @@ -33,11 +32,8 @@ export function useQuickBotDrop(channelId: string | null) { setState({ pending: true, error: null }); try { - const { runtime } = resolvePersonaRuntime( - persona.runtime, - providers, - defaultProvider, - ); + const { harnessOverride, runtime } = + resolveProvisioningRuntimeForDefinition(persona.runtime, providers); if (!runtime) { setState({ @@ -54,6 +50,7 @@ export function useQuickBotDrop(channelId: string | null) { avatarUrl: persona.avatarUrl ?? undefined, personaId: persona.id, model: persona.model ?? undefined, + harnessOverride, }); setState({ pending: false, error: null }); @@ -64,7 +61,7 @@ export function useQuickBotDrop(channelId: string | null) { }); } }, - [channelId, createMutation, defaultProvider, providers, state.pending], + [channelId, createMutation, providers, state.pending], ); return { ...state, addBot }; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6d4e007cd4..1a95ae8755 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -10,7 +10,7 @@ import { useProvisionChannelManagedAgentMutation, useStartManagedAgentMutation, } from "@/features/agents/hooks"; -import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { resolveProvisioningRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; @@ -313,7 +313,6 @@ export function useMentionSendFlow({ } const runtimes = await getAvailableRuntimes(); - const defaultRuntime = runtimes[0] ?? null; const errors: string[] = []; const agents: ManagedAgent[] = []; const pubkeys: string[] = []; @@ -327,11 +326,8 @@ export function useMentionSendFlow({ } seenPersonaIds.add(persona.id); - const { runtime } = resolvePersonaRuntime( - persona.runtime, - runtimes, - defaultRuntime, - ); + const { harnessOverride, runtime } = + resolveProvisioningRuntimeForDefinition(persona.runtime, runtimes); if (!runtime) { errors.push(`${displayName}: No agent runtime available.`); continue; @@ -345,6 +341,7 @@ export function useMentionSendFlow({ runtime, name: persona.displayName, personaId: persona.id, + harnessOverride, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: persona.model ?? undefined, From 4ba301d87d42bdebdc10675a1507671287b8fab4 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 18:00:34 -0700 Subject: [PATCH 14/22] Address harness visibility review feedback --- desktop/src/features/agents/AGENTS.md | 6 ++- .../features/agents/ui/AgentConfigFields.tsx | 4 +- .../agents/ui/AgentDefinitionDialog.tsx | 14 +++++-- .../agents/ui/AgentInstanceEditDialog.tsx | 18 ++++---- .../agents/ui/agentConfigOptions.test.mjs | 41 +++++++++++++++++-- .../features/agents/ui/agentConfigOptions.tsx | 35 ++++++++++++---- .../features/onboarding/welcomeGuide.test.mjs | 32 +++++++++++++++ .../src/features/onboarding/welcomeGuide.ts | 30 +++++++++++++- 8 files changed, 154 insertions(+), 26 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 431e295cc2..867e5d8eb2 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -102,8 +102,10 @@ with a TypeScript lookup table or an id comparison in a component. visible-runtime set only for members that need an implicit fallback, so a fully pinned team remains deployable even when every installed runtime is hidden. Definitions already pinned to a hidden runtime remain runnable. - Provider pickers use `getPersonaHiddenProviderIds` as the shared - relay-mesh visibility policy. + Welcome Team reconciliation skips runtime updates for existing agents on a + hidden runtime. Provider pickers use `getPersonaHiddenProviderIds` as the + shared relay-mesh visibility policy, deriving support from each runtime's + catalog-provided `providerEnvVar` rather than its ID. 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/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 5afe782637..834c034f31 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -590,10 +590,10 @@ export function AgentConfigFields({ getPersonaHiddenProviderIds({ bakedEnvKeys, selectableRuntimes: selectedRuntime ? [selectedRuntime] : [], - currentRuntimeId: selectedRuntimeId, + currentRuntime: selectedRuntime, preserveCurrentRuntime: false, }), - [bakedEnvKeys, selectedRuntime, selectedRuntimeId], + [bakedEnvKeys, selectedRuntime], ); const providerOptions = getPersonaProviderOptions( providerValue, diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 74d9a60c82..f13ed8b73b 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -36,6 +36,7 @@ import { getPersonaHiddenProviderIds, getPersonaModelOptions, getPersonaProviderOptions, + getRelayMeshRuntime, getRuntimePersonaModelOptions, NO_RUNTIME_DROPDOWN_VALUE, runtimeSupportsLlmProviderSelection, @@ -516,7 +517,7 @@ export function AgentDefinitionDialog({ const hideProviderIds = getPersonaHiddenProviderIds({ bakedEnvKeys: bakedEnvKeys ?? [], selectableRuntimes, - currentRuntimeId: runtime, + currentRuntime: selectedRuntime, preserveCurrentRuntime: !isCreateMode, }); const providerOptions = getPersonaProviderOptions( @@ -681,11 +682,16 @@ export function AgentDefinitionDialog({ function handleProviderDropdownChange(nextValue: string) { const nextProvider = nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue; - if (nextProvider === "relay-mesh" && runtime !== "buzz-agent") { - handleRuntimeDropdownChange("buzz-agent"); + const relayMeshRuntime = + nextProvider === "relay-mesh" + ? getRelayMeshRuntime(selectableRuntimes, selectedRuntime) + : null; + const nextRuntime = relayMeshRuntime?.id ?? runtime; + if (nextRuntime !== runtime) { + handleRuntimeDropdownChange(nextRuntime); } const nextSelection = selectionOnProviderDropdownChange(selection, { - runtime: nextProvider === "relay-mesh" ? "buzz-agent" : runtime, + runtime: nextRuntime, nextValue, clearModelWhenApiKeyMissing: true, }); diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index d3ba615224..be13b557ef 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -32,6 +32,7 @@ import { getDefaultPersonaRuntime, getPersonaHiddenProviderIds, getPersonaProviderOptions, + getRelayMeshRuntime, getProviderApiKeyEnvVar, isMissingRequiredDropdownField, NO_RUNTIME_DROPDOWN_VALUE, @@ -527,14 +528,17 @@ export function AgentInstanceEditDialog({ function handleProviderDropdownChange(nextValue: string) { const nextProvider = nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue; - if (nextProvider === "relay-mesh" && selectedRuntimeId !== "buzz-agent") { - handleRuntimeDropdownChange("buzz-agent"); + const relayMeshRuntime = + nextProvider === "relay-mesh" + ? getRelayMeshRuntime(selectableRuntimes, selectedRuntime) + : null; + const nextRuntimeId = + relayMeshRuntime?.id ?? selectedRuntime?.id ?? selectedRuntimeId; + if (nextRuntimeId !== selectedRuntimeId) { + handleRuntimeDropdownChange(nextRuntimeId); } const nextSelection = selectionOnProviderDropdownChange(selection, { - runtime: - nextProvider === "relay-mesh" - ? "buzz-agent" - : (selectedRuntime?.id ?? selectedRuntimeId), + runtime: nextRuntimeId, nextValue, clearModelWhenApiKeyMissing: false, }); @@ -772,7 +776,7 @@ export function AgentInstanceEditDialog({ const hideProviderIds = getPersonaHiddenProviderIds({ bakedEnvKeys: bakedEnvKeys ?? [], selectableRuntimes, - currentRuntimeId: selectedRuntimeId, + currentRuntime: selectedRuntime, preserveCurrentRuntime: true, }); const providerOptions = getPersonaProviderOptions( diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index ae078f2fc2..d2dc068f90 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -14,13 +14,22 @@ import { formatModelDiscoveryErrorStatus } from "./personaModelDiscoveryStatus.t // ── helpers ────────────────────────────────────────────────────────────────── -function makeRuntime(id, availability = "available") { +function makeRuntime( + id, + availability = "available", + providerEnvVar = id === "buzz-agent" + ? "BUZZ_AGENT_PROVIDER" + : id === "goose" + ? "GOOSE_PROVIDER" + : null, +) { return { id, label: id, command: id, defaultArgs: [], mcpCommand: null, + providerEnvVar, availability, }; } @@ -80,7 +89,7 @@ test("hidden Buzz Agent suppresses shared compute for new selections", () => { const hidden = getPersonaHiddenProviderIds({ bakedEnvKeys: [], selectableRuntimes: [makeRuntime("goose")], - currentRuntimeId: "goose", + currentRuntime: makeRuntime("goose"), preserveCurrentRuntime: false, }); const ids = getPersonaProviderOptions("", "goose", "", hidden).map( @@ -93,7 +102,7 @@ test("an existing hidden Buzz Agent keeps its shared compute provider", () => { const hidden = getPersonaHiddenProviderIds({ bakedEnvKeys: [], selectableRuntimes: [makeRuntime("goose")], - currentRuntimeId: "buzz-agent", + currentRuntime: makeRuntime("buzz-agent"), preserveCurrentRuntime: true, }); const ids = getPersonaProviderOptions("", "buzz-agent", "", hidden).map( @@ -102,6 +111,32 @@ test("an existing hidden Buzz Agent keeps its shared compute provider", () => { assert.ok(ids.includes("relay-mesh")); }); +test("shared compute visibility follows runtime catalog metadata instead of runtime ids", () => { + const hidden = getPersonaHiddenProviderIds({ + bakedEnvKeys: [], + selectableRuntimes: [ + makeRuntime("future-shared-compute", "available", "BUZZ_AGENT_PROVIDER"), + ], + preserveCurrentRuntime: false, + }); + const ids = getPersonaProviderOptions( + "", + "future-shared-compute", + "", + hidden, + ).map((option) => option.id); + assert.ok(ids.includes("relay-mesh")); + + const renamedCapability = getPersonaHiddenProviderIds({ + bakedEnvKeys: [], + selectableRuntimes: [ + makeRuntime("buzz-agent", "available", "GOOSE_PROVIDER"), + ], + preserveCurrentRuntime: false, + }); + assert.ok(renamedCapability.has("relay-mesh")); +}); + // ── getDefaultPersonaRuntime — buzz-agent first ─────────────────────────────── test("getDefaultPersonaRuntime honors an available global preference", () => { diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index fda3825e11..d8ceb01449 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -21,24 +21,45 @@ export const BLOCK_BUILD_HIDDEN_PROVIDER_IDS: ReadonlySet = new Set([ "databricks", ]); +type RuntimeProviderCapability = Pick< + AcpRuntimeCatalogEntry, + "id" | "providerEnvVar" +>; + +export function runtimeSupportsRelayMesh( + runtime: RuntimeProviderCapability | null | undefined, +): boolean { + return runtime?.providerEnvVar === "BUZZ_AGENT_PROVIDER"; +} + +export function getRelayMeshRuntime( + selectableRuntimes: readonly T[], + currentRuntime?: T | null, +): T | null { + if (runtimeSupportsRelayMesh(currentRuntime)) { + return currentRuntime ?? null; + } + return selectableRuntimes.find(runtimeSupportsRelayMesh) ?? null; +} + export function getPersonaHiddenProviderIds({ bakedEnvKeys, selectableRuntimes, - currentRuntimeId, + currentRuntime, preserveCurrentRuntime, }: { bakedEnvKeys: readonly string[]; - selectableRuntimes: readonly Pick[]; - currentRuntimeId: string; + selectableRuntimes: readonly RuntimeProviderCapability[]; + currentRuntime?: RuntimeProviderCapability | null; preserveCurrentRuntime: boolean; }): ReadonlySet { const hidden = bakedEnvKeys.includes("BUZZ_AGENT_PROVIDER") ? new Set(BLOCK_BUILD_HIDDEN_PROVIDER_IDS) : new Set(); - const buzzAgentSelectable = - selectableRuntimes.some((runtime) => runtime.id === "buzz-agent") || - (preserveCurrentRuntime && currentRuntimeId.trim() === "buzz-agent"); - if (!buzzAgentSelectable) hidden.add("relay-mesh"); + const relayMeshSelectable = + selectableRuntimes.some(runtimeSupportsRelayMesh) || + (preserveCurrentRuntime && runtimeSupportsRelayMesh(currentRuntime)); + if (!relayMeshSelectable) hidden.add("relay-mesh"); return hidden; } diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index b3def930f1..2fe0bc1b35 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -289,6 +289,38 @@ test("existing Welcome starter needs no update when runtime already matches", () ); }); +test("existing Welcome starter keeps its runtime when that harness is hidden", () => { + const existing = makeAgent({ + personaId: WELCOME_GUIDE_PERSONA_ID, + agentCommand: "buzz-agent", + agentArgs: [], + model: "auto", + provider: "relay-mesh", + }); + + assert.equal( + welcomeStarterRuntimeUpdate( + existing, + { + name: "Fizz", + agentCommand: "goose", + agentArgs: [], + mcpCommand: "", + model: "gpt-5", + provider: "openai", + }, + { + runtimes: [ + { id: "buzz-agent", command: "buzz-agent" }, + { id: "goose", command: "goose" }, + ], + disabledRuntimeIds: ["buzz-agent"], + }, + ), + null, + ); +}); + test("welcome team starter definitions and role identities are stable", () => { assert.equal(WELCOME_TEAM_ID, "builtin-team:welcome"); assert.deepEqual(WELCOME_TEAM_STARTERS, [ diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index aa8deb7c19..f1f9f274de 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -2,6 +2,10 @@ import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, } from "@/features/agents/lib/instanceInputForDefinition"; +import { + filterEnabledAcpRuntimes, + getDisabledAcpRuntimeIdsSnapshot, +} from "@/features/agents/lib/runtimeVisibilityPreference"; import { addChannelMembers, createManagedAgent, @@ -14,6 +18,7 @@ import { getGlobalAgentConfig } from "@/shared/api/tauriGlobalAgentConfig"; import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas"; import type { AcpRuntime, + AcpRuntimeCatalogEntry, AgentPersona, CreateManagedAgentInput, ManagedAgent, @@ -232,9 +237,28 @@ export async function buildWelcomeStarterCreateInput( export function welcomeStarterRuntimeUpdate( existing: ManagedAgent, desired: CreateManagedAgentInput, + visibility?: { + runtimes: readonly AcpRuntimeCatalogEntry[]; + disabledRuntimeIds: readonly string[]; + }, ) { if (!desired.agentCommand) return null; + const existingRuntime = visibility?.runtimes.find( + (runtime) => + runtime.command?.trim() === existing.agentCommand.trim() || + runtime.id.trim() === existing.agentCommand.trim(), + ); + if ( + existingRuntime && + filterEnabledAcpRuntimes( + [existingRuntime], + visibility?.disabledRuntimeIds ?? [], + ).length === 0 + ) { + return null; + } + const desiredArgs = desired.agentArgs ?? []; const desiredModel = desired.model ?? null; const desiredProvider = desired.provider ?? null; @@ -282,6 +306,7 @@ async function provisionWelcomeTeam( const runtimes = runtimeCatalog.filter( (runtime): runtime is AcpRuntime => runtime.availability === "available", ); + const disabledRuntimeIds = getDisabledAcpRuntimeIdsSnapshot(); const agents: ManagedAgent[] = []; for (const starter of WELCOME_TEAM_STARTERS) { @@ -302,7 +327,10 @@ async function provisionWelcomeTeam( relayUrl, ); if (existing) { - const runtimeUpdate = welcomeStarterRuntimeUpdate(existing, desired); + const runtimeUpdate = welcomeStarterRuntimeUpdate(existing, desired, { + runtimes: runtimeCatalog, + disabledRuntimeIds, + }); agents.push( runtimeUpdate ? (await updateManagedAgent(runtimeUpdate)).agent From 0a0fba24fe4dfaee6e06973a378219bbbc89e5d3 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 08:24:48 -0700 Subject: [PATCH 15/22] Fix harness visibility smoke fixture --- desktop/tests/e2e/doctor-states.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 5563d8fa96..a789fd7931 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -248,7 +248,7 @@ test.describe("Doctor panel state screenshots", () => { 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.getByTestId("global-agent-provider-option-relay-mesh").click(); await page.getByRole("button", { name: "Save defaults" }).click(); const savedConfig = await page.evaluate(async () => ( @@ -261,7 +261,7 @@ test.describe("Doctor panel state screenshots", () => { ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null), ); expect(savedConfig).toMatchObject({ - provider: "openai", + provider: "relay-mesh", preferred_runtime: "buzz-agent", }); @@ -269,6 +269,7 @@ test.describe("Doctor panel state screenshots", () => { await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("tab", { name: "Customize for this agent" }).click(); const harnessDropdown = page.locator("#persona-runtime"); await expect(harnessDropdown).toContainText("Buzz Agent"); From 5a50252ff62b5c08406dcd6a645b0fcd77ad20f6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 08:54:11 -0700 Subject: [PATCH 16/22] Use harness labels for current options --- .../agents/ui/AgentDefinitionDialog.tsx | 3 ++- .../agents/ui/AgentInstanceEditDialog.tsx | 5 +++-- .../agents/ui/agentConfigOptions.test.mjs | 20 +++++++++++++++++++ .../features/agents/ui/agentConfigOptions.tsx | 12 +++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index f13ed8b73b..407f91739c 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -31,6 +31,7 @@ import { AUTO_PROVIDER_DROPDOWN_VALUE, CUSTOM_PROVIDER_DROPDOWN_VALUE, computeLocalModeGate, + formatCurrentRuntimeOptionLabel, formatRuntimeOptionLabel, getDefaultPersonaRuntime, getPersonaHiddenProviderIds, @@ -569,7 +570,7 @@ export function AgentDefinitionDialog({ !runtimeDropdownOptions.some((option) => option.value === runtime) ) { runtimeDropdownOptions.push({ - label: `${runtime.trim()} (current)`, + label: formatCurrentRuntimeOptionLabel(runtimes, runtime), value: runtime.trim(), }); } diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index be13b557ef..6d35a6c800 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -27,6 +27,7 @@ import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields"; import { AUTO_PROVIDER_DROPDOWN_VALUE, CUSTOM_PROVIDER_DROPDOWN_VALUE, + formatCurrentRuntimeOptionLabel, formatRuntimeOptionLabel, getDefaultLlmModelLabel, getDefaultPersonaRuntime, @@ -229,12 +230,12 @@ export function AgentInstanceEditDialog({ !options.some((o) => o.value === selectedRuntimeId) ) { options.push({ - label: `${selectedRuntimeId} (current)`, + label: formatCurrentRuntimeOptionLabel(runtimes, selectedRuntimeId), value: selectedRuntimeId, }); } return options; - }, [sortedRuntimes, selectedRuntimeId]); + }, [runtimes, sortedRuntimes, selectedRuntimeId]); // Resolve the dialog-opening command as the catalog loads. Edit-state runtime // ids mutate during selection changes and cannot identify the original state. diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index d2dc068f90..a93e862f96 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + formatCurrentRuntimeOptionLabel, getDefaultPersonaRuntime, getPersonaHiddenProviderIds, getPersonaModelOptions, @@ -34,6 +35,25 @@ function makeRuntime( }; } +test("formatCurrentRuntimeOptionLabel uses the catalog display label", () => { + const runtime = { + ...makeRuntime("goose"), + label: "Goose", + }; + + assert.equal( + formatCurrentRuntimeOptionLabel([runtime], "goose"), + "Goose (current)", + ); +}); + +test("formatCurrentRuntimeOptionLabel falls back to an unknown runtime id", () => { + assert.equal( + formatCurrentRuntimeOptionLabel([], " custom-runtime "), + "custom-runtime (current)", + ); +}); + // ── getPersonaProviderOptions — hideProviderIds ─────────────────────────────── test("getPersonaProviderOptions returns databricks v1 and v2 when hideProviderIds is empty", () => { diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index d8ceb01449..f31a20441d 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -489,6 +489,18 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { return `${runtime.label}${suffix}`; } +export function formatCurrentRuntimeOptionLabel( + runtimes: readonly AcpRuntimeCatalogEntry[], + runtimeId: string, +) { + const trimmedRuntimeId = runtimeId.trim(); + const runtime = runtimes.find( + (candidate) => candidate.id === trimmedRuntimeId, + ); + const label = runtime ? formatRuntimeOptionLabel(runtime) : trimmedRuntimeId; + return `${label} (current)`; +} + function runtimeAvailabilitySortRank( availability: AcpRuntimeCatalogEntry["availability"], ) { From 87ebbf41a48e4d6bfe346b19d64ca03a9ed67337 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 09:54:59 -0700 Subject: [PATCH 17/22] Filter hidden harnesses from remaining pickers --- .../lib/runtimeVisibilityPreference.test.mjs | 22 +++++++++++ .../agents/lib/runtimeVisibilityPreference.ts | 32 ++++++++++++++++ .../onboarding/ui/DefaultConfigStep.tsx | 11 ++++-- .../ui/onboardingRuntimeSelection.test.mjs | 12 ++++++ .../ui/onboardingRuntimeSelection.ts | 4 +- .../ui/ChannelTemplatesSettingsCard.tsx | 28 +++++++++++++- .../e2e/onboarding-agent-defaults.spec.ts | 37 +++++++++++++++++++ 7 files changed, 140 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs index 7230d49424..9bf8014932 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs @@ -8,6 +8,7 @@ import { nextDisabledAcpRuntimeIds, parseDisabledAcpRuntimeIds, readDisabledAcpRuntimeIds, + runtimesForAcpConfigurationPicker, runtimesForImplicitAcpSelection, visibleAcpRuntimeSeedForCreate, } from "./runtimeVisibilityPreference.ts"; @@ -56,6 +57,27 @@ test("disabled runtimes are removed from selectable catalog entries", () => { ); }); +test("configuration pickers hide new choices but preserve the current runtime", () => { + const runtimes = [ + { id: "buzz-agent", label: "Buzz Agent" }, + { id: "goose", label: "Goose" }, + { id: "codex", label: "Codex" }, + ]; + + assert.deepEqual( + runtimesForAcpConfigurationPicker( + runtimes, + ["buzz-agent", "goose"], + "goose", + ), + [runtimes[2], runtimes[1]], + ); + assert.deepEqual( + runtimesForAcpConfigurationPicker(runtimes, ["buzz-agent", "goose"], null), + [runtimes[2]], + ); +}); + test("create seeds replace hidden runtimes but preserve selectable ones", () => { const runtimes = [{ id: "buzz-agent" }, { id: "goose" }]; assert.equal( diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts index 89932e6030..040831c289 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -95,6 +95,38 @@ export function filterEnabledAcpRuntimes( ); } +/** + * Filter new configuration choices while preserving one explicitly saved + * runtime as a current-only option. + */ +export function runtimesForAcpConfigurationPicker( + runtimes: readonly T[], + disabledRuntimeIds: readonly string[], + currentRuntimeId?: string | null, +): T[] { + const selectableRuntimes = filterEnabledAcpRuntimes( + runtimes, + disabledRuntimeIds, + ); + const normalizedCurrentRuntimeId = normalizeRuntimeId(currentRuntimeId ?? ""); + if ( + !normalizedCurrentRuntimeId || + selectableRuntimes.some( + (runtime) => + normalizeRuntimeId(runtime.id) === normalizedCurrentRuntimeId, + ) + ) { + return selectableRuntimes; + } + + const currentRuntime = runtimes.find( + (runtime) => normalizeRuntimeId(runtime.id) === normalizedCurrentRuntimeId, + ); + return currentRuntime + ? [...selectableRuntimes, currentRuntime] + : selectableRuntimes; +} + /** * Apply visibility only when the app is choosing a runtime implicitly. * Existing definitions pinned to a runtime remain runnable. diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 90f8b22d62..0eb613e437 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -10,6 +10,7 @@ import { } from "@/features/agents/ui/AgentConfigFields"; import { resetConfigForHarnessChange } from "@/features/agents/ui/agentConfigOptions"; import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; +import { useDisabledAcpRuntimeIds } from "@/features/agents/lib/runtimeVisibilityPreference"; import { createSaveCoalescer } from "./saveCoalescer"; import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri"; import { @@ -126,14 +127,16 @@ function AgentDefaultsSection({ () => new Set(effectiveReadyRuntimeIds), [effectiveReadyRuntimeIds], ); + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); // Setup already confirmed readiness. Re-filter only for onboarding // visibility here; a transient auth recheck must not invalidate that handoff. const readyRuntimes = React.useMemo( () => - getVisibleOnboardingRuntimes(runtimesQuery.data ?? []).filter((runtime) => - readyRuntimeIdSet.has(runtime.id), - ), - [readyRuntimeIdSet, runtimesQuery.data], + getVisibleOnboardingRuntimes( + runtimesQuery.data ?? [], + disabledRuntimeIds, + ).filter((runtime) => readyRuntimeIdSet.has(runtime.id)), + [disabledRuntimeIds, readyRuntimeIdSet, runtimesQuery.data], ); const selectedRuntime = React.useMemo( () => diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index b10aa19154..e3aced820c 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -34,6 +34,18 @@ test("visible onboarding runtimes use the product order", () => { ); }); +test("onboarding defaults exclude device-disabled harnesses", () => { + const runtimes = [ + runtime("codex", "available", "logged_in"), + runtime("claude", "available", "logged_in"), + ]; + + assert.deepEqual( + getVisibleOnboardingRuntimes(runtimes, ["claude"]).map(({ id }) => id), + ["codex"], + ); +}); + test("readiness requires an available and authenticated runtime", () => { assert.equal( runtimeIsReadyForOnboarding(runtime("claude", "available", "logged_in")), diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afe..74c501d81a 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,4 +1,5 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { runtimesForAcpConfigurationPicker } from "@/features/agents/lib/runtimeVisibilityPreference"; export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; @@ -20,8 +21,9 @@ export function runtimeIsReadyForOnboarding(runtime: AcpRuntimeCatalogEntry) { export function getVisibleOnboardingRuntimes( runtimes: readonly AcpRuntimeCatalogEntry[], + disabledRuntimeIds: readonly string[] = [], ) { - return runtimes + return runtimesForAcpConfigurationPicker(runtimes, disabledRuntimeIds) .filter((runtime) => runtimeIsVisibleInOnboarding(runtime.id)) .sort( (left, right) => diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx index 82680ae5a8..6e619a26e7 100644 --- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx @@ -16,6 +16,10 @@ import { usePersonasQuery, useTeamsQuery, } from "@/features/agents/hooks"; +import { + runtimesForAcpConfigurationPicker, + useDisabledAcpRuntimeIds, +} from "@/features/agents/lib/runtimeVisibilityPreference"; import { useChannelTemplatesQuery, useCreateChannelTemplateMutation, @@ -292,6 +296,7 @@ function TemplateFormDialog({ const teamsQuery = useTeamsQuery(); const providersQuery = useAvailableAcpRuntimes(); const runtimes = providersQuery.data ?? []; + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); const [name, setName] = React.useState(""); const [description, setDescription] = React.useState(""); @@ -559,6 +564,7 @@ function TemplateFormDialog({ personaRuntimes={personaRuntimes} providers={runtimes} providersLoading={providersQuery.isLoading} + disabledRuntimeIds={disabledRuntimeIds} selectedPersonaIds={selectedPersonaIds} selectedTeamIds={selectedTeamIds} teamRuntimes={teamRuntimes} @@ -638,6 +644,7 @@ function TemplateTeamSelector({ } function RuntimeAssignments({ + disabledRuntimeIds, isPending, onPersonaRuntimeChange, onTeamRuntimeChange, @@ -650,6 +657,7 @@ function RuntimeAssignments({ teamRuntimes, teams, }: { + disabledRuntimeIds: readonly string[]; isPending: boolean; onPersonaRuntimeChange: (personaId: string, runtimeId: string) => void; onTeamRuntimeChange: (teamId: string, runtimeId: string) => void; @@ -697,6 +705,7 @@ function RuntimeAssignments({ onChange={(runtimeId) => onPersonaRuntimeChange(persona.id, runtimeId) } + disabledRuntimeIds={disabledRuntimeIds} providers={providers} value={personaRuntimes[persona.id] ?? ""} /> @@ -708,6 +717,7 @@ function RuntimeAssignments({ icon="team" label={team.name} onChange={(runtimeId) => onTeamRuntimeChange(team.id, runtimeId)} + disabledRuntimeIds={disabledRuntimeIds} providers={providers} value={teamRuntimes[team.id] ?? ""} /> @@ -721,6 +731,7 @@ function RuntimeAssignments({ function RuntimeRow({ avatarUrl, disabled, + disabledRuntimeIds, icon, label, onChange, @@ -729,12 +740,24 @@ function RuntimeRow({ }: { avatarUrl?: string | null | undefined; disabled: boolean; + disabledRuntimeIds: readonly string[]; icon?: "team"; label: string; onChange: (runtimeId: string) => void; providers: AcpRuntime[]; value: string; }) { + const runtimeOptions = runtimesForAcpConfigurationPicker( + providers, + disabledRuntimeIds, + value, + ); + const selectableRuntimeIds = new Set( + runtimesForAcpConfigurationPicker(providers, disabledRuntimeIds).map( + (runtime) => runtime.id, + ), + ); + return (
@@ -756,9 +779,12 @@ function RuntimeRow({ value={value} > - {providers.map((runtime) => ( + {runtimeOptions.map((runtime) => ( ))} diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index f8e6322ed3..18287b7b34 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { ACP_RUNTIME_VISIBILITY_STORAGE_KEY } from "@/features/agents/lib/runtimeVisibilityPreference"; import { installMockBridge } from "../helpers/bridge"; import { passThroughBackupStep } from "../helpers/onboarding"; @@ -551,6 +552,42 @@ test("defaults auto-selects the only ready visible harness", async ({ await expect.poll(() => readSavedRuntime(page)).toBe("claude"); }); +test("defaults exclude device-disabled ready harnesses", async ({ page }) => { + await page.addInitScript((storageKey) => { + window.localStorage.setItem(storageKey, JSON.stringify(["claude"])); + }, ACP_RUNTIME_VISIBILITY_STORAGE_KEY); + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + const harness = page.getByTestId("global-agent-default-harness"); + await expect(harness).toHaveText("Codex"); + await harness.click(); + await expect( + page.getByTestId("global-agent-default-harness-option-claude"), + ).toHaveCount(0); + await expect( + page.getByTestId("global-agent-default-harness-option-codex"), + ).toBeVisible(); +}); + test("Finish waits for the latest rapid harness choice to persist", async ({ page, }) => { From 26ca6e0f5381fa762af7fca20b4891149fc57e2b Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 10:25:15 -0700 Subject: [PATCH 18/22] Filter hidden runtimes from welcome readiness --- .../onboarding/welcomeKickoff.test.mjs | 23 +++++++++++++ .../src/features/onboarding/welcomeKickoff.ts | 33 +++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs index 6f472b8068..08d1287dd8 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.test.mjs +++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs @@ -9,6 +9,7 @@ import { classifyWelcomeKickoffResolution, createWelcomeKickoffCoordinator, mergeKickoffEvents, + resolveWelcomeAgentReadiness, resolveWelcomeAgentSet, selectWelcomeKickoffIntroTeammates, waitForWelcomeKickoffBeat, @@ -33,6 +34,28 @@ const fizz = agent("Fizz", "builtin:fizz", "f".repeat(64)); const honey = agent("Honey", "builtin:honey", "h".repeat(64)); const bumble = agent("Bumble", "builtin:bumble", "b".repeat(64)); +test("welcome readiness ignores a hidden logged-in runtime", () => { + const runtimes = [ + { + id: "claude", + label: "Claude", + availability: "available", + authStatus: { status: "logged_in" }, + }, + ]; + const globalConfig = { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }; + + assert.deepEqual( + resolveWelcomeAgentReadiness(runtimes, globalConfig, ["claude"]), + { ready: false }, + ); +}); + test("resolveWelcomeAgentSet orders agents by stable persona identity", () => { assert.deepEqual(resolveWelcomeAgentSet([bumble, fizz, honey]), { lead: fizz, diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index 5d40fbb858..6a385e45f8 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -5,6 +5,10 @@ import { useAcpRuntimesQuery, useManagedAgentsQuery, } from "@/features/agents/hooks"; +import { + filterEnabledAcpRuntimes, + useDisabledAcpRuntimeIds, +} from "@/features/agents/lib/runtimeVisibilityPreference"; import { useImplicitGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { useCommunities } from "@/features/communities/useCommunities"; import { welcomeKickoffMarker } from "@/features/onboarding/devFreshOnboarding"; @@ -26,7 +30,13 @@ import { hasManagedAgentChannelMessageMarker } from "@/shared/api/tauriManagedAg import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages"; import { getPresence, listManagedAgents } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; -import type { Channel, ManagedAgent, RelayEvent } from "@/shared/api/types"; +import type { + AcpRuntimeCatalogEntry, + Channel, + GlobalAgentConfig, + ManagedAgent, + RelayEvent, +} from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { useQueryClient } from "@tanstack/react-query"; @@ -45,6 +55,17 @@ export const WELCOME_KICKOFF_PROVIDER_MESSAGE = const WELCOME_KICKOFF_CTA = "What can we help you build? Bring us something you're working on, or give us a quick challenge to see how we work together."; +export function resolveWelcomeAgentReadiness( + runtimes: readonly AcpRuntimeCatalogEntry[], + globalConfig: GlobalAgentConfig, + disabledRuntimeIds: readonly string[], +) { + return resolveAgentReadiness( + filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds), + globalConfig, + ); +} + function formatAgentNames(agents: readonly ManagedAgent[]) { if (agents.length === 0) return ""; if (agents.length === 1) return agents[0]?.name ?? ""; @@ -493,6 +514,7 @@ export function useWelcomeKickoff( const { activeCommunity } = useCommunities(); const runtimesQuery = useAcpRuntimesQuery(); const managedAgentsQuery = useManagedAgentsQuery(); + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); const { globalConfig, isLoading: configLoading } = useImplicitGlobalAgentConfig(); const channelId = activeChannel?.id ?? null; @@ -546,8 +568,13 @@ export function useWelcomeKickoff( [activeCommunity?.relayUrl, managedAgentsQuery.data], ); const readiness = React.useMemo( - () => resolveAgentReadiness(runtimesQuery.data ?? [], globalConfig), - [globalConfig, runtimesQuery.data], + () => + resolveWelcomeAgentReadiness( + runtimesQuery.data ?? [], + globalConfig, + disabledRuntimeIds, + ), + [disabledRuntimeIds, globalConfig, runtimesQuery.data], ); React.useEffect(() => { if ( From d9c2b08cf707edb04302b98e03e438ee14224f98 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 10:56:32 -0700 Subject: [PATCH 19/22] Preserve hidden Welcome runtime aliases --- .../features/onboarding/welcomeGuide.test.mjs | 30 +++++++++++++++++++ .../src/features/onboarding/welcomeGuide.ts | 6 ++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index 2fe0bc1b35..bd009bd71e 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -321,6 +321,36 @@ test("existing Welcome starter keeps its runtime when that harness is hidden", ( ); }); +test("existing Welcome starter keeps an aliased runtime when that harness is hidden", () => { + const existing = makeAgent({ + personaId: WELCOME_GUIDE_PERSONA_ID, + agentCommand: "claude-code-acp", + agentArgs: [], + }); + + assert.equal( + welcomeStarterRuntimeUpdate( + existing, + { + name: "Fizz", + agentCommand: "goose", + agentArgs: [], + mcpCommand: "", + model: null, + provider: null, + }, + { + runtimes: [ + { id: "claude", command: "claude-agent-acp" }, + { id: "goose", command: "goose" }, + ], + disabledRuntimeIds: ["claude"], + }, + ), + null, + ); +}); + test("welcome team starter definitions and role identities are stable", () => { assert.equal(WELCOME_TEAM_ID, "builtin-team:welcome"); assert.deepEqual(WELCOME_TEAM_STARTERS, [ diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index f1f9f274de..91de5d8d79 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -1,3 +1,4 @@ +import { commandsMatch } from "@/features/agents/agentReuse"; import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, @@ -246,8 +247,9 @@ export function welcomeStarterRuntimeUpdate( const existingRuntime = visibility?.runtimes.find( (runtime) => - runtime.command?.trim() === existing.agentCommand.trim() || - runtime.id.trim() === existing.agentCommand.trim(), + (runtime.command + ? commandsMatch(existing.agentCommand, runtime.command) + : false) || commandsMatch(existing.agentCommand, runtime.id), ); if ( existingRuntime && From eb015bc7612e826fc159217ff086384d2012d1a1 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 19:23:07 -0700 Subject: [PATCH 20/22] Handle hidden runtimes during Welcome kickoff --- .../onboarding/welcomeKickoff.test.mjs | 21 ++++++ .../src/features/onboarding/welcomeKickoff.ts | 33 ++++++++++ desktop/src/testing/e2eBridge.ts | 4 ++ desktop/tests/e2e/onboarding.spec.ts | 66 +++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs index 08d1287dd8..be09d82ad1 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.test.mjs +++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs @@ -8,6 +8,7 @@ import { buildWelcomeKickoffOpenerSendInput, classifyWelcomeKickoffResolution, createWelcomeKickoffCoordinator, + hasEnabledWelcomeRuntime, mergeKickoffEvents, resolveWelcomeAgentReadiness, resolveWelcomeAgentSet, @@ -56,6 +57,26 @@ test("welcome readiness ignores a hidden logged-in runtime", () => { ); }); +test("welcome provisioning requires an enabled available runtime", () => { + const runtimes = [ + { + id: "goose", + label: "Goose", + availability: "available", + authStatus: { status: "not_applicable" }, + }, + { + id: "claude", + label: "Claude", + availability: "adapter_missing", + authStatus: { status: "unknown" }, + }, + ]; + + assert.equal(hasEnabledWelcomeRuntime(runtimes, ["goose"]), false); + assert.equal(hasEnabledWelcomeRuntime(runtimes, []), true); +}); + test("resolveWelcomeAgentSet orders agents by stable persona identity", () => { assert.deepEqual(resolveWelcomeAgentSet([bumble, fizz, honey]), { lead: fizz, diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index 6a385e45f8..6323c9f3cc 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -66,6 +66,15 @@ export function resolveWelcomeAgentReadiness( ); } +export function hasEnabledWelcomeRuntime( + runtimes: readonly AcpRuntimeCatalogEntry[], + disabledRuntimeIds: readonly string[], +) { + return filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds).some( + (runtime) => runtime.availability === "available", + ); +} + function formatAgentNames(agents: readonly ManagedAgent[]) { if (agents.length === 0) return ""; if (agents.length === 1) return agents[0]?.name ?? ""; @@ -576,11 +585,17 @@ export function useWelcomeKickoff( ), [disabledRuntimeIds, globalConfig, runtimesQuery.data], ); + const canProvisionWelcomeTeam = React.useMemo( + () => + hasEnabledWelcomeRuntime(runtimesQuery.data ?? [], disabledRuntimeIds), + [disabledRuntimeIds, runtimesQuery.data], + ); React.useEffect(() => { if ( !channelId || !isActiveWelcome || configLoading || + managedAgentsQuery.isPending || runtimesQuery.isPending ) { return; @@ -593,6 +608,21 @@ export function useWelcomeKickoff( focusedWelcomeChannelRef.current !== channelId; void (async () => { try { + if (await markerExists(channelId, closerMarker)) { + return; + } + if (!canProvisionWelcomeTeam) { + if (!agentSet) return; + await sendManagedAgentChannelMessage({ + agentPubkey: agentSet.lead.pubkey, + channelId, + content: WELCOME_KICKOFF_PROVIDER_MESSAGE, + marker: providerMarker, + markerScope: "channel", + }); + return; + } + const welcomeTeam = await ensureWelcomeTeam( channelId, activeCommunity?.relayUrl, @@ -710,9 +740,12 @@ export function useWelcomeKickoff( })(); }, [ activeCommunity?.relayUrl, + agentSet, + canProvisionWelcomeTeam, channelId, configLoading, isActiveWelcome, + managedAgentsQuery.isPending, onKickoffOpenerPosted, queryClient, readiness, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5169c329a0..c2497a8cc9 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -70,6 +70,7 @@ type MockManagedAgentSeed = { name: string; avatarUrl?: string | null; personaId?: string | null; + teamId?: string | null; status?: RawManagedAgent["status"]; channelNames?: string[]; channelIds?: string[]; @@ -701,6 +702,7 @@ type RawManagedAgent = { pubkey: string; name: string; persona_id: string | null; + team_id?: string | null; relay_url: string; acp_command: string; agent_command: string; @@ -1452,6 +1454,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { pubkey: agent.pubkey, name: agent.name, persona_id: agent.persona_id, + team_id: agent.team_id ?? null, relay_url: agent.relay_url, acp_command: agent.acp_command, agent_command: agent.agent_command, @@ -1985,6 +1988,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { pubkey: seed.pubkey, name: seed.name, persona_id: seed.personaId ?? null, + team_id: seed.teamId ?? null, relay_url: DEFAULT_RELAY_WS_URL, acp_command: "buzz-acp", agent_command: "goose", diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 0c4d630994..f80ab4646f 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -2,6 +2,7 @@ import { hexToBytes } from "@noble/hashes/utils.js"; import { expect, test, type Page } from "@playwright/test"; import { nsecEncode } from "nostr-tools/nip19"; +import { ACP_RUNTIME_VISIBILITY_STORAGE_KEY } from "@/features/agents/lib/runtimeVisibilityPreference"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { installFakeCamera } from "../helpers/fakeCamera"; import { @@ -2714,6 +2715,71 @@ test("first-run onboarding lands before Welcome team bootstrap completes", async expect(await commandCount(page, "create_managed_agent")).toBe(3); }); +test("first-run onboarding reuses the Welcome team when every harness is hidden", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await page.addInitScript((storageKey) => { + window.localStorage.setItem(storageKey, JSON.stringify(["goose"])); + }, ACP_RUNTIME_VISIBILITY_STORAGE_KEY); + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + { + id: "goose", + label: "Goose", + avatar_url: "", + availability: "available", + command: "goose", + binary_path: "/usr/local/bin/goose", + default_args: ["acp"], + mcp_command: null, + install_hint: "Install Goose", + install_instructions_url: "https://block.github.io/goose/", + can_auto_install: true, + requires_external_cli: true, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "not_applicable" }, + login_hint: null, + }, + ], + managedAgents: [ + { + pubkey: "f".repeat(64), + name: "Fizz", + personaId: "builtin:fizz", + teamId: "builtin-team:welcome", + }, + { + pubkey: "e".repeat(64), + name: "Honey", + personaId: "builtin:honey", + teamId: "builtin-team:welcome", + }, + { + pubkey: "d".repeat(64), + name: "Bumble", + personaId: "builtin:bumble", + teamId: "builtin-team:welcome", + }, + ], + }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("onboarding-display-name").fill("Morty QA"); + await completeProfileOnboarding(page); + + await expectPrivateWelcomeLanding(page); + await expect(page.getByTestId("message-timeline")).toContainText( + "connect to an AI provider in Settings", + ); + expect(await commandCount(page, "create_managed_agent")).toBe(0); +}); + test("existing relay profile with display name auto-skips onboarding without localStorage", async ({ page, }) => { From c77ad4f49f7f138c0d54383cc090ce9cca879930 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 19:39:27 -0700 Subject: [PATCH 21/22] Respect hidden harnesses during onboarding --- .../src/features/onboarding/ui/SetupStep.tsx | 11 +++++--- .../ui/onboardingRuntimeSelection.test.mjs | 14 ++++++++++ .../ui/onboardingRuntimeSelection.ts | 3 +- .../e2e/onboarding-agent-defaults.spec.ts | 28 +++++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 288ca30259..05f2962bdb 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -8,6 +8,7 @@ import { useConnectAcpRuntimeMutation, useInstallAcpRuntimeMutation, } from "@/features/agents/hooks"; +import { useDisabledAcpRuntimeIds } from "@/features/agents/lib/runtimeVisibilityPreference"; import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { getInstallErrorMessage } from "@/shared/lib/installError"; @@ -656,12 +657,14 @@ function SetupStepContent({ const { runtimeProviders } = state; const [installResults, setInstallResults] = React.useState({}); + const disabledRuntimeIds = useDisabledAcpRuntimeIds(); const readyRuntimeIds = React.useMemo( () => - getReadyOnboardingRuntimes(runtimeProviders.items).map( - (runtime) => runtime.id, - ), - [runtimeProviders.items], + getReadyOnboardingRuntimes( + runtimeProviders.items, + disabledRuntimeIds, + ).map((runtime) => runtime.id), + [disabledRuntimeIds, runtimeProviders.items], ); const readyRuntimeIdsKey = readyRuntimeIds.join("\0"); // The key prevents catalog object refreshes from creating an effect loop diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index e3aced820c..ea836f655f 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -80,3 +80,17 @@ test("ready onboarding runtimes exclude hidden ready harnesses", () => { ["claude"], ); }); + +test("ready onboarding runtimes exclude device-disabled harnesses", () => { + const runtimes = [ + runtime("codex", "available", "logged_in"), + runtime("claude", "available", "logged_in"), + ]; + + assert.deepEqual( + getReadyOnboardingRuntimes(runtimes, ["claude", "codex"]).map( + ({ id }) => id, + ), + [], + ); +}); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 74c501d81a..4b2d97a712 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -34,8 +34,9 @@ export function getVisibleOnboardingRuntimes( export function getReadyOnboardingRuntimes( runtimes: readonly AcpRuntimeCatalogEntry[], + disabledRuntimeIds: readonly string[] = [], ) { - return getVisibleOnboardingRuntimes(runtimes).filter( + return getVisibleOnboardingRuntimes(runtimes, disabledRuntimeIds).filter( runtimeIsReadyForOnboarding, ); } diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 01718f5478..bf0a3bc9fb 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -148,6 +148,34 @@ test("ready state is detected and enables Next without persisting a default", as expect(await readSavedRuntime(page)).toBeNull(); }); +test("setup cannot advance to defaults when every ready harness is device-disabled", async ({ + page, +}) => { + await page.addInitScript((storageKey) => { + window.localStorage.setItem( + storageKey, + JSON.stringify(["claude", "codex"]), + ); + }, ACP_RUNTIME_VISIBILITY_STORAGE_KEY); + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_in" }), + ], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + + await expect(page.getByTestId("onboarding-setup-next")).toBeDisabled(); + await page.getByTestId("onboarding-setup-skip").click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + await expect(page.getByTestId("onboarding-page-config")).toHaveCount(0); +}); + test("setup shows runtime discovery loading before rendering harnesses", async ({ page, }) => { From 33a0a60848a1e5ade56e29289fb03f3ce6a68d8e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 20:18:28 -0700 Subject: [PATCH 22/22] Align hidden harness fallback readiness --- .../src/managed_agents/global_config/mod.rs | 12 ++++--- .../src/managed_agents/global_config/tests.rs | 19 +++++++++++ desktop/src/features/agents/AGENTS.md | 3 ++ .../lib/runtimeVisibilityPreference.test.mjs | 16 +++++++++ .../agents/lib/runtimeVisibilityPreference.ts | 7 ++-- .../onboarding/welcomeKickoff.test.mjs | 34 +++++++++++++++++++ .../src/features/onboarding/welcomeKickoff.ts | 19 +++++++++-- 7 files changed, 100 insertions(+), 10 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 7ff2773474..6419b253dc 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -211,6 +211,8 @@ pub fn save_global_agent_config(app: &AppHandle, config: &GlobalAgentConfig) -> /// command is pinned on the record, and provider/model defaults belonging to a /// different preferred runtime must not cross the harness boundary. Explicitly /// configured definitions and standalone agents keep normal global inheritance. +/// Configs written before `preferred_runtime` existed implicitly belong to +/// Buzz Agent. pub(crate) fn global_model_provider_for_record<'a>( record: &ManagedAgentRecord, personas: &[AgentDefinition], @@ -232,17 +234,17 @@ pub(crate) fn global_model_provider_for_record<'a>( return global_values; } - let Some(preferred_runtime) = global + let preferred_runtime = global .preferred_runtime .as_deref() .and_then(crate::managed_agents::known_acp_runtime) - else { - return global_values; - }; + .or_else(|| crate::managed_agents::known_acp_runtime("buzz-agent")); let selected_command = crate::managed_agents::record_agent_command(record, personas); let selected_runtime = crate::managed_agents::known_acp_runtime(&selected_command); - if selected_runtime.is_some_and(|selected| preferred_runtime.id == selected.id) { + if selected_runtime.is_some_and(|selected| { + preferred_runtime.is_some_and(|preferred| preferred.id == selected.id) + }) { global_values } else { (None, None) diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index a79870566e..20bd2ee4fa 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -488,6 +488,25 @@ fn runtime_fallback_does_not_inherit_defaults_from_a_different_preferred_runtime ); } +#[test] +fn legacy_runtime_fallback_does_not_inherit_implicit_buzz_agent_defaults() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + record.agent_command_override = Some("goose".to_string()); + let personas = vec![persona("p1", None, None)]; + let global = GlobalAgentConfig { + model: Some("auto".to_string()), + provider: Some("relay-mesh".to_string()), + preferred_runtime: None, + ..Default::default() + }; + + assert_eq!( + resolve_effective_model_provider(&record, &personas, &global), + (None, None) + ); +} + #[test] fn default_command_fallback_does_not_inherit_defaults_from_another_preferred_runtime() { let mut record = bare_record(); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 435793007d..a3bbfb28cc 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -92,6 +92,9 @@ with a TypeScript lookup table or an id comparison in a component. editor persists its visible fallback on the next save. Its dependent provider/model defaults are also ignored for new implicit fallback agents, without changing the persisted configuration used by existing agents. + Legacy global defaults with no saved preferred runtime are treated as + Buzz Agent-owned for this masking boundary, so hiding Buzz Agent cannot + leak its provider/model into another implicit fallback. Create-mode dialogs use the implicit masked config; existing definition and instance edit dialogs use the raw persisted config. `resolvePersonaRuntime` is the shared visibility boundary for every diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs index 9bf8014932..16ec7ff4ce 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs @@ -118,3 +118,19 @@ test("a disabled saved runtime and its dependent defaults are masked", () => { }); assert.equal(maskDisabledAcpRuntimePreference(config, ["claude"]), config); }); + +test("legacy defaults without a saved runtime are owned by buzz-agent", () => { + const config = { + env_vars: {}, + provider: "relay-mesh", + model: "auto", + preferred_runtime: null, + }; + + assert.deepEqual(maskDisabledAcpRuntimePreference(config, ["buzz-agent"]), { + ...config, + provider: null, + model: null, + }); + assert.equal(maskDisabledAcpRuntimePreference(config, ["goose"]), config); +}); diff --git a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts index 040831c289..45e53e614d 100644 --- a/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -166,7 +166,8 @@ export function visibleAcpRuntimeSeedForCreate( * The persisted config is left untouched until the user next saves defaults; * existing agents keep their configuration while new consumers immediately * fall back through the normal runtime selection path without carrying a - * provider or model selected for the hidden harness. + * provider or model selected for the hidden harness. Configs written before + * preferred_runtime existed implicitly belong to Buzz Agent. */ export function maskDisabledAcpRuntimePreference< T extends { @@ -176,11 +177,11 @@ export function maskDisabledAcpRuntimePreference< }, >(config: T, disabledRuntimeIds: readonly string[]): T { const preferredRuntime = config.preferred_runtime; + const implicitLegacyRuntime = preferredRuntime?.trim() || "buzz-agent"; if ( - !preferredRuntime || !disabledRuntimeIds .map(normalizeRuntimeId) - .includes(normalizeRuntimeId(preferredRuntime)) + .includes(normalizeRuntimeId(implicitLegacyRuntime)) ) { return config; } diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs index be09d82ad1..c21641b12c 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.test.mjs +++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs @@ -57,6 +57,40 @@ test("welcome readiness ignores a hidden logged-in runtime", () => { ); }); +test("welcome readiness evaluates the runtime selected by the visible fallback", () => { + const runtimes = [ + { + id: "buzz-agent", + label: "Buzz Agent", + availability: "available", + authStatus: { status: "not_applicable" }, + }, + { + id: "goose", + label: "Goose", + availability: "available", + authStatus: { status: "not_applicable" }, + }, + { + id: "claude", + label: "Claude", + availability: "available", + authStatus: { status: "logged_in" }, + }, + ]; + const globalConfig = { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: "buzz-agent", + }; + + assert.deepEqual( + resolveWelcomeAgentReadiness(runtimes, globalConfig, ["buzz-agent"]), + { ready: false }, + ); +}); + test("welcome provisioning requires an enabled available runtime", () => { const runtimes = [ { diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index 6323c9f3cc..b9a22e7963 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -9,6 +9,7 @@ import { filterEnabledAcpRuntimes, useDisabledAcpRuntimeIds, } from "@/features/agents/lib/runtimeVisibilityPreference"; +import { getDefaultPersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useImplicitGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { useCommunities } from "@/features/communities/useCommunities"; import { welcomeKickoffMarker } from "@/features/onboarding/devFreshOnboarding"; @@ -60,9 +61,23 @@ export function resolveWelcomeAgentReadiness( globalConfig: GlobalAgentConfig, disabledRuntimeIds: readonly string[], ) { + const visibleRuntimes = filterEnabledAcpRuntimes( + runtimes, + disabledRuntimeIds, + ); + const selectedRuntime = getDefaultPersonaRuntime( + visibleRuntimes, + globalConfig.preferred_runtime, + ); + if (!selectedRuntime) return { ready: false } as const; + return resolveAgentReadiness( - filterEnabledAcpRuntimes(runtimes, disabledRuntimeIds), - globalConfig, + visibleRuntimes, + { + ...globalConfig, + preferred_runtime: selectedRuntime.id, + }, + "preferred", ); }