diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index bd479579c..a3cca69ce 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -17,6 +17,8 @@ use std::collections::HashSet; use std::fs; use std::io; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; use tauri::{AppHandle, Manager}; use crate::managed_agents::discovery::known_skill_dirs; @@ -525,22 +527,6 @@ fn escape_md_cell(s: &str) -> String { s.replace('|', "\\|").replace('\n', " ") } -/// Normalize a relay URL for equality: trim surrounding space, drop a trailing -/// slash, and lowercase. Scheme and host are ASCII-case-insensitive and these -/// relay URLs carry no meaningful path, so this collapses the incidental -/// variation (trailing `/`, casing) without over-parsing. -fn normalize_relay_url(url: &str) -> String { - url.trim().trim_end_matches('/').to_ascii_lowercase() -} - -/// True iff the instance's pinned `relay_url` is the active workspace relay. -/// A *local* check on data already in the store — unconditional, unlike the -/// archive filter (there is no fetch to fail open on). Records pinned to a -/// defunct relay must not render under the active relay's header. -fn is_on_active_relay(record: &ManagedAgentRecord, active_relay_normalized: &str) -> bool { - normalize_relay_url(&record.relay_url) == active_relay_normalized -} - /// True iff the relay has archived this instance's identity. Membership is /// tested against the relay's `kind:13535` snapshot (lowercased hex); an empty /// set (relay unreachable) fails open — see [`regenerate_nest_context`]. @@ -554,10 +540,12 @@ pub fn render_dynamic_section( archived: &HashSet, relay_url: &str, ) -> String { - let active_relay = normalize_relay_url(relay_url); + // Every managed agent is eligible on every community — `relay_url` is a + // legacy creation-era field that `effective_agent_relay_url()` deliberately + // ignores, and snapshot-imported records store it empty by design. The only + // roster filter is identity-archive. let live: Vec<&ManagedAgentRecord> = agents .iter() - .filter(|a| is_on_active_relay(a, &active_relay)) .filter(|a| !is_archived(a, archived)) .collect(); let active_agents = if live.is_empty() { @@ -677,7 +665,64 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io Ok(()) } -pub async fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { +/// Serializes nest-context writes so a slow, stale regeneration cannot roll the +/// file back over a newer one. +/// +/// Each regeneration request claims a monotonic generation *synchronously* at +/// request time (see [`NestRegenCoalescer::claim`]), so the generation encodes +/// program order: boot's regen is claimed before `apply_workspace`'s, an edit's +/// regen before the next edit's. The claimed generation travels with the +/// spawned task and gates its write in [`NestRegenCoalescer::commit`]: a task +/// whose generation is below the high-water mark drops its result instead of +/// overwriting the newer file. The commit lock is held across the compare and +/// the synchronous file write, so two tasks cannot both read a stale mark and +/// both write — a bare mutex acquired around only the write would still permit +/// that stale-last rollback. +struct NestRegenCoalescer { + /// Next generation to hand out. `fetch_add` under a single atomic preserves + /// the program order of `claim` calls regardless of memory ordering. + next_gen: AtomicU64, + /// Highest generation already written. `0` means nothing written yet. + last_written: Mutex, +} + +impl NestRegenCoalescer { + const fn new() -> Self { + Self { + next_gen: AtomicU64::new(1), + last_written: Mutex::new(0), + } + } + + /// Claim the next generation. Call synchronously at request time so the + /// value reflects when the regeneration was requested, not when its task + /// happens to run. + fn claim(&self) -> u64 { + self.next_gen.fetch_add(1, Ordering::SeqCst) + } + + /// Commit `content` for `generation`, dropping the write when a newer + /// generation has already committed. Returns whether the file was written. + /// The lock spans the compare and the write so the check-and-write is + /// atomic and no await occurs while it is held. + fn commit(&self, agents_md: &Path, content: &str, generation: u64) -> io::Result { + let mut last = self + .last_written + .lock() + .expect("nest regen coalescer lock poisoned"); + if generation < *last { + return Ok(false); + } + upsert_managed_section(agents_md, content)?; + *last = generation; + Ok(true) + } +} + +/// Process-wide coalescer for nest-context regeneration. +static NEST_REGEN: NestRegenCoalescer = NestRegenCoalescer::new(); + +pub async fn regenerate_nest_context(app: &AppHandle, generation: u64) -> Result<(), String> { let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; let agents_md = nest.join("AGENTS.md"); @@ -691,30 +736,40 @@ pub async fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { let relay_url = relay_ws_url_with_override(&state); // Identity-archived agents live only in the relay's `kind:13535` snapshot; // local records all read `is_active: true`. Fails open (empty set → render - // everyone) so an unreachable relay can't blank the roster. + // everyone) so an unreachable relay can't blank the roster. The archive read + // and the relay URL above are read for this generation; a later generation's + // task always wins the commit, so a fallback-relay boot render cannot bury a + // later apply_workspace render. let archived: HashSet = fetch_archived_pubkeys(&state).await.into_iter().collect(); let content = render_dynamic_section(&personas, &agents, &archived, &relay_url); - upsert_managed_section(&agents_md, &content) + NEST_REGEN + .commit(&agents_md, &content, generation) .map_err(|e| format!("regenerate nest context: {e}"))?; Ok(()) } -/// Convenience wrapper: regenerates nest context, logging a warning on failure. +/// Convenience wrapper: claims a regeneration generation, then regenerates on a +/// spawned task, logging a warning on failure. /// /// All call sites treat regeneration as fire-and-forget — agents run fine with /// a stale AGENTS.md, so we warn and continue rather than propagating the error. -/// Regeneration reads relay archive state, so it runs on a spawned async task; -/// callers do not await it. A just-archived agent may linger for one regen -/// cycle until the next regen (any agent/team edit or the next launch). +/// The generation is claimed *here*, synchronously, so it encodes call order; +/// the spawned task carries it into [`NestRegenCoalescer::commit`], which drops +/// a stale render rather than letting a slow task overwrite a newer file. A +/// just-archived agent may linger for one regen cycle until the next regen (any +/// agent/team edit or the next launch). pub fn try_regenerate_nest(app: &AppHandle) { + let generation = NEST_REGEN.claim(); let app = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(error) = regenerate_nest_context(&app).await { + if let Err(error) = regenerate_nest_context(&app, generation).await { eprintln!("buzz-desktop: nest context regeneration failed: {error}"); } }); } +#[cfg(test)] +mod render_tests; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs new file mode 100644 index 000000000..a62f5c90f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -0,0 +1,599 @@ +//! Tests for the dynamic AGENTS.md section renderer, the managed-section +//! upsert, and the regeneration coalescer. Split from `tests.rs` to keep +//! each test file under the repository's per-file line ratchet. + +use super::*; +use std::collections::HashSet; + +/// Relay URL passed to render calls. Since the roster no longer filters on +/// `relay_url`, this is only echoed into the Workspace footer. +const TEST_RELAY: &str = "ws://example.com:3000"; + +fn make_persona(id: &str, display_name: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: display_name.to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: 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: TEST_RELAY.to_string(), + 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, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::default(), + backend_agent_id: None, + provider_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + env_vars: std::collections::BTreeMap::new(), + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} + +#[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, &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Kit | Builder | @Kit |")); + assert!(output.contains("| Name | Persona | How to address |")); + assert!(output.contains("## Workspace")); +} + +#[test] +fn test_render_dynamic_section_empty() { + let output = render_dynamic_section(&[], &[], &HashSet::new(), TEST_RELAY); + 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, &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Scout | — | @Scout |")); +} + +#[test] +fn test_render_excludes_archived_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let mut live = make_agent("Live", Some("p1")); + live.pubkey = "aa".repeat(32); + let mut gone = make_agent("Archived", Some("p1")); + gone.pubkey = "bb".repeat(32); + let archived: HashSet = [gone.pubkey.clone()].into_iter().collect(); + + let output = render_dynamic_section(&personas, &[live, gone], &archived, TEST_RELAY); + + assert!(output.contains("| Live | Builder | @Live |")); + assert!( + !output.contains("Archived"), + "archived agent must not render" + ); +} + +#[test] +fn test_render_archived_match_is_case_insensitive() { + let personas = vec![make_persona("p1", "Builder")]; + let mut gone = make_agent("Archived", Some("p1")); + gone.pubkey = "AB".repeat(32); // uppercase hex in the record + // Snapshot pubkeys are lowercased by `archived_pubkeys_from_snapshot`. + let archived: HashSet = ["ab".repeat(32)].into_iter().collect(); + + let output = render_dynamic_section(&personas, &[gone], &archived, TEST_RELAY); + + assert!( + output.contains("No agents deployed yet"), + "all-archived roster renders the empty placeholder" + ); +} + +#[test] +fn test_render_empty_archived_set_renders_all() { + let personas = vec![make_persona("p1", "Builder")]; + let mut a = make_agent("Kit", Some("p1")); + a.pubkey = "cc".repeat(32); + // Fail-open: an empty snapshot (relay unreachable) must render everyone. + let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Kit | Builder | @Kit |")); +} + +#[test] +fn test_render_keeps_agent_with_legacy_foreign_relay_pin() { + // `relay_url` is a legacy creation-era field that `effective_agent_relay_url()` + // deliberately ignores — every agent is eligible on every community. A record + // whose stored pin points at a now-defunct relay must still render on the + // active workspace; only identity-archive removes an agent. + let personas = vec![make_persona("p1", "Builder")]; + let here = make_agent("Local", Some("p1")); + let mut elsewhere = make_agent("Foreign", Some("p1")); + elsewhere.relay_url = "wss://defunct.communities.buzz.xyz".to_string(); + + let output = render_dynamic_section(&personas, &[here, elsewhere], &HashSet::new(), TEST_RELAY); + + assert!(output.contains("| Local | Builder | @Local |")); + assert!( + output.contains("| Foreign | Builder | @Foreign |"), + "a legacy foreign relay pin must not hide an agent — the pin is ignored" + ); +} + +#[test] +fn test_render_keeps_snapshot_imported_agent_with_empty_relay_pin() { + // Snapshot-imported records store `relay_url: ""` by design; they resolve + // to the workspace relay at runtime. Such an agent must appear on the active + // workspace, not be hidden by an empty pin. + let personas = vec![make_persona("p1", "Builder")]; + let mut imported = make_agent("Imported", Some("p1")); + imported.relay_url = String::new(); + + let output = render_dynamic_section(&personas, &[imported], &HashSet::new(), TEST_RELAY); + + assert!( + output.contains("| Imported | Builder | @Imported |"), + "an empty relay_url (snapshot-import shape) must still render" + ); +} + +#[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" + ); +} + +/// Write an AGENTS.md skeleton with an empty managed section and return its path. +fn agents_md_with_markers(dir: &Path) -> PathBuf { + let file = dir.join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\n\n\n", + ) + .unwrap(); + file +} + +#[test] +fn commit_newer_generation_wins_over_a_stale_finisher() { + // Models the CRUD race: generation A snapshots pre-edit state and its relay + // fetch is slow; generation B snapshots post-edit state and commits first. + // When A finally finishes and commits LAST, its lower generation is dropped + // so the file still reflects B. Ordering of *finishing* is the only variable — + // the generation, claimed at request time, decides the winner. + let coalescer = NestRegenCoalescer::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen_a = coalescer.claim(); // pre-edit request + let gen_b = coalescer.claim(); // post-edit request + assert!(gen_a < gen_b); + + // B (newer) commits first. + assert!(coalescer.commit(&file, "post-edit roster", gen_b).unwrap()); + // A (older) finishes last and must be dropped. + assert!( + !coalescer.commit(&file, "pre-edit roster", gen_a).unwrap(), + "a stale (lower-generation) render must not overwrite a newer one" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("post-edit roster")); + assert!( + !content.contains("pre-edit roster"), + "final file must reflect the newer generation, not the stale finisher" + ); +} + +#[test] +fn commit_boot_fallback_relay_cannot_bury_apply_workspace_relay() { + // Models boot→apply_workspace relay switching: the boot regen (generation 1, + // fallback relay) is claimed first but finishes last; the apply_workspace + // regen (generation 2, workspace relay) commits first. The workspace relay + // render must survive even though the fallback-relay task writes afterward. + let coalescer = NestRegenCoalescer::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let boot_gen = coalescer.claim(); // boot, fallback relay + let apply_gen = coalescer.claim(); // apply_workspace, workspace relay + + // apply_workspace's render lands first. + assert!(coalescer + .commit( + &file, + "## Workspace\n- Relay: wss://workspace.example", + apply_gen, + ) + .unwrap()); + // Boot's slower fallback-relay render finishes last and is dropped. + assert!(!coalescer + .commit( + &file, + "## Workspace\n- Relay: wss://fallback.example", + boot_gen, + ) + .unwrap()); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("wss://workspace.example")); + assert!( + !content.contains("wss://fallback.example"), + "the fallback-relay boot render must not overwrite the workspace-relay render" + ); +} + +#[test] +fn commit_equal_generation_is_allowed() { + // The gate rejects only strictly-lower generations. Re-committing the same + // generation (e.g. a retried request) is permitted and refreshes the file. + let coalescer = NestRegenCoalescer::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen = coalescer.claim(); + assert!(coalescer.commit(&file, "first", gen).unwrap()); + assert!( + coalescer.commit(&file, "second", gen).unwrap(), + "an equal generation must still be allowed to write" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("second")); +} diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 641d42d09..bc67a5b69 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -1,9 +1,5 @@ use super::*; -/// Relay URL every `make_agent` record is pinned to; render calls pass the same -/// value so the relay-scope filter keeps them unless a test overrides it. -const TEST_RELAY: &str = "ws://example.com:3000"; - #[test] fn nest_dir_is_under_home() { if let Some(dir) = nest_dir() { @@ -426,518 +422,6 @@ fn ensure_cli_symlink_does_not_clobber_regular_file_dev() { ); } -fn make_persona(id: &str, display_name: &str) -> AgentDefinition { - AgentDefinition { - id: id.to_string(), - display_name: display_name.to_string(), - avatar_url: None, - system_prompt: String::new(), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - env_vars: std::collections::BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: 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: TEST_RELAY.to_string(), - 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, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::default(), - backend_agent_id: None, - provider_policy_pending: false, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::default(), - respond_to_allowlist: vec![], - env_vars: std::collections::BTreeMap::new(), - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } -} - -#[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, &HashSet::new(), TEST_RELAY); - assert!(output.contains("| Kit | Builder | @Kit |")); - assert!(output.contains("| Name | Persona | How to address |")); - assert!(output.contains("## Workspace")); -} - -#[test] -fn test_render_dynamic_section_empty() { - let output = render_dynamic_section(&[], &[], &HashSet::new(), TEST_RELAY); - 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, &HashSet::new(), TEST_RELAY); - assert!(output.contains("| Scout | — | @Scout |")); -} - -#[test] -fn test_render_excludes_archived_agents() { - let personas = vec![make_persona("p1", "Builder")]; - let mut live = make_agent("Live", Some("p1")); - live.pubkey = "aa".repeat(32); - let mut gone = make_agent("Archived", Some("p1")); - gone.pubkey = "bb".repeat(32); - let archived: HashSet = [gone.pubkey.clone()].into_iter().collect(); - - let output = render_dynamic_section(&personas, &[live, gone], &archived, TEST_RELAY); - - assert!(output.contains("| Live | Builder | @Live |")); - assert!( - !output.contains("Archived"), - "archived agent must not render" - ); -} - -#[test] -fn test_render_archived_match_is_case_insensitive() { - let personas = vec![make_persona("p1", "Builder")]; - let mut gone = make_agent("Archived", Some("p1")); - gone.pubkey = "AB".repeat(32); // uppercase hex in the record - // Snapshot pubkeys are lowercased by `archived_pubkeys_from_snapshot`. - let archived: HashSet = ["ab".repeat(32)].into_iter().collect(); - - let output = render_dynamic_section(&personas, &[gone], &archived, TEST_RELAY); - - assert!( - output.contains("No agents deployed yet"), - "all-archived roster renders the empty placeholder" - ); -} - -#[test] -fn test_render_empty_archived_set_renders_all() { - let personas = vec![make_persona("p1", "Builder")]; - let mut a = make_agent("Kit", Some("p1")); - a.pubkey = "cc".repeat(32); - // Fail-open: an empty snapshot (relay unreachable) must render everyone. - let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY); - assert!(output.contains("| Kit | Builder | @Kit |")); -} - -#[test] -fn test_render_excludes_foreign_relay_agents() { - let personas = vec![make_persona("p1", "Builder")]; - let here = make_agent("Local", Some("p1")); - let mut elsewhere = make_agent("Foreign", Some("p1")); - elsewhere.relay_url = "wss://defunct.communities.buzz.xyz".to_string(); - - let output = render_dynamic_section(&personas, &[here, elsewhere], &HashSet::new(), TEST_RELAY); - - assert!(output.contains("| Local | Builder | @Local |")); - assert!( - !output.contains("Foreign"), - "an agent pinned to another relay must not render" - ); -} - -#[test] -fn test_render_relay_match_ignores_trailing_slash_and_case() { - let personas = vec![make_persona("p1", "Builder")]; - let mut a = make_agent("Kit", Some("p1")); - a.relay_url = "WS://Example.com:3000/".to_string(); - // Active relay lacks the trailing slash and differs in case. - let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY); - assert!(output.contains("| Kit | Builder | @Kit |")); -} - -#[test] -fn test_render_all_foreign_relay_renders_empty_placeholder() { - let personas = vec![make_persona("p1", "Builder")]; - let mut a = make_agent("Kit", Some("p1")); - a.relay_url = "wss://defunct.communities.buzz.xyz".to_string(); - let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY); - assert!( - output.contains("No agents deployed yet"), - "a store with no active-relay records renders the placeholder, not a bare header" - ); -} - -#[test] -fn test_render_filters_are_order_independent() { - // A record that is BOTH foreign-relay and archived is dropped once; the two - // predicates are independent so neither filter's outcome depends on the - // other running first. - let personas = vec![make_persona("p1", "Builder")]; - let keep = make_agent("Keep", Some("p1")); - let mut both = make_agent("Both", Some("p1")); - both.pubkey = "dd".repeat(32); - both.relay_url = "wss://defunct.communities.buzz.xyz".to_string(); - let archived: HashSet = [both.pubkey.clone()].into_iter().collect(); - - let output = render_dynamic_section(&personas, &[keep, both], &archived, TEST_RELAY); - - assert!(output.contains("| Keep | Builder | @Keep |")); - assert!( - !output.contains("Both"), - "a foreign-and-archived record must not render" - ); -} - -#[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" - ); -} - #[test] fn refresh_agents_md_writes_version_file() { let tmp = tempfile::tempdir().unwrap();