diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 5140bb2cd..7f9ded5a1 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 48e8d5479..a222e0108 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -561,21 +561,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` @@ -752,6 +737,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 000000000..972d05fdf --- /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 00ee5950b..445e89779 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -245,7 +245,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 58d60218a..6d882de26 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 f7cc56a69..98c07a42a 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -102,10 +102,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. 10. **The defaults modal is progressively disclosed.** An unset global config starts on the Buzz Agent-first deployment fallback and carries that visible harness into the next saved edit. The `progressive-defaults` disclosure diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index a4588420e..8f2238c45 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 73b24d165..21812c882 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 1bd8af897..5afe78263 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -31,8 +31,8 @@ import { } from "@/features/agents/ui/bakedEnvHelpers"; import { AUTO_PROVIDER_DROPDOWN_VALUE, - BLOCK_BUILD_HIDDEN_PROVIDER_IDS, CUSTOM_PROVIDER_DROPDOWN_VALUE, + getPersonaHiddenProviderIds, getPersonaProviderOptions, getProviderApiKeyEnvVar, runtimeSupportsLlmProviderSelection, @@ -585,21 +585,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 dbb91ce67..537ca66f7 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 63bf6f438..cd8e8beb8 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 f141ca438..5283a402f 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 6d4e007cd..1a95ae875 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,