Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions desktop/src-tauri/src/commands/agents_deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
37 changes: 37 additions & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions desktop/src-tauri/src/managed_agents/discovery/overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 1 addition & 15 deletions desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
);
}
72 changes: 54 additions & 18 deletions desktop/src-tauri/src/managed_agents/global_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
klopez4212 marked this conversation as resolved.
};
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],
Expand All @@ -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)
}
Expand Down
77 changes: 77 additions & 0 deletions desktop/src-tauri/src/managed_agents/global_config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
7 changes: 3 additions & 4 deletions desktop/src-tauri/src/managed_agents/types/requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,9 @@ pub struct CreateManagedAgentRequest {
pub relay_url: Option<String>,
pub acp_command: Option<String>,
pub agent_command: Option<String>,
/// 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)]
Expand Down
29 changes: 28 additions & 1 deletion desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 3 additions & 4 deletions desktop/src/features/agents/channelAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading