diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 35fd60fa4..b1315dcbe 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -50,7 +50,7 @@ const overrides = new Map([ ["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests - ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + ["src-tauri/src/managed_agents/runtime.rs", 895], // spawn logic + respond-to env builder + persona resolution helper (tests extracted to runtime/tests.rs) ["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 2cf295154..edaae25cb 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -500,6 +500,37 @@ pub(crate) fn build_respond_to_env( Ok((set, remove)) } +/// Resolve the effective system prompt and model for a managed agent. +/// +/// When `persona_id` is set, looks up the persona in the provided list and +/// returns its current `system_prompt` and `model`. Falls back to the +/// agent record's stale snapshot (`record_prompt`, `record_model`) when: +/// - `persona_id` is `None` +/// - the persona can't be found in the store (e.g. deleted) +/// +/// Extracted from `spawn_agent_child` for testability. +pub(crate) fn resolve_effective_prompt_and_model( + persona_id: Option<&str>, + personas: &[super::PersonaRecord], + record_prompt: Option<&str>, + record_model: Option<&str>, +) -> (Option, Option) { + let from_persona: Option<(String, Option)> = persona_id.and_then(|pid| { + personas + .iter() + .find(|p| p.id == pid) + .map(|p| (p.system_prompt.clone(), p.model.clone())) + }); + + match from_persona { + Some((prompt, model)) => (Some(prompt), model), + None => ( + record_prompt.map(|s| s.to_owned()), + record_model.map(|s| s.to_owned()), + ), + } +} + /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. @@ -623,30 +654,16 @@ pub fn spawn_agent_child( command.env("SPROUT_ACP_PERSONA_NAME", persona_name); } - // Resolve system prompt and model: prefer the persona definition (if a - // persona pack is configured and the persona matched), otherwise fall back - // to the record-level overrides. - let has_persona_pack = - record.persona_pack_path.is_some() && record.persona_name_in_pack.is_some(); - let persona_prompt_and_model: Option<(String, Option)> = has_persona_pack - .then(|| { - record - .persona_id - .as_deref() - .and_then(|pid| { - super::load_personas(app) - .ok()? - .into_iter() - .find(|p| p.id == pid) - }) - .map(|p| (p.system_prompt, p.model)) - }) - .flatten(); - - let (effective_prompt, effective_model) = match persona_prompt_and_model { - Some((prompt, model)) => (Some(prompt), model), - None => (record.system_prompt.clone(), record.model.clone()), - }; + // Resolve system prompt and model via the extracted helper. Re-reads + // the persona store at spawn time so edits take effect without + // deleting and recreating the agent. + let personas = super::load_personas(app).unwrap_or_default(); + let (effective_prompt, effective_model) = resolve_effective_prompt_and_model( + record.persona_id.as_deref(), + &personas, + record.system_prompt.as_deref(), + record.model.as_deref(), + ); if let Some(prompt) = &effective_prompt { command.env("SPROUT_ACP_SYSTEM_PROMPT", prompt); @@ -871,168 +888,4 @@ pub fn stop_managed_agent_process( } #[cfg(test)] -mod tests { - use crate::managed_agents::known_acp_provider; - - #[test] - fn sprout_agent_has_mcp_hooks() { - let p = known_acp_provider("sprout-agent").expect("should resolve"); - assert!(p.mcp_hooks); - assert_eq!(p.mcp_command, Some("sprout-dev-mcp")); - } - - #[test] - fn sprout_agent_resolved_via_path() { - assert!(known_acp_provider("/usr/local/bin/sprout-agent").is_some_and(|p| p.mcp_hooks)); - } - - #[test] - fn goose_has_no_mcp_hooks() { - let p = known_acp_provider("goose").expect("should resolve"); - assert!(!p.mcp_hooks); - assert_eq!(p.mcp_command, None); - } - - #[test] - fn unknown_command_returns_none() { - assert!(known_acp_provider("custom-agent").is_none()); - } - - // ── build_respond_to_env tests ─────────────────────────────────────── - - use super::build_respond_to_env; - use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; - - /// Construct a minimal record fixture for env-building tests. Only the - /// fields read by `build_respond_to_env` matter here. - fn fixture( - respond_to: RespondTo, - allowlist: Vec, - auth_tag: Option, - ) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "p".into(), - name: "n".into(), - persona_id: None, - private_key_nsec: "nsec1fake".into(), - auth_tag, - relay_url: "ws://localhost:3000".into(), - acp_command: "sprout-acp".into(), - agent_command: "goose".into(), - agent_args: vec![], - mcp_command: "sprout-mcp-server".into(), - turn_timeout_seconds: 320, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - mcp_toolsets: None, - env_vars: std::collections::BTreeMap::new(), - start_on_app_launch: false, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_binary_path: None, - persona_pack_path: None, - persona_name_in_pack: None, - created_at: "now".into(), - updated_at: "now".into(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - respond_to, - respond_to_allowlist: allowlist, - } - } - - #[test] - fn build_env_owner_only_sets_mode_and_removes_others() { - let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); - let (set, remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); - let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); - assert_eq!( - set_map.get("SPROUT_ACP_RESPOND_TO").map(String::as_str), - Some("owner-only") - ); - assert!(!set_map.contains_key("SPROUT_ACP_RESPOND_TO_ALLOWLIST")); - assert!(remove.contains(&"SPROUT_ACP_RESPOND_TO_ALLOWLIST")); - // auth_tag is present → no AGENT_OWNER fallback fires. - assert!(remove.contains(&"SPROUT_ACP_AGENT_OWNER")); - } - - #[test] - fn build_env_allowlist_sets_both_envs_and_joins() { - let a = "a".repeat(64); - let b = "b".repeat(64); - let rec = fixture( - RespondTo::Allowlist, - vec![a.clone(), b.clone()], - Some("tag".into()), - ); - let (set, _remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); - let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); - assert_eq!( - set_map.get("SPROUT_ACP_RESPOND_TO").map(String::as_str), - Some("allowlist") - ); - assert_eq!( - set_map - .get("SPROUT_ACP_RESPOND_TO_ALLOWLIST") - .map(String::as_str), - Some(format!("{a},{b}").as_str()), - ); - } - - #[test] - fn build_env_anyone_omits_allowlist_var() { - let rec = fixture(RespondTo::Anyone, vec![], Some("tag".into())); - let (set, remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); - let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); - assert_eq!( - set_map.get("SPROUT_ACP_RESPOND_TO").map(String::as_str), - Some("anyone") - ); - assert!(!set_map.contains_key("SPROUT_ACP_RESPOND_TO_ALLOWLIST")); - assert!(remove.contains(&"SPROUT_ACP_RESPOND_TO_ALLOWLIST")); - } - - #[test] - fn build_env_legacy_record_without_auth_tag_emits_agent_owner() { - let rec = fixture(RespondTo::OwnerOnly, vec![], None); - let (set, remove) = build_respond_to_env(&rec, Some("ownerhex")).unwrap(); - let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); - assert_eq!( - set_map.get("SPROUT_ACP_AGENT_OWNER").map(String::as_str), - Some("ownerhex") - ); - assert!(!remove.contains(&"SPROUT_ACP_AGENT_OWNER")); - } - - #[test] - fn build_env_legacy_record_without_owner_hex_removes_agent_owner() { - // No owner available to forward → make sure we don't inherit a leaked - // env var from the parent. - let rec = fixture(RespondTo::OwnerOnly, vec![], None); - let (_set, remove) = build_respond_to_env(&rec, None).unwrap(); - assert!(remove.contains(&"SPROUT_ACP_AGENT_OWNER")); - } - - #[test] - fn build_env_rejects_corrupted_allowlist() { - let rec = fixture( - RespondTo::Allowlist, - vec!["not-hex".into()], - Some("tag".into()), - ); - assert!(build_respond_to_env(&rec, Some("owner")).is_err()); - } - - #[test] - fn build_env_rejects_empty_allowlist_in_allowlist_mode() { - let rec = fixture(RespondTo::Allowlist, vec![], Some("tag".into())); - let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); - assert!(err.contains("at least one pubkey")); - } -} +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs new file mode 100644 index 000000000..446a6ba28 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -0,0 +1,257 @@ +use crate::managed_agents::known_acp_provider; + +#[test] +fn sprout_agent_has_mcp_hooks() { + let p = known_acp_provider("sprout-agent").expect("should resolve"); + assert!(p.mcp_hooks); + assert_eq!(p.mcp_command, Some("sprout-dev-mcp")); +} + +#[test] +fn sprout_agent_resolved_via_path() { + assert!(known_acp_provider("/usr/local/bin/sprout-agent").is_some_and(|p| p.mcp_hooks)); +} + +#[test] +fn goose_has_no_mcp_hooks() { + let p = known_acp_provider("goose").expect("should resolve"); + assert!(!p.mcp_hooks); + assert_eq!(p.mcp_command, None); +} + +#[test] +fn unknown_command_returns_none() { + assert!(known_acp_provider("custom-agent").is_none()); +} + +// ── build_respond_to_env tests ─────────────────────────────────────── + +use super::build_respond_to_env; +use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; + +/// Construct a minimal record fixture for env-building tests. Only the +/// fields read by `build_respond_to_env` matter here. +fn fixture( + respond_to: RespondTo, + allowlist: Vec, + auth_tag: Option, +) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".into(), + name: "n".into(), + persona_id: None, + private_key_nsec: "nsec1fake".into(), + auth_tag, + relay_url: "ws://localhost:3000".into(), + acp_command: "sprout-acp".into(), + agent_command: "goose".into(), + agent_args: vec![], + mcp_command: "sprout-mcp-server".into(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + mcp_toolsets: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + persona_pack_path: None, + persona_name_in_pack: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to, + respond_to_allowlist: allowlist, + } +} + +#[test] +fn build_env_owner_only_sets_mode_and_removes_others() { + let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); + let (set, remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("SPROUT_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only") + ); + assert!(!set_map.contains_key("SPROUT_ACP_RESPOND_TO_ALLOWLIST")); + assert!(remove.contains(&"SPROUT_ACP_RESPOND_TO_ALLOWLIST")); + // auth_tag is present → no AGENT_OWNER fallback fires. + assert!(remove.contains(&"SPROUT_ACP_AGENT_OWNER")); +} + +#[test] +fn build_env_allowlist_sets_both_envs_and_joins() { + let a = "a".repeat(64); + let b = "b".repeat(64); + let rec = fixture( + RespondTo::Allowlist, + vec![a.clone(), b.clone()], + Some("tag".into()), + ); + let (set, _remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("SPROUT_ACP_RESPOND_TO").map(String::as_str), + Some("allowlist") + ); + assert_eq!( + set_map + .get("SPROUT_ACP_RESPOND_TO_ALLOWLIST") + .map(String::as_str), + Some(format!("{a},{b}").as_str()), + ); +} + +#[test] +fn build_env_anyone_omits_allowlist_var() { + let rec = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + let (set, remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("SPROUT_ACP_RESPOND_TO").map(String::as_str), + Some("anyone") + ); + assert!(!set_map.contains_key("SPROUT_ACP_RESPOND_TO_ALLOWLIST")); + assert!(remove.contains(&"SPROUT_ACP_RESPOND_TO_ALLOWLIST")); +} + +#[test] +fn build_env_legacy_record_without_auth_tag_emits_agent_owner() { + let rec = fixture(RespondTo::OwnerOnly, vec![], None); + let (set, remove) = build_respond_to_env(&rec, Some("ownerhex")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("SPROUT_ACP_AGENT_OWNER").map(String::as_str), + Some("ownerhex") + ); + assert!(!remove.contains(&"SPROUT_ACP_AGENT_OWNER")); +} + +#[test] +fn build_env_legacy_record_without_owner_hex_removes_agent_owner() { + // No owner available to forward → make sure we don't inherit a leaked + // env var from the parent. + let rec = fixture(RespondTo::OwnerOnly, vec![], None); + let (_set, remove) = build_respond_to_env(&rec, None).unwrap(); + assert!(remove.contains(&"SPROUT_ACP_AGENT_OWNER")); +} + +#[test] +fn build_env_rejects_corrupted_allowlist() { + let rec = fixture( + RespondTo::Allowlist, + vec!["not-hex".into()], + Some("tag".into()), + ); + assert!(build_respond_to_env(&rec, Some("owner")).is_err()); +} + +#[test] +fn build_env_rejects_empty_allowlist_in_allowlist_mode() { + let rec = fixture(RespondTo::Allowlist, vec![], Some("tag".into())); + let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); + assert!(err.contains("at least one pubkey")); +} + +// ── resolve_effective_prompt_and_model tests ───────────────────────── + +use super::resolve_effective_prompt_and_model; +use crate::managed_agents::PersonaRecord; + +fn persona_fixture(id: &str, prompt: &str, model: Option<&str>) -> PersonaRecord { + PersonaRecord { + id: id.to_string(), + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: prompt.to_string(), + provider: None, + model: model.map(|m| m.to_string()), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_pack: None, + source_pack_persona_slug: None, + env_vars: std::collections::BTreeMap::new(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } +} + +#[test] +fn resolve_prompt_reads_fresh_persona_when_id_matches() { + let personas = vec![persona_fixture("custom:bot", "Fresh prompt", Some("gpt-5"))]; + + let (prompt, model) = resolve_effective_prompt_and_model( + Some("custom:bot"), + &personas, + Some("Stale prompt"), + Some("gpt-4"), + ); + + assert_eq!(prompt.as_deref(), Some("Fresh prompt")); + assert_eq!(model.as_deref(), Some("gpt-5")); +} + +#[test] +fn resolve_prompt_falls_back_when_no_persona_id() { + let personas = vec![persona_fixture("custom:bot", "Fresh prompt", Some("gpt-5"))]; + + let (prompt, model) = resolve_effective_prompt_and_model( + None, + &personas, + Some("Record prompt"), + Some("record-model"), + ); + + assert_eq!(prompt.as_deref(), Some("Record prompt")); + assert_eq!(model.as_deref(), Some("record-model")); +} + +#[test] +fn resolve_prompt_falls_back_when_persona_not_found() { + let personas = vec![persona_fixture("custom:other", "Other prompt", None)]; + + let (prompt, model) = resolve_effective_prompt_and_model( + Some("custom:deleted"), + &personas, + Some("Stale snapshot"), + Some("old-model"), + ); + + assert_eq!(prompt.as_deref(), Some("Stale snapshot")); + assert_eq!(model.as_deref(), Some("old-model")); +} + +#[test] +fn resolve_prompt_returns_none_when_no_persona_and_no_record_prompt() { + let (prompt, model) = resolve_effective_prompt_and_model(None, &[], None, None); + + assert_eq!(prompt, None); + assert_eq!(model, None); +} + +#[test] +fn resolve_prompt_persona_model_none_passes_through() { + // Persona exists but has no model set — should return None for model, + // not fall back to the record's model. + let personas = vec![persona_fixture("custom:bot", "Persona prompt", None)]; + + let (prompt, model) = resolve_effective_prompt_and_model( + Some("custom:bot"), + &personas, + Some("Record prompt"), + Some("record-model"), + ); + + assert_eq!(prompt.as_deref(), Some("Persona prompt")); + assert_eq!(model, None); +}