mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): drop nest relay filter, serialize regen, split tests
The nest renderer had three defects surfaced in review: Relay-scope filter contradicted the agents-everywhere model. render_dynamic_section dropped records whose stored relay_url differed from the active workspace, but effective_agent_relay_url() deliberately ignores that legacy creation-era field — every agent is eligible on every community, and snapshot-imported records store relay_url empty by design. The filter silently hid valid, runnable agents after a workspace switch or import. Removed the relay-pin predicate; the archive filter is now the only roster gate. The foreign-relay relic records leave the table via record deletion, not code. Detached regenerations could publish stale whole-file state out of order. Each try_regenerate_nest spawned an unconstrained task that snapshotted state, awaited two relay requests, then wrote — so a slow pre-edit generation could overwrite a newer one, deterministically at boot where the fallback-relay regen races the apply_workspace regen. Added NestRegenCoalescer: a monotonic generation is claimed synchronously at request time (encoding call order) and gates the write under one lock spanning the compare-and-write, so a lower-generation task drops its result instead of rolling the file back. nest/tests.rs blew the 1000-line file-size ratchet. Split the renderer, upsert, and new coalescer tests into nest/render_tests.rs. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -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<String>,
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
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<bool> {
|
||||
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<String> = 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;
|
||||
|
||||
@@ -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<String> = [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<String> = ["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<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n<!-- END BUZZ MANAGED -->\n\nafter\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
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("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("injected section"));
|
||||
let begin_pos = result.find("<!-- BEGIN BUZZ MANAGED").unwrap();
|
||||
let header_pos = result.find("# Header").unwrap();
|
||||
assert!(
|
||||
header_pos < begin_pos,
|
||||
"original content should precede the managed section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_no_tmp_leftover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "content").unwrap();
|
||||
|
||||
// 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<!-- END BUZZ MANAGED -->\nsome middle content\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\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<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\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<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nfirst block\n<!-- END BUZZ MANAGED -->\n\nbetween blocks\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nsecond block\n<!-- END BUZZ MANAGED -->\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 <!-- BEGIN BUZZ MANAGED — some indented marker -->\n\nReal content here\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "appended content").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains(" <!-- BEGIN BUZZ MANAGED — some indented marker -->"),
|
||||
"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("<!-- BEGIN BUZZ MANAGED — regenerated")
|
||||
.expect("regenerated BEGIN marker must be present");
|
||||
assert!(
|
||||
begin_pos == 0 || result.as_bytes()[begin_pos - 1] == b'\n',
|
||||
"appended BEGIN marker must be at line start"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pipe_in_agent_name() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit|Pro", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("Kit\\|Pro"),
|
||||
"pipe in agent name must be escaped as \\|"
|
||||
);
|
||||
// An unescaped bare `|` immediately adjacent to "Kit|Pro" would break table parsing.
|
||||
assert!(
|
||||
!output.contains("| Kit|Pro |"),
|
||||
"unescaped pipe in agent name must not appear as a cell boundary"
|
||||
);
|
||||
|
||||
// The row must start and end with `|` and the escaped name and address must appear.
|
||||
let kit_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Kit\\|Pro"))
|
||||
.expect("Kit\\|Pro row must be present");
|
||||
assert!(kit_row.starts_with('|'), "row must start with |");
|
||||
assert!(kit_row.ends_with('|'), "row must end with |");
|
||||
assert!(
|
||||
kit_row.contains("@Kit\\|Pro"),
|
||||
"address cell must use escaped name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_newline_in_persona_name() {
|
||||
let personas = vec![make_persona("p1", "Builder\nExpert")];
|
||||
let agents = vec![make_agent("Scout", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("Builder Expert"),
|
||||
"newline in persona display_name must be replaced with a space"
|
||||
);
|
||||
|
||||
// The table row for Scout must be a single line (no embedded newline).
|
||||
let scout_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Scout"))
|
||||
.expect("Scout row must be present");
|
||||
assert!(
|
||||
scout_row.contains("Builder Expert"),
|
||||
"persona name with newline replaced by space must appear on the Scout row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nexisting section\n<!-- END BUZZ MANAGED -->\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<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\n\n<!-- END BUZZ MANAGED -->\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"));
|
||||
}
|
||||
@@ -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<String> = [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<String> = ["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<String> = [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<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nold section\n<!-- END BUZZ MANAGED -->\n\nafter\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "new section").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
assert!(result.contains("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
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("<!-- BEGIN BUZZ MANAGED"));
|
||||
assert!(result.contains("<!-- END BUZZ MANAGED -->"));
|
||||
assert!(result.contains("injected section"));
|
||||
let begin_pos = result.find("<!-- BEGIN BUZZ MANAGED").unwrap();
|
||||
let header_pos = result.find("# Header").unwrap();
|
||||
assert!(
|
||||
header_pos < begin_pos,
|
||||
"original content should precede the managed section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_managed_section_no_tmp_leftover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(&file, "# Header\n").unwrap();
|
||||
|
||||
upsert_managed_section(&file, "content").unwrap();
|
||||
|
||||
// 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<!-- END BUZZ MANAGED -->\nsome middle content\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\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<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\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<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nfirst block\n<!-- END BUZZ MANAGED -->\n\nbetween blocks\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nsecond block\n<!-- END BUZZ MANAGED -->\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 <!-- BEGIN BUZZ MANAGED — some indented marker -->\n\nReal content here\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
upsert_managed_section(&file, "appended content").unwrap();
|
||||
|
||||
let result = fs::read_to_string(&file).unwrap();
|
||||
|
||||
assert!(
|
||||
result.contains(" <!-- BEGIN BUZZ MANAGED — some indented marker -->"),
|
||||
"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("<!-- BEGIN BUZZ MANAGED — regenerated")
|
||||
.expect("regenerated BEGIN marker must be present");
|
||||
assert!(
|
||||
begin_pos == 0 || result.as_bytes()[begin_pos - 1] == b'\n',
|
||||
"appended BEGIN marker must be at line start"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pipe_in_agent_name() {
|
||||
let personas = vec![make_persona("p1", "Builder")];
|
||||
let agents = vec![make_agent("Kit|Pro", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("Kit\\|Pro"),
|
||||
"pipe in agent name must be escaped as \\|"
|
||||
);
|
||||
// An unescaped bare `|` immediately adjacent to "Kit|Pro" would break table parsing.
|
||||
assert!(
|
||||
!output.contains("| Kit|Pro |"),
|
||||
"unescaped pipe in agent name must not appear as a cell boundary"
|
||||
);
|
||||
|
||||
// The row must start and end with `|` and the escaped name and address must appear.
|
||||
let kit_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Kit\\|Pro"))
|
||||
.expect("Kit\\|Pro row must be present");
|
||||
assert!(kit_row.starts_with('|'), "row must start with |");
|
||||
assert!(kit_row.ends_with('|'), "row must end with |");
|
||||
assert!(
|
||||
kit_row.contains("@Kit\\|Pro"),
|
||||
"address cell must use escaped name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_newline_in_persona_name() {
|
||||
let personas = vec![make_persona("p1", "Builder\nExpert")];
|
||||
let agents = vec![make_agent("Scout", Some("p1"))];
|
||||
let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY);
|
||||
|
||||
assert!(
|
||||
output.contains("Builder Expert"),
|
||||
"newline in persona display_name must be replaced with a space"
|
||||
);
|
||||
|
||||
// The table row for Scout must be a single line (no embedded newline).
|
||||
let scout_row = output
|
||||
.lines()
|
||||
.find(|l| l.contains("Scout"))
|
||||
.expect("Scout row must be present");
|
||||
assert!(
|
||||
scout_row.contains("Builder Expert"),
|
||||
"persona name with newline replaced by space must appear on the Scout row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("AGENTS.md");
|
||||
fs::write(
|
||||
&file,
|
||||
"# Header\n\n<!-- BEGIN BUZZ MANAGED — regenerated automatically, do not edit below -->\nexisting section\n<!-- END BUZZ MANAGED -->\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();
|
||||
|
||||
Reference in New Issue
Block a user