use super::test_support::*; use super::*; #[test] fn canonical_dev_data_dir_replaces_last_component() { let current = PathBuf::from("/Users/me/Library/Application Support/xyz.block.buzz.app.dev.my-branch"); let canonical = canonical_dev_data_dir(¤t).unwrap(); assert_eq!( canonical, PathBuf::from("/Users/me/Library/Application Support/xyz.block.buzz.app.dev") ); } #[test] fn canonical_dev_data_dir_returns_none_for_root() { // A root path has no parent — should return None. assert!(canonical_dev_data_dir(Path::new("/")).is_none()); } #[test] fn legacy_app_data_dir_maps_release_identifier() { let current = PathBuf::from("/Users/me/Library/Application Support/xyz.block.buzz.app"); let legacy = legacy_app_data_dir(¤t).unwrap(); assert_eq!( legacy, PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app") ); } #[test] fn legacy_app_data_dir_maps_dev_worktree_identifier() { let current = PathBuf::from("/Users/me/Library/Application Support/xyz.block.buzz.app.dev.my-branch"); let legacy = legacy_app_data_dir(¤t).unwrap(); assert_eq!( legacy, PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app.dev.my-branch",) ); } #[test] fn copy_dir_all_preserves_nested_files_without_overwriting() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("old"); let dst = dir.path().join("new"); std::fs::create_dir_all(src.join("agents")).unwrap(); std::fs::write(src.join("identity.key"), "old-key").unwrap(); std::fs::write(src.join("agents/managed-agents.json"), "old-agents").unwrap(); std::fs::create_dir_all(&dst).unwrap(); std::fs::write(dst.join("identity.key"), "new-key").unwrap(); copy_dir_all(&src, &dst).unwrap(); assert_eq!( std::fs::read_to_string(dst.join("identity.key")).unwrap(), "new-key" ); assert_eq!( std::fs::read_to_string(dst.join("agents/managed-agents.json")).unwrap(), "old-agents" ); } /// Helper: create a temp dir structure mimicking canonical + worktree layout. /// Packs live in a `.main` sibling (not canonical) to match real-world state. /// Returns `(parent_dir_handle, canonical_dir, worktree_dir)`. #[cfg(unix)] fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) { let parent = tempfile::tempdir().unwrap(); let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); let worktree = parent.path().join("xyz.block.buzz.app.dev.my-branch"); let main_instance = parent.path().join("xyz.block.buzz.app.dev.main"); std::fs::create_dir_all(canonical.join("agents")).unwrap(); std::fs::write( canonical.join("agents/managed-agents.json"), r#"[{"id":"agent-1"}]"#, ) .unwrap(); std::fs::write( canonical.join("agents/personas.json"), r#"[{"id":"builtin:fizz"}]"#, ) .unwrap(); std::fs::write(canonical.join("agents/teams.json"), r#"[{"id":"team-1"}]"#).unwrap(); // Teams installed from `.main` — canonical has no teams dir. let team_dir = main_instance.join("agents/teams/com.example.test-pack"); std::fs::create_dir_all(&team_dir).unwrap(); std::fs::write(team_dir.join("instructions.md"), "# Test pack").unwrap(); std::fs::write(team_dir.join("fizz.persona.md"), "# Fizz").unwrap(); (parent, canonical, worktree) } /// Helper: sync files directly (without a Tauri AppHandle) for unit testing. /// Mirrors the symlink loop of `sync_shared_agent_data` but takes explicit /// paths. `sync_shared_agent_data` requires a live Tauri AppHandle and /// cannot be unit-tested directly. #[cfg(unix)] fn sync_files(canonical: &Path, worktree: &Path) -> u32 { // Seed-up: mirrors the SHARED_AGENT_FILES seed-up in `sync_shared_agent_data`. // Kept logic-identical to production so these tests exercise real behavior. for rel in SHARED_AGENT_FILES { let canonical_file = canonical.join(rel); if canonical_file.exists() { continue; } let Some(parent) = canonical.parent() else { continue; }; let Ok(entries) = std::fs::read_dir(parent) else { continue; }; for entry in entries.flatten() { let sibling = entry.path(); if sibling == canonical { continue; } let sibling_file = sibling.join(rel); if sibling_file.is_file() && !sibling_file.is_symlink() { if let Some(file_parent) = canonical_file.parent() { std::fs::create_dir_all(file_parent).unwrap(); } let _ = std::fs::rename(&sibling_file, &canonical_file); break; } } } let mut synced = 0u32; for rel in SHARED_AGENT_FILES { let src = canonical.join(rel); let dst = worktree.join(rel); if !src.exists() { continue; } if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent).unwrap(); } if dst.is_symlink() { if let Ok(target) = std::fs::read_link(&dst) { if target == src { continue; } } } if dst.exists() || dst.is_symlink() { let _ = std::fs::remove_file(&dst); } std::os::unix::fs::symlink(&src, &dst).unwrap(); synced += 1; } // Migrate packs from siblings to canonical (mirrors production logic). for rel in SHARED_AGENT_DIRS { let canonical_target = canonical.join(rel); if !canonical_target.exists() { std::fs::create_dir_all(&canonical_target).unwrap(); if let Some(parent) = canonical.parent() { if let Ok(entries) = std::fs::read_dir(parent) { for entry in entries.flatten() { let sibling = entry.path(); if sibling == canonical { continue; } let sibling_dir = sibling.join(rel); if sibling_dir.is_dir() && !sibling_dir.is_symlink() { if let Ok(children) = std::fs::read_dir(&sibling_dir) { for child in children.flatten() { let dest = canonical_target.join(child.file_name()); if !dest.exists() { let _ = std::fs::rename(child.path(), &dest); } } } let _ = std::fs::remove_dir_all(&sibling_dir); let _ = std::os::unix::fs::symlink(&canonical_target, &sibling_dir); break; } } } } } } for rel in SHARED_AGENT_DIRS { let src = canonical.join(rel); let dst = worktree.join(rel); if !src.exists() { continue; } if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent).unwrap(); } if dst.is_symlink() { if let Ok(target) = std::fs::read_link(&dst) { if target == src { continue; } } } if dst.is_symlink() { let _ = std::fs::remove_file(&dst); } else if dst.exists() { let _ = std::fs::remove_dir_all(&dst); } std::os::unix::fs::symlink(&src, &dst).unwrap(); synced += 1; } synced } #[cfg(unix)] #[test] fn sync_creates_symlinks_to_fresh_worktree() { let (_parent, canonical, worktree) = setup_sync_layout(); let synced = sync_files(&canonical, &worktree); assert_eq!(synced, 4); for rel in SHARED_AGENT_FILES { let dst = worktree.join(rel); assert!(dst.is_symlink(), "{rel} should be a symlink"); assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); } for rel in SHARED_AGENT_DIRS { let dst = worktree.join(rel); assert!(dst.is_symlink(), "{rel} should be a symlink"); assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); } assert_eq!( std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(), r#"[{"id":"agent-1"}]"#, ); } #[cfg(unix)] #[test] fn sync_replaces_existing_files_with_symlinks() { let (_parent, canonical, worktree) = setup_sync_layout(); std::fs::create_dir_all(worktree.join("agents")).unwrap(); std::fs::write(worktree.join("agents/managed-agents.json"), "[]").unwrap(); std::fs::write(worktree.join("agents/personas.json"), "[]").unwrap(); std::fs::write(worktree.join("agents/teams.json"), "[]").unwrap(); let synced = sync_files(&canonical, &worktree); assert_eq!(synced, 4); for rel in SHARED_AGENT_FILES { let dst = worktree.join(rel); assert!( dst.is_symlink(), "{rel} should be a symlink after replacing regular file" ); assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); } assert_eq!( std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(), r#"[{"id":"agent-1"}]"#, ); } #[cfg(unix)] #[test] fn sync_preserves_correct_symlinks() { let (_parent, canonical, worktree) = setup_sync_layout(); assert_eq!(sync_files(&canonical, &worktree), 4); assert_eq!(sync_files(&canonical, &worktree), 0); for rel in SHARED_AGENT_FILES { let dst = worktree.join(rel); assert!(dst.is_symlink()); assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); } } #[cfg(unix)] #[test] fn sync_replaces_wrong_symlinks() { let (_parent, canonical, worktree) = setup_sync_layout(); let wrong_target = PathBuf::from("/nonexistent/wrong-target.json"); std::fs::create_dir_all(worktree.join("agents")).unwrap(); for rel in SHARED_AGENT_FILES { std::os::unix::fs::symlink(&wrong_target, worktree.join(rel)).unwrap(); } let synced = sync_files(&canonical, &worktree); assert_eq!(synced, 4); for rel in SHARED_AGENT_FILES { assert_eq!( std::fs::read_link(worktree.join(rel)).unwrap(), canonical.join(rel) ); } } #[cfg(unix)] #[test] fn sync_handles_broken_symlinks() { let (_parent, canonical, worktree) = setup_sync_layout(); std::fs::create_dir_all(worktree.join("agents")).unwrap(); let broken_target = PathBuf::from("/this/does/not/exist.json"); for rel in SHARED_AGENT_FILES { std::os::unix::fs::symlink(&broken_target, worktree.join(rel)).unwrap(); } let synced = sync_files(&canonical, &worktree); assert_eq!(synced, 4); for rel in SHARED_AGENT_FILES { let dst = worktree.join(rel); assert!(dst.is_symlink()); assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); // Content should be readable through the fixed symlink. assert!(std::fs::read_to_string(&dst).is_ok()); } } #[cfg(unix)] #[test] fn writes_through_symlink_reach_canonical() { let (_parent, canonical, worktree) = setup_sync_layout(); sync_files(&canonical, &worktree); let worktree_path = worktree.join("agents/personas.json"); let canonical_path = canonical.join("agents/personas.json"); // Write through the symlink using the same pattern as atomic_write_json. let new_content = r#"[{"id":"builtin:fizz","updated":true}]"#; let resolved = std::fs::canonicalize(&worktree_path).unwrap(); let tmp = resolved.with_extension("json.tmp"); std::fs::write(&tmp, new_content.as_bytes()).unwrap(); std::fs::rename(&tmp, &resolved).unwrap(); // The canonical file should have the new content. assert_eq!( std::fs::read_to_string(&canonical_path).unwrap(), new_content ); // The worktree path should still be a symlink. assert!(worktree_path.is_symlink()); // Reading through the symlink should return the new content. assert_eq!( std::fs::read_to_string(&worktree_path).unwrap(), new_content ); } #[cfg(unix)] #[test] fn seed_up_migrates_sibling_file_to_canonical_then_symlinks() { let (_parent, canonical, worktree) = setup_sync_layout(); let rel = "agents/personas.json"; // Canonical is missing the file; a sibling (.main) holds real content. std::fs::remove_file(canonical.join(rel)).unwrap(); let sibling = canonical .parent() .unwrap() .join("xyz.block.buzz.app.dev.main"); std::fs::create_dir_all(sibling.join("agents")).unwrap(); std::fs::write(sibling.join(rel), r#"[{"id":"brain"}]"#).unwrap(); sync_files(&canonical, &worktree); // The real file landed at canonical (proves the rename, not a dangling link). let canonical_file = canonical.join(rel); assert!( canonical_file.is_file() && !canonical_file.is_symlink(), "canonical should hold the migrated real file" ); assert_eq!( std::fs::read_to_string(&canonical_file).unwrap(), r#"[{"id":"brain"}]"#, ); // The worktree is symlinked to canonical. let dst = worktree.join(rel); assert!(dst.is_symlink()); assert_eq!(std::fs::read_link(&dst).unwrap(), canonical_file); } #[cfg(unix)] #[test] fn seed_up_no_sibling_content_is_noop() { let (_parent, canonical, worktree) = setup_sync_layout(); let rel = "agents/personas.json"; // Canonical missing the file and no sibling holds it. std::fs::remove_file(canonical.join(rel)).unwrap(); sync_files(&canonical, &worktree); // Nothing to seed: canonical stays missing, worktree gets no symlink for it. assert!(!canonical.join(rel).exists()); assert!(!worktree.join(rel).exists()); } #[cfg(unix)] #[test] fn seed_up_skipped_when_canonical_has_file() { let (_parent, canonical, worktree) = setup_sync_layout(); let rel = "agents/personas.json"; // A sibling also holds different content, but canonical already has the file. let sibling = canonical .parent() .unwrap() .join("xyz.block.buzz.app.dev.main"); std::fs::create_dir_all(sibling.join("agents")).unwrap(); std::fs::write(sibling.join(rel), r#"[{"id":"should-not-win"}]"#).unwrap(); sync_files(&canonical, &worktree); // Canonical's original content is untouched; the sibling did not seed it. assert_eq!( std::fs::read_to_string(canonical.join(rel)).unwrap(), r#"[{"id":"builtin:fizz"}]"#, ); // Pull-symlink path is unchanged: worktree links to canonical. let dst = worktree.join(rel); assert!(dst.is_symlink()); assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); } #[cfg(unix)] #[test] fn seed_up_ignores_sibling_symlink_as_source() { let (_parent, canonical, worktree) = setup_sync_layout(); let rel = "agents/personas.json"; std::fs::remove_file(canonical.join(rel)).unwrap(); // Sibling holds only a symlink (not real content) — not a valid seed source. let sibling = canonical .parent() .unwrap() .join("xyz.block.buzz.app.dev.main"); std::fs::create_dir_all(sibling.join("agents")).unwrap(); std::os::unix::fs::symlink( PathBuf::from("/nonexistent/elsewhere.json"), sibling.join(rel), ) .unwrap(); sync_files(&canonical, &worktree); // The symlink was not promoted; canonical stays missing. assert!(!canonical.join(rel).exists()); } #[test] fn canonical_dev_data_dir_returns_self_for_canonical_instance() { // When the current app data dir IS the canonical dev identifier, // canonical_dev_data_dir returns the exact same path — the caller // (sync_shared_agent_data) uses this equality to skip the sync. // The env-var guards (BUZZ_SHARE_IDENTITY, BUZZ_PRIVATE_KEY) // require a live Tauri AppHandle and are covered by integration // testing only. let current = PathBuf::from("/Users/me/Library/Application Support/xyz.block.buzz.app.dev"); assert_eq!(canonical_dev_data_dir(¤t).unwrap(), current); // Also verify with a temp dir on the real filesystem. let parent = tempfile::tempdir().unwrap(); let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); assert_eq!(canonical_dev_data_dir(&canonical).unwrap(), canonical); } #[cfg(unix)] #[test] fn sync_creates_teams_directory_symlink() { let (_parent, canonical, worktree) = setup_sync_layout(); sync_files(&canonical, &worktree); let teams_link = worktree.join("agents/teams"); assert!(teams_link.is_symlink()); assert_eq!( std::fs::read_link(&teams_link).unwrap(), canonical.join("agents/teams") ); assert_eq!( std::fs::read_to_string( worktree.join("agents/teams/com.example.test-pack/instructions.md") ) .unwrap(), "# Test pack" ); } #[cfg(unix)] #[test] fn sync_migrates_teams_from_sibling_to_canonical() { let (_parent, canonical, worktree) = setup_sync_layout(); let main_instance = canonical .parent() .unwrap() .join("xyz.block.buzz.app.dev.main"); // Before sync: canonical has no teams, .main has the real team dir. assert!(!canonical.join("agents/teams").exists()); assert!(main_instance .join("agents/teams/com.example.test-pack") .is_dir()); sync_files(&canonical, &worktree); // After sync: canonical has the team, .main is now a symlink. assert!(canonical .join("agents/teams/com.example.test-pack/instructions.md") .exists()); assert!(main_instance.join("agents/teams").is_symlink()); assert_eq!( std::fs::read_link(main_instance.join("agents/teams")).unwrap(), canonical.join("agents/teams") ); } #[cfg(unix)] #[test] fn sync_replaces_real_teams_dir_with_symlink() { let (_parent, canonical, worktree) = setup_sync_layout(); let real_teams = worktree.join("agents/teams"); std::fs::create_dir_all(&real_teams).unwrap(); std::fs::write(real_teams.join("stale-file.txt"), "stale").unwrap(); sync_files(&canonical, &worktree); assert!(worktree.join("agents/teams").is_symlink()); assert_eq!( std::fs::read_link(worktree.join("agents/teams")).unwrap(), canonical.join("agents/teams") ); } /// `patch_json_records` rewrites `managed-agents.json`, which carries plaintext /// agent nsecs on a keyringless host — the writeback must land `0o600` from the /// write itself (no post-write `chmod`), or a launch-time reconcile reopens the /// umask window SECURITY.md:90 closes (Thufir, migration.rs:288). #[cfg(unix)] #[test] fn patch_json_records_rewrites_secret_store_owner_only() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); write_agents_json( dir.path(), &serde_json::json!([{ "private_key_nsec": "nsec1secret", "provider": "goose" }]), ); let path = dir.path().join("agents/managed-agents.json"); // Mutate so the write actually fires (it only writes back on `changed`). patch_json_records(&path, |obj| { let provider = obj.remove("provider").unwrap(); obj.insert("runtime".to_string(), provider); true }); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "secret-bearing rewrite must be owner-only"); let records = read_agents_json(dir.path()); assert_eq!(records[0]["private_key_nsec"], "nsec1secret"); assert_eq!(records[0]["runtime"], "goose"); } #[test] fn rename_provider_to_runtime_migrates_field() { let dir = tempfile::tempdir().unwrap(); write_personas_json( dir.path(), &serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "provider": "goose" }]), ); rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); let records = read_personas_json(dir.path()); assert_eq!(records[0]["runtime"], "goose"); assert!(records[0].get("provider").is_none()); } #[test] fn rename_provider_to_runtime_is_idempotent() { let dir = tempfile::tempdir().unwrap(); write_personas_json( dir.path(), &serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]), ); let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); assert_eq!( before, after, "file should not be rewritten when already migrated" ); } #[test] fn rename_provider_to_runtime_skips_record_without_either_key() { let dir = tempfile::tempdir().unwrap(); write_personas_json( dir.path(), &serde_json::json!([{ "id": "persona-1", "displayName": "Alice" }]), ); let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); assert_eq!( before, after, "file should not be rewritten when no provider key exists" ); } #[test] fn rename_provider_to_runtime_preserves_existing_runtime_over_provider() { let dir = tempfile::tempdir().unwrap(); write_personas_json( dir.path(), &serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "provider": "old-value", "runtime": "correct-value" }]), ); rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); let records = read_personas_json(dir.path()); assert_eq!(records[0]["runtime"], "correct-value"); // provider key should still be there since the closure returns false when runtime exists assert_eq!(records[0]["provider"], "old-value"); } #[test] fn reconcile_mcp_commands_clears_stale_buzz_mcp_server() { let dir = tempfile::tempdir().unwrap(); write_agents_json( dir.path(), &serde_json::json!([{ "name": "Fizz", "agent_command": "goose", "mcp_command": "buzz-mcp-server" }]), ); reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); let records = read_agents_json(dir.path()); assert_eq!(records[0]["mcp_command"], ""); } #[test] fn reconcile_mcp_commands_sets_canonical_for_buzz_agent() { let dir = tempfile::tempdir().unwrap(); write_agents_json( dir.path(), &serde_json::json!([{ "name": "Stilgar", "agent_command": "buzz-agent", "mcp_command": "buzz-mcp-server" }]), ); reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); let records = read_agents_json(dir.path()); assert_eq!(records[0]["mcp_command"], "buzz-dev-mcp"); } #[test] fn reconcile_mcp_commands_leaves_custom_value_untouched() { let dir = tempfile::tempdir().unwrap(); let json = serde_json::json!([{ "name": "Fizz", "agent_command": "goose", "mcp_command": "my-custom-mcp" }]); write_agents_json(dir.path(), &json); let path = dir.path().join("agents/managed-agents.json"); let before = std::fs::read_to_string(&path).unwrap(); reconcile_mcp_commands_in_file(&path); assert_eq!(before, std::fs::read_to_string(&path).unwrap()); } #[test] fn reconcile_mcp_commands_leaves_unknown_runtime_untouched() { let dir = tempfile::tempdir().unwrap(); let json = serde_json::json!([{ "name": "Custom", "agent_command": "my-custom-agent", "mcp_command": "buzz-mcp-server" }]); write_agents_json(dir.path(), &json); let path = dir.path().join("agents/managed-agents.json"); let before = std::fs::read_to_string(&path).unwrap(); reconcile_mcp_commands_in_file(&path); assert_eq!(before, std::fs::read_to_string(&path).unwrap()); } #[test] fn reconcile_mcp_commands_is_idempotent() { let dir = tempfile::tempdir().unwrap(); write_agents_json( dir.path(), &serde_json::json!([{ "name": "Fizz", "agent_command": "goose", "mcp_command": "buzz-mcp-server" }]), ); let path = dir.path().join("agents/managed-agents.json"); reconcile_mcp_commands_in_file(&path); let after_first = std::fs::read_to_string(&path).unwrap(); reconcile_mcp_commands_in_file(&path); assert_eq!(after_first, std::fs::read_to_string(&path).unwrap()); } #[test] fn reconcile_mcp_commands_handles_mixed_agents() { let dir = tempfile::tempdir().unwrap(); write_agents_json( dir.path(), &serde_json::json!([ {"name": "Stale Goose", "agent_command": "goose", "mcp_command": "buzz-mcp-server"}, {"name": "Clean Goose", "agent_command": "goose", "mcp_command": ""}, {"name": "Custom Agent", "agent_command": "goose", "mcp_command": "my-custom-mcp"}, {"name": "Stale Buzz", "agent_command": "buzz-agent", "mcp_command": "buzz-mcp-server"} ]), ); reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); let records = read_agents_json(dir.path()); assert_eq!(records[0]["mcp_command"], ""); assert_eq!(records[1]["mcp_command"], ""); assert_eq!(records[2]["mcp_command"], "my-custom-mcp"); assert_eq!(records[3]["mcp_command"], "buzz-dev-mcp"); } #[test] fn reconcile_mcp_commands_resolves_persona_runtime_over_stale_snapshot() { // The frozen snapshot is buzz-agent (wants buzz-dev-mcp), but the linked // persona's runtime is goose (wants no mcp). The reconcile must follow the // EFFECTIVE harness (persona-wins) and clear the stale buzz-mcp-server. let dir = tempfile::tempdir().unwrap(); write_agents_json( dir.path(), &serde_json::json!([{ "name": "Fizz", "persona_id": "p1", "agent_command": "buzz-agent", "mcp_command": "buzz-mcp-server" }]), ); write_personas_json( dir.path(), &serde_json::json!([{"id": "p1", "runtime": "goose"}]), ); reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); let records = read_agents_json(dir.path()); assert_eq!(records[0]["mcp_command"], ""); } #[test] fn reconcile_mcp_commands_sees_team_dir_runtime_edit_same_launch() { // Gate (b): sync_team_personas (writer) MUST run before // reconcile_provider_mcp_commands (reader) on the same launch, so a team-dir // harness edit reaches the derived mcp_command immediately, not a launch // behind. This drives the writer→reader sequence at the file layer the boot // path exercises: load_persona_runtimes reads the same personas.json the // sync writes. Reader-first would derive the OLD mcp_command (asserted), so // the post-write derivation of the NEW value is the ordering regression catch. let dir = tempfile::tempdir().unwrap(); write_agents_json( dir.path(), &serde_json::json!([{ "name": "Fizz", "persona_id": "p1", "agent_command": "buzz-agent", "mcp_command": "" }]), ); // Pre-edit launch state: persona runtime is goose (no mcp_command). A // reader running against this stale file derives the empty goose value. write_personas_json( dir.path(), &serde_json::json!([{"id": "p1", "runtime": "goose"}]), ); reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); assert_eq!( read_agents_json(dir.path())[0]["mcp_command"], "", "reader-before-writer would see only the stale goose runtime" ); // Same launch: sync_team_personas propagates a team-dir harness edit // (goose → buzz-agent) into personas.json. The reader runs AFTER, so it // must derive the NEW buzz-agent mcp_command without a second launch. write_personas_json( dir.path(), &serde_json::json!([{"id": "p1", "runtime": "buzz-agent"}]), ); reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); assert_eq!( read_agents_json(dir.path())[0]["mcp_command"], "buzz-dev-mcp", "writer-before-reader must surface the new runtime's mcp_command same launch" ); } #[test] fn reconcile_mcp_commands_honors_explicit_override_over_persona() { // An explicit per-instance pin (agent_command_override) beats the persona // runtime: persona is goose (no mcp) but the pin is buzz-agent, so the // reconcile sets the buzz-agent mcp_command. let dir = tempfile::tempdir().unwrap(); write_agents_json( dir.path(), &serde_json::json!([{ "name": "Fizz", "persona_id": "p1", "agent_command": "goose", "agent_command_override": "buzz-agent", "mcp_command": "" }]), ); write_personas_json( dir.path(), &serde_json::json!([{"id": "p1", "runtime": "goose"}]), ); reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); let records = read_agents_json(dir.path()); assert_eq!(records[0]["mcp_command"], "buzz-dev-mcp"); } #[test] fn reconcile_mcp_commands_skips_record_without_agent_command() { let dir = tempfile::tempdir().unwrap(); let json = serde_json::json!([{ "name": "No Command", "mcp_command": "buzz-mcp-server" }]); write_agents_json(dir.path(), &json); let path = dir.path().join("agents/managed-agents.json"); let before = std::fs::read_to_string(&path).unwrap(); reconcile_mcp_commands_in_file(&path); assert_eq!(before, std::fs::read_to_string(&path).unwrap()); } #[test] fn migrate_legacy_nest_carries_knowledge_and_skips_repos() { let dir = tempfile::tempdir().unwrap(); let legacy = dir.path().join(".sprout"); let current = dir.path().join(".buzz"); // Knowledge: a top-level file plus a nested dir. std::fs::create_dir_all(legacy.join("RESEARCH")).unwrap(); std::fs::write(legacy.join("AGENTS.md"), "agents").unwrap(); std::fs::write(legacy.join("RESEARCH/NOTES.md"), "notes").unwrap(); // A fat REPOS/ that must NOT be copied. std::fs::create_dir_all(legacy.join("REPOS/buzz")).unwrap(); std::fs::write(legacy.join("REPOS/buzz/huge.bin"), "checkout").unwrap(); let migrated = super::migrate_legacy_nest_at(&legacy, ¤t); assert!(migrated, "migration ran because legacy nest existed"); assert!( !current.join("REPOS").exists(), "REPOS/ must never be migrated" ); assert_eq!( std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), "agents" ); assert_eq!( std::fs::read_to_string(current.join("RESEARCH/NOTES.md")).unwrap(), "notes" ); } #[test] fn migrate_legacy_nest_does_not_clobber_existing_destination() { let dir = tempfile::tempdir().unwrap(); let legacy = dir.path().join(".sprout"); let current = dir.path().join(".buzz"); std::fs::create_dir_all(legacy.join("RESEARCH")).unwrap(); std::fs::write(legacy.join("AGENTS.md"), "legacy-agents").unwrap(); std::fs::write(legacy.join("RESEARCH/NOTES.md"), "legacy-notes").unwrap(); // Pre-existing live content the migration must preserve. std::fs::create_dir_all(current.join("RESEARCH")).unwrap(); std::fs::write(current.join("AGENTS.md"), "live-agents").unwrap(); std::fs::write(current.join("RESEARCH/NOTES.md"), "live-notes").unwrap(); super::migrate_legacy_nest_at(&legacy, ¤t); assert_eq!( std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), "live-agents", "existing top-level file must not be clobbered" ); assert_eq!( std::fs::read_to_string(current.join("RESEARCH/NOTES.md")).unwrap(), "live-notes", "existing nested file must not be clobbered" ); } #[test] fn migrate_legacy_nest_is_idempotent_on_rerun() { let dir = tempfile::tempdir().unwrap(); let legacy = dir.path().join(".sprout"); let current = dir.path().join(".buzz"); std::fs::create_dir_all(legacy.join("PLANS")).unwrap(); std::fs::write(legacy.join("PLANS/PLAN.md"), "plan").unwrap(); super::migrate_legacy_nest_at(&legacy, ¤t); super::migrate_legacy_nest_at(&legacy, ¤t); assert_eq!( std::fs::read_to_string(current.join("PLANS/PLAN.md")).unwrap(), "plan" ); } #[test] fn migrate_legacy_nest_noops_when_legacy_absent() { let dir = tempfile::tempdir().unwrap(); let legacy = dir.path().join(".sprout"); let current = dir.path().join(".buzz"); let migrated = super::migrate_legacy_nest_at(&legacy, ¤t); assert!(!migrated, "no migration when legacy nest is absent"); assert!( !current.exists(), "no destination created when legacy absent" ); } #[test] fn migrate_legacy_nest_respects_deliberate_dev_reset() { let dir = tempfile::tempdir().unwrap(); let legacy = dir.path().join(".sprout"); let current = dir.path().join(".buzz-dev"); std::fs::create_dir_all(legacy.join("RESEARCH")).unwrap(); std::fs::write(legacy.join("RESEARCH/NOTES.md"), "legacy-notes").unwrap(); std::fs::create_dir_all(¤t).unwrap(); std::fs::write(current.join(".dev-nest-migrated"), "").unwrap(); let migrated = super::migrate_legacy_nest_at(&legacy, ¤t); assert!(!migrated, "reset marker opts out of legacy nest imports"); assert!(!current.join("RESEARCH").exists()); } #[test] fn migrate_legacy_nest_overwrites_generated_default_agents_md() { let dir = tempfile::tempdir().unwrap(); let legacy = dir.path().join(".sprout"); let current = dir.path().join(".buzz"); std::fs::create_dir_all(&legacy).unwrap(); std::fs::write(legacy.join("AGENTS.md"), "legacy team instructions").unwrap(); // First-time launch order: ensure_nest writes the generated default into // ~/.buzz/AGENTS.md, then migration runs. crate::managed_agents::ensure_nest_at(¤t).unwrap(); assert_eq!( std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), crate::managed_agents::AGENTS_MD, "precondition: ensure_nest writes the generated default" ); super::migrate_legacy_nest_at(&legacy, ¤t); assert_eq!( std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), "legacy team instructions", "legacy AGENTS.md must overwrite the untouched generated default" ); } #[test] fn migrate_legacy_nest_preserves_user_edited_agents_md() { let dir = tempfile::tempdir().unwrap(); let legacy = dir.path().join(".sprout"); let current = dir.path().join(".buzz"); std::fs::create_dir_all(&legacy).unwrap(); std::fs::write(legacy.join("AGENTS.md"), "legacy team instructions").unwrap(); std::fs::create_dir_all(¤t).unwrap(); std::fs::write(current.join("AGENTS.md"), "user-edited live AGENTS").unwrap(); super::migrate_legacy_nest_at(&legacy, ¤t); assert_eq!( std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), "user-edited live AGENTS", "a user-edited live AGENTS.md must never be clobbered" ); }