fix: propagate persona harness edits to live agent instances (#1244)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-24 13:41:19 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 45067ec135
commit 2e426b2fdd
18 changed files with 652 additions and 58 deletions
+18 -6
View File
@@ -37,15 +37,27 @@ const overrides = new Map([
// self-contained repos_dir functions and their unit tests live in repos.rs;
// this is the seam that must stay in nest.rs. Approved override; still queued
// to split with the rest of this list.
["src-tauri/src/managed_agents/nest.rs", 1447],
["src-tauri/src/managed_agents/runtime.rs", 1953],
["src-tauri/src/managed_agents/nest.rs", 1448],
// harness-persona-sync: persona-runtime resolution threaded into the spawn
// path here. Load-bearing feature growth; queued to split in the resolver
// unify refactor followup.
["src-tauri/src/managed_agents/runtime.rs", 1966],
["src-tauri/src/managed_agents/personas.rs", 1080],
["src-tauri/src/managed_agents/persona_card.rs", 1050],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir — a 4-line
// overage from load-bearing parameter plumbing, not generic debt growth.
// Approved override; still queued to split.
["src/shared/api/tauri.ts", 1199],
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
// parameter plumbing, not generic debt growth. Approved override; still
// queued to split.
["src/shared/api/tauri.ts", 1202],
// harness-persona-sync feature growth, queued to split in the resolver-unify
// refactor followup. discovery.rs is dominated by the new test module
// (the effective_agent_command / divergent / create-time override matrix);
// types.rs adds the persona/instance harness fields; migration_tests.rs adds
// the harness-sync migration coverage. Load-bearing, not generic debt.
["src-tauri/src/managed_agents/discovery.rs", 1043],
["src-tauri/src/managed_agents/types.rs", 1010],
["src-tauri/src/migration_tests.rs", 1033],
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295],
+34 -6
View File
@@ -49,11 +49,21 @@ pub async fn get_agent_models(
let resolved = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
let args = normalize_agent_args(&record.agent_command, record.agent_args.clone());
// Resolve the effective harness from the linked persona (mirrors spawn),
// so model discovery runs against the persona's current harness, not the
// frozen record snapshot. An explicit per-agent override wins.
let personas = load_personas(&app).unwrap_or_default();
let effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
&personas,
record.agent_command_override.as_deref(),
);
let resolved_agent = resolve_command(&record.agent_command)
let args = normalize_agent_args(&effective_command, record.agent_args.clone());
let resolved_agent = resolve_command(&effective_command)
.map(|p| p.display().to_string())
.unwrap_or_else(|| record.agent_command.clone());
.unwrap_or_else(|| effective_command.clone());
// Same env layering as runtime spawn: persona env < agent env.
// Model discovery needs the user's credentials. Fail closed on
@@ -65,7 +75,6 @@ pub async fn get_agent_models(
// Resolve the effective model from the linked persona so the ModelPicker
// dropdown shows the current persona model as selected.
let personas = load_personas(&app).unwrap_or_default();
let (_prompt, effective_model, _provider) = resolve_effective_prompt_model_provider(
record.persona_id.as_deref(),
&personas,
@@ -197,8 +206,18 @@ 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.
if let Some(agent_command) = input.agent_command {
record.agent_command = agent_command;
let personas = load_personas(&app).unwrap_or_default();
record.agent_command_override = crate::managed_agents::divergent_agent_command_override(
record.persona_id.as_deref(),
&personas,
Some(&agent_command),
);
}
if let Some(agent_args) = input.agent_args {
record.agent_args = agent_args;
@@ -253,10 +272,19 @@ pub async fn update_managed_agent(
&relay_ws_url_with_override(&state),
);
let display_name = record.name.clone();
// Avatar fallback derives from the EFFECTIVE harness (persona-wins),
// not the frozen snapshot, so an inherited harness picks the right
// default avatar.
let personas = load_personas(&app).unwrap_or_default();
let effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
&personas,
record.agent_command_override.as_deref(),
);
let avatar_url = record
.avatar_url
.clone()
.or_else(|| managed_agent_avatar_url(&record.agent_command));
.or_else(|| managed_agent_avatar_url(&effective_command));
let auth_tag = record.auth_tag.clone();
Some((agent_keys, relay_url, display_name, avatar_url, auth_tag))
} else {
+55 -17
View File
@@ -158,10 +158,9 @@ fn build_deploy_payload(
// Resolve effective model/provider from the persona's structured fields.
// Agent record's model takes precedence (user override via UI).
let personas = load_personas(app)
.map_err(|e| format!("failed to load personas for deploy payload resolution: {e}"))?;
let (effective_model, effective_provider) = if let Some(ref pid) = record.persona_id {
let personas = load_personas(app).map_err(|e| {
format!("failed to load personas for deploy payload model resolution: {e}")
})?;
let persona = personas.iter().find(|p| p.id == *pid);
let model = record
.model
@@ -173,6 +172,17 @@ fn build_deploy_payload(
(record.model.clone(), None)
};
// Resolve the effective harness (persona-wins, override-honored) so the
// remote provider runs the same harness a local spawn would — derive args
// from it rather than the frozen record snapshot.
let effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
&personas,
record.agent_command_override.as_deref(),
);
let effective_args =
crate::managed_agents::normalize_agent_args(&effective_command, record.agent_args.clone());
Ok(serde_json::json!({
"name": &record.name,
// Resolve the per-agent pin against the active workspace relay here:
@@ -186,8 +196,8 @@ fn build_deploy_payload(
),
"private_key_nsec": &record.private_key_nsec,
"auth_tag": &record.auth_tag,
"agent_command": &record.agent_command,
"agent_args": &record.agent_args,
"agent_command": &effective_command,
"agent_args": &effective_args,
"system_prompt": &record.system_prompt,
"model": effective_model,
"provider": effective_provider,
@@ -461,13 +471,31 @@ pub async fn create_managed_agent(
None
};
let agent_command = input
.agent_command
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(crate::managed_agents::default_agent_command);
// 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 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.
let agent_command = crate::managed_agents::effective_agent_command(
requested_persona_id.as_deref(),
&personas,
agent_command_override.as_deref(),
);
let agent_args = normalize_agent_args(
&agent_command,
input
@@ -496,7 +524,6 @@ pub async fn create_managed_agent(
// matches on this internal name, NOT display_name.
let pack_metadata: Option<(std::path::PathBuf, String)> =
requested_persona_id.as_deref().and_then(|pid| {
let personas = load_personas(&app).ok()?;
let persona = personas.iter().find(|p| p.id == pid)?;
let team_id = persona.source_team.as_deref()?;
let slug = persona.source_team_persona_slug.as_deref()?;
@@ -512,11 +539,11 @@ pub async fn create_managed_agent(
// fallback. Storing it lets reconciliation compare against what was
// actually published instead of re-deriving it.
let persona_avatar_url = requested_persona_id.as_ref().and_then(|persona_id| {
load_personas(&app)
.ok()?
.into_iter()
personas
.iter()
.find(|persona| persona.id == *persona_id)?
.avatar_url
.clone()
});
let resolved_avatar_url = resolve_created_avatar_url(
input.avatar_url.as_deref(),
@@ -540,6 +567,7 @@ pub async fn create_managed_agent(
.unwrap_or(DEFAULT_ACP_COMMAND)
.to_string(),
agent_command,
agent_command_override,
agent_args,
mcp_command,
turn_timeout_seconds: input
@@ -797,6 +825,16 @@ pub async fn start_managed_agent(
let record = find_managed_agent_mut(&mut records, &pubkey)?;
// Resolve the effective harness for the avatar-fallback derivation in
// profile reconcile (the create-time snapshot may be empty or stale for
// a persona-inherited harness).
let reconcile_personas = load_personas(&app).unwrap_or_default();
let reconcile_effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
&reconcile_personas,
record.agent_command_override.as_deref(),
);
let reconcile = ProfileReconcileData {
private_key_nsec: record.private_key_nsec.clone(),
name: record.name.clone(),
@@ -804,7 +842,7 @@ pub async fn start_managed_agent(
avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(),
agent_command: record.agent_command.clone(),
agent_command: reconcile_effective_command,
persona_id: record.persona_id.clone(),
};
@@ -243,6 +243,107 @@ pub fn default_agent_command() -> String {
.to_string()
}
/// Resolve the agent command (harness) for a spawn/deploy/summary. Mirrors the
/// model resolution in `resolve_effective_prompt_model_provider`: the linked
/// persona wins so persona harness edits propagate on the next spawn. An
/// explicit per-instance override (`agent_command_override`) takes precedence,
/// matching the opt-in `record.model` override pattern.
///
/// Resolution order:
/// 1. explicit override (non-empty) — a deliberate per-instance pin;
/// 2. the linked persona's `runtime` id mapped to its primary command;
/// 3. `default_agent_command()` — no persona/runtime, or persona deleted.
pub fn effective_agent_command(
persona_id: Option<&str>,
personas: &[crate::managed_agents::types::PersonaRecord],
agent_command_override: Option<&str>,
) -> String {
if let Some(pin) = agent_command_override
.map(str::trim)
.filter(|value| !value.is_empty())
{
return pin.to_string();
}
persona_id
.and_then(|pid| personas.iter().find(|p| p.id == pid))
.and_then(|persona| persona.runtime.as_deref())
.and_then(known_acp_runtime_exact)
.and_then(|r| r.commands.first().copied())
.map(str::to_string)
.unwrap_or_else(default_agent_command)
}
/// Decide whether a user-picked harness command is an explicit per-instance
/// pin or merely the persona's own runtime restated. Returns the override to
/// persist: `Some(picked)` when it diverges from the persona, `None` when it
/// inherits.
///
/// Comparison is by RUNTIME IDENTITY, not raw string: a persona on the `claude`
/// runtime resolves to `claude-agent-acp`, but a client with only the
/// `claude-code-acp` adapter installed sends that command instead. Both map to
/// the same `claude` runtime, so neither is a real divergence — string equality
/// would wrongly bake a pin. An unknown/custom command (no matching runtime)
/// only inherits when it exactly equals the persona command.
pub fn divergent_agent_command_override(
persona_id: Option<&str>,
personas: &[crate::managed_agents::types::PersonaRecord],
picked_command: Option<&str>,
) -> Option<String> {
let picked = picked_command
.map(str::trim)
.filter(|value| !value.is_empty())?;
let persona_command = effective_agent_command(persona_id, personas, None);
let same_runtime = match (
known_acp_runtime(picked),
known_acp_runtime(&persona_command),
) {
(Some(a), Some(b)) => std::ptr::eq(a, b),
_ => picked == persona_command,
};
if same_runtime {
None
} else {
Some(picked.to_string())
}
}
/// Decide the `agent_command_override` to persist at AGENT CREATE time.
///
/// A persona-backed create receives its harness command from
/// `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
/// non-persona runtime in a deploy dialog that exposes a runtime selector (e.g.
/// `AddChannelBotDialog`, "overriding persona preferences"). This is a real pin
/// and is preserved via `divergent_agent_command_override`.
/// - 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
/// harness even after the persona's runtime is installed and the persona is
/// re-edited, the exact bug this resolver chain exists to prevent. Stores `None`
/// 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.
///
/// Persona-less creates (`persona_id` is `None`, e.g. the standalone
/// CreateAgentDialog) have no persona to inherit, so the picked command is always a
/// real pin and is preserved via `divergent_agent_command_override` regardless of
/// `harness_override`.
pub fn create_time_agent_command_override(
persona_id: Option<&str>,
personas: &[crate::managed_agents::types::PersonaRecord],
picked_command: Option<&str>,
harness_override: bool,
) -> Option<String> {
if persona_id.is_some() && !harness_override {
return None;
}
divergent_agent_command_override(persona_id, personas, picked_command)
}
fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]),
@@ -577,9 +678,10 @@ mod tests {
use std::path::PathBuf;
use super::{
classify_runtime, default_agent_command, find_via_login_shell, managed_agent_avatar_url,
normalize_agent_args, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL,
GOOSE_AVATAR_URL,
classify_runtime, create_time_agent_command_override, default_agent_command,
divergent_agent_command_override, effective_agent_command, find_via_login_shell,
managed_agent_avatar_url, normalize_agent_args, BUZZ_AGENT_AVATAR_URL,
CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
};
use crate::managed_agents::AcpAvailabilityStatus;
@@ -760,4 +862,181 @@ mod tests {
assert_eq!(cmd.as_deref(), Some("codex-acp"));
assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp"));
}
fn persona_with_runtime(
id: &str,
runtime: Option<&str>,
) -> crate::managed_agents::PersonaRecord {
crate::managed_agents::PersonaRecord {
id: id.to_string(),
display_name: id.to_string(),
avatar_url: None,
system_prompt: String::new(),
runtime: runtime.map(str::to_string),
model: None,
provider: None,
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: std::collections::BTreeMap::new(),
created_at: "2026-06-09T00:00:00Z".to_string(),
updated_at: "2026-06-09T00:00:00Z".to_string(),
}
}
#[test]
fn effective_agent_command_explicit_override_wins() {
// An explicit pin beats the persona's runtime.
let personas = vec![persona_with_runtime("p1", Some("claude"))];
assert_eq!(
effective_agent_command(Some("p1"), &personas, Some("codex-acp")),
"codex-acp"
);
}
#[test]
fn effective_agent_command_inherits_persona_runtime() {
// No override → persona runtime id maps to its primary command.
let personas = vec![persona_with_runtime("p1", Some("claude"))];
assert_eq!(
effective_agent_command(Some("p1"), &personas, None),
"claude-agent-acp"
);
}
#[test]
fn effective_agent_command_empty_override_is_inherit() {
// A blank/whitespace override is treated as "inherit", not a pin.
let personas = vec![persona_with_runtime("p1", Some("goose"))];
assert_eq!(
effective_agent_command(Some("p1"), &personas, Some(" ")),
"goose"
);
}
#[test]
fn effective_agent_command_falls_back_to_default() {
// No override, no persona runtime, and a deleted persona all fall back
// to the bundled default.
let personas = vec![persona_with_runtime("p1", None)];
assert_eq!(
effective_agent_command(Some("p1"), &personas, None),
default_agent_command()
);
assert_eq!(
effective_agent_command(Some("gone"), &personas, None),
default_agent_command()
);
assert_eq!(
effective_agent_command(None, &personas, None),
default_agent_command()
);
}
#[test]
fn divergent_override_none_when_picked_matches_persona_runtime() {
// The persona-backed create/edit flow sends the persona's resolved
// command. It must be treated as "inherit" (None), not a pin.
let personas = vec![persona_with_runtime("p1", Some("goose"))];
assert_eq!(
divergent_agent_command_override(Some("p1"), &personas, Some("goose")),
None
);
}
#[test]
fn divergent_override_none_for_alternate_command_of_same_runtime() {
// A client with only `claude-code-acp` installed sends that command for
// a `claude` persona whose primary command is `claude-agent-acp`. Both
// map to the `claude` runtime, so it inherits — string equality would
// wrongly bake a pin (CRITICAL-3).
let personas = vec![persona_with_runtime("p1", Some("claude"))];
assert_eq!(
divergent_agent_command_override(Some("p1"), &personas, Some("claude-code-acp")),
None
);
}
#[test]
fn divergent_override_some_when_picked_is_different_runtime() {
// A deliberate pin to a different runtime is preserved.
let personas = vec![persona_with_runtime("p1", Some("goose"))];
assert_eq!(
divergent_agent_command_override(Some("p1"), &personas, Some("codex-acp")),
Some("codex-acp".to_string())
);
}
#[test]
fn divergent_override_none_for_empty_or_absent_pick() {
// The "Inherit from persona" sentinel (empty) and a name-only edit
// (absent) both clear the pin.
let personas = vec![persona_with_runtime("p1", Some("goose"))];
assert_eq!(
divergent_agent_command_override(Some("p1"), &personas, Some(" ")),
None
);
assert_eq!(
divergent_agent_command_override(Some("p1"), &personas, None),
None
);
}
#[test]
fn create_time_override_none_when_persona_runtime_not_installed() {
// CRITICAL-3 (Case 3): a `claude`-persona agent created on a machine
// where the claude adapter isn't installed. `resolvePersonaRuntime`
// falls back to the default (`buzz-agent`) and sends THAT command with
// `harness_override` false (the user did not pick it). At create this
// is a fallback, not a deliberate pin — it must store `None` so the
// agent inherits the persona's runtime once it's installed and the
// persona is re-edited. Baking `Some("buzz-agent")` here is the exact
// bug this resolver chain exists to kill.
let personas = vec![persona_with_runtime("p1", Some("claude"))];
assert_eq!(
create_time_agent_command_override(Some("p1"), &personas, Some("buzz-agent"), false),
None
);
}
#[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`
// sends the persona's own command with no override. Inherits — no pin.
let personas = vec![persona_with_runtime("p1", Some("goose"))];
assert_eq!(
create_time_agent_command_override(Some("p1"), &personas, Some("goose"), false),
None
);
}
#[test]
fn create_time_override_preserves_pin_for_persona_less_create() {
// The standalone CreateAgentDialog creates persona-LESS agents. With no
// persona to inherit, the picked command IS the agent's harness and must
// be preserved as a real pin (divergence from the bundled default),
// regardless of the override flag.
let personas = vec![persona_with_runtime("p1", Some("goose"))];
assert_eq!(
create_time_agent_command_override(None, &personas, Some("codex-acp"), false),
Some("codex-acp".to_string())
);
}
}
@@ -995,6 +995,7 @@ mod tests {
avatar_url: None,
acp_command: String::new(),
agent_command: String::new(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: 0,
@@ -71,6 +71,7 @@ mod tests {
avatar_url: None,
acp_command: "buzz-acp".into(),
agent_command: "goose".into(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: 320,
@@ -198,11 +198,20 @@ pub async fn restore_managed_agents_on_launch(
// releasing the lock. This mirrors the fire-and-forget pattern in
// start_managed_agent — ensuring boot-restored agents get the same profile
// self-healing as UI-started agents.
let reconcile_personas = super::load_personas(app).unwrap_or_default();
let reconcile_items: Vec<(String, crate::commands::ProfileReconcileData)> =
successfully_spawned
.iter()
.filter_map(|pubkey| {
let record = records.iter().find(|r| r.pubkey == *pubkey)?;
// Resolve the effective harness for the avatar-fallback
// derivation (the snapshot may be empty/stale for an inherited
// harness). Mirrors the UI start path.
let effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
&reconcile_personas,
record.agent_command_override.as_deref(),
);
Some((
pubkey.clone(),
crate::commands::ProfileReconcileData {
@@ -212,7 +221,7 @@ pub async fn restore_managed_agents_on_launch(
avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(),
agent_command: record.agent_command.clone(),
agent_command: effective_command,
persona_id: record.persona_id.clone(),
},
))
+39 -12
View File
@@ -1353,15 +1353,29 @@ pub fn build_managed_agent_summary(
record.model.clone(),
);
// Resolve the effective harness the same way, then derive args/mcp from it,
// so the UI reflects the persona's current harness (or an explicit pin).
let effective_command = crate::managed_agents::effective_agent_command(
record.persona_id.as_deref(),
personas,
record.agent_command_override.as_deref(),
);
let effective_args = normalize_agent_args(&effective_command, record.agent_args.clone());
let effective_mcp_command = known_acp_runtime(&effective_command)
.and_then(|r| r.mcp_command)
.unwrap_or("")
.to_string();
Ok(ManagedAgentSummary {
pubkey: record.pubkey.clone(),
name: record.name.clone(),
persona_id: record.persona_id.clone(),
relay_url: record.relay_url.clone(),
acp_command: record.acp_command.clone(),
agent_command: record.agent_command.clone(),
agent_args: record.agent_args.clone(),
mcp_command: record.mcp_command.clone(),
agent_command: effective_command,
agent_command_override: record.agent_command_override.clone(),
agent_args: effective_args,
mcp_command: effective_mcp_command,
turn_timeout_seconds: record.turn_timeout_seconds,
idle_timeout_seconds: record.idle_timeout_seconds,
max_turn_duration_seconds: record.max_turn_duration_seconds,
@@ -1500,27 +1514,40 @@ pub fn spawn_agent_child(
let stderr = stdout
.try_clone()
.map_err(|error| format!("failed to clone log handle: {error}"))?;
let agent_args = normalize_agent_args(&record.agent_command, record.agent_args.clone());
// Resolve the effective harness (agent command) from the linked persona, so
// persona harness edits propagate on the next spawn; an explicit per-agent
// override wins. `agent_args` and `mcp_command` are pure derivations of the
// command, so we recompute them from the effective value rather than the
// frozen record snapshot. Mirrors the model resolution below.
let personas = super::load_personas(app).unwrap_or_default();
let effective_command = super::effective_agent_command(
record.persona_id.as_deref(),
&personas,
record.agent_command_override.as_deref(),
);
let agent_args = normalize_agent_args(&effective_command, record.agent_args.clone());
let resolved_acp_command = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
let resolved_mcp_command: Option<std::path::PathBuf> = if record.mcp_command.is_empty() {
let effective_mcp_command = known_acp_runtime(&effective_command)
.and_then(|r| r.mcp_command)
.unwrap_or("");
let resolved_mcp_command: Option<std::path::PathBuf> = if effective_mcp_command.is_empty() {
None
} else {
match resolve_command(&record.mcp_command) {
match resolve_command(effective_mcp_command) {
Some(path) => Some(path),
None => {
eprintln!(
"buzz-desktop: mcp_command {:?} not found, skipping",
record.mcp_command
"buzz-desktop: mcp_command {effective_mcp_command:?} not found, skipping"
);
None
}
}
};
// Resolve agent command to a full path (DMG launches have minimal PATH).
let resolved_agent_command = resolve_command(&record.agent_command)
let resolved_agent_command = resolve_command(&effective_command)
.map(|p| p.display().to_string())
.unwrap_or_else(|| record.agent_command.clone());
.unwrap_or_else(|| effective_command.clone());
// The agent's effective relay drives both the child's relay connection
// (BUZZ_RELAY_URL) and git credential-helper URL: an explicit per-agent
@@ -1571,7 +1598,7 @@ pub fn spawn_agent_child(
}
// Enable MCP hook tools (_Stop, _PostCompact) for agents that need them.
// Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp".
let runtime_meta = known_acp_runtime(&record.agent_command);
let runtime_meta = known_acp_runtime(&effective_command);
if runtime_meta.is_some_and(|r| r.mcp_hooks) {
command.env("MCP_HOOK_SERVERS", "*");
}
@@ -1610,7 +1637,7 @@ pub fn spawn_agent_child(
// source of truth, so persona edits reach the agent on the next spawn. Fall
// back to the record snapshot only when no persona is linked or it was
// deleted. Provider flows from the persona (the record has no provider).
let personas = super::load_personas(app).unwrap_or_default();
// `personas` was loaded above for the harness resolution.
let (effective_prompt, effective_model, effective_provider) =
resolve_effective_prompt_model_provider(
record.persona_id.as_deref(),
@@ -132,6 +132,7 @@ fn fixture(
avatar_url: None,
acp_command: "buzz-acp".into(),
agent_command: "goose".into(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: 320,
@@ -281,6 +281,7 @@ mod tests {
avatar_url: None,
acp_command: String::new(),
agent_command: String::new(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: 0,
@@ -109,6 +109,15 @@ pub struct ManagedAgentRecord {
pub avatar_url: Option<String>,
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.
#[serde(default)]
pub agent_command_override: Option<String>,
pub agent_args: Vec<String>,
pub mcp_command: String,
pub turn_timeout_seconds: u64,
@@ -226,6 +235,11 @@ 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).
pub agent_command_override: Option<String>,
pub agent_args: Vec<String>,
pub mcp_command: String,
pub turn_timeout_seconds: u64,
@@ -262,6 +276,13 @@ 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 the user deliberately picked to
/// override the linked persona (a deploy-dialog runtime selector). Distinguishes
/// a real pin from a missing-runtime fallback so a persona-backed create only
/// stores an `agent_command_override` for the former. Defaults `false`: callers
/// that don't set it (persona-less creates, fallback divergence) inherit.
#[serde(default)]
pub harness_override: bool,
#[serde(default)]
pub agent_args: Vec<String>,
pub mcp_command: Option<String>,
+47 -3
View File
@@ -819,12 +819,31 @@ pub fn migrate_packs_to_teams(app: &tauri::AppHandle) {
}
fn reconcile_mcp_commands_in_file(path: &Path) {
// Resolve each record's EFFECTIVE harness (persona-wins, override-honored)
// before deriving its mcp_command, so a persona-inherited harness switch
// doesn't leave a stale persisted mcp_command. The persona runtime is read
// from the sibling personas.json; missing entries fall back to the record's
// own agent_command (the create-time snapshot).
let persona_runtimes = load_persona_runtimes(path);
patch_json_records(path, |obj| {
let agent_command = match obj.get("agent_command").and_then(|v| v.as_str()) {
let override_cmd = obj
.get("agent_command_override")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty());
let snapshot = obj.get("agent_command").and_then(|v| v.as_str());
let persona_cmd = obj
.get("persona_id")
.and_then(|v| v.as_str())
.and_then(|pid| persona_runtimes.get(pid))
.map(String::as_str)
.and_then(crate::managed_agents::known_acp_runtime_exact)
.and_then(|r| r.commands.first().copied());
let effective_command = match override_cmd.or(persona_cmd).or(snapshot) {
Some(cmd) => cmd.to_string(),
None => return false,
};
let Some(runtime) = crate::managed_agents::known_acp_runtime(&agent_command) else {
let Some(runtime) = crate::managed_agents::known_acp_runtime(&effective_command) else {
return false;
};
let expected = runtime.mcp_command.unwrap_or("");
@@ -843,7 +862,7 @@ fn reconcile_mcp_commands_in_file(path: &Path) {
eprintln!(
"buzz-desktop: runtime-reconcile: {:?} ({:?}): mcp_command {:?} → {:?}",
obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
agent_command,
effective_command,
current,
expected,
);
@@ -855,6 +874,31 @@ fn reconcile_mcp_commands_in_file(path: &Path) {
});
}
/// Build a `persona_id → runtime` map from the personas.json sibling of the
/// given managed-agents.json path. Returns an empty map when personas can't be
/// read or parsed — callers then fall back to the record's own snapshot.
fn load_persona_runtimes(agents_path: &Path) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
let Some(personas_path) = agents_path.parent().map(|dir| dir.join("personas.json")) else {
return map;
};
let Ok(content) = std::fs::read_to_string(&personas_path) else {
return map;
};
let Ok(records) = serde_json::from_str::<Vec<serde_json::Value>>(&content) else {
return map;
};
for record in records {
if let (Some(id), Some(runtime)) = (
record.get("id").and_then(|v| v.as_str()),
record.get("runtime").and_then(|v| v.as_str()),
) {
map.insert(id.to_string(), runtime.to_string());
}
}
map
}
fn replace_command_field(
obj: &mut serde_json::Map<String, serde_json::Value>,
field: &str,
+49
View File
@@ -829,6 +829,55 @@ fn reconcile_mcp_commands_handles_mixed_agents() {
assert_eq!(records[3]["mcp_command"], "buzz-dev-mcp");
}
#[test]
fn reconcile_mcp_commands_resolves_persona_runtime_over_stale_snapshot() {
// The frozen snapshot is buzz-agent (wants buzz-dev-mcp), but the linked
// persona's runtime is goose (wants no mcp). The reconcile must follow the
// EFFECTIVE harness (persona-wins) and clear the stale buzz-mcp-server.
let dir = tempfile::tempdir().unwrap();
write_agents_json(
dir.path(),
&serde_json::json!([{
"name": "Fizz",
"persona_id": "p1",
"agent_command": "buzz-agent",
"mcp_command": "buzz-mcp-server"
}]),
);
write_personas_json(
dir.path(),
&serde_json::json!([{"id": "p1", "runtime": "goose"}]),
);
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
let records = read_agents_json(dir.path());
assert_eq!(records[0]["mcp_command"], "");
}
#[test]
fn reconcile_mcp_commands_honors_explicit_override_over_persona() {
// An explicit per-instance pin (agent_command_override) beats the persona
// runtime: persona is goose (no mcp) but the pin is buzz-agent, so the
// reconcile sets the buzz-agent mcp_command.
let dir = tempfile::tempdir().unwrap();
write_agents_json(
dir.path(),
&serde_json::json!([{
"name": "Fizz",
"persona_id": "p1",
"agent_command": "goose",
"agent_command_override": "buzz-agent",
"mcp_command": ""
}]),
);
write_personas_json(
dir.path(),
&serde_json::json!([{"id": "p1", "runtime": "goose"}]),
);
reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json"));
let records = read_agents_json(dir.path());
assert_eq!(records[0]["mcp_command"], "buzz-dev-mcp");
}
#[test]
fn reconcile_mcp_commands_skips_record_without_agent_command() {
let dir = tempfile::tempdir().unwrap();
@@ -60,6 +60,13 @@ export type CreateChannelManagedAgentInput = {
systemPrompt?: string;
avatarUrl?: string;
personaId?: 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.
*/
harnessOverride?: boolean;
/** Preferred model ID from the persona. Passed to createManagedAgent. */
model?: string;
role?: Exclude<ChannelRole, "owner">;
@@ -368,6 +375,7 @@ export async function createChannelManagedAgent(
name: trimmedName,
acpCommand: "buzz-acp",
agentCommand: input.runtime.command,
harnessOverride: input.harnessOverride ?? false,
agentArgs: input.runtime.defaultArgs,
mcpCommand: input.runtime.mcpCommand ?? "",
personaId: input.personaId ?? undefined,
@@ -41,6 +41,12 @@ export function EditAgentDialog({
const [relayUrl, setRelayUrl] = React.useState(agent.relayUrl);
const [acpCommand, setAcpCommand] = React.useState(agent.acpCommand);
const [agentCommand, setAgentCommand] = React.useState(agent.agentCommand);
// Whether the harness inherits from the linked persona (no explicit pin).
// Only meaningful when a persona is linked; seeded from the override field
// so an unset override shows as "inherit" rather than re-pinning on save.
const [inheritHarness, setInheritHarness] = React.useState(
agent.personaId != null && agent.agentCommandOverride == null,
);
const [agentArgs, setAgentArgs] = React.useState(agent.agentArgs.join(","));
const [mcpCommand, setMcpCommand] = React.useState(agent.mcpCommand);
const [mcpToolsets, setMcpToolsets] = React.useState(agent.mcpToolsets ?? "");
@@ -55,11 +61,14 @@ export function EditAgentDialog({
);
const [envVars, setEnvVars] = React.useState<EnvVarsValue>(agent.envVars);
const personasQuery = usePersonasQuery();
const inheritedEnvVars = React.useMemo(() => {
if (!agent.personaId) return {};
const persona = personasQuery.data?.find((p) => p.id === agent.personaId);
return persona?.envVars ?? {};
}, [agent.personaId, personasQuery.data]);
const linkedPersona = React.useMemo(
() =>
agent.personaId
? (personasQuery.data?.find((p) => p.id === agent.personaId) ?? null)
: null,
[agent.personaId, personasQuery.data],
);
const inheritedEnvVars = linkedPersona?.envVars ?? {};
const [respondTo, setRespondTo] = React.useState<RespondToMode>(
agent.respondTo,
);
@@ -78,6 +87,9 @@ export function EditAgentDialog({
setRelayUrl(agent.relayUrl);
setAcpCommand(agent.acpCommand);
setAgentCommand(agent.agentCommand);
setInheritHarness(
agent.personaId != null && agent.agentCommandOverride == null,
);
setAgentArgs(agent.agentArgs.join(","));
setMcpCommand(agent.mcpCommand);
setMcpToolsets(agent.mcpToolsets ?? "");
@@ -127,6 +139,20 @@ export function EditAgentDialog({
.map((v) => v.trim())
.filter((v) => v.length > 0);
// Harness pin resolution. The backend treats an empty string as the
// "inherit from persona" sentinel (clears the override) and any concrete
// command as an explicit pin. When inheriting, only send the sentinel if
// there's a pin to clear — a name-only edit must leave the record alone.
// When pinning, send the command only if it diverges from the resolved
// value the dialog opened with, so an unchanged save stays a no-op.
const agentCommandUpdate = inheritHarness
? agent.agentCommandOverride != null
? ""
: undefined
: agentCommand.trim() !== agent.agentCommand
? agentCommand.trim()
: undefined;
const input: UpdateManagedAgentInput = {
pubkey: agent.pubkey,
name: name.trim() !== agent.name ? name.trim() : undefined,
@@ -136,10 +162,7 @@ export function EditAgentDialog({
acpCommand.trim() !== agent.acpCommand
? acpCommand.trim()
: undefined,
agentCommand:
agentCommand.trim() !== agent.agentCommand
? agentCommand.trim()
: undefined,
agentCommand: agentCommandUpdate,
agentArgs:
parsedArgs.join(",") !== agent.agentArgs.join(",")
? parsedArgs
@@ -214,6 +237,34 @@ export function EditAgentDialog({
onModeChange={setRespondTo}
/>
{linkedPersona ? (
<div className="space-y-1.5">
<label
className="flex items-center gap-2 text-sm font-medium"
htmlFor="agent-inherit-harness"
>
<input
checked={inheritHarness}
id="agent-inherit-harness"
onChange={(event) =>
setInheritHarness(event.target.checked)
}
type="checkbox"
/>
Inherit harness from persona
</label>
<p className="text-xs text-muted-foreground">
{inheritHarness
? `Uses the ${linkedPersona.displayName} persona's runtime${
linkedPersona.runtime
? ` (${linkedPersona.runtime})`
: ""
}. Editing the persona and respawning propagates the new harness.`
: "Pins this agent to a specific harness command, overriding the persona's runtime."}
</p>
</div>
) : null}
<CreateAgentRuntimeFields
acpCommand={acpCommand}
agentArgs={agentArgs}
@@ -231,7 +282,10 @@ export function EditAgentDialog({
onTurnTimeoutChange={setTurnTimeoutSeconds}
parallelism={parallelism}
relayUrl={relayUrl}
selectedRuntimeId="custom"
// "custom" surfaces the agent-command input so a user can pin a
// harness; when inheriting we hide it (any non-"custom" id) since
// the command comes from the persona's runtime.
selectedRuntimeId={inheritHarness ? "inherit" : "custom"}
systemPrompt={systemPrompt}
turnTimeoutSeconds={turnTimeoutSeconds}
/>
@@ -355,6 +355,10 @@ export function AddChannelBotDialog({
runtime: resolved.runtime ?? effectiveFallback ?? providers[0],
name: persona.displayName,
personaId: persona.id,
// A deliberate runtime-selector pick overrides the persona; a Case-3
// fallback (persona runtime not installed) is NOT a pin. `isOverridden`
// alone can't tell them apart, so thread the explicit user intent.
harnessOverride: isOverrideActive,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
+3
View File
@@ -198,6 +198,7 @@ export type RawManagedAgent = {
relay_url: string;
acp_command: string;
agent_command: string;
agent_command_override?: string | null;
agent_args: string[];
mcp_command: string;
turn_timeout_seconds: number;
@@ -856,6 +857,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
relayUrl: agent.relay_url,
acpCommand: agent.acp_command,
agentCommand: agent.agent_command,
agentCommandOverride: agent.agent_command_override ?? null,
agentArgs: agent.agent_args,
mcpCommand: agent.mcp_command,
turnTimeoutSeconds: agent.turn_timeout_seconds,
@@ -1005,6 +1007,7 @@ export async function createManagedAgent(input: CreateManagedAgentInput) {
relayUrl: input.relayUrl,
acpCommand: input.acpCommand,
agentCommand: input.agentCommand,
harnessOverride: input.harnessOverride ?? false,
agentArgs: input.agentArgs,
mcpCommand: input.mcpCommand,
mcpToolsets: input.mcpToolsets,
+14
View File
@@ -277,7 +277,14 @@ export type ManagedAgent = {
personaId: string | null;
relayUrl: string;
acpCommand: string;
/** Resolved/effective harness command (persona-wins, override-honored). */
agentCommand: string;
/**
* Explicit per-instance harness pin. `null` means the agent inherits its
* harness from the linked persona's runtime. Lets the Edit dialog show
* "Inherit from persona" vs a concrete pin.
*/
agentCommandOverride: string | null;
agentArgs: string[];
mcpCommand: string;
turnTimeoutSeconds: number;
@@ -340,6 +347,13 @@ export type CreateManagedAgentInput = {
relayUrl?: string;
acpCommand?: string;
agentCommand?: string;
/**
* True when `agentCommand` is a runtime the user deliberately picked to
* override the linked persona (a deploy-dialog runtime selector). Lets the
* backend distinguish a real pin from a missing-runtime fallback. Omit/false
* for persona-less creates and fallback divergence both inherit.
*/
harnessOverride?: boolean;
agentArgs?: string[];
mcpCommand?: string;
turnTimeoutSeconds?: number;