From 78b4334a93423895fae9bd6e63ff8b877fad54c4 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 14:48:33 -0400 Subject: [PATCH 1/5] feat(desktop): dynamic nest AGENTS.md regeneration AGENTS.md in ~/.sprout is now dynamically regenerated whenever personas, agents, or workspace config changes. Agents discover their teammates on every fresh session via a managed section demarcated by HTML comment markers. User edits outside the markers are preserved across regenerations. --- .../src-tauri/src/commands/agent_models.rs | 9 +- desktop/src-tauri/src/commands/agents.rs | 23 +- desktop/src-tauri/src/commands/personas.rs | 26 +- desktop/src-tauri/src/commands/workspace.rs | 10 +- desktop/src-tauri/src/lib.rs | 6 +- desktop/src-tauri/src/managed_agents/nest.rs | 244 ++++++++++++++++++ .../src/managed_agents/nest_agents.md | 7 + 7 files changed, 311 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 55b6207b..50986b4d 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -8,8 +8,9 @@ use crate::{ managed_agents::{ build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut, load_managed_agents, managed_agent_avatar_url, missing_command_message, - normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes, - AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, + normalize_agent_args, regenerate_nest_context, resolve_command, save_managed_agents, + sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse, + UpdateManagedAgentRequest, UpdateManagedAgentResponse, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -246,6 +247,10 @@ pub async fn update_managed_agent( (summary, sync_params) }; // lock dropped here + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + // Phase 2: relay profile sync (async, best-effort, outside lock) let profile_sync_error = if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 69b0e373..20111b7c 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -8,12 +8,13 @@ use crate::{ build_managed_agent_summary, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, - normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary, - save_managed_agents, start_managed_agent_process, stop_managed_agent_process, - sync_managed_agent_processes, validate_provider_config, BackendKind, BackendProviderInfo, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse, - ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, - DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND, + normalize_agent_args, provider_deploy, read_log_tail, regenerate_nest_context, + resolve_provider_binary, save_managed_agents, start_managed_agent_process, + stop_managed_agent_process, sync_managed_agent_processes, validate_provider_config, + BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, + ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND, + DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + DEFAULT_MCP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -453,6 +454,10 @@ pub async fn create_managed_agent( (agent, spawn_error) }; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── let avatar_url = input .avatar_url @@ -714,7 +719,11 @@ pub fn delete_managed_agent( if records.len() == initial_len { return Err(format!("agent {pubkey} not found")); } - save_managed_agents(&app, &records) + save_managed_agents(&app, &records)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + Ok(()) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index f5ece7f0..53124299 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -7,7 +7,7 @@ use crate::{ managed_agents::{ encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents, load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona, - parse_zip_personas, save_managed_agents, save_personas, + parse_zip_personas, regenerate_nest_context, save_managed_agents, save_personas, uninstall_persona_pack as do_uninstall_persona_pack, validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest, PackSummary, ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest, @@ -85,6 +85,9 @@ pub fn create_persona( }; personas.push(persona.clone()); save_personas(&app, &personas)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } Ok(persona) } @@ -134,6 +137,9 @@ pub fn update_persona( persona.updated_at = now_iso(); save_personas(&app, &personas)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } personas .into_iter() .find(|record| record.id == input.id) @@ -182,6 +188,9 @@ pub fn delete_persona( if changed_agents { save_managed_agents(&app, &agents)?; } + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } Ok(()) } @@ -230,6 +239,9 @@ pub fn set_persona_active( let updated = persona.clone(); save_personas(&app, &personas)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } Ok(updated) } @@ -383,7 +395,11 @@ pub fn install_persona_pack( if !source.is_dir() { return Err(format!("pack path is not a directory: {path}")); } - import_persona_pack(&app, &source) + let result = import_persona_pack(&app, &source)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + Ok(result) } #[tauri::command] @@ -396,7 +412,11 @@ pub fn uninstall_persona_pack( .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - do_uninstall_persona_pack(&app, &pack_id) + do_uninstall_persona_pack(&app, &pack_id)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + Ok(()) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index e75d1801..615dd232 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,8 +1,9 @@ use nostr::Keys; use serde::Serialize; -use tauri::State; +use tauri::{AppHandle, State}; use crate::app_state::AppState; +use crate::managed_agents::regenerate_nest_context; use crate::relay; #[derive(Serialize)] @@ -30,6 +31,7 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result, + app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { // ── Validate before mutating ────────────────────────────────────────── @@ -51,5 +53,11 @@ pub fn apply_workspace( *keys_guard = keys; } + if let Err(error) = regenerate_nest_context(&app) { + eprintln!( + "sprout-desktop: failed to regenerate nest context after workspace switch: {error}" + ); + } + Ok(()) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5e71c257..70ed6940 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -24,7 +24,7 @@ use huddle::{ speak_agent_message, start_huddle, start_stt_pipeline, }; use managed_agents::{ - ensure_nest, kill_stale_tracked_processes, load_managed_agents, + ensure_nest, kill_stale_tracked_processes, load_managed_agents, regenerate_nest_context, restore_managed_agents_on_launch, save_managed_agents, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; @@ -393,6 +393,10 @@ pub fn run() { eprintln!("sprout-desktop: failed to create nest: {error}"); } + if let Err(error) = regenerate_nest_context(&app_handle) { + eprintln!("sprout-desktop: failed to regenerate nest context: {error}"); + } + // Pre-download voice models in the background so they're ready // when the user starts their first huddle. Idempotent — no-op if // already downloaded. ~87 MB total (50 MB Moonshine + 87 MB Kokoro). diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index b9e9f119..958e0c4b 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -6,8 +6,15 @@ //! //! Idempotent: existing files and directories are never overwritten. +use super::{ + load_managed_agents, load_personas, BackendKind, ManagedAgentRecord, PersonaRecord, RespondTo, +}; +use crate::app_state::AppState; +use crate::relay::relay_ws_url_with_override; use std::fs; +use std::io; use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; /// Subdirectories created inside the nest. const NEST_DIRS: &[&str] = &[ @@ -122,6 +129,106 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { Ok(()) } +const CLI_QUICK_REFERENCE: &str = "\ +## CLI Quick Reference +`sprout messages send --channel --content ` — send a message +`sprout messages get --channel ` — read recent messages +`sprout channels list` — list available channels +`sprout workflows trigger --workflow ` — trigger a workflow +Run `sprout --help` for the full command reference."; + +pub fn render_dynamic_section( + personas: &[PersonaRecord], + agents: &[ManagedAgentRecord], + relay_url: &str, +) -> String { + let active_agents = if agents.is_empty() { + "## Active Agents\n\n*(No agents deployed yet. Add agents in the Sprout desktop app.)*" + .to_string() + } else { + let mut table = + "## Active Agents\n\n| Name | Role | How to address |\n|------|------|----------------|" + .to_string(); + for agent in agents { + let role = agent + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| p.display_name.as_str()) + .unwrap_or("—"); + table.push_str(&format!( + "\n| {} | {} | @{} |", + agent.name, role, agent.name + )); + } + table + }; + + format!("{active_agents}\n\n## Workspace\n- Relay: {relay_url}\n\n{CLI_QUICK_REFERENCE}") +} + +pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io::Result<()> { + let current = fs::read_to_string(file_path)?; + + const BEGIN: &str = ""; + + let replacement = format!( + "\n{new_section_content}\n\n" + ); + + let new_content = + if let (Some(begin_pos), Some(end_pos)) = (current.find(BEGIN), current.find(END)) { + // Find the start of the BEGIN marker's line. + let line_start = current[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0); + // END marker spans to the end of its content + the newline after it. + let end_of_end = end_pos + END.len(); + let after_end = if current[end_of_end..].starts_with('\n') { + end_of_end + 1 + } else { + end_of_end + }; + format!( + "{}{}{}", + ¤t[..line_start], + replacement, + ¤t[after_end..] + ) + } else { + format!("{}\n\n{}", current.trim_end_matches('\n'), replacement) + }; + + let tmp_path = file_path.with_extension( + file_path + .extension() + .map(|e| format!("{}.tmp", e.to_string_lossy())) + .unwrap_or_else(|| "tmp".to_string()), + ); + fs::write(&tmp_path, new_content)?; + fs::rename(&tmp_path, file_path)?; + + Ok(()) +} + +pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { + let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; + let agents_md = nest.join("AGENTS.md"); + + if !agents_md.exists() { + return Ok(()); + } + + let personas = load_personas(app)?; + let agents = load_managed_agents(app)?; + let state = app.state::(); + let relay_url = relay_ws_url_with_override(&state); + let content = render_dynamic_section(&personas, &agents, &relay_url); + upsert_managed_section(&agents_md, &content) + .map_err(|e| format!("regenerate nest context: {e}"))?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -230,4 +337,141 @@ mod tests { "symlinked child's target should not be chmod'd" ); } + + fn make_persona(id: &str, display_name: &str) -> PersonaRecord { + PersonaRecord { + id: id.to_string(), + display_name: display_name.to_string(), + avatar_url: None, + system_prompt: String::new(), + provider: None, + model: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_pack: None, + source_pack_persona_slug: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: name.to_string(), + persona_id: persona_id.map(|s| s.to_string()), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + acp_command: String::new(), + agent_command: String::new(), + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + mcp_toolsets: None, + start_on_app_launch: false, + runtime_pid: None, + backend: BackendKind::default(), + backend_agent_id: None, + provider_binary_path: None, + persona_pack_path: None, + persona_name_in_pack: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + } + } + + #[test] + fn test_render_dynamic_section_with_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Kit", Some("p1"))]; + let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); + assert!(output.contains("| Kit | Builder | @Kit |")); + assert!(output.contains("## CLI Quick Reference")); + } + + #[test] + fn test_render_dynamic_section_empty() { + let output = render_dynamic_section(&[], &[], "ws://example.com:3000"); + assert!(output.contains("No agents deployed yet")); + } + + #[test] + fn test_render_dynamic_section_agent_no_persona() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Scout", Some("nonexistent"))]; + let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); + assert!(output.contains("| Scout | — | @Scout |")); + } + + #[test] + fn test_upsert_managed_section_with_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("")); + assert!(result.contains("new section")); + assert!(!result.contains("old section")); + assert!(result.contains("# Header")); + assert!(result.contains("some content")); + assert!(result.contains("after")); + } + + #[test] + fn test_upsert_managed_section_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write(&file, "# Header\n\nexisting content\n").unwrap(); + + upsert_managed_section(&file, "injected section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("# Header")); + assert!(result.contains("existing content")); + assert!(result.contains("")); + assert!(result.contains("injected section")); + let begin_pos = result.find(" +## Active Agents + +*(No agents deployed yet. Add agents in the Sprout desktop app.)* + + From 7f80210675e776577d376072a5fc20d29ba087e4 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 14:50:35 -0400 Subject: [PATCH 2/5] fix: gate test-only imports behind #[cfg(test)] in nest.rs BackendKind and RespondTo are only used in test helper constructors. --- desktop/src-tauri/src/managed_agents/nest.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 958e0c4b..b0314da9 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -6,9 +6,9 @@ //! //! Idempotent: existing files and directories are never overwritten. -use super::{ - load_managed_agents, load_personas, BackendKind, ManagedAgentRecord, PersonaRecord, RespondTo, -}; +use super::{load_managed_agents, load_personas, ManagedAgentRecord, PersonaRecord}; +#[cfg(test)] +use super::{BackendKind, RespondTo}; use crate::app_state::AppState; use crate::relay::relay_ws_url_with_override; use std::fs; From 7ee7d8898942bd9d299d738533fccbf0ff561b37 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 16:23:15 -0400 Subject: [PATCH 3/5] fix(desktop): address review findings for nest AGENTS.md regeneration Fixes identified by crossfire review (Codex + Gemini) and plan author: - Use tempfile::NamedTempFile for atomic writes instead of deterministic .tmp path that races under concurrent regeneration triggers - Enforce ordered BEGIN/END marker search with line-start anchoring to prevent inverted slicing when markers are out of order or mid-line - Strip orphan BEGIN markers before appending new managed section - Escape pipe and newline characters in agent/persona names to prevent Markdown table corruption - Rename "Role" column to "Persona" (display_name is a name, not a role) - Move regenerate_nest_context calls outside lock scope in all mutation hooks to reduce lock hold time and eliminate future deadlock risk - Add 7 adversarial unit tests: marker ordering, orphan cleanup, duplicates, code-block false positives, pipe/newline escaping, idempotency --- desktop/src-tauri/src/commands/agents.rs | 76 ++--- desktop/src-tauri/src/commands/personas.rs | 12 +- desktop/src-tauri/src/managed_agents/nest.rs | 318 ++++++++++++++++--- 3 files changed, 326 insertions(+), 80 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 20111b7c..a81bc1e9 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -678,48 +678,50 @@ pub fn delete_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; + { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes) { - save_managed_agents(&app, &records)?; - } + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } - // Guard: reject deletion of deployed remote agents unless explicitly forced. - // This turns "don't orphan remote infra" from a UI convention into a backend - // invariant — a buggy or compromised IPC caller cannot silently orphan a live - // remote deployment. The frontend sends force_remote_delete: true only after - // the user confirms the orphan warning. - if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { - if record.backend != BackendKind::Local - && record.backend_agent_id.is_some() - && !force_remote_delete.unwrap_or(false) - { - return Err( - "cannot delete a deployed remote agent without force_remote_delete: true" - .to_string(), - ); + // Guard: reject deletion of deployed remote agents unless explicitly forced. + // This turns "don't orphan remote infra" from a UI convention into a backend + // invariant — a buggy or compromised IPC caller cannot silently orphan a live + // remote deployment. The frontend sends force_remote_delete: true only after + // the user confirms the orphan warning. + if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { + if record.backend != BackendKind::Local + && record.backend_agent_id.is_some() + && !force_remote_delete.unwrap_or(false) + { + return Err( + "cannot delete a deployed remote agent without force_remote_delete: true" + .to_string(), + ); + } } - } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - // For local agents: kills the process. For remote agents: no-op (the frontend - // sends !shutdown via WebSocket before calling delete). Either way, safe. - stop_managed_agent_process(&app, record, &mut runtimes)?; - } - let initial_len = records.len(); - records.retain(|record| record.pubkey != pubkey); - if records.len() == initial_len { - return Err(format!("agent {pubkey} not found")); + if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { + // For local agents: kills the process. For remote agents: no-op (the frontend + // sends !shutdown via WebSocket before calling delete). Either way, safe. + stop_managed_agent_process(&app, record, &mut runtimes)?; + } + let initial_len = records.len(); + records.retain(|record| record.pubkey != pubkey); + if records.len() == initial_len { + return Err(format!("agent {pubkey} not found")); + } + save_managed_agents(&app, &records)?; } - save_managed_agents(&app, &records)?; if let Err(error) = regenerate_nest_context(&app) { eprintln!("sprout-desktop: nest context regeneration failed: {error}"); } diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 53124299..39db8a98 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -128,22 +128,20 @@ pub fn update_persona( .filter(|s| !s.is_empty()) .collect(); if let Some(env_vars) = input.env_vars { - // Caller explicitly sent env_vars — replace entirely (empty = clear). crate::managed_agents::validate_user_env_keys(&env_vars)?; persona.env_vars = env_vars; } - // Absent env_vars means "don't touch" — preserve existing creds when - // the caller only meant to edit a different field. persona.updated_at = now_iso(); save_personas(&app, &personas)?; + let result = personas + .into_iter() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?; if let Err(error) = regenerate_nest_context(&app) { eprintln!("sprout-desktop: nest context regeneration failed: {error}"); } - personas - .into_iter() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id)) + Ok(result) } #[tauri::command] diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index b0314da9..fc40f3a7 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -31,6 +31,9 @@ const NEST_DIRS: &[&str] = &[ /// Fully static — no runtime interpolation, no secrets, no user paths. const AGENTS_MD: &str = include_str!("nest_agents.md"); +const BEGIN_MARKER: &str = ""; + /// Returns the nest root path (`~/.sprout`), or `None` if the home /// directory cannot be resolved. pub fn nest_dir() -> Option { @@ -137,6 +140,10 @@ const CLI_QUICK_REFERENCE: &str = "\ `sprout workflows trigger --workflow ` — trigger a workflow Run `sprout --help` for the full command reference."; +fn escape_md_cell(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ") +} + pub fn render_dynamic_section( personas: &[PersonaRecord], agents: &[ManagedAgentRecord], @@ -147,7 +154,7 @@ pub fn render_dynamic_section( .to_string() } else { let mut table = - "## Active Agents\n\n| Name | Role | How to address |\n|------|------|----------------|" + "## Active Agents\n\n| Name | Persona | How to address |\n|------|---------|----------------|" .to_string(); for agent in agents { let role = agent @@ -156,10 +163,9 @@ pub fn render_dynamic_section( .and_then(|pid| personas.iter().find(|p| p.id == pid)) .map(|p| p.display_name.as_str()) .unwrap_or("—"); - table.push_str(&format!( - "\n| {} | {} | @{} |", - agent.name, role, agent.name - )); + let name = escape_md_cell(&agent.name); + let role_escaped = escape_md_cell(role); + table.push_str(&format!("\n| {name} | {role_escaped} | @{name} |")); } table }; @@ -167,45 +173,80 @@ pub fn render_dynamic_section( format!("{active_agents}\n\n## Workspace\n- Relay: {relay_url}\n\n{CLI_QUICK_REFERENCE}") } +/// Find a marker that appears at the start of a line (position 0 or preceded by `\n`). +fn find_marker_at_line_start(content: &str, marker: &str) -> Option { + let mut search_from = 0; + while let Some(pos) = content[search_from..].find(marker) { + let abs_pos = search_from + pos; + if abs_pos == 0 || content.as_bytes()[abs_pos - 1] == b'\n' { + return Some(abs_pos); + } + search_from = abs_pos + 1; + } + None +} + +/// Find the first valid ordered BEGIN/END marker pair, both at line starts. +/// Returns `(begin_line_start, after_end)` byte offsets for slicing. +fn find_managed_markers(content: &str) -> Option<(usize, usize)> { + let begin_pos = find_marker_at_line_start(content, BEGIN_MARKER)?; + let begin_line_start = content[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0); + let end_pos = content[begin_pos..].find(END_MARKER).map(|p| p + begin_pos)?; + let end_of_end = end_pos + END_MARKER.len(); + let after_end = if content[end_of_end..].starts_with('\n') { + end_of_end + 1 + } else { + end_of_end + }; + Some((begin_line_start, after_end)) +} + +/// Remove an orphan BEGIN marker line (one with no matching END after it). +fn strip_orphan_begin_marker(content: &str) -> String { + if let Some(pos) = find_marker_at_line_start(content, BEGIN_MARKER) { + let line_start = content[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0); + let line_end = content[pos..].find('\n').map(|p| pos + p + 1).unwrap_or(content.len()); + format!( + "{}{}", + &content[..line_start], + content[line_end..].trim_start_matches('\n') + ) + } else { + content.to_string() + } +} + pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io::Result<()> { let current = fs::read_to_string(file_path)?; - const BEGIN: &str = ""; - let replacement = format!( - "\n{new_section_content}\n\n" + "{BEGIN_MARKER} — regenerated automatically, do not edit below -->\n{new_section_content}\n{END_MARKER}\n" ); - let new_content = - if let (Some(begin_pos), Some(end_pos)) = (current.find(BEGIN), current.find(END)) { - // Find the start of the BEGIN marker's line. - let line_start = current[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0); - // END marker spans to the end of its content + the newline after it. - let end_of_end = end_pos + END.len(); - let after_end = if current[end_of_end..].starts_with('\n') { - end_of_end + 1 - } else { - end_of_end - }; + let new_content = match find_managed_markers(¤t) { + Some((begin_line_start, after_end)) => { format!( "{}{}{}", - ¤t[..line_start], + ¤t[..begin_line_start], replacement, ¤t[after_end..] ) - } else { - format!("{}\n\n{}", current.trim_end_matches('\n'), replacement) - }; - - let tmp_path = file_path.with_extension( - file_path - .extension() - .map(|e| format!("{}.tmp", e.to_string_lossy())) - .unwrap_or_else(|| "tmp".to_string()), - ); - fs::write(&tmp_path, new_content)?; - fs::rename(&tmp_path, file_path)?; + } + None => { + let cleaned = strip_orphan_begin_marker(¤t); + format!("{}\n\n{}", cleaned.trim_end_matches('\n'), replacement) + } + }; + + let parent = file_path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "file path has no parent directory") + })?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + { + use std::io::Write; + tmp.write_all(new_content.as_bytes())?; + } + tmp.persist(file_path).map_err(|e| e.error)?; Ok(()) } @@ -399,6 +440,7 @@ mod tests { let agents = vec![make_agent("Kit", Some("p1"))]; let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); assert!(output.contains("| Kit | Builder | @Kit |")); + assert!(output.contains("| Name | Persona | How to address |")); assert!(output.contains("## CLI Quick Reference")); } @@ -468,10 +510,214 @@ mod tests { upsert_managed_section(&file, "content").unwrap(); - let tmp_path = file.with_extension("md.tmp"); + // Verify no stray temp files in the directory + let entries: Vec<_> = fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(entries.len(), 1, "only AGENTS.md should remain, no temp files"); + assert_eq!(entries[0].file_name(), "AGENTS.md"); + } + + #[test] + fn test_upsert_end_before_begin() { + // An END marker that precedes a BEGIN marker forms no valid ordered pair. + // find_managed_markers returns None (BEGIN found, but no END after it), + // so the orphan BEGIN line is stripped and a new block is appended. + // The stray END line and content between END and BEGIN remain in the file + // because strip_orphan_begin_marker only removes the BEGIN line itself. + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\nsome middle content\n\nold section\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!(result.contains("new section"), "new content must be present"); + assert!(result.contains("some middle content"), "content between markers must survive"); + + // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); + + // The single BEGIN marker must have a matching END marker after it. + let begin_pos = result.find(BEGIN_MARKER).expect("BEGIN marker must be present"); + let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); + assert!( + end_pos.is_some(), + "an END marker must appear after the appended BEGIN marker" + ); + } + + #[test] + fn test_upsert_begin_only_no_end() { + // A file with BEGIN but no END has an orphan marker. + // find_managed_markers returns None (no END found after BEGIN), + // so strip_orphan_begin_marker removes the BEGIN line. + // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, + // not the body that came after it). + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\norphaned section without end marker\n", + ) + .unwrap(); + + upsert_managed_section(&file, "fresh section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!(result.contains("some content"), "original body must survive"); + assert!(result.contains("fresh section"), "new content must be present"); + + let begin_pos = result.find(BEGIN_MARKER).expect("BEGIN marker must be present"); + let end_pos = result.find(END_MARKER).expect("END marker must be present"); assert!( - !tmp_path.exists(), - ".tmp file should not exist after successful upsert" + begin_pos < end_pos, + "the appended BEGIN marker must precede the appended END marker" + ); + + // Exactly one BEGIN marker after orphan cleanup. + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); + } + + #[test] + fn test_upsert_duplicate_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "replaced").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("replaced"), "replacement content must be present"); + assert!(!result.contains("first block"), "first block must be replaced"); + assert!(result.contains("second block"), "second pair content must survive"); + assert!(result.contains("between blocks"), "text between pairs must survive"); + } + + #[test] + fn test_upsert_marker_in_code_block() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. + fs::write( + &file, + "# Header\n\n \n\nReal content here\n", + ) + .unwrap(); + + upsert_managed_section(&file, "appended content").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains(" "), + "indented marker inside code block must be preserved verbatim" + ); + assert!(result.contains("appended content"), "new content must be appended"); + assert!(result.contains("Real content here"), "existing body must survive"); + + // The real markers appended at the end must be at line-start (column 0). + let begin_pos = result + .find("\nexisting section\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_first = fs::read_to_string(&file).unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_second = fs::read_to_string(&file).unwrap(); + + assert_eq!( + after_first, after_second, + "upsert must be idempotent: second call must not alter the file" ); } } From d4afa2745f056da2fce957e219c027107efea936 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 16:24:20 -0400 Subject: [PATCH 4/5] style: apply rustfmt to review-fix commit --- desktop/src-tauri/src/managed_agents/nest.rs | 78 ++++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index fc40f3a7..0b9ab682 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -191,7 +191,9 @@ fn find_marker_at_line_start(content: &str, marker: &str) -> Option { fn find_managed_markers(content: &str) -> Option<(usize, usize)> { let begin_pos = find_marker_at_line_start(content, BEGIN_MARKER)?; let begin_line_start = content[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0); - let end_pos = content[begin_pos..].find(END_MARKER).map(|p| p + begin_pos)?; + let end_pos = content[begin_pos..] + .find(END_MARKER) + .map(|p| p + begin_pos)?; let end_of_end = end_pos + END_MARKER.len(); let after_end = if content[end_of_end..].starts_with('\n') { end_of_end + 1 @@ -205,7 +207,10 @@ fn find_managed_markers(content: &str) -> Option<(usize, usize)> { fn strip_orphan_begin_marker(content: &str) -> String { if let Some(pos) = find_marker_at_line_start(content, BEGIN_MARKER) { let line_start = content[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0); - let line_end = content[pos..].find('\n').map(|p| pos + p + 1).unwrap_or(content.len()); + let line_end = content[pos..] + .find('\n') + .map(|p| pos + p + 1) + .unwrap_or(content.len()); format!( "{}{}", &content[..line_start], @@ -239,7 +244,10 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io }; let parent = file_path.parent().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "file path has no parent directory") + io::Error::new( + io::ErrorKind::InvalidInput, + "file path has no parent directory", + ) })?; let mut tmp = tempfile::NamedTempFile::new_in(parent)?; { @@ -515,7 +523,11 @@ mod tests { .unwrap() .filter_map(|e| e.ok()) .collect(); - assert_eq!(entries.len(), 1, "only AGENTS.md should remain, no temp files"); + assert_eq!( + entries.len(), + 1, + "only AGENTS.md should remain, no temp files" + ); assert_eq!(entries[0].file_name(), "AGENTS.md"); } @@ -539,8 +551,14 @@ mod tests { let result = fs::read_to_string(&file).unwrap(); assert!(result.contains("# Header"), "original header must survive"); - assert!(result.contains("new section"), "new content must be present"); - assert!(result.contains("some middle content"), "content between markers must survive"); + assert!( + result.contains("new section"), + "new content must be present" + ); + assert!( + result.contains("some middle content"), + "content between markers must survive" + ); // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). assert_eq!( @@ -550,7 +568,9 @@ mod tests { ); // The single BEGIN marker must have a matching END marker after it. - let begin_pos = result.find(BEGIN_MARKER).expect("BEGIN marker must be present"); + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); assert!( end_pos.is_some(), @@ -578,10 +598,18 @@ mod tests { let result = fs::read_to_string(&file).unwrap(); assert!(result.contains("# Header"), "original header must survive"); - assert!(result.contains("some content"), "original body must survive"); - assert!(result.contains("fresh section"), "new content must be present"); + assert!( + result.contains("some content"), + "original body must survive" + ); + assert!( + result.contains("fresh section"), + "new content must be present" + ); - let begin_pos = result.find(BEGIN_MARKER).expect("BEGIN marker must be present"); + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); let end_pos = result.find(END_MARKER).expect("END marker must be present"); assert!( begin_pos < end_pos, @@ -610,10 +638,22 @@ mod tests { let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("replaced"), "replacement content must be present"); - assert!(!result.contains("first block"), "first block must be replaced"); - assert!(result.contains("second block"), "second pair content must survive"); - assert!(result.contains("between blocks"), "text between pairs must survive"); + assert!( + result.contains("replaced"), + "replacement content must be present" + ); + assert!( + !result.contains("first block"), + "first block must be replaced" + ); + assert!( + result.contains("second block"), + "second pair content must survive" + ); + assert!( + result.contains("between blocks"), + "text between pairs must survive" + ); } #[test] @@ -635,8 +675,14 @@ mod tests { result.contains(" "), "indented marker inside code block must be preserved verbatim" ); - assert!(result.contains("appended content"), "new content must be appended"); - assert!(result.contains("Real content here"), "existing body must survive"); + assert!( + result.contains("appended content"), + "new content must be appended" + ); + assert!( + result.contains("Real content here"), + "existing body must survive" + ); // The real markers appended at the end must be at line-start (column 0). let begin_pos = result From 74a2c1aad53a70ab6134a693a9625c26c79bda74 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 15 May 2026 15:51:02 -0400 Subject: [PATCH 5/5] fix(desktop): add nest.rs to file size check overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nest.rs grew to 770 lines with regenerate_nest_context, marker helpers, and 19 unit tests. The 500-line default is too tight for this file — override to 800 following the established pattern. --- desktop/scripts/check-file-sizes.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index ba850f11..1ffddfc3 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -52,6 +52,7 @@ const overrides = new Map([ ["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/types.rs", 715], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field + ["src-tauri/src/managed_agents/nest.rs", 800], // regenerate_nest_context + render_dynamic_section + upsert_managed_section + marker helpers + 19 unit tests ["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) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh ["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation