Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions desktop/src-tauri/src/managed_agents/personas.rs

Large diffs are not rendered by default.

155 changes: 155 additions & 0 deletions desktop/src-tauri/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//! discovery table. Ensures known providers always have their canonical
//! `mcp_command`; unknown/custom agents are left untouched.

use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tauri::Manager;

Expand Down Expand Up @@ -175,6 +176,7 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
// Post-fold readers of the runtime map (`load_persona_runtimes`) fall
// back to the unified store's definitions.
fold_personas_into_agent_store(app);
refresh_builtin_agent_avatars(app);
// B5: manufacture definitions for standalone agents AFTER the fold (so
// pre-existing definition slugs are present for collision checks) and
// before event sync republishes — the backfilled link is what flips the
Expand Down Expand Up @@ -511,6 +513,159 @@ fn patch_json_records(
}
}

const LEGACY_BUILTIN_AVATAR_HASHES: &[(&str, &str)] = &[
(
"builtin:fizz",
"2771b8c9c46aa3c8ac1c4d2acfa23fa9ba35b79c4b1694554e923081e3b8b4d0",
),
(
"builtin:honey",
"1979e54ef77fc94ec688170bd74dade35c563e7fcc82bb0714c672dfb018eab9",
),
(
"builtin:bumble",
"c08cf3b8b4c3f8721df6143367ababdebae8f913b9c654401ba74bb3d233655b",
),
];

/// Refresh the prior seeded avatar on built-in definitions and linked agent
/// instances while preserving any avatar the user customized. Matching by the
/// full data URL digest makes the migration idempotent and avoids relying on
/// timestamps or other persona fields the user may also have edited.
fn refresh_builtin_agent_avatars(app: &tauri::AppHandle) {
let Ok(dir) = app.path().app_data_dir() else {
return;
};
let path = dir.join("agents/managed-agents.json");
if path.exists() {
refresh_builtin_agent_avatars_in_file(
&path,
LEGACY_BUILTIN_AVATAR_HASHES,
&crate::util::now_iso(),
);
}
}

fn refresh_builtin_agent_avatars_in_file(path: &Path, legacy_hashes: &[(&str, &str)], now: &str) {
let Ok(contents) = std::fs::read_to_string(path) else {
return;
};
let Ok(mut records) = serde_json::from_str::<Vec<serde_json::Value>>(&contents) else {
eprintln!(
"buzz-desktop: refresh-builtin-agent-avatars: invalid JSON in {}",
path.display()
);
return;
};

// Definitions must be migrated first so linked instances can advance from
// the exact old persona hash to the exact new one. Only advance an instance
// that was in sync before migration; a genuinely drifted instance keeps its
// old source version and therefore keeps its out-of-date indicator.
let mut persona_version_updates = std::collections::HashMap::new();
let mut changed = false;
for record in &mut records {
let Some(persona_id) = legacy_avatar_persona_id(record, legacy_hashes) else {
continue;
};
let is_definition = record
.get("pubkey")
.and_then(serde_json::Value::as_str)
.is_some_and(str::is_empty)
&& record.get("slug").and_then(serde_json::Value::as_str) == Some(&persona_id);
if !is_definition {
continue;
}
let old_version = persona_version_from_record(record);
if !replace_builtin_avatar(record, &persona_id, now) {
continue;
}
changed = true;
if let (Some(old_version), Some(new_version)) =
(old_version, persona_version_from_record(record))
{
persona_version_updates.insert(persona_id, (old_version, new_version));
}
}

for record in &mut records {
let Some(persona_id) = legacy_avatar_persona_id(record, legacy_hashes) else {
continue;
};
let is_linked_instance = record
.get("pubkey")
.and_then(serde_json::Value::as_str)
.is_some_and(|pubkey| !pubkey.is_empty())
&& record.get("persona_id").and_then(serde_json::Value::as_str) == Some(&persona_id);
if !is_linked_instance || !replace_builtin_avatar(record, &persona_id, now) {
continue;
}
changed = true;
if let Some((old_version, new_version)) = persona_version_updates.get(&persona_id) {
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
let source_was_current = record
.get("persona_source_version")
.and_then(serde_json::Value::as_str)
== Some(old_version.as_str());
if source_was_current {
record["persona_source_version"] = serde_json::Value::String(new_version.clone());
}
}
}

if changed {
if let Ok(bytes) = serde_json::to_vec_pretty(&records) {
if let Err(e) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) {
eprintln!("buzz-desktop: refresh-builtin-agent-avatars: {e}");
}
}
}
}

fn legacy_avatar_persona_id(
record: &serde_json::Value,
legacy_hashes: &[(&str, &str)],
) -> Option<String> {
let persona_id = record
.get("persona_id")
.and_then(serde_json::Value::as_str)
.or_else(|| record.get("slug").and_then(serde_json::Value::as_str))?;
let legacy_hash = legacy_hashes
.iter()
.find_map(|(id, hash)| (*id == persona_id).then_some(*hash))?;
let current_avatar = record
.get("avatar_url")
.and_then(serde_json::Value::as_str)?;
(hex::encode(Sha256::digest(current_avatar.as_bytes())) == legacy_hash)
.then(|| persona_id.to_string())
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
}

fn persona_version_from_record(record: &serde_json::Value) -> Option<String> {
let record: crate::managed_agents::ManagedAgentRecord =
serde_json::from_value(record.clone()).ok()?;
let definition = record.to_definition_view()?;
Some(crate::managed_agents::persona_events::persona_content_hash(
&crate::managed_agents::persona_events::persona_event_content(&definition),
))
}

fn replace_builtin_avatar(record: &mut serde_json::Value, persona_id: &str, now: &str) -> bool {
let Some(replacement) = crate::managed_agents::built_in_persona_avatar_url(persona_id) else {
return false;
};
let Some(record) = record.as_object_mut() else {
return false;
};
record.insert(
"avatar_url".to_string(),
serde_json::Value::String(replacement.to_string()),
);
record.insert(
"updated_at".to_string(),
serde_json::Value::String(now.to_string()),
);
true
}

/// Create symlinks for shared agent data files from the current (worktree)
/// data directory to the canonical dev data directory.
///
Expand Down
146 changes: 146 additions & 0 deletions desktop/src-tauri/src/migration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -991,3 +991,149 @@ fn migrate_legacy_nest_preserves_user_edited_agents_md() {
"a user-edited live AGENTS.md must never be clobbered"
);
}

#[test]
fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizations() {
use sha2::{Digest as _, Sha256};

let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("managed-agents.json");
let old_fizz = "data:image/png;base64,old-fizz";
let old_honey = "data:image/png;base64,old-honey";
let fizz_hash = hex::encode(Sha256::digest(old_fizz.as_bytes()));
let honey_hash = hex::encode(Sha256::digest(old_honey.as_bytes()));
let legacy_hashes = [
("builtin:fizz", fizz_hash.as_str()),
("builtin:honey", honey_hash.as_str()),
];
let definition = crate::managed_agents::AgentDefinition {
id: "builtin:fizz".to_string(),
display_name: "Fizz".to_string(),
avatar_url: Some(old_fizz.to_string()),
system_prompt: "A customized built-in prompt".to_string(),
runtime: Some("goose".to_string()),
model: Some("test-model".to_string()),
provider: Some("test-provider".to_string()),
name_pool: vec!["Fizzy".to_string()],
is_builtin: true,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: Default::default(),
respond_to: None,
respond_to_allowlist: Vec::new(),
parallelism: None,
created_at: "before".to_string(),
updated_at: "before".to_string(),
};
let old_persona_version = crate::managed_agents::persona_events::persona_content_hash(
&crate::managed_agents::persona_events::persona_event_content(&definition),
);
let mut definition_record = serde_json::to_value(definition.into_agent_record()).unwrap();
definition_record["future_definition_field"] = serde_json::json!("preserved");

let instance =
|pubkey: &str, persona_id: &str, avatar_url: &str, persona_source_version: &str| {
serde_json::json!({
"name": pubkey,
"pubkey": pubkey,
"persona_id": persona_id,
"relay_url": "ws://localhost:3000",
"avatar_url": avatar_url,
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"parallelism": 4,
"system_prompt": "A customized built-in prompt",
"model": "test-model",
"provider": "test-provider",
"persona_source_version": persona_source_version,
"env_vars": {},
"start_on_app_launch": true,
"created_at": "before",
"updated_at": "before",
"last_started_at": null,
"last_stopped_at": null,
"last_exit_code": null,
"last_error": null
})
};
let mut synced_instance = instance(
"fizz-instance",
"builtin:fizz",
old_fizz,
&old_persona_version,
);
synced_instance["future_instance_field"] = serde_json::json!("preserved");
let records = serde_json::Value::Array(vec![
definition_record,
synced_instance,
instance(
"drifted-fizz-instance",
"builtin:fizz",
old_fizz,
"genuinely-drifted-version",
),
instance(
"honey-instance",
"builtin:honey",
"data:image/png;base64,user-customized",
"honey-version",
),
instance("custom-instance", "custom:fizz", old_fizz, "custom-version"),
]);
std::fs::write(&path, serde_json::to_vec_pretty(&records).unwrap()).unwrap();

refresh_builtin_agent_avatars_in_file(&path, &legacy_hashes, "after");

let migrated: Vec<serde_json::Value> =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let new_fizz = crate::managed_agents::built_in_persona_avatar_url("builtin:fizz").unwrap();
assert_eq!(migrated[0]["avatar_url"], new_fizz);
assert_eq!(migrated[0]["updated_at"], "after");
assert_eq!(migrated[0]["future_definition_field"], "preserved");
let migrated_definition: crate::managed_agents::ManagedAgentRecord =
serde_json::from_value(migrated[0].clone()).unwrap();
let new_persona_version = crate::managed_agents::persona_events::persona_content_hash(
&crate::managed_agents::persona_events::persona_event_content(
&migrated_definition.to_definition_view().unwrap(),
),
);
assert_ne!(new_persona_version, old_persona_version);
assert_eq!(migrated[1]["avatar_url"], new_fizz);
assert_eq!(migrated[1]["updated_at"], "after");
assert_eq!(migrated[1]["persona_source_version"], new_persona_version);
assert_eq!(migrated[1]["future_instance_field"], "preserved");
assert_eq!(migrated[2]["avatar_url"], new_fizz);
assert_eq!(migrated[2]["updated_at"], "after");
assert_eq!(
migrated[2]["persona_source_version"],
"genuinely-drifted-version"
);
assert_eq!(
migrated[3]["avatar_url"],
"data:image/png;base64,user-customized"
);
assert_eq!(migrated[3]["updated_at"], "before");
assert_eq!(migrated[4]["avatar_url"], old_fizz);
assert_eq!(migrated[4]["updated_at"], "before");

let once = std::fs::read(&path).unwrap();
refresh_builtin_agent_avatars_in_file(&path, &legacy_hashes, "later");
assert_eq!(std::fs::read(&path).unwrap(), once);
}

#[test]
fn current_builtin_agent_avatars_do_not_match_legacy_hashes() {
use sha2::{Digest as _, Sha256};

for (id, legacy_hash) in LEGACY_BUILTIN_AVATAR_HASHES {
let current = crate::managed_agents::built_in_persona_avatar_url(id).unwrap();
assert_ne!(
hex::encode(Sha256::digest(current.as_bytes())),
*legacy_hash
);
}
}
Loading