mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
45067ec135
commit
2e426b2fdd
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
))
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user