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; // 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 // this is the seam that must stay in nest.rs. Approved override; still queued
// to split with the rest of this list. // to split with the rest of this list.
["src-tauri/src/managed_agents/nest.rs", 1447], ["src-tauri/src/managed_agents/nest.rs", 1448],
["src-tauri/src/managed_agents/runtime.rs", 1953], // 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/personas.rs", 1080],
["src-tauri/src/managed_agents/persona_card.rs", 1050], ["src-tauri/src/managed_agents/persona_card.rs", 1050],
// applyWorkspace reposDir parameter plus the validateReposDir binding, // applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir — a 4-line // threaded through Tauri invokes for configurable repos_dir, plus the
// overage from load-bearing parameter plumbing, not generic debt growth. // harness-persona-sync `harnessOverride` create-input bit — load-bearing
// Approved override; still queued to split. // parameter plumbing, not generic debt growth. Approved override; still
["src/shared/api/tauri.ts", 1199], // 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-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022], ["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295], ["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) let resolved = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness 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()) .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. // Same env layering as runtime spawn: persona env < agent env.
// Model discovery needs the user's credentials. Fail closed on // 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 // Resolve the effective model from the linked persona so the ModelPicker
// dropdown shows the current persona model as selected. // 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( let (_prompt, effective_model, _provider) = resolve_effective_prompt_model_provider(
record.persona_id.as_deref(), record.persona_id.as_deref(),
&personas, &personas,
@@ -197,8 +206,18 @@ pub async fn update_managed_agent(
if let Some(acp_command) = input.acp_command { if let Some(acp_command) = input.acp_command {
record.acp_command = 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 { 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 { if let Some(agent_args) = input.agent_args {
record.agent_args = agent_args; record.agent_args = agent_args;
@@ -253,10 +272,19 @@ pub async fn update_managed_agent(
&relay_ws_url_with_override(&state), &relay_ws_url_with_override(&state),
); );
let display_name = record.name.clone(); 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 let avatar_url = record
.avatar_url .avatar_url
.clone() .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(); let auth_tag = record.auth_tag.clone();
Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) Some((agent_keys, relay_url, display_name, avatar_url, auth_tag))
} else { } else {
+55 -17
View File
@@ -158,10 +158,9 @@ fn build_deploy_payload(
// Resolve effective model/provider from the persona's structured fields. // Resolve effective model/provider from the persona's structured fields.
// Agent record's model takes precedence (user override via UI). // 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 (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 persona = personas.iter().find(|p| p.id == *pid);
let model = record let model = record
.model .model
@@ -173,6 +172,17 @@ fn build_deploy_payload(
(record.model.clone(), None) (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!({ Ok(serde_json::json!({
"name": &record.name, "name": &record.name,
// Resolve the per-agent pin against the active workspace relay here: // 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, "private_key_nsec": &record.private_key_nsec,
"auth_tag": &record.auth_tag, "auth_tag": &record.auth_tag,
"agent_command": &record.agent_command, "agent_command": &effective_command,
"agent_args": &record.agent_args, "agent_args": &effective_args,
"system_prompt": &record.system_prompt, "system_prompt": &record.system_prompt,
"model": effective_model, "model": effective_model,
"provider": effective_provider, "provider": effective_provider,
@@ -461,13 +471,31 @@ pub async fn create_managed_agent(
None None
}; };
let agent_command = input // Load personas once for harness/pack/avatar resolution below.
.agent_command let personas = load_personas(&app).unwrap_or_default();
.as_deref()
.map(str::trim) // Harness resolution: the persona's runtime is authoritative. A
.filter(|value| !value.is_empty()) // persona-backed create stores an `agent_command_override` ONLY when the
.map(str::to_string) // user deliberately picked a divergent runtime (`harness_override`) —
.unwrap_or_else(crate::managed_agents::default_agent_command); // 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( let agent_args = normalize_agent_args(
&agent_command, &agent_command,
input input
@@ -496,7 +524,6 @@ pub async fn create_managed_agent(
// matches on this internal name, NOT display_name. // matches on this internal name, NOT display_name.
let pack_metadata: Option<(std::path::PathBuf, String)> = let pack_metadata: Option<(std::path::PathBuf, String)> =
requested_persona_id.as_deref().and_then(|pid| { requested_persona_id.as_deref().and_then(|pid| {
let personas = load_personas(&app).ok()?;
let persona = personas.iter().find(|p| p.id == pid)?; let persona = personas.iter().find(|p| p.id == pid)?;
let team_id = persona.source_team.as_deref()?; let team_id = persona.source_team.as_deref()?;
let slug = persona.source_team_persona_slug.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 // fallback. Storing it lets reconciliation compare against what was
// actually published instead of re-deriving it. // actually published instead of re-deriving it.
let persona_avatar_url = requested_persona_id.as_ref().and_then(|persona_id| { let persona_avatar_url = requested_persona_id.as_ref().and_then(|persona_id| {
load_personas(&app) personas
.ok()? .iter()
.into_iter()
.find(|persona| persona.id == *persona_id)? .find(|persona| persona.id == *persona_id)?
.avatar_url .avatar_url
.clone()
}); });
let resolved_avatar_url = resolve_created_avatar_url( let resolved_avatar_url = resolve_created_avatar_url(
input.avatar_url.as_deref(), input.avatar_url.as_deref(),
@@ -540,6 +567,7 @@ pub async fn create_managed_agent(
.unwrap_or(DEFAULT_ACP_COMMAND) .unwrap_or(DEFAULT_ACP_COMMAND)
.to_string(), .to_string(),
agent_command, agent_command,
agent_command_override,
agent_args, agent_args,
mcp_command, mcp_command,
turn_timeout_seconds: input turn_timeout_seconds: input
@@ -797,6 +825,16 @@ pub async fn start_managed_agent(
let record = find_managed_agent_mut(&mut records, &pubkey)?; 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 { let reconcile = ProfileReconcileData {
private_key_nsec: record.private_key_nsec.clone(), private_key_nsec: record.private_key_nsec.clone(),
name: record.name.clone(), name: record.name.clone(),
@@ -804,7 +842,7 @@ pub async fn start_managed_agent(
avatar_url: record.avatar_url.clone(), avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(), auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(), pubkey: record.pubkey.clone(),
agent_command: record.agent_command.clone(), agent_command: reconcile_effective_command,
persona_id: record.persona_id.clone(), persona_id: record.persona_id.clone(),
}; };
@@ -243,6 +243,107 @@ pub fn default_agent_command() -> String {
.to_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>> { fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_command_identity(command).as_str() { match normalize_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]), "goose" => Some(vec!["acp".to_string()]),
@@ -577,9 +678,10 @@ mod tests {
use std::path::PathBuf; use std::path::PathBuf;
use super::{ use super::{
classify_runtime, default_agent_command, find_via_login_shell, managed_agent_avatar_url, classify_runtime, create_time_agent_command_override, default_agent_command,
normalize_agent_args, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, divergent_agent_command_override, effective_agent_command, find_via_login_shell,
GOOSE_AVATAR_URL, 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; use crate::managed_agents::AcpAvailabilityStatus;
@@ -760,4 +862,181 @@ mod tests {
assert_eq!(cmd.as_deref(), Some("codex-acp")); assert_eq!(cmd.as_deref(), Some("codex-acp"));
assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/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, avatar_url: None,
acp_command: String::new(), acp_command: String::new(),
agent_command: String::new(), agent_command: String::new(),
agent_command_override: None,
agent_args: vec![], agent_args: vec![],
mcp_command: String::new(), mcp_command: String::new(),
turn_timeout_seconds: 0, turn_timeout_seconds: 0,
@@ -71,6 +71,7 @@ mod tests {
avatar_url: None, avatar_url: None,
acp_command: "buzz-acp".into(), acp_command: "buzz-acp".into(),
agent_command: "goose".into(), agent_command: "goose".into(),
agent_command_override: None,
agent_args: vec![], agent_args: vec![],
mcp_command: String::new(), mcp_command: String::new(),
turn_timeout_seconds: 320, 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 // releasing the lock. This mirrors the fire-and-forget pattern in
// start_managed_agent — ensuring boot-restored agents get the same profile // start_managed_agent — ensuring boot-restored agents get the same profile
// self-healing as UI-started agents. // self-healing as UI-started agents.
let reconcile_personas = super::load_personas(app).unwrap_or_default();
let reconcile_items: Vec<(String, crate::commands::ProfileReconcileData)> = let reconcile_items: Vec<(String, crate::commands::ProfileReconcileData)> =
successfully_spawned successfully_spawned
.iter() .iter()
.filter_map(|pubkey| { .filter_map(|pubkey| {
let record = records.iter().find(|r| r.pubkey == *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(( Some((
pubkey.clone(), pubkey.clone(),
crate::commands::ProfileReconcileData { crate::commands::ProfileReconcileData {
@@ -212,7 +221,7 @@ pub async fn restore_managed_agents_on_launch(
avatar_url: record.avatar_url.clone(), avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(), auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(), pubkey: record.pubkey.clone(),
agent_command: record.agent_command.clone(), agent_command: effective_command,
persona_id: record.persona_id.clone(), persona_id: record.persona_id.clone(),
}, },
)) ))
+39 -12
View File
@@ -1353,15 +1353,29 @@ pub fn build_managed_agent_summary(
record.model.clone(), 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 { Ok(ManagedAgentSummary {
pubkey: record.pubkey.clone(), pubkey: record.pubkey.clone(),
name: record.name.clone(), name: record.name.clone(),
persona_id: record.persona_id.clone(), persona_id: record.persona_id.clone(),
relay_url: record.relay_url.clone(), relay_url: record.relay_url.clone(),
acp_command: record.acp_command.clone(), acp_command: record.acp_command.clone(),
agent_command: record.agent_command.clone(), agent_command: effective_command,
agent_args: record.agent_args.clone(), agent_command_override: record.agent_command_override.clone(),
mcp_command: record.mcp_command.clone(), agent_args: effective_args,
mcp_command: effective_mcp_command,
turn_timeout_seconds: record.turn_timeout_seconds, turn_timeout_seconds: record.turn_timeout_seconds,
idle_timeout_seconds: record.idle_timeout_seconds, idle_timeout_seconds: record.idle_timeout_seconds,
max_turn_duration_seconds: record.max_turn_duration_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds,
@@ -1500,27 +1514,40 @@ pub fn spawn_agent_child(
let stderr = stdout let stderr = stdout
.try_clone() .try_clone()
.map_err(|error| format!("failed to clone log handle: {error}"))?; .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) let resolved_acp_command = resolve_command(&record.acp_command)
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness 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 None
} else { } else {
match resolve_command(&record.mcp_command) { match resolve_command(effective_mcp_command) {
Some(path) => Some(path), Some(path) => Some(path),
None => { None => {
eprintln!( eprintln!(
"buzz-desktop: mcp_command {:?} not found, skipping", "buzz-desktop: mcp_command {effective_mcp_command:?} not found, skipping"
record.mcp_command
); );
None None
} }
} }
}; };
// Resolve agent command to a full path (DMG launches have minimal PATH). // 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()) .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 // The agent's effective relay drives both the child's relay connection
// (BUZZ_RELAY_URL) and git credential-helper URL: an explicit per-agent // (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. // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them.
// Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp". // 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) { if runtime_meta.is_some_and(|r| r.mcp_hooks) {
command.env("MCP_HOOK_SERVERS", "*"); 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 // 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 // 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). // 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) = let (effective_prompt, effective_model, effective_provider) =
resolve_effective_prompt_model_provider( resolve_effective_prompt_model_provider(
record.persona_id.as_deref(), record.persona_id.as_deref(),
@@ -132,6 +132,7 @@ fn fixture(
avatar_url: None, avatar_url: None,
acp_command: "buzz-acp".into(), acp_command: "buzz-acp".into(),
agent_command: "goose".into(), agent_command: "goose".into(),
agent_command_override: None,
agent_args: vec![], agent_args: vec![],
mcp_command: String::new(), mcp_command: String::new(),
turn_timeout_seconds: 320, turn_timeout_seconds: 320,
@@ -281,6 +281,7 @@ mod tests {
avatar_url: None, avatar_url: None,
acp_command: String::new(), acp_command: String::new(),
agent_command: String::new(), agent_command: String::new(),
agent_command_override: None,
agent_args: vec![], agent_args: vec![],
mcp_command: String::new(), mcp_command: String::new(),
turn_timeout_seconds: 0, turn_timeout_seconds: 0,
@@ -109,6 +109,15 @@ pub struct ManagedAgentRecord {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub acp_command: String, pub acp_command: String,
pub agent_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 agent_args: Vec<String>,
pub mcp_command: String, pub mcp_command: String,
pub turn_timeout_seconds: u64, pub turn_timeout_seconds: u64,
@@ -226,6 +235,11 @@ pub struct ManagedAgentSummary {
pub relay_url: String, pub relay_url: String,
pub acp_command: String, pub acp_command: String,
pub agent_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 agent_args: Vec<String>,
pub mcp_command: String, pub mcp_command: String,
pub turn_timeout_seconds: u64, pub turn_timeout_seconds: u64,
@@ -262,6 +276,13 @@ pub struct CreateManagedAgentRequest {
pub relay_url: Option<String>, pub relay_url: Option<String>,
pub acp_command: Option<String>, pub acp_command: Option<String>,
pub agent_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)] #[serde(default)]
pub agent_args: Vec<String>, pub agent_args: Vec<String>,
pub mcp_command: Option<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) { 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| { 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(), Some(cmd) => cmd.to_string(),
None => return false, 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; return false;
}; };
let expected = runtime.mcp_command.unwrap_or(""); let expected = runtime.mcp_command.unwrap_or("");
@@ -843,7 +862,7 @@ fn reconcile_mcp_commands_in_file(path: &Path) {
eprintln!( eprintln!(
"buzz-desktop: runtime-reconcile: {:?} ({:?}): mcp_command {:?} → {:?}", "buzz-desktop: runtime-reconcile: {:?} ({:?}): mcp_command {:?} → {:?}",
obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
agent_command, effective_command,
current, current,
expected, 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( fn replace_command_field(
obj: &mut serde_json::Map<String, serde_json::Value>, obj: &mut serde_json::Map<String, serde_json::Value>,
field: &str, 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"); 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] #[test]
fn reconcile_mcp_commands_skips_record_without_agent_command() { fn reconcile_mcp_commands_skips_record_without_agent_command() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -60,6 +60,13 @@ export type CreateChannelManagedAgentInput = {
systemPrompt?: string; systemPrompt?: string;
avatarUrl?: string; avatarUrl?: string;
personaId?: string | null; 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. */ /** Preferred model ID from the persona. Passed to createManagedAgent. */
model?: string; model?: string;
role?: Exclude<ChannelRole, "owner">; role?: Exclude<ChannelRole, "owner">;
@@ -368,6 +375,7 @@ export async function createChannelManagedAgent(
name: trimmedName, name: trimmedName,
acpCommand: "buzz-acp", acpCommand: "buzz-acp",
agentCommand: input.runtime.command, agentCommand: input.runtime.command,
harnessOverride: input.harnessOverride ?? false,
agentArgs: input.runtime.defaultArgs, agentArgs: input.runtime.defaultArgs,
mcpCommand: input.runtime.mcpCommand ?? "", mcpCommand: input.runtime.mcpCommand ?? "",
personaId: input.personaId ?? undefined, personaId: input.personaId ?? undefined,
@@ -41,6 +41,12 @@ export function EditAgentDialog({
const [relayUrl, setRelayUrl] = React.useState(agent.relayUrl); const [relayUrl, setRelayUrl] = React.useState(agent.relayUrl);
const [acpCommand, setAcpCommand] = React.useState(agent.acpCommand); const [acpCommand, setAcpCommand] = React.useState(agent.acpCommand);
const [agentCommand, setAgentCommand] = React.useState(agent.agentCommand); 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 [agentArgs, setAgentArgs] = React.useState(agent.agentArgs.join(","));
const [mcpCommand, setMcpCommand] = React.useState(agent.mcpCommand); const [mcpCommand, setMcpCommand] = React.useState(agent.mcpCommand);
const [mcpToolsets, setMcpToolsets] = React.useState(agent.mcpToolsets ?? ""); const [mcpToolsets, setMcpToolsets] = React.useState(agent.mcpToolsets ?? "");
@@ -55,11 +61,14 @@ export function EditAgentDialog({
); );
const [envVars, setEnvVars] = React.useState<EnvVarsValue>(agent.envVars); const [envVars, setEnvVars] = React.useState<EnvVarsValue>(agent.envVars);
const personasQuery = usePersonasQuery(); const personasQuery = usePersonasQuery();
const inheritedEnvVars = React.useMemo(() => { const linkedPersona = React.useMemo(
if (!agent.personaId) return {}; () =>
const persona = personasQuery.data?.find((p) => p.id === agent.personaId); agent.personaId
return persona?.envVars ?? {}; ? (personasQuery.data?.find((p) => p.id === agent.personaId) ?? null)
}, [agent.personaId, personasQuery.data]); : null,
[agent.personaId, personasQuery.data],
);
const inheritedEnvVars = linkedPersona?.envVars ?? {};
const [respondTo, setRespondTo] = React.useState<RespondToMode>( const [respondTo, setRespondTo] = React.useState<RespondToMode>(
agent.respondTo, agent.respondTo,
); );
@@ -78,6 +87,9 @@ export function EditAgentDialog({
setRelayUrl(agent.relayUrl); setRelayUrl(agent.relayUrl);
setAcpCommand(agent.acpCommand); setAcpCommand(agent.acpCommand);
setAgentCommand(agent.agentCommand); setAgentCommand(agent.agentCommand);
setInheritHarness(
agent.personaId != null && agent.agentCommandOverride == null,
);
setAgentArgs(agent.agentArgs.join(",")); setAgentArgs(agent.agentArgs.join(","));
setMcpCommand(agent.mcpCommand); setMcpCommand(agent.mcpCommand);
setMcpToolsets(agent.mcpToolsets ?? ""); setMcpToolsets(agent.mcpToolsets ?? "");
@@ -127,6 +139,20 @@ export function EditAgentDialog({
.map((v) => v.trim()) .map((v) => v.trim())
.filter((v) => v.length > 0); .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 = { const input: UpdateManagedAgentInput = {
pubkey: agent.pubkey, pubkey: agent.pubkey,
name: name.trim() !== agent.name ? name.trim() : undefined, name: name.trim() !== agent.name ? name.trim() : undefined,
@@ -136,10 +162,7 @@ export function EditAgentDialog({
acpCommand.trim() !== agent.acpCommand acpCommand.trim() !== agent.acpCommand
? acpCommand.trim() ? acpCommand.trim()
: undefined, : undefined,
agentCommand: agentCommand: agentCommandUpdate,
agentCommand.trim() !== agent.agentCommand
? agentCommand.trim()
: undefined,
agentArgs: agentArgs:
parsedArgs.join(",") !== agent.agentArgs.join(",") parsedArgs.join(",") !== agent.agentArgs.join(",")
? parsedArgs ? parsedArgs
@@ -214,6 +237,34 @@ export function EditAgentDialog({
onModeChange={setRespondTo} 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 <CreateAgentRuntimeFields
acpCommand={acpCommand} acpCommand={acpCommand}
agentArgs={agentArgs} agentArgs={agentArgs}
@@ -231,7 +282,10 @@ export function EditAgentDialog({
onTurnTimeoutChange={setTurnTimeoutSeconds} onTurnTimeoutChange={setTurnTimeoutSeconds}
parallelism={parallelism} parallelism={parallelism}
relayUrl={relayUrl} 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} systemPrompt={systemPrompt}
turnTimeoutSeconds={turnTimeoutSeconds} turnTimeoutSeconds={turnTimeoutSeconds}
/> />
@@ -355,6 +355,10 @@ export function AddChannelBotDialog({
runtime: resolved.runtime ?? effectiveFallback ?? providers[0], runtime: resolved.runtime ?? effectiveFallback ?? providers[0],
name: persona.displayName, name: persona.displayName,
personaId: persona.id, 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, systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined, avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined, model: persona.model ?? undefined,
+3
View File
@@ -198,6 +198,7 @@ export type RawManagedAgent = {
relay_url: string; relay_url: string;
acp_command: string; acp_command: string;
agent_command: string; agent_command: string;
agent_command_override?: string | null;
agent_args: string[]; agent_args: string[];
mcp_command: string; mcp_command: string;
turn_timeout_seconds: number; turn_timeout_seconds: number;
@@ -856,6 +857,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
relayUrl: agent.relay_url, relayUrl: agent.relay_url,
acpCommand: agent.acp_command, acpCommand: agent.acp_command,
agentCommand: agent.agent_command, agentCommand: agent.agent_command,
agentCommandOverride: agent.agent_command_override ?? null,
agentArgs: agent.agent_args, agentArgs: agent.agent_args,
mcpCommand: agent.mcp_command, mcpCommand: agent.mcp_command,
turnTimeoutSeconds: agent.turn_timeout_seconds, turnTimeoutSeconds: agent.turn_timeout_seconds,
@@ -1005,6 +1007,7 @@ export async function createManagedAgent(input: CreateManagedAgentInput) {
relayUrl: input.relayUrl, relayUrl: input.relayUrl,
acpCommand: input.acpCommand, acpCommand: input.acpCommand,
agentCommand: input.agentCommand, agentCommand: input.agentCommand,
harnessOverride: input.harnessOverride ?? false,
agentArgs: input.agentArgs, agentArgs: input.agentArgs,
mcpCommand: input.mcpCommand, mcpCommand: input.mcpCommand,
mcpToolsets: input.mcpToolsets, mcpToolsets: input.mcpToolsets,
+14
View File
@@ -277,7 +277,14 @@ export type ManagedAgent = {
personaId: string | null; personaId: string | null;
relayUrl: string; relayUrl: string;
acpCommand: string; acpCommand: string;
/** Resolved/effective harness command (persona-wins, override-honored). */
agentCommand: string; 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[]; agentArgs: string[];
mcpCommand: string; mcpCommand: string;
turnTimeoutSeconds: number; turnTimeoutSeconds: number;
@@ -340,6 +347,13 @@ export type CreateManagedAgentInput = {
relayUrl?: string; relayUrl?: string;
acpCommand?: string; acpCommand?: string;
agentCommand?: 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[]; agentArgs?: string[];
mcpCommand?: string; mcpCommand?: string;
turnTimeoutSeconds?: number; turnTimeoutSeconds?: number;