diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index ba850f11c..1ffddfc3c 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 diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 55b6207b4..50986b4d4 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 69b0e3736..a81bc1e94 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 @@ -673,48 +678,54 @@ 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)?; + 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)?; } - 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 Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); } - save_managed_agents(&app, &records) + Ok(()) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index f5ece7f01..39db8a987 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) } @@ -125,19 +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)?; - personas + let result = personas .into_iter() .find(|record| record.id == input.id) - .ok_or_else(|| format!("persona {} disappeared unexpectedly", 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}"); + } + Ok(result) } #[tauri::command] @@ -182,6 +186,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 +237,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 +393,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 +410,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 e75d1801d..615dd2324 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 5e71c2579..70ed6940c 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 b9e9f119b..0b9ab6822 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, ManagedAgentRecord, PersonaRecord}; +#[cfg(test)] +use super::{BackendKind, 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] = &[ @@ -24,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 { @@ -122,6 +132,152 @@ 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."; + +fn escape_md_cell(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ") +} + +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 | Persona | 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("—"); + let name = escape_md_cell(&agent.name); + let role_escaped = escape_md_cell(role); + table.push_str(&format!("\n| {name} | {role_escaped} | @{name} |")); + } + table + }; + + 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)?; + + let replacement = format!( + "{BEGIN_MARKER} — regenerated automatically, do not edit below -->\n{new_section_content}\n{END_MARKER}\n" + ); + + let new_content = match find_managed_markers(¤t) { + Some((begin_line_start, after_end)) => { + format!( + "{}{}{}", + ¤t[..begin_line_start], + replacement, + ¤t[after_end..] + ) + } + 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(()) +} + +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 +386,384 @@ 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("| Name | Persona | How to address |")); + 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("\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!( + 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" + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 463a6b068..83ee19c3c 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -70,3 +70,10 @@ created: 2026-01-15 - **Never push without approval** — do not `git push` to any remote - **Stay on task** — only stage files relevant to your current work - **Tagging or @mentioning others** — you can mention other bots or users by simply @'ing them in your message, but you cannot bold, italicize, or otherwise format the mention text if you want them to actually be alerted + + +## Active Agents + +*(No agents deployed yet. Add agents in the Sprout desktop app.)* + +