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);