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..fcc99c1038 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -191,6 +191,43 @@ 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 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/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 57993fc040..7ff2773474 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,58 @@ 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(preferred_runtime) = global + .preferred_runtime + .as_deref() + .and_then(crate::managed_agents::known_acp_runtime) + else { + return global_values; + }; + 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) { + 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 +267,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..a79870566e 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,83 @@ 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 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(); + 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-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 dc61984487..867e5d8eb2 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -79,7 +79,34 @@ 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. **The defaults modal is progressively disclosed.** An unset global config +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. 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. 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 + these shared resolvers instead of duplicating default selection in a start + 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. + 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 preset therefore begins at Provider for Buzz Agent, then reveals Model, 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/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index d4ba32e603..58c9d9a312 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -4,7 +4,9 @@ import test from "node:test"; import { availableRuntimesForStart, buildInstanceInputForDefinition, + resolveProvisioningRuntimeForDefinition, resolveStartRuntimeForDefinition, + shouldPinSelectedRuntimeForDefinition, } from "./instanceInputForDefinition.ts"; // ── Phase 1B.3.5: the single definition→instance mapping ──────────────────── @@ -74,6 +76,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, @@ -97,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"); @@ -321,6 +353,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 7db36b7241..0da53bf2ef 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."); @@ -88,6 +101,45 @@ 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 + ); +} + +/** + * 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 @@ -145,7 +197,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/resolvePersonaRuntime.test.mjs b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs index 37796b8bba..ffe75d69b3 100644 --- a/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs +++ b/desktop/src/features/agents/lib/resolvePersonaRuntime.test.mjs @@ -2,14 +2,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + canResolveAllPersonaRuntimes, collectRuntimeWarnings, resolvePersonaRuntime, } 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]; @@ -32,6 +34,57 @@ 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 — 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", + ]); + 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); @@ -177,3 +230,42 @@ 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, + ); +}); + +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 f833d7113d..6e1b9977aa 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,20 @@ 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 getDefaultPersonaRuntime(visibleRuntimes, defaultRuntime.id); +} + /** * Collect runtime-resolution warnings for a list of personas. * @@ -142,3 +180,22 @@ 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, + disabledRuntimeIds: readonly string[] = getDisabledAcpRuntimeIdsSnapshot(), +): boolean { + return personas.every( + (persona) => + 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 new file mode 100644 index 0000000000..7230d49424 --- /dev/null +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ACP_RUNTIME_VISIBILITY_STORAGE_KEY, + filterEnabledAcpRuntimes, + maskDisabledAcpRuntimePreference, + nextDisabledAcpRuntimeIds, + parseDisabledAcpRuntimeIds, + readDisabledAcpRuntimeIds, + runtimesForImplicitAcpSelection, + visibleAcpRuntimeSeedForCreate, +} 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], + ]); + 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("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) { + assert.equal(key, ACP_RUNTIME_VISIBILITY_STORAGE_KEY); + return '["claude"]'; + }, + }; + + assert.deepEqual(readDisabledAcpRuntimeIds(storage), ["claude"]); +}); + +test("a disabled saved runtime and its dependent defaults are masked", () => { + const config = { + env_vars: {}, + 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 new file mode 100644 index 0000000000..89932e6030 --- /dev/null +++ b/desktop/src/features/agents/lib/runtimeVisibilityPreference.ts @@ -0,0 +1,251 @@ +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)), + ); +} + +/** + * 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); +} + +/** 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. + * + * 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. + */ +export function maskDisabledAcpRuntimePreference< + T extends { + model: string | null; + preferred_runtime: string | null; + provider: 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, + model: null, + preferred_runtime: null, + provider: null, + }; +} + +export function getDisabledAcpRuntimeIdsSnapshot(): 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 = getDisabledAcpRuntimeIdsSnapshot(); + 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, + getDisabledAcpRuntimeIdsSnapshot, + () => 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/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 99d3167d00..552373743f 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -5,13 +5,16 @@ 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, resolveTeamPersonas, } from "@/features/agents/lib/teamPersonas"; +import { shouldPinSelectedRuntimeForDefinition } from "@/features/agents/lib/instanceInputForDefinition"; +import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference"; import { + canResolveAllPersonaRuntimes, collectRuntimeWarnings, getDefaultPersonaRuntime, resolvePersonaRuntime, @@ -51,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(""); @@ -68,11 +71,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, ); @@ -83,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 @@ -118,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; } @@ -133,18 +142,24 @@ 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, + personaRuntime.id, + ), model: persona.model ?? undefined, personaId: persona.id, teamId: team.id, @@ -255,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} @@ -300,7 +316,7 @@ export function AddTeamToChannelDialog({ disabled={ !team || !selectedChannel || - !defaultProvider || + !canResolveTeamRuntimes || resolved.length === 0 || missingPersonaCount > 0 || channelsQuery.isLoading || diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af8976..834c034f31 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] : [], + currentRuntime: selectedRuntime, + preserveCurrentRuntime: false, + }), + [bakedEnvKeys, selectedRuntime], + ); const providerOptions = getPersonaProviderOptions( providerValue, credentialRuntimeId, diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 69e8b3a5b4..e5325d6faf 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -27,6 +27,7 @@ import { import { formatRuntimeOptionLabel, getDefaultPersonaRuntime, + reconcilePreferredRuntimeFallback, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, resetConfigForHarnessChange, @@ -39,6 +40,7 @@ import { } from "@/features/agents/ui/AgentConfigFields"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { useSelectableAcpRuntimes } from "@/features/agents/lib/runtimeVisibilityPreference"; type SaveState = "idle" | "saving" | "saved" | "error"; @@ -135,9 +137,10 @@ export function AgentDefaultsEditor({ }, []); const runtimesQuery = useAcpRuntimesQuery(); + const selectableRuntimes = useSelectableAcpRuntimes(runtimesQuery.data ?? []); const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimesQuery.data ?? []), - [runtimesQuery.data], + () => sortPersonaRuntimes(selectableRuntimes), + [selectableRuntimes], ); // An unset preferred runtime uses the same Buzz Agent-first fallback as // deployment. The rendered draft below carries that fallback forward so the @@ -152,16 +155,14 @@ export function AgentDefaultsEditor({ sortedRuntimes[0] ); }, [config.preferred_runtime, sortedRuntimes]); - const renderedConfig = React.useMemo( - () => - config.preferred_runtime || !selectedRuntime - ? config - : { ...config, preferred_runtime: selectedRuntime.id }, - [config, selectedRuntime], - ); const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( selectedRuntime?.id ?? "", ); + const effectiveConfig = React.useMemo( + () => + reconcilePreferredRuntimeFallback(config, selectedRuntime?.id ?? null), + [config, selectedRuntime], + ); const harnessOptions = React.useMemo( () => sortedRuntimes.map((runtime) => ({ @@ -185,7 +186,7 @@ export function AgentDefaultsEditor({ } function handleHarnessChange(runtimeId: string) { - handleConfigChange(resetConfigForHarnessChange(config, runtimeId)); + handleConfigChange(resetConfigForHarnessChange(effectiveConfig, runtimeId)); setConfigIsValid(false); setIsCustomModelEditing(false); setIsCustomProvider(false); @@ -194,7 +195,11 @@ export function AgentDefaultsEditor({ async function handleSave() { // Snapshot the config being submitted so we can detect edits that arrive // during the IPC round-trip and avoid clobbering the user's newer input. - const submittedConfig = config; + const draftAtSubmit = configRef.current; + const submittedConfig = reconcilePreferredRuntimeFallback( + draftAtSubmit, + selectedRuntime?.id ?? null, + ); onSavingChange?.(true); setSaveState("saving"); setSaveError(null); @@ -204,7 +209,7 @@ export function AgentDefaultsEditor({ // IPC window. If the user edited, keep their newer value and leave dirty=true // so they can save again. setDirty(false) runs inside the updater so both // state updates batch into the same render (React 18 automatic batching). - const savedCurrentDraft = configRef.current === submittedConfig; + const savedCurrentDraft = configRef.current === draftAtSubmit; setConfig((current) => { if (!savedCurrentDraft) { // Mid-flight edit detected — do not overwrite newer user input. @@ -240,7 +245,7 @@ export function AgentDefaultsEditor({ void; - onSubmit: ( - input: CreatePersonaInput | UpdatePersonaInput, - ) => Promise; - /** Rendered below the form fields in create mode only ("Where to run"). */ - createRunSection?: React.ReactNode; - /** Extra create-mode submit gate (e.g. incomplete provider config). */ - createSubmitBlocked?: boolean; -}; - -const ADVANCED_FIELDS_MOTION_TRANSITION = { - duration: 0.18, - ease: [0.23, 1, 0.32, 1], -} as const; +import { + useSelectableAcpRuntimes, + visibleAcpRuntimeSeedForCreate, +} from "../lib/runtimeVisibilityPreference"; +import type { AgentDefinitionDialogProps } from "./AgentDefinitionDialog.types"; +import { ADVANCED_FIELDS_MOTION_TRANSITION } from "./agentAdvancedFieldsMotion"; export function AgentDefinitionDialog({ open, @@ -141,18 +118,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); - // 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. + // Prevent "No preference" from snapping back to the default. const hasSeededForOpenRef = React.useRef(false); const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [isAvatarUploadPending, setIsAvatarUploadPending] = @@ -164,10 +134,11 @@ export function AgentDefinitionDialog({ model: inheritedModelDefault, }, inheritedEnvVars: inheritedEnvVarsForAdvanced, - } = useAgentDialogDefaults({ open }); - const defaultRuntime = React.useMemo( - () => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime), - [globalConfig.preferred_runtime, runtimes], + } = useDefinitionAgentDialogDefaults(initialValues, open); + const selectableRuntimes = useSelectableAcpRuntimes(runtimes); + const defaultRuntime = getDefaultPersonaRuntime( + selectableRuntimes, + globalConfig.preferred_runtime, ); const isCreateMode = Boolean(initialValues && !("id" in initialValues)); const shouldReduceMotion = useReducedMotion(); @@ -215,6 +186,38 @@ export function AgentDefinitionDialog({ 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 || @@ -231,10 +234,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]); @@ -296,16 +296,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 { @@ -372,11 +370,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 } = useRuntimeFileConfigQuery(runtime, { enabled: open, }); @@ -457,12 +451,6 @@ export function AgentDefinitionDialog({ 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 }, @@ -471,8 +459,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) && @@ -527,17 +514,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, + currentRuntime: selectedRuntime, + preserveCurrentRuntime: !isCreateMode, + }); const providerOptions = getPersonaProviderOptions( trimmedProvider, runtime, @@ -553,8 +535,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..." @@ -582,6 +564,7 @@ export function AgentDefinitionDialog({ })), ]; if ( + !isCreateMode && runtime.trim().length > 0 && !runtimeDropdownOptions.some((option) => option.value === runtime) ) { @@ -699,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/AgentDefinitionDialog.types.ts b/desktop/src/features/agents/ui/AgentDefinitionDialog.types.ts new file mode 100644 index 0000000000..99a211d315 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.types.ts @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +import type { + AcpRuntimeCatalogEntry, + CreatePersonaInput, + UpdatePersonaInput, +} from "@/shared/api/types"; + +export type AgentDefinitionDialogProps = { + open: boolean; + title: string; + description: string; + submitLabel: string; + initialValues: CreatePersonaInput | UpdatePersonaInput | null; + error: Error | null; + isPending: boolean; + runtimes: AcpRuntimeCatalogEntry[]; + runtimesLoading?: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: ( + input: CreatePersonaInput | UpdatePersonaInput, + ) => Promise; + /** Rendered below the form fields in create mode only ("Where to run"). */ + createRunSection?: ReactNode; + /** Extra create-mode submit gate (e.g. incomplete provider config). */ + createSubmitBlocked?: boolean; +}; diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 5cd5c7a016..be13b557ef 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -14,11 +14,9 @@ import { } from "@/features/agents/hooks"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import type { - ManagedAgent, RespondToMode, UpdateManagedAgentInput, } from "@/shared/api/types"; -import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; @@ -28,12 +26,14 @@ 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, + getRelayMeshRuntime, + getProviderApiKeyEnvVar, isMissingRequiredDropdownField, NO_RUNTIME_DROPDOWN_VALUE, PERSONA_FIELD_CONTROL_CLASS, @@ -76,18 +76,15 @@ import { getBakedModelInheritLabel, getBakedProviderInheritLabel, } from "./bakedEnvHelpers"; -import { getProviderApiKeyEnvVar } from "./agentConfigOptions"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; +import { useSelectableAcpRuntimes } from "../lib/runtimeVisibilityPreference"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; - -const ADVANCED_FIELDS_MOTION_TRANSITION = { - duration: 0.18, - ease: [0.23, 1, 0.32, 1], -} as const; +import { ADVANCED_FIELDS_MOTION_TRANSITION } from "./agentAdvancedFieldsMotion"; +import type { AgentInstanceEditDialogProps } from "./AgentInstanceEditDialog.types"; export function AgentInstanceEditDialog({ agent, @@ -96,22 +93,13 @@ export function AgentInstanceEditDialog({ onEditLinkedPersona, onOpenChange, onUpdated, -}: { - agent: ManagedAgent; - /** Optional field to scroll/focus when the dialog opens from a card deep-link. */ - initialFocus?: EditAgentFocusTarget; - open: boolean; - /** Present only when the linked definition is editable (non-built-in, - * resolved). Caller closes this dialog and enters definition-edit. */ - onEditLinkedPersona?: () => void; - onOpenChange: (open: boolean) => void; - onUpdated?: (agent: ManagedAgent) => void; -}) { +}: AgentInstanceEditDialogProps) { const updateMutation = useUpdateManagedAgentMutation(); const startMutation = useStartManagedAgentMutation(); 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); @@ -216,8 +204,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( @@ -366,7 +354,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 rationale. @@ -536,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, }); @@ -778,13 +773,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, + currentRuntime: selectedRuntime, + preserveCurrentRuntime: true, + }); const providerOptions = getPersonaProviderOptions( trimmedProvider, selectedRuntime?.id ?? "", diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.types.ts b/desktop/src/features/agents/ui/AgentInstanceEditDialog.types.ts new file mode 100644 index 0000000000..c7c16331cb --- /dev/null +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.types.ts @@ -0,0 +1,14 @@ +import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; +import type { ManagedAgent } from "@/shared/api/types"; + +export type AgentInstanceEditDialogProps = { + agent: ManagedAgent; + /** Optional field to scroll/focus when the dialog opens from a card deep-link. */ + initialFocus?: EditAgentFocusTarget; + open: boolean; + /** Present only when the linked definition is editable (non-built-in, + * resolved). Caller closes this dialog and enters definition-edit. */ + onEditLinkedPersona?: () => void; + onOpenChange: (open: boolean) => void; + onUpdated?: (agent: ManagedAgent) => void; +}; diff --git a/desktop/src/features/agents/ui/agentAdvancedFieldsMotion.ts b/desktop/src/features/agents/ui/agentAdvancedFieldsMotion.ts new file mode 100644 index 0000000000..0a39b11fdd --- /dev/null +++ b/desktop/src/features/agents/ui/agentAdvancedFieldsMotion.ts @@ -0,0 +1,4 @@ +export const ADVANCED_FIELDS_MOTION_TRANSITION = { + duration: 0.18, + ease: [0.23, 1, 0.32, 1], +} as const; diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index d0690cefc3..d2dc068f90 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -3,8 +3,10 @@ import test from "node:test"; import { getDefaultPersonaRuntime, + getPersonaHiddenProviderIds, getPersonaModelOptions, getPersonaProviderOptions, + reconcilePreferredRuntimeFallback, resetConfigForHarnessChange, runtimeSupportsLlmProviderSelection, } from "./agentConfigOptions.tsx"; @@ -12,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, }; } @@ -74,6 +85,58 @@ 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")], + currentRuntime: makeRuntime("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")], + currentRuntime: makeRuntime("buzz-agent"), + preserveCurrentRuntime: true, + }); + const ids = getPersonaProviderOptions("", "buzz-agent", "", hidden).map( + (option) => option.id, + ); + 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", () => { @@ -189,6 +252,37 @@ test("resetConfigForHarnessChange does not carry relay mesh to Goose", () => { 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); +}); + +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 6ae81ff6cb..d8ceb01449 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -14,15 +14,55 @@ 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", ]); +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, + currentRuntime, + preserveCurrentRuntime, +}: { + bakedEnvKeys: readonly 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 relayMeshSelectable = + selectableRuntimes.some(runtimeSupportsRelayMesh) || + (preserveCurrentRuntime && runtimeSupportsRelayMesh(currentRuntime)); + if (!relayMeshSelectable) 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 = @@ -198,6 +238,29 @@ export function resetConfigForHarnessChange( }; } +/** + * 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 (!fallbackRuntimeId || config.preferred_runtime === fallbackRuntimeId) { + return config; + } + + if (!config.preferred_runtime) { + return { + ...config, + preferred_runtime: fallbackRuntimeId, + }; + } + + return resetConfigForHarnessChange(config, fallbackRuntimeId); +} + function effectiveModelProviderForOptions( runtimeId: string, providerId: string | null | undefined, 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, + }); +} 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 4b90beb43d..7b5e09191c 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"; @@ -42,3 +48,27 @@ export function useGlobalAgentConfig(): { 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/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts index 1f7d066fad..537ca66f7b 100644 --- a/desktop/src/features/channel-templates/useApplyTemplate.ts +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -9,7 +9,7 @@ import { usePersonasQuery, useTeamsQuery, } from "@/features/agents/hooks"; -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"; @@ -72,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[] = []; @@ -86,15 +81,18 @@ export function useApplyTemplate() { if (!persona) continue; if (seenPersonaIds.has(persona.id)) continue; seenPersonaIds.add(persona.id); - const resolved = resolvePersonaRuntime( - entry.runtime ?? persona.runtime, + const requestedRuntimeId = entry.runtime ?? persona.runtime; + const resolved = resolveProvisioningRuntimeForDefinition( + requestedRuntimeId, runtimes, - defaultProvider, + lastRuntimeId, ); + if (!resolved.runtime) continue; inputs.push({ - runtime: resolved.runtime ?? defaultProvider, + runtime: resolved.runtime, name: persona.displayName, personaId: persona.id, + harnessOverride: resolved.harnessOverride, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: entry.model ?? persona.model ?? undefined, @@ -111,15 +109,18 @@ export function useApplyTemplate() { for (const persona of resolvedPersonas) { if (seenPersonaIds.has(persona.id)) continue; seenPersonaIds.add(persona.id); - const resolved = resolvePersonaRuntime( - teamEntry.runtime ?? persona.runtime, + const requestedRuntimeId = teamEntry.runtime ?? persona.runtime; + const resolved = resolveProvisioningRuntimeForDefinition( + requestedRuntimeId, runtimes, - defaultProvider, + lastRuntimeId, ); + if (!resolved.runtime) continue; inputs.push({ - runtime: resolved.runtime ?? defaultProvider, + runtime: resolved.runtime, name: persona.displayName, personaId: persona.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 fb27c659d5..cd8e8beb80 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -8,7 +8,8 @@ import { type CreateChannelManagedAgentResult, } from "@/features/agents/hooks"; import { getActivePersonas } from "@/features/agents/lib/catalog"; -import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +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"; @@ -135,26 +136,33 @@ 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 resolved = resolvePersonaRuntime( + const inputs = selectedPersonas.flatMap((persona) => { + const resolved = resolveProvisioningRuntimeForDefinition( 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: resolved.harnessOverride, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + role: "bot" as const, + backend: { type: "local" as const }, + }, + ]; }); setSubmissionNotice(null); @@ -189,9 +197,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 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, 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 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); 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,75 @@ 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, + ], + globalAgentConfig: { + env_vars: {}, + provider: "anthropic", + model: "claude-opus", + preferred_runtime: "goose", + }, + }); + + 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(); + + 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(); + 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 +551,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);