Fix Buzz command migration for saved agents (#979)

This commit is contained in:
Wes
2026-06-11 11:27:58 -07:00
committed by GitHub
parent 63738139ba
commit 824c55114e
3 changed files with 407 additions and 1 deletions

View File

@@ -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}");

View File

@@ -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<String, serde_json::Value>,
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<String> {
let (frontmatter, body) = buzz_persona_pkg::persona::split_frontmatter(content).ok()?;
let mut value = serde_yaml::from_str::<serde_yaml::Value>(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(&current_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;

View File

@@ -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<serde_json::Value> {
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<serde_json::Value> {
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"));
}