diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 461fed5db..3de82fdb7 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -692,6 +692,7 @@ mod tests { definition_parallelism: None, relay_mesh: None, agent_command_override: None, + agent_command_override_is_implicit: false, persona_source_version: None, provider: None, } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b5ebeca4..826d86882 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -759,20 +759,21 @@ pub async fn create_managed_agent( // Load personas once for harness/pack/avatar resolution below. let personas = load_personas(&app).unwrap_or_default(); - // Harness resolution: the persona's runtime is authoritative. A - // persona-backed create stores an `agent_command_override` ONLY when the - // user deliberately picked a divergent runtime (`harness_override`) — - // e.g. AddChannelBotDialog's runtime selector. A divergence WITHOUT that - // flag is a missing-runtime fallback from `resolvePersonaRuntime`, not a - // pin, and must inherit so it doesn't freeze on the fallback harness once - // the persona's runtime is installed. A persona-less create always - // preserves the picked command as a real pin. - let agent_command_override = crate::managed_agents::create_time_agent_command_override( - requested_persona_id.as_deref(), - &personas, - input.agent_command.as_deref(), - input.harness_override, - ); + // The persona runtime is authoritative. Persist an override only when + // the selected runtime must remain pinned (`harness_override`). This + // includes explicit picks and the visible fallback for a runtime-less + // persona. `implicit_harness_fallback` distinguishes those intents so + // the pin cannot adopt defaults owned by another harness. A divergence + // without `harness_override` remains inherited so it can recover when + // its configured runtime is installed. Persona-less creates always pin. + let (agent_command_override, agent_command_override_is_implicit) = + crate::managed_agents::create_time_agent_command_override_state( + requested_persona_id.as_deref(), + &personas, + input.agent_command.as_deref(), + input.harness_override, + input.implicit_harness_fallback, + ); // The create-time snapshot used for arg/mcp/avatar derivations and // legacy reconcile. Authoritative spawn resolution re-derives this via // `effective_agent_command` at use-time. @@ -891,6 +892,7 @@ pub async fn create_managed_agent( .to_string(), agent_command, agent_command_override, + agent_command_override_is_implicit, agent_args, mcp_command, // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness; diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index b1951f195..63a5ab3eb 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -19,6 +19,7 @@ fn bare_agent_record( acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: "".to_string(), turn_timeout_seconds: 300, @@ -235,6 +236,26 @@ fn deploy_resolver_ignores_defaults_from_a_different_implicit_runtime() { ); } +#[test] +fn deploy_resolver_masks_mismatched_defaults_for_an_implicit_fallback_override() { + let mut record = bare_agent_record(Some("p1"), None, None); + record.agent_command = "goose".to_string(); + record.agent_command_override = Some("goose".to_string()); + record.agent_command_override_is_implicit = true; + 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); diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 316af5f72..1a3dc38cc 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -27,6 +27,7 @@ fn make_agent( acp_command: "buzz-acp".to_string(), agent_command: "buzz-agent".to_string(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: "".to_string(), turn_timeout_seconds: 300, diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index 1000e48b7..a731e9baa 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -165,6 +165,7 @@ fn local_agent() -> ManagedAgentRecord { acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_command_override: Some("claude".to_string()), + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: "buzz-dev-mcp".to_string(), turn_timeout_seconds: 320, diff --git a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs index ba855ccbd..1b837dcd5 100644 --- a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs @@ -15,6 +15,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge acp_command: String::new(), agent_command: String::new(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 0, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index ac5c0eace..6cd5311fd 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -441,6 +441,7 @@ pub async fn confirm_agent_snapshot_import( acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), agent_command: String::new(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 0, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b..e4d134c02 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -31,6 +31,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { acp_command: String::new(), agent_command: String::new(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 0, diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 0476be79a..b7b2ada92 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -560,6 +560,7 @@ pub async fn confirm_team_snapshot_import( acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), agent_command: String::new(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 0, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc6183..009da1209 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -183,6 +183,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), agent_command: String::new(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 0, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index ba4407d16..d2353213a 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -168,6 +168,7 @@ mod tests { acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec!["--flag".to_string()], mcp_command: "buzz-dev-mcp".to_string(), turn_timeout_seconds: 320, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index b0bf8f599..cfd14d603 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -485,9 +485,10 @@ mod tests { acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot agent_command: "goose".to_string(), // MUST NOT appear in snapshot agent_command_override: Some("goose-override".to_string()), // MUST NOT appear - agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot - mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot - turn_timeout_seconds: 120, // deprecated, MUST NOT appear + agent_command_override_is_implicit: false, + agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot + mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot + turn_timeout_seconds: 120, // deprecated, MUST NOT appear idle_timeout_seconds: Some(30), max_turn_duration_seconds: Some(600), parallelism: 2, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 4c11cd6c4..17b6dbffa 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -113,6 +113,7 @@ fn test_record() -> ManagedAgentRecord { definition_parallelism: None, relay_mesh: None, agent_command_override: None, + agent_command_override_is_implicit: false, persona_source_version: None, provider: None, } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2b7264b42..f2f945e12 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -362,7 +362,10 @@ pub fn effective_agent_command( } mod overrides; -pub use overrides::{apply_agent_command_update, create_time_agent_command_override}; +pub use overrides::{ + apply_agent_command_update, create_time_agent_command_override, + create_time_agent_command_override_state, +}; /// Prefix of the typed dangling-harness error produced by /// `try_record_agent_command` / `resolve_effective_harness_descriptor`. diff --git a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs index 7f9ded5a1..3b6c08490 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/overrides.rs @@ -102,6 +102,7 @@ pub fn apply_agent_command_update( Some(agent_command), harness_override, ); + record.agent_command_override_is_implicit = false; if agent_command.trim().is_empty() && record.persona_id.is_some() { record.runtime = None; } @@ -151,3 +152,26 @@ pub fn create_time_agent_command_override( divergent_agent_command_override(persona_id, personas, picked_command) } + +/// Resolve the create-time harness pin and whether it represents an automatic +/// runtime-less fallback rather than explicit selection intent. +pub fn create_time_agent_command_override_state( + persona_id: Option<&str>, + personas: &[crate::managed_agents::types::AgentDefinition], + picked_command: Option<&str>, + harness_override: bool, + implicit_harness_fallback: bool, +) -> (Option, bool) { + let command = + create_time_agent_command_override(persona_id, personas, picked_command, harness_override); + let is_runtime_less_persona = persona_id + .and_then(|id| personas.iter().find(|persona| persona.id == id)) + .is_some_and(|persona| { + persona + .runtime + .as_deref() + .is_none_or(|runtime| runtime.trim().is_empty()) + }); + let is_implicit = command.is_some() && implicit_harness_fallback && is_runtime_less_persona; + (command, is_implicit) +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index a222e0108..7f1806a5c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -324,6 +324,7 @@ fn record_with( acp_command: String::new(), agent_command: String::new(), agent_command_override: override_cmd.map(str::to_string), + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 0, @@ -698,10 +699,12 @@ fn apply_agent_command_update_inherit_sentinel_clears_pin_and_runtime() { // live definition immediately — not on the next spawn. let personas = vec![persona_with_runtime("p1", Some("goose"))]; let mut record = record_with(Some("claude"), Some("p1"), Some("codex-acp")); + record.agent_command_override_is_implicit = true; apply_agent_command_update(&mut record, &personas, "", false); assert_eq!(record.agent_command_override, None); + assert!(!record.agent_command_override_is_implicit); assert_eq!(record.runtime, None); assert_eq!(record_agent_command(&record, &personas), "goose"); } @@ -726,10 +729,12 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() { // the next snapshot apply. The pin shadows it in resolution either way. let personas = vec![persona_with_runtime("p1", Some("goose"))]; let mut record = record_with(Some("claude"), Some("p1"), None); + record.agent_command_override_is_implicit = true; apply_agent_command_update(&mut record, &personas, "codex-acp", true); assert_eq!(record.agent_command_override.as_deref(), Some("codex-acp")); + assert!(!record.agent_command_override_is_implicit); assert_eq!(record.runtime.as_deref(), Some("claude")); assert_eq!(record_agent_command(&record, &personas), "codex-acp"); } 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 index 972d05fdf..1a21dd1f4 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/provisioning_overrides.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/provisioning_overrides.rs @@ -1,3 +1,4 @@ +use super::super::create_time_agent_command_override_state; use super::{create_time_agent_command_override, persona_with_runtime}; #[test] @@ -17,3 +18,30 @@ fn pins_visible_fallback_for_runtime_less_persona() { Some("goose".to_string()) ); } + +#[test] +fn marks_only_runtime_less_automatic_fallback_pins_as_implicit() { + let runtime_less = vec![persona_with_runtime("p1", None)]; + assert_eq!( + create_time_agent_command_override_state( + Some("p1"), + &runtime_less, + Some("goose"), + true, + true, + ), + (Some("goose".to_string()), true) + ); + + let configured = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + create_time_agent_command_override_state( + Some("p1"), + &configured, + Some("codex-acp"), + true, + true, + ), + (Some("codex-acp".to_string()), false) + ); +} 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 be3ec97b6..334fc06ab 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -211,11 +211,15 @@ pub fn save_global_agent_config(app: &AppHandle, config: &GlobalAgentConfig) -> /// /// 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. -/// Configs written before `preferred_runtime` existed implicitly belong to -/// Buzz Agent. +/// command is stored as the record snapshot, and provider/model defaults +/// belonging to a different preferred runtime must not cross the harness +/// boundary. A non-empty `agent_command_override` keeps normal global +/// inheritance only when it represents an explicit harness selection. An +/// automatic runtime-less fallback carries a separate durable marker so its +/// pin survives provisioning without adopting defaults owned by another +/// harness. Explicitly configured definitions and standalone agents keep +/// normal global inheritance. Configs written before `preferred_runtime` +/// existed implicitly belong to Buzz Agent. pub(crate) fn global_model_provider_for_record<'a>( record: &ManagedAgentRecord, personas: &[AgentDefinition], @@ -237,6 +241,15 @@ pub(crate) fn global_model_provider_for_record<'a>( return global_values; } + if !record.agent_command_override_is_implicit + && record + .agent_command_override + .as_deref() + .is_some_and(|command| !command.trim().is_empty()) + { + return global_values; + } + let preferred_runtime = global .preferred_runtime .as_deref() 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 8dc2296f4..4a519db49 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -309,6 +309,7 @@ fn bare_record() -> ManagedAgentRecord { acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: "".to_string(), turn_timeout_seconds: 300, @@ -473,11 +474,36 @@ fn resolve_global_fallback_when_record_and_persona_have_none() { } #[test] -fn runtime_fallback_does_not_inherit_defaults_from_a_different_preferred_runtime() { +fn explicit_harness_override_keeps_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 { + env_vars: BTreeMap::from([("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string())]), + model: Some("auto".to_string()), + provider: Some("relay-mesh".to_string()), + preferred_runtime: Some("buzz-agent".to_string()), + }; + + assert_eq!( + resolve_effective_model_provider(&record, &personas, &global), + (Some("auto".to_string()), Some("relay-mesh".to_string())) + ); + assert_eq!( + global_env_vars_for_record(&record, &personas, &global), + global.env_vars + ); +} + +#[test] +fn implicit_fallback_override_masks_defaults_from_a_different_preferred_runtime() { + let mut record = bare_record(); + record.persona_id = Some("p1".to_string()); + record.agent_command = "goose".to_string(); + record.agent_command_override = Some("goose".to_string()); + record.agent_command_override_is_implicit = true; + let personas = vec![persona("p1", None, None)]; let global = GlobalAgentConfig { model: Some("auto".to_string()), provider: Some("relay-mesh".to_string()), @@ -492,7 +518,7 @@ fn runtime_fallback_does_not_inherit_defaults_from_a_different_preferred_runtime } #[test] -fn legacy_runtime_fallback_does_not_inherit_implicit_buzz_agent_defaults() { +fn explicit_harness_override_keeps_legacy_global_defaults() { let mut record = bare_record(); record.persona_id = Some("p1".to_string()); record.agent_command_override = Some("goose".to_string()); @@ -506,7 +532,7 @@ fn legacy_runtime_fallback_does_not_inherit_implicit_buzz_agent_defaults() { assert_eq!( resolve_effective_model_provider(&record, &personas, &global), - (None, None) + (Some("auto".to_string()), Some("relay-mesh".to_string())) ); } diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index a95938160..f0a258f4f 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -445,6 +445,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { acp_command: String::new(), agent_command: String::new(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 0, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 5b62615a8..0b0b80054 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -439,6 +439,7 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe { if !std::ptr::eq(pin_runtime, def_runtime) { record.agent_command_override = None; + record.agent_command_override_is_implicit = false; } } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c5480b247..a1b1b31a5 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1475,6 +1475,7 @@ mod tests { acp_command: "buzz-acp".to_string(), agent_command: "buzz-agent".to_string(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 320, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 8deb0c4da..3f9c3972c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -140,6 +140,7 @@ fn fixture( acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 320, diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 292cdbbb7..3cc2fb89d 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -14,6 +14,7 @@ fn record() -> ManagedAgentRecord { acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 320, diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index d88a36272..b6536f493 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -263,6 +263,7 @@ mod tests { acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear agent_command: "goose".to_string(), // MUST NOT appear agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 120, diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 140ac3cab..d69319383 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -174,6 +174,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { acp_command: "buzz-acp".to_string(), agent_command: "buzz-agent".to_string(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: vec![], mcp_command: String::new(), turn_timeout_seconds: 300, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index dcb8095a7..a2aa0b5a4 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -95,6 +95,7 @@ impl AgentDefinition { acp_command: DEFAULT_ACP_COMMAND.to_string(), agent_command: String::new(), agent_command_override: None, + agent_command_override_is_implicit: false, agent_args: Vec::new(), mcp_command: String::new(), turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, @@ -225,15 +226,21 @@ pub struct ManagedAgentRecord { pub avatar_url: Option, pub acp_command: String, pub agent_command: String, - /// Explicit per-instance harness pin. `None` (the default) means inherit - /// the harness from the linked persona's `runtime`, so persona harness - /// edits propagate on the next spawn — mirroring the opt-in `model` - /// override. `Some` is set only when the user deliberately picks a harness - /// that diverges from the persona. Resolved via `effective_agent_command`; - /// `agent_command` above is the create-time snapshot kept for avatar/legacy - /// derivations and is not authoritative for spawn. + /// Per-instance harness pin. `None` (the default) means inherit the harness + /// from the linked persona's `runtime`, so persona harness edits propagate + /// on the next spawn — mirroring the opt-in `model` override. `Some` is + /// normally an explicit divergent selection, but may preserve the visible + /// automatic fallback chosen for a runtime-less persona; the accompanying + /// marker distinguishes those intents. Resolved via + /// `effective_agent_command`; `agent_command` above is the create-time + /// snapshot kept for avatar/legacy derivations and is not authoritative for + /// spawn. #[serde(default)] pub agent_command_override: Option, + /// Whether `agent_command_override` preserves an automatic runtime-less + /// fallback rather than an explicit per-instance harness selection. + #[serde(default)] + pub agent_command_override_is_implicit: bool, pub agent_args: Vec, /// Create-time snapshot of the catalog MCP command. Never read at spawn — /// the effective MCP command is always re-derived from the runtime catalog @@ -473,10 +480,12 @@ pub struct ManagedAgentSummary { pub relay_url: String, pub acp_command: String, pub agent_command: String, - /// Mirrors `ManagedAgentRecord.agent_command_override`: `Some` when the user - /// has explicitly pinned this instance's harness, `None` when it inherits - /// from the persona. Lets the Edit dialog seed "Inherit from persona" vs a - /// concrete pin (`agent_command` above is the resolved/effective command). + /// Mirrors `ManagedAgentRecord.agent_command_override`: `Some` when this + /// instance has a durable harness pin, `None` when it inherits from the + /// persona. A pin may be an explicit selection or an automatic fallback + /// for a runtime-less persona. Lets the Edit dialog seed "Inherit from + /// persona" vs a concrete pin (`agent_command` above is the + /// resolved/effective command). pub agent_command_override: Option, pub agent_args: Vec, /// Catalog-derived from the effective harness (not the record's stored diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 6d882de26..3c8130c48 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -136,10 +136,14 @@ pub struct CreateManagedAgentRequest { pub acp_command: Option, pub agent_command: Option, /// True when `agent_command` must survive linked-persona inheritance. - /// Includes explicit selections, installed aliases, and the visible - /// implicit fallback for a runtime-less persona. + /// Includes explicit selections, installed aliases, and automatic + /// fallbacks for runtime-less personas. #[serde(default)] pub harness_override: bool, + /// True when `harness_override` preserves an automatic runtime-less + /// fallback rather than an explicit harness selection. + #[serde(default)] + pub implicit_harness_fallback: bool, #[serde(default)] pub agent_args: Vec, /// Accepted for wire compatibility; not applied to the record. The diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 667a41a53..294e096d4 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -272,6 +272,22 @@ fn create_request_deserializes_camel_case_relay_mesh() { model_ref: "Qwen3".to_string() }) ); + assert!(!request.implicit_harness_fallback); +} + +#[test] +fn create_request_deserializes_implicit_harness_fallback_intent() { + let request: CreateManagedAgentRequest = serde_json::from_str( + r#"{ + "name": "fallback-agent", + "harnessOverride": true, + "implicitHarnessFallback": true + }"#, + ) + .expect("camelCase implicit fallback intent should deserialize"); + + assert!(request.harness_override); + assert!(request.implicit_harness_fallback); } /// Persisted records use snake_case; the camelCase alias must not break @@ -442,6 +458,7 @@ fn managed_agent_record_without_key_deserializes_empty() { .expect("keyring-backed record without inline key should deserialize"); assert_eq!(record.private_key_nsec, ""); + assert!(!record.agent_command_override_is_implicit); } fn sample_agent_record() -> ManagedAgentRecord { diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 33b23a530..e88f92042 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -98,7 +98,11 @@ with a TypeScript lookup table or an id comparison in a component. without changing the persisted configuration used by existing agents. Legacy global defaults with no saved preferred runtime are treated as Buzz Agent-owned for this masking boundary, so hiding Buzz Agent cannot - leak its provider/model into another implicit fallback. + leak its provider/model into another implicit fallback. A persisted + per-instance harness override carries a durable explicit-vs-implicit marker: + explicit selections continue to inherit global provider/model defaults even + when the selected harness differs from the global preference, while + automatic runtime-less fallback pins keep mismatched defaults masked. Create-mode dialogs use the implicit masked config; existing definition and instance edit dialogs use the raw persisted config. `resolvePersonaRuntime` is the shared visibility boundary for every diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index ebb2f7a7e..d0eb447f1 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -66,6 +66,8 @@ export type CreateChannelManagedAgentInput = { * unpinned so the persona remains authoritative. */ harnessOverride?: boolean; + /** The pin preserves an automatic runtime-less fallback, not an explicit pick. */ + implicitHarnessFallback?: boolean; /** Preferred model ID from the persona. Passed to createManagedAgent. */ model?: string; role?: Exclude; @@ -355,6 +357,7 @@ export async function provisionChannelManagedAgent( acpCommand: "buzz-acp", agentCommand: input.runtime.command, harnessOverride: input.harnessOverride ?? false, + implicitHarnessFallback: input.implicitHarnessFallback ?? false, // Do NOT seed agentArgs from runtime.defaultArgs (see instanceInputForDefinition.ts // for the rationale — empty args let spawn resolve definition args live). agentArgs: [], diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index 8f2238c45..48cbd8499 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -84,12 +84,14 @@ test("row 2: harnessOverride follows the backend-aligned formula", async () => { gooseRuntime, ); assert.equal(match.harnessOverride, true, "picked == configured → true"); + assert.equal(match.implicitHarnessFallback, false); const noPreference = await buildInstanceInputForDefinition( persona({ runtime: undefined }), gooseRuntime, ); assert.equal(noPreference.harnessOverride, true, "no preference → true"); + assert.equal(noPreference.implicitHarnessFallback, true); const differs = await buildInstanceInputForDefinition( persona({ runtime: "claude" }), @@ -100,6 +102,7 @@ test("row 2: harnessOverride follows the backend-aligned formula", async () => { false, "picked != configured → false (definition stays authoritative)", ); + assert.equal(differs.implicitHarnessFallback, false); }); test("provisioning pins a visible fallback for a runtime-less definition", () => { @@ -111,6 +114,7 @@ test("provisioning pins a visible fallback for a runtime-less definition", () => ); assert.equal(resolved.runtime, gooseRuntime); assert.equal(resolved.harnessOverride, true); + assert.equal(resolved.implicitHarnessFallback, true); }); test("provisioning uses product ordering and leaves unavailable configured fallbacks unpinned", () => { @@ -121,12 +125,14 @@ test("provisioning uses product ordering and leaves unavailable configured fallb ]); assert.equal(ordered.runtime, buzzAgentRuntime); assert.equal(ordered.harnessOverride, true); + assert.equal(ordered.implicitHarnessFallback, true); const unavailable = resolveProvisioningRuntimeForDefinition("missing", [ gooseRuntime, ]); assert.equal(unavailable.runtime, gooseRuntime); assert.equal(unavailable.harnessOverride, false); + assert.equal(unavailable.implicitHarnessFallback, false); }); test("row 3: plain avatar URLs pass through; base64 data URIs upload via the injectable", async () => { @@ -205,6 +211,7 @@ test("no backend intent is byte-identical to the pre-intent mapping", async () = agentArgs: [], mcpCommand: "goose-mcp", harnessOverride: true, + implicitHarnessFallback: false, model: undefined, provider: undefined, spawnAfterCreate: true, diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 21812c882..32e83f8cb 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -120,7 +120,10 @@ export function resolveProvisioningRuntimeForDefinition( runtimes: readonly AcpRuntime[], preferredRuntimeId?: string | null, disabledRuntimeIds: readonly string[] = getDisabledAcpRuntimeIdsSnapshot(), -): ResolvePersonaRuntimeResult & { harnessOverride: boolean } { +): ResolvePersonaRuntimeResult & { + harnessOverride: boolean; + implicitHarnessFallback: boolean; +} { const defaultRuntime = getDefaultPersonaRuntime(runtimes, preferredRuntimeId); const resolved = resolvePersonaRuntime( definitionRuntimeId, @@ -137,6 +140,8 @@ export function resolveProvisioningRuntimeForDefinition( definitionRuntimeId, resolved.runtime.id, ), + implicitHarnessFallback: + resolved.runtime !== null && !definitionRuntimeId?.trim(), }; } @@ -207,6 +212,7 @@ export async function buildInstanceInputForDefinition( persona.runtime, runtime.id, ), + implicitHarnessFallback: !persona.runtime?.trim(), model: persona.model ?? undefined, provider: persona.provider ?? undefined, spawnAfterCreate: true, diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 552373743..b7b0d5bcc 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -160,6 +160,7 @@ export function AddTeamToChannelDialog({ persona.runtime, personaRuntime.id, ), + implicitHarnessFallback: !persona.runtime?.trim(), model: persona.model ?? undefined, personaId: persona.id, teamId: team.id, diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts index 537ca66f7..0b4f4ad47 100644 --- a/desktop/src/features/channel-templates/useApplyTemplate.ts +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -93,6 +93,7 @@ export function useApplyTemplate() { name: persona.displayName, personaId: persona.id, harnessOverride: resolved.harnessOverride, + implicitHarnessFallback: resolved.implicitHarnessFallback, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: entry.model ?? persona.model ?? undefined, @@ -121,6 +122,7 @@ export function useApplyTemplate() { name: persona.displayName, personaId: persona.id, harnessOverride: resolved.harnessOverride, + implicitHarnessFallback: resolved.implicitHarnessFallback, 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 cd8e8beb8..53b9463a2 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -156,6 +156,7 @@ export function AddChannelBotDialog({ name: persona.displayName, personaId: persona.id, harnessOverride: resolved.harnessOverride, + implicitHarnessFallback: resolved.implicitHarnessFallback, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: persona.model ?? undefined, diff --git a/desktop/src/features/channels/ui/useQuickBotDrop.ts b/desktop/src/features/channels/ui/useQuickBotDrop.ts index 5283a402f..82f930f72 100644 --- a/desktop/src/features/channels/ui/useQuickBotDrop.ts +++ b/desktop/src/features/channels/ui/useQuickBotDrop.ts @@ -32,7 +32,7 @@ export function useQuickBotDrop(channelId: string | null) { setState({ pending: true, error: null }); try { - const { harnessOverride, runtime } = + const { harnessOverride, implicitHarnessFallback, runtime } = resolveProvisioningRuntimeForDefinition(persona.runtime, providers); if (!runtime) { @@ -51,6 +51,7 @@ export function useQuickBotDrop(channelId: string | null) { personaId: persona.id, model: persona.model ?? undefined, harnessOverride, + implicitHarnessFallback, }); setState({ pending: false, error: null }); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 1a95ae875..33ceb25ee 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -326,7 +326,7 @@ export function useMentionSendFlow({ } seenPersonaIds.add(persona.id); - const { harnessOverride, runtime } = + const { harnessOverride, implicitHarnessFallback, runtime } = resolveProvisioningRuntimeForDefinition(persona.runtime, runtimes); if (!runtime) { errors.push(`${displayName}: No agent runtime available.`); @@ -342,6 +342,7 @@ export function useMentionSendFlow({ name: persona.displayName, personaId: persona.id, harnessOverride, + implicitHarnessFallback, systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, model: persona.model ?? undefined, diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index e8a8b885c..98ef84da0 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -874,6 +874,7 @@ export async function createManagedAgent(input: CreateManagedAgentInput) { acpCommand: input.acpCommand, agentCommand: input.agentCommand, harnessOverride: input.harnessOverride ?? false, + implicitHarnessFallback: input.implicitHarnessFallback ?? false, agentArgs: input.agentArgs, mcpCommand: input.mcpCommand, turnTimeoutSeconds: input.turnTimeoutSeconds, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 50842d0d0..225e50cca 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -456,6 +456,11 @@ export type CreateManagedAgentInput = { * same persona runtime id, while still ignoring missing-runtime fallbacks. */ harnessOverride?: boolean; + /** + * True when `harnessOverride` pins an automatically selected runtime for a + * runtime-less persona rather than a runtime the user explicitly selected. + */ + implicitHarnessFallback?: boolean; agentArgs?: string[]; mcpCommand?: string; turnTimeoutSeconds?: number;