Skip to content
Merged
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
5 changes: 4 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ const overrides = new Map([
// path instead of being dropped back to inherit. Load-bearing, not debt.
// unified-agent-model 1A.1: inline test module moved to discovery/tests.rs,
// ratcheting 1259 -> 802 (under the 1000 default; entry kept as a ratchet).
["src-tauri/src/managed_agents/discovery.rs", 802],
// agent-config-propagation: the agent_command_override decision family
// (divergent / create-time / update-time / apply) moved to
// discovery/overrides.rs; ratcheting 802 -> 685 to bank the headroom.
["src-tauri/src/managed_agents/discovery.rs", 685],
// identity-import-keyring: the identity resolution state machine's behavioral
// matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
// adoption / read-back-corruption / marker-failure arms, recovery-mode
Expand Down
45 changes: 21 additions & 24 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -839,9 +839,9 @@ pub async fn update_managed_agent(
if let Some(parallelism) = input.parallelism {
record.parallelism = parallelism;
}
if let Some(turn_timeout_seconds) = input.turn_timeout_seconds {
record.turn_timeout_seconds = turn_timeout_seconds;
}
// turn_timeout_seconds is intentionally not applied here —
// BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness.
// Use idle_timeout_seconds or max_turn_duration_seconds instead.
// Store the relay override exactly as supplied (trimmed). An explicit
// value pins the agent; empty falls back to the workspace relay at
// read-time. A name-only edit (relay_url == None) leaves the pin intact.
Expand All @@ -851,33 +851,30 @@ pub async fn update_managed_agent(
if let Some(acp_command) = input.acp_command {
record.acp_command = acp_command;
}
// Harness edit: the persona's runtime is authoritative, so we persist an
// explicit `agent_command_override` ONLY when the user picks a command
// that diverges from the persona. An empty/whitespace value (the
// "Inherit from persona" sentinel) clears the pin back to `None`. A
// name-only edit (`agent_command == None`) leaves the pin intact.
//
// `harness_override` threads the user's explicit intent: when they pick
// a runtime/Custom command in the dialog it is a real pin even if it
// maps to the persona's own runtime, so a same-runtime pick is kept
// rather than dropped back to inherit (see
// `update_time_agent_command_override`).
// Harness edit: the persona's runtime is authoritative, so an explicit
// `agent_command_override` is persisted ONLY when the user picks a
// command that diverges from the persona, and the empty/whitespace
// "Inherit from persona" sentinel clears both the pin and the
// materialized record runtime. A name-only edit
// (`agent_command == None`) leaves the pin intact. `harness_override`
// threads the user's explicit intent — see `apply_agent_command_update`
// and `update_time_agent_command_override` for the full resolution
// rules.
if let Some(agent_command) = input.agent_command {
let personas = load_personas(&app).unwrap_or_default();
record.agent_command_override =
crate::managed_agents::update_time_agent_command_override(
record.persona_id.as_deref(),
&personas,
Some(&agent_command),
input.harness_override,
);
crate::managed_agents::apply_agent_command_update(
record,
&personas,
&agent_command,
input.harness_override,
);
}
if let Some(agent_args) = input.agent_args {
record.agent_args = agent_args;
}
if let Some(mcp_command) = input.mcp_command {
record.mcp_command = mcp_command;
}
// mcp_command is intentionally not applied here — the effective MCP
// command is always catalog-derived (known_acp_runtime at spawn time)
// and the per-record field is never read by the runtime.
if let Some(env_vars) = input.env_vars {
crate::managed_agents::validate_user_env_keys(&env_vars)?;
record.env_vars = env_vars;
Expand Down
33 changes: 33 additions & 0 deletions desktop/src-tauri/src/commands/agent_models_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,39 @@ fn saved_agent_model_discovery_uses_record_snapshot() {
// Parse/filter/pagination tests live in crates/buzz-agent/src/catalog.rs
// (they moved there with the Option C refactor).

// ---------------------------------------------------------------------------
// Dead-knob guards: mcp_command and turn_timeout_seconds
// ---------------------------------------------------------------------------

#[test]
fn update_request_mcp_command_parses_for_wire_compat() {
// UpdateManagedAgentRequest accepts mcpCommand for backward-compatibility
// with frontends that still send it: the deprecated field must keep
// parsing cleanly. Nothing consumes it — the patching loop in
// update_managed_agent has no mcp_command arm (the effective MCP command
// is always catalog-derived at spawn). That absent-arm invariant lives in
// the code, not in this test: it only guards the wire shape.
let req: crate::managed_agents::UpdateManagedAgentRequest =
serde_json::from_str(r#"{"pubkey": "abc", "mcpCommand": "user-override"}"#)
.expect("request with deprecated mcpCommand parses");
assert_eq!(req.mcp_command.as_deref(), Some("user-override"));
}

#[test]
fn update_request_turn_timeout_parses_for_wire_compat() {
// UpdateManagedAgentRequest accepts turnTimeoutSeconds for
// backward-compatibility with frontends that still send it: the deprecated
// field must keep parsing cleanly. Nothing consumes it — the patching loop
// in update_managed_agent has no turn_timeout_seconds arm
// (BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness). That
// absent-arm invariant lives in the code, not in this test: it only
// guards the wire shape.
let req: crate::managed_agents::UpdateManagedAgentRequest =
serde_json::from_str(r#"{"pubkey": "abc", "turnTimeoutSeconds": 9999}"#)
.expect("request with deprecated turnTimeoutSeconds parses");
assert_eq!(req.turn_timeout_seconds, Some(9999));
}

#[test]
fn is_databricks_provider_matches_both_variants() {
assert!(is_databricks_provider(Some("databricks")));
Expand Down
61 changes: 17 additions & 44 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,37 +254,14 @@ async fn start_local_agent_with_preflight(
return Err(format!("agent {pubkey} is no longer a local agent"));
}
// Re-snapshot the persona onto the record at every spawn so the agent always
// starts with the current persona config (system_prompt, model, provider).
// This clears the "out of date" drift badge without requiring a
// delete+recreate. `record.env_vars` is NOT rewritten: it holds agent-level
// overrides only, and spawn merges the live persona env underneath at read
// time — so persona env edits refresh here too. When the persona leaves
// model or provider blank, the agent record's own configured values are
// preserved so a user-set model/provider is never clobbered by an
// unconfigured persona.
// starts with the current persona config (system_prompt, model, provider,
// runtime). This clears the "out of date" drift badge without requiring a
// delete+recreate. See `apply_persona_snapshot` for the precedence and
// env-override self-heal rules.
if let Some(persona_id) = record.persona_id.clone() {
let personas = load_personas(app).unwrap_or_default();
if let Some(persona) = personas.iter().find(|p| p.id == persona_id) {
let snapshot =
crate::managed_agents::persona_events::persona_snapshot_with_agent_config_fallback(
persona,
record.model.as_deref(), // fallback: record.model
record.provider.as_deref(), // fallback: record.provider
);
if let Some(prompt) = snapshot.system_prompt {
record.system_prompt = Some(prompt);
}
record.model = snapshot.model;
record.provider = snapshot.provider;
// Self-heal records written before env refresh: persona env used to
// be baked into `record.env_vars` at create/spawn, turning inherited
// values into pseudo-overrides that shadow later persona edits. An
// override equal to the persona's current value is indistinguishable
// from inheritance, so drop it and let the live merge supply it.
record
.env_vars
.retain(|k, v| persona.env_vars.get(k) != Some(v));
record.persona_source_version = Some(snapshot.source_version);
crate::managed_agents::persona_events::apply_persona_snapshot(record, persona);
record.updated_at = crate::util::now_iso();
}
}
Expand Down Expand Up @@ -594,18 +571,14 @@ pub async fn create_managed_agent(
.collect::<Vec<_>>(),
);

let mcp_command = input
.mcp_command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(
|| match crate::managed_agents::known_acp_runtime(&agent_command) {
Some(p) => p.mcp_command.unwrap_or("").to_string(),
None => String::new(),
},
);
// Derive MCP command exclusively from the runtime catalog — the
// per-record field is never read at spawn time so user-supplied input
// is silently discarded. Always sourcing from the catalog ensures
// new agents pick up the correct value without any stored override.
let mcp_command = match crate::managed_agents::known_acp_runtime(&agent_command) {
Some(p) => p.mcp_command.unwrap_or("").to_string(),
None => String::new(),
};

// For pack-backed personas, resolve the installed pack path and the
// persona's internal name (slug). ACP's resolve_persona_by_name()
Expand Down Expand Up @@ -699,10 +672,10 @@ pub async fn create_managed_agent(
agent_command_override,
agent_args,
mcp_command,
turn_timeout_seconds: input
.turn_timeout_seconds
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS),
// BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness;
// store the schema default only. Use idle_timeout_seconds or
// max_turn_duration_seconds for actual turn-length control.
turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
// 0 or None → harness uses its own default (320s idle, 3600s max), and the CLI also clamps 0 → minimum.
idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0),
max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0),
Expand Down
Loading
Loading