diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 98c07a42a..2c41a4787 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -108,8 +108,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 5afe78263..834c034f3 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 bb2eeb510..ee6177ce3 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -42,6 +42,7 @@ import { getPersonaHiddenProviderIds, getPersonaModelOptions, getPersonaProviderOptions, + getRelayMeshRuntime, getRuntimePersonaModelOptions, NO_RUNTIME_DROPDOWN_VALUE, runtimeSupportsLlmProviderSelection, @@ -549,7 +550,7 @@ export function AgentDefinitionDialog({ const hideProviderIds = getPersonaHiddenProviderIds({ bakedEnvKeys: bakedEnvKeys ?? [], selectableRuntimes, - currentRuntimeId: runtime, + currentRuntime: selectedRuntime, preserveCurrentRuntime: !isCreateMode, }); const providerOptions = getPersonaProviderOptions( @@ -709,11 +710,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 21116d05a..1d87bf058 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -33,6 +33,7 @@ import { getDefaultPersonaRuntime, getPersonaHiddenProviderIds, getPersonaProviderOptions, + getRelayMeshRuntime, getProviderApiKeyEnvVar, isMissingRequiredDropdownField, NO_RUNTIME_DROPDOWN_VALUE, @@ -541,14 +542,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, }); @@ -793,7 +797,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 ae078f2fc..d2dc068f9 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 fda3825e1..d8ceb0144 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 b3def930f..2fe0bc1b3 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 aa8deb7c1..f1f9f274d 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