diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e98c86696..17112ebe1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -530,8 +530,9 @@ pub fn run() { migration::sync_shared_agent_data(&app_handle); migration::migrate_packs_to_teams(&app_handle); migration::reconcile_persona_team_dirs(&app_handle); - migration::reconcile_provider_mcp_commands(&app_handle); migration::migrate_persona_provider_to_runtime(&app_handle); + migration::reconcile_legacy_command_names(&app_handle); + migration::reconcile_provider_mcp_commands(&app_handle); if let Err(e) = managed_agents::sync_team_personas(&app_handle) { eprintln!("buzz-desktop: sync-team-personas: {e}"); diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index c6343e32c..76fe6f77c 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -7,6 +7,9 @@ //! instances share the same physical files — edits in any worktree are //! immediately visible to all others. //! +//! **Command reconciliation** (`reconcile_legacy_command_names`): Per-launch +//! fix-up of persisted built-in command names from the Sprout→Buzz rename. +//! //! **Provider reconciliation** (`reconcile_provider_mcp_commands`): Per-launch //! fix-up of `mcp_command` values in `managed-agents.json` against the //! discovery table. Ensures known providers always have their canonical @@ -596,6 +599,191 @@ fn reconcile_mcp_commands_in_file(path: &Path) { }); } +fn replace_command_field( + obj: &mut serde_json::Map, + field: &str, + replacement: String, +) -> bool { + let Some(current) = obj.get(field).and_then(|v| v.as_str()) else { + return false; + }; + if current == replacement { + return false; + } + eprintln!( + "buzz-desktop: command-rename-reconcile: {:?}: {field} {:?} → {:?}", + obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), + current, + replacement, + ); + obj.insert(field.to_string(), serde_json::Value::String(replacement)); + true +} + +fn reconcile_legacy_command_names_in_file(path: &Path) { + patch_json_records(path, |obj| { + let mut changed = false; + + if let Some(acp_command) = obj + .get("acp_command") + .and_then(|v| v.as_str()) + .map(str::to_string) + { + if acp_command == "sprout-acp" { + changed |= replace_command_field(obj, "acp_command", "buzz-acp".to_string()); + } + } + + let mut agent_command = obj + .get("agent_command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if agent_command == "sprout-agent" { + agent_command = "buzz-agent".to_string(); + changed |= replace_command_field(obj, "agent_command", agent_command.clone()); + } + + if let Some(mcp_command) = obj + .get("mcp_command") + .and_then(|v| v.as_str()) + .map(str::to_string) + { + match mcp_command.as_str() { + "sprout-dev-mcp" => { + changed |= + replace_command_field(obj, "mcp_command", "buzz-dev-mcp".to_string()); + } + "sprout-mcp" | "sprout-mcp-server" | "buzz-mcp-server" => { + let replacement = if agent_command == "buzz-agent" { + "buzz-dev-mcp" + } else { + "" + }; + changed |= replace_command_field(obj, "mcp_command", replacement.to_string()); + } + _ => {} + } + } + + changed + }); +} + +fn reconcile_legacy_persona_runtimes_in_file(path: &Path) { + patch_json_records(path, |obj| { + let Some(runtime) = obj.get("runtime").and_then(|v| v.as_str()) else { + return false; + }; + if runtime != "sprout-agent" { + return false; + } + eprintln!( + "buzz-desktop: command-rename-reconcile: persona {:?}: runtime {:?} → {:?}", + obj.get("display_name") + .or_else(|| obj.get("displayName")) + .and_then(|v| v.as_str()) + .unwrap_or("?"), + runtime, + "buzz-agent", + ); + obj.insert( + "runtime".to_string(), + serde_json::Value::String("buzz-agent".to_string()), + ); + true + }); +} + +fn rewrite_legacy_persona_md_runtime(content: &str) -> Option { + let (frontmatter, body) = buzz_persona_pkg::persona::split_frontmatter(content).ok()?; + let mut value = serde_yaml::from_str::(frontmatter).ok()?; + let mapping = value.as_mapping_mut()?; + let runtime = mapping.get_mut(serde_yaml::Value::String("runtime".to_string()))?; + if runtime.as_str()? != "sprout-agent" { + return None; + } + *runtime = serde_yaml::Value::String("buzz-agent".to_string()); + let frontmatter = serde_yaml::to_string(&value).ok()?; + Some(format!("---\n{frontmatter}---\n{body}")) +} + +fn reconcile_legacy_team_persona_runtime_files(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + reconcile_legacy_team_persona_runtime_files(&path); + continue; + } + if !file_type.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.ends_with(".persona.md") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Some(updated) = rewrite_legacy_persona_md_runtime(&content) else { + continue; + }; + if updated == content { + continue; + } + match std::fs::write(&path, updated) { + Ok(()) => { + eprintln!( + "buzz-desktop: command-rename-reconcile: updated {}", + path.display() + ); + } + Err(error) => { + eprintln!( + "buzz-desktop: command-rename-reconcile: failed to update {}: {error}", + path.display() + ); + } + } + } +} + +/// Reconcile exact built-in command values persisted before the Sprout→Buzz +/// rename. Custom commands and explicit paths are left untouched. +pub fn reconcile_legacy_command_names(app: &tauri::AppHandle) { + let Ok(current_dir) = app.path().app_data_dir() else { + return; + }; + let mut dirs = vec![current_dir.clone()]; + if let Some(canonical) = canonical_dev_data_dir(¤t_dir) { + if canonical.exists() && canonical != current_dir { + dirs.push(canonical); + } + } + for dir in dirs { + let path = dir.join("agents/managed-agents.json"); + if path.exists() { + reconcile_legacy_command_names_in_file(&path); + } + let personas_path = dir.join("agents/personas.json"); + if personas_path.exists() { + reconcile_legacy_persona_runtimes_in_file(&personas_path); + } + let teams_dir = dir.join("agents/teams"); + if teams_dir.exists() && !teams_dir.is_symlink() { + reconcile_legacy_team_persona_runtime_files(&teams_dir); + } + } +} + /// Reconcile `mcp_command` values in managed-agents.json against the /// discovery table. Known runtimes get their canonical mcp_command; /// unknown/custom agents are left untouched. Covers both the current @@ -646,3 +834,7 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) { #[cfg(test)] #[path = "migration_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "migration_command_tests.rs"] +mod command_tests; diff --git a/desktop/src-tauri/src/migration_command_tests.rs b/desktop/src-tauri/src/migration_command_tests.rs new file mode 100644 index 000000000..35e35fe2b --- /dev/null +++ b/desktop/src-tauri/src/migration_command_tests.rs @@ -0,0 +1,213 @@ +use super::*; +use std::path::Path; + +fn write_agents_json(dir: &Path, records: &serde_json::Value) { + std::fs::create_dir_all(dir.join("agents")).unwrap(); + std::fs::write( + dir.join("agents/managed-agents.json"), + serde_json::to_vec_pretty(records).unwrap(), + ) + .unwrap(); +} + +fn read_agents_json(dir: &Path) -> Vec { + let content = std::fs::read_to_string(dir.join("agents/managed-agents.json")).unwrap(); + serde_json::from_str(&content).unwrap() +} + +fn write_personas_json(dir: &Path, records: &serde_json::Value) { + std::fs::create_dir_all(dir.join("agents")).unwrap(); + std::fs::write( + dir.join("agents/personas.json"), + serde_json::to_vec_pretty(records).unwrap(), + ) + .unwrap(); +} + +fn read_personas_json(dir: &Path) -> Vec { + let content = std::fs::read_to_string(dir.join("agents/personas.json")).unwrap(); + serde_json::from_str(&content).unwrap() +} + +#[test] +fn reconcile_legacy_command_names_rewrites_renamed_sidecars() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Brain", + "acp_command": "sprout-acp", + "agent_command": "sprout-agent", + "mcp_command": "sprout-dev-mcp" + }]), + ); + + reconcile_legacy_command_names_in_file(&dir.path().join("agents/managed-agents.json")); + + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["acp_command"], "buzz-acp"); + assert_eq!(records[0]["agent_command"], "buzz-agent"); + assert_eq!(records[0]["mcp_command"], "buzz-dev-mcp"); +} + +#[test] +fn reconcile_legacy_command_names_updates_removed_mcp_server_for_buzz_agent() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Brain", + "acp_command": "sprout-acp", + "agent_command": "sprout-agent", + "mcp_command": "sprout-mcp-server" + }]), + ); + + reconcile_legacy_command_names_in_file(&dir.path().join("agents/managed-agents.json")); + + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["acp_command"], "buzz-acp"); + assert_eq!(records[0]["agent_command"], "buzz-agent"); + assert_eq!(records[0]["mcp_command"], "buzz-dev-mcp"); +} + +#[test] +fn reconcile_legacy_command_names_clears_removed_mcp_server_for_goose() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Solo", + "acp_command": "sprout-acp", + "agent_command": "goose", + "mcp_command": "sprout-mcp-server" + }]), + ); + + reconcile_legacy_command_names_in_file(&dir.path().join("agents/managed-agents.json")); + + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["acp_command"], "buzz-acp"); + assert_eq!(records[0]["agent_command"], "goose"); + assert_eq!(records[0]["mcp_command"], ""); +} + +#[test] +fn reconcile_legacy_command_names_preserves_custom_commands() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!([{ + "name": "Custom", + "acp_command": "custom-acp", + "agent_command": "custom-agent", + "mcp_command": "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_legacy_command_names_in_file(&path); + + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); +} + +#[test] +fn reconcile_legacy_command_names_rewrites_persona_runtime() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ + "id": "persona-1", + "display_name": "Brain", + "runtime": "sprout-agent" + }]), + ); + + reconcile_legacy_persona_runtimes_in_file(&dir.path().join("agents/personas.json")); + + let records = read_personas_json(dir.path()); + assert_eq!(records[0]["runtime"], "buzz-agent"); +} + +#[test] +fn reconcile_legacy_command_names_rewrites_runtime_after_provider_migration() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ + "id": "persona-1", + "display_name": "Brain", + "provider": "sprout-agent" + }]), + ); + let path = dir.path().join("agents/personas.json"); + + rename_provider_to_runtime_in_personas(&path); + reconcile_legacy_persona_runtimes_in_file(&path); + + let records = read_personas_json(dir.path()); + assert_eq!(records[0]["runtime"], "buzz-agent"); + assert!(records[0].get("provider").is_none()); +} + +#[test] +fn reconcile_legacy_command_names_preserves_non_legacy_persona_runtime() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ + "id": "persona-1", + "display_name": "Solo", + "runtime": "goose" + }]), + ); + let path = dir.path().join("agents/personas.json"); + let before = std::fs::read_to_string(&path).unwrap(); + + reconcile_legacy_persona_runtimes_in_file(&path); + + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); +} + +#[test] +fn rewrite_legacy_persona_md_runtime_rewrites_frontmatter_only() { + let content = concat!( + "---\n", + "name: brain\n", + "display_name: Brain\n", + "description: Test persona\n", + "runtime: sprout-agent\n", + "---\n", + "Body mentions runtime: sprout-agent.\n", + ); + + let updated = rewrite_legacy_persona_md_runtime(content).unwrap(); + + assert!(updated.contains("runtime: buzz-agent\n")); + assert!(updated.contains("Body mentions runtime: sprout-agent.\n")); +} + +#[test] +fn reconcile_legacy_team_persona_runtime_files_rewrites_persona_md() { + let dir = tempfile::tempdir().unwrap(); + let teams_dir = dir.path().join("agents/teams/com.example.team/agents"); + std::fs::create_dir_all(&teams_dir).unwrap(); + let persona_path = teams_dir.join("brain.persona.md"); + std::fs::write( + &persona_path, + concat!( + "---\n", + "name: brain\n", + "display_name: Brain\n", + "description: Test persona\n", + "runtime: sprout-agent\n", + "---\n", + "Prompt\n", + ), + ) + .unwrap(); + + reconcile_legacy_team_persona_runtime_files(&dir.path().join("agents/teams")); + + let updated = std::fs::read_to_string(persona_path).unwrap(); + assert!(updated.contains("runtime: buzz-agent\n")); +}