From 57b84eec932e519fec9a941ec763a3792fb0e410 Mon Sep 17 00:00:00 2001 From: brainx <12695242+brainx@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:44:30 +0200 Subject: [PATCH] fix(desktop): adopt matching folded agent definitions Signed-off-by: brainx <12695242+brainx@users.noreply.github.com> --- desktop/src-tauri/src/migration.rs | 14 +- desktop/src-tauri/src/migration/backfill.rs | 201 +++++++- .../src-tauri/src/migration/backfill_tests.rs | 441 +++++++++++++++++- 3 files changed, 634 insertions(+), 22 deletions(-) diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 0de8ea0a82..0f1082a681 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -176,15 +176,19 @@ 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); + // Canonicalize structured provider/model fields and strip stale derived + // env keys BEFORE matching standalone agents to folded definitions. If + // this ran after backfill, pre-cleanup data could prevent a valid match + // and any provider rewrite would stale the newly written source hash. + reconcile_databricks_v1_to_v2(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 - // 30177 projection to its slim shape. + // B5: link standalone agents to compatible folded definitions, otherwise + // manufacture definitions, AFTER the fold and provider/env cleanup (so + // matching sees canonical records) and before event sync republishes — + // the backfilled link is what flips the 30177 projection to its slim shape. backfill_standalone_agents(app); detach_directory_backed_teams(app); reconcile_provider_mcp_commands(app); - reconcile_databricks_v1_to_v2(app); materialize_agent_runtimes(app); } diff --git a/desktop/src-tauri/src/migration/backfill.rs b/desktop/src-tauri/src/migration/backfill.rs index b7f85df138..800cea49f2 100644 --- a/desktop/src-tauri/src/migration/backfill.rs +++ b/desktop/src-tauri/src/migration/backfill.rs @@ -1,32 +1,40 @@ //! B5 (unified agent model): one-time backfill of standalone agents into //! definition-linked records. Every keyed record with `persona_id: None` -//! gets a key-less definition manufactured from its own settings, retiring -//! the standalone-create pattern (Wes's Option 2: backfill, not grandfather). +//! adopts exactly one matching folded definition when possible. Records with +//! no match get a key-less definition manufactured from their own settings; +//! ambiguous records are skipped rather than creating another duplicate. use std::path::Path; use crate::managed_agents::{ persona_events::{persona_content_hash, persona_event_content}, - ManagedAgentRecord, + AgentDefinition, ManagedAgentRecord, }; -/// Manufacture definitions for standalone agents (B5 backfill). +/// Link standalone agents to definitions (B5 backfill). /// -/// For each keyed record with `persona_id: None`, append a key-less -/// definition record snapshotting the agent's own config and link the agent -/// to it. Safety rails (pinned in the B5 review gates): +/// For each keyed record with `persona_id: None`, adopt one matching key-less +/// definition left by the persona fold. If there is no match, append a key-less +/// definition snapshotting the agent's own config and link the agent to it. If +/// multiple definitions match, skip the record for manual disambiguation. +/// Safety rails (pinned in the B5 review gates): /// - **Idempotent**: linked records are skipped, so a second run is a no-op. /// - **`.bak` create-if-absent**: the pre-migration backup is taken once and /// never clobbered — a partial first run must not replace the pristine /// backup with a half-migrated snapshot on re-run. -/// - **Fail loudly per record**: a record that cannot be backfilled (slug -/// collision) is logged and skipped; the rest proceed. -/// - **No behavior change**: the definition snapshots the record's own -/// values (prompt present-even-if-empty via `to_definition_view`'s -/// `unwrap_or_default`, env COPIED so later instances inherit a working -/// config, quad copied to the definition defaults) and the record gains -/// `persona_source_version` = the new definition's content hash, so -/// neither `spawn_config_hash` nor the drift badge moves. +/// - **Unambiguous adoption**: only one active, non-team custom definition +/// whose snapshotted config matches can be adopted; multiple matches are +/// logged and skipped instead of manufacturing a third definition. +/// - **Fail loudly per record**: a record that cannot be backfilled (ambiguous +/// match or slug collision) is logged and skipped; the rest proceed. +/// - **Behavior preservation**: adoption requires compatible persisted +/// harness and spawn config and leaves instance-only fields untouched. A +/// manufactured definition snapshots the record's own values (prompt +/// present-even-if-empty via `to_definition_view`'s `unwrap_or_default`, env +/// COPIED so later instances inherit a working config, quad copied to the +/// definition defaults), keeping `spawn_config_hash` stable. In both paths +/// the record gains `persona_source_version` = the linked definition's +/// content hash so the drift badge starts in sync. /// /// The manufactured definition's slug is the agent's pubkey: 64-hex passes /// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys @@ -39,13 +47,150 @@ pub fn backfill_standalone_agents(app: &tauri::AppHandle) { Ok(0) => {} Ok(backfilled) => { eprintln!( - "buzz-desktop: standalone-backfill: {backfilled} agents linked to manufactured definitions" + "buzz-desktop: standalone-backfill: {backfilled} agents linked to definitions" ); } Err(e) => eprintln!("buzz-desktop: standalone-backfill: {e}"), } } +fn runtime_matches(definition: &AgentDefinition, record: &ManagedAgentRecord) -> bool { + let definition_runtime = definition + .runtime + .as_deref() + .map(str::trim) + .filter(|runtime| !runtime.is_empty()); + + if let Some(override_command) = record + .agent_command_override + .as_deref() + .map(str::trim) + .filter(|command| !command.is_empty()) + { + // Snapshot application only clears a known override when the + // definition names a different known runtime. A matching known pin, + // a custom command, or a definition without a catalog runtime remains + // authoritative and keeps the effective harness unchanged. + if let (Some(definition_runtime), Some(override_runtime)) = ( + definition_runtime.and_then(crate::managed_agents::known_acp_runtime_exact), + crate::managed_agents::known_acp_runtime(override_command), + ) { + return definition_runtime.id == override_runtime.id; + } + return true; + } + + // Pre-unified standalone records have neither `runtime` nor an override; + // their persisted `agent_command` is the only record of the harness they + // were created to run. The current general resolver intentionally ignores + // that legacy snapshot, but this migration must use it to recognize the + // just-folded definition that originally produced the agent. + let legacy_command = record.agent_command.trim(); + let before_command = + if record.runtime.is_none() && record.persona_id.is_none() && !legacy_command.is_empty() { + legacy_command.to_string() + } else { + crate::managed_agents::record_agent_command(record, &[]) + }; + let default_command = crate::managed_agents::default_agent_command(); + let after_command = definition_runtime + .and_then(crate::managed_agents::known_acp_runtime_exact) + .and_then(|runtime| runtime.commands.first().copied()) + .unwrap_or(default_command.as_str()); + match ( + crate::managed_agents::known_acp_runtime(&before_command), + crate::managed_agents::known_acp_runtime(after_command), + ) { + (Some(before), Some(after)) => before.id == after.id, + _ => before_command == after_command, + } +} + +fn effective_env_matches(definition: &AgentDefinition, record: &ManagedAgentRecord) -> bool { + let without_definition = + crate::managed_agents::merged_user_env(&Default::default(), &record.env_vars); + let with_definition = + crate::managed_agents::merged_user_env(&definition.env_vars, &record.env_vars); + without_definition == with_definition +} + +fn behavior_defaults_match(definition: &AgentDefinition, record: &ManagedAgentRecord) -> bool { + let mode_matches = definition + .respond_to + .as_deref() + .map(|mode| mode == record.respond_to.as_str()) + .unwrap_or(true); + let allowlist_matches = definition.respond_to.as_deref() + != Some(crate::managed_agents::RespondTo::Allowlist.as_str()) + || definition.respond_to_allowlist == record.respond_to_allowlist; + let parallelism_matches = definition + .parallelism + .map(|parallelism| parallelism == record.parallelism) + .unwrap_or(true); + + mode_matches && allowlist_matches && parallelism_matches +} + +fn snapshot_field_matches(definition_value: Option<&str>, record_value: Option<&str>) -> bool { + definition_value + .filter(|value| !value.trim().is_empty()) + .map(|value| record_value == Some(value)) + .unwrap_or(true) +} + +fn folded_definition_matches(definition: &AgentDefinition, record: &ManagedAgentRecord) -> bool { + !definition.is_builtin + && definition.is_active + && definition.source_team.is_none() + && definition.source_team_persona_slug.is_none() + // Folded definitions cannot represent relay-mesh instance metadata. + && record.relay_mesh.is_none() + && definition.display_name + == record + .display_name + .as_deref() + .unwrap_or(record.name.as_str()) + && definition.system_prompt == record.system_prompt.as_deref().unwrap_or_default() + // Persona folding can retain a data URI after agent creation has + // uploaded that avatar and stored its media URL. Snapshot application + // never overwrites the instance avatar, so it is not an identity key. + && runtime_matches(definition, record) + // Snapshot application preserves the record when either optional + // definition field is absent or blank. + && snapshot_field_matches(definition.model.as_deref(), record.model.as_deref()) + && snapshot_field_matches(definition.provider.as_deref(), record.provider.as_deref()) + // Definition env is layered under per-instance overrides. Only adopt + // when activating that lower layer leaves the effective env unchanged. + && effective_env_matches(definition, record) + && (record.name_pool.is_empty() || definition.name_pool == record.name_pool) + // These fields were absent before B5, so absence means unknown. Once a + // definition carries explicit defaults they help disambiguate identity. + && behavior_defaults_match(definition, record) +} + +enum FoldedDefinitionMatch<'a> { + NoMatch, + Unique(&'a AgentDefinition), + Ambiguous, +} + +fn matching_definition<'a>( + definitions: &'a [AgentDefinition], + record: &ManagedAgentRecord, +) -> FoldedDefinitionMatch<'a> { + let mut matches = definitions + .iter() + .filter(|definition| folded_definition_matches(definition, record)); + let Some(matching) = matches.next() else { + return FoldedDefinitionMatch::NoMatch; + }; + if matches.next().is_some() { + FoldedDefinitionMatch::Ambiguous + } else { + FoldedDefinitionMatch::Unique(matching) + } +} + /// Core backfill logic, decoupled from the Tauri `AppHandle` for testing. /// Returns the number of records backfilled (0 = nothing to do). fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result { @@ -74,10 +219,34 @@ fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result { let existing_slugs: std::collections::HashSet = all.iter().filter_map(|r| r.slug.clone()).collect(); + let existing_definitions: Vec = all + .iter() + .filter(|record| record.pubkey.is_empty()) + .filter_map(ManagedAgentRecord::to_definition_view) + .collect(); let mut manufactured: Vec = Vec::new(); let mut backfilled = 0usize; for record in all.iter_mut().filter(|r| needs_backfill(r)) { + match matching_definition(&existing_definitions, record) { + FoldedDefinitionMatch::Unique(definition) => { + record.persona_id = Some(definition.id.clone()); + record.persona_source_version = + Some(persona_content_hash(&persona_event_content(definition))); + backfilled += 1; + continue; + } + FoldedDefinitionMatch::Ambiguous => { + eprintln!( + "buzz-desktop: standalone-backfill: multiple folded definitions match agent {} \ + — skipped; disambiguate the definitions to let the next launch backfill it", + record.pubkey + ); + continue; + } + FoldedDefinitionMatch::NoMatch => {} + } + // Pubkeys are unique so this cannot fire against another manufactured // definition — only against a pre-existing definition improbably // slugged as this agent's pubkey. Fail loudly, skip, continue: the diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index 9d2731d241..1ea4c253cf 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -1,7 +1,11 @@ use super::backfill_standalone_agents_in_dir; use crate::managed_agents::spawn_hash::spawn_config_hash; -use crate::managed_agents::{AgentDefinition, ManagedAgentRecord}; +use crate::managed_agents::{ + persona_events::{persona_content_hash, persona_event_content}, + AgentDefinition, ManagedAgentRecord, DEFAULT_AGENT_PARALLELISM, +}; use crate::migration::test_support::{read_agents_json, write_agents_json}; +use std::collections::BTreeMap; use std::path::Path; fn standalone_agent_json(name: &str, pubkey: &str, prompt: Option<&str>) -> serde_json::Value { @@ -39,6 +43,441 @@ fn base(dir: &Path) -> std::path::PathBuf { dir.join("agents") } +fn folded_definition_json( + slug: &str, + name: &str, + prompt: &str, + provider: &str, +) -> serde_json::Value { + serde_json::to_value( + AgentDefinition { + id: slug.to_string(), + display_name: name.to_string(), + avatar_url: Some("data:image/png;base64,Zm9sZGVk".to_string()), + system_prompt: prompt.to_string(), + runtime: Some("goose".to_string()), + model: Some("gpt-x".to_string()), + provider: Some(provider.to_string()), + name_pool: vec!["Sparrow".to_string(), "Robin".to_string()], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::from([("API_KEY".to_string(), "secret".to_string())]), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2025-12-01T00:00:00Z".to_string(), + updated_at: "2025-12-02T00:00:00Z".to_string(), + } + .into_agent_record(), + ) + .unwrap() +} + +fn standalone_with_legacy_defaults(name: &str, pubkey: &str, prompt: &str) -> serde_json::Value { + let mut standalone = standalone_agent_json(name, pubkey, Some(prompt)); + standalone["avatar_url"] = serde_json::json!("https://media.example/avatar.png"); + standalone["agent_command_override"] = serde_json::json!("goose"); + standalone["env_vars"] = serde_json::json!({ + "API_KEY": "instance-override", + "INSTANCE_ONLY": "override" + }); + standalone["respond_to"] = serde_json::json!("owner-only"); + standalone["parallelism"] = serde_json::json!(DEFAULT_AGENT_PARALLELISM); + standalone +} + +#[test] +fn backfill_adopts_matching_folded_definition_without_manufacturing_duplicate() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "0".repeat(64); + let folded_slug = "legacy-persona-id"; + let mut standalone = standalone_with_legacy_defaults("Solo", &pubkey, "You are Solo."); + standalone + .as_object_mut() + .unwrap() + .remove("agent_command_override"); + standalone["runtime"] = serde_json::json!("goose"); + write_agents_json( + dir.path(), + &serde_json::json!([ + folded_definition_json(folded_slug, "Solo", "You are Solo.", "openai"), + standalone, + ]), + ); + + let before = load_typed(dir.path()); + let before_instance = before.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + let before_definitions: Vec = before + .iter() + .filter_map(ManagedAgentRecord::to_definition_view) + .collect(); + let hash_before = spawn_config_hash( + before_instance, + &before_definitions, + &[], + "wss://ws.example", + &Default::default(), + ); + + let backfilled = backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + assert_eq!(backfilled, 1); + + let records = load_typed(dir.path()); + assert_eq!( + records.len(), + 2, + "matching fold output should be adopted, not duplicated" + ); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(folded_slug)); + + let definitions: Vec<_> = records.iter().filter(|r| r.pubkey.is_empty()).collect(); + assert_eq!(definitions.len(), 1); + assert_eq!(definitions[0].slug.as_deref(), Some(folded_slug)); + assert_eq!( + definitions[0].created_at, "2025-12-01T00:00:00Z", + "adoption must preserve the existing definition" + ); + assert_eq!( + definitions[0].avatar_url.as_deref(), + Some("data:image/png;base64,Zm9sZGVk") + ); + assert_eq!( + instance.avatar_url.as_deref(), + Some("https://media.example/avatar.png"), + "the instance keeps its uploaded avatar URL" + ); + assert_eq!( + instance.env_vars.get("INSTANCE_ONLY").map(String::as_str), + Some("override"), + "instance overrides remain on the instance" + ); + assert_eq!( + instance.env_vars.get("API_KEY").map(String::as_str), + Some("instance-override"), + "instance values continue to win over definition env" + ); + + let adopted = definitions[0].to_definition_view().unwrap(); + let expected_source_version = persona_content_hash(&persona_event_content(&adopted)); + assert_eq!( + instance.persona_source_version.as_deref(), + Some(expected_source_version.as_str()) + ); + let after_definitions: Vec = records + .iter() + .filter_map(ManagedAgentRecord::to_definition_view) + .collect(); + let hash_after = spawn_config_hash( + instance, + &after_definitions, + &[], + "wss://ws.example", + &Default::default(), + ); + assert_eq!( + hash_before, hash_after, + "adoption must keep the effective spawn configuration stable" + ); +} + +#[test] +fn backfill_does_not_adopt_definition_with_different_provider() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "3".repeat(64); + let folded_slug = "different-provider"; + write_agents_json( + dir.path(), + &serde_json::json!([ + folded_definition_json(folded_slug, "Solo", "You are Solo.", "anthropic"), + standalone_with_legacy_defaults("Solo", &pubkey, "You are Solo."), + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let records = load_typed(dir.path()); + assert_eq!( + records.len(), + 3, + "a semantic mismatch must keep the folded definition separate" + ); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(pubkey.as_str())); + assert!(records + .iter() + .any(|record| record.slug.as_deref() == Some(folded_slug))); + assert!(records + .iter() + .any(|record| record.slug.as_deref() == Some(pubkey.as_str()))); +} + +#[test] +fn backfill_does_not_adopt_when_definition_would_add_effective_env() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "9".repeat(64); + let mut standalone = standalone_with_legacy_defaults("Solo", &pubkey, "You are Solo."); + standalone["env_vars"] = serde_json::json!({ "INSTANCE_ONLY": "override" }); + write_agents_json( + dir.path(), + &serde_json::json!([ + folded_definition_json("credentialed-persona", "Solo", "You are Solo.", "openai"), + standalone, + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let records = load_typed(dir.path()); + assert_eq!(records.len(), 3); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(pubkey.as_str())); +} + +#[test] +fn backfill_adopts_when_optional_definition_config_is_absent() { + for (field, marker) in [("model", "c"), ("provider", "d")] { + let dir = tempfile::tempdir().unwrap(); + let pubkey = marker.repeat(64); + let folded_slug = format!("no-{field}"); + let mut definition = + folded_definition_json(&folded_slug, "Solo", "You are Solo.", "openai"); + definition[field] = serde_json::Value::Null; + write_agents_json( + dir.path(), + &serde_json::json!([ + definition, + standalone_with_legacy_defaults("Solo", &pubkey, "You are Solo."), + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1, + "{field}" + ); + let records = load_typed(dir.path()); + assert_eq!(records.len(), 2, "{field}"); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!( + instance.persona_id.as_deref(), + Some(folded_slug.as_str()), + "{field}" + ); + } +} + +#[test] +fn backfill_does_not_adopt_definition_with_divergent_known_runtime_override() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "6".repeat(64); + let folded_slug = "goose-persona"; + let mut standalone = standalone_with_legacy_defaults("Solo", &pubkey, "You are Solo."); + standalone["agent_command_override"] = serde_json::json!("claude-agent-acp"); + write_agents_json( + dir.path(), + &serde_json::json!([ + folded_definition_json(folded_slug, "Solo", "You are Solo.", "openai"), + standalone, + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let records = load_typed(dir.path()); + assert_eq!(records.len(), 3); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(pubkey.as_str())); + assert_eq!( + instance.agent_command_override.as_deref(), + Some("claude-agent-acp") + ); +} + +#[test] +fn backfill_uses_same_runtime_override_before_stale_legacy_command() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "e".repeat(64); + let folded_slug = "goose-persona"; + let mut standalone = standalone_with_legacy_defaults("Solo", &pubkey, "You are Solo."); + standalone["agent_command"] = serde_json::json!("claude-agent-acp"); + write_agents_json( + dir.path(), + &serde_json::json!([ + folded_definition_json(folded_slug, "Solo", "You are Solo.", "openai"), + standalone, + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let records = load_typed(dir.path()); + assert_eq!(records.len(), 2); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(folded_slug)); + assert_eq!(instance.agent_command_override.as_deref(), Some("goose")); +} + +#[test] +fn backfill_adopts_legacy_command_without_runtime_or_override() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "f".repeat(64); + let folded_slug = "legacy-goose-persona"; + write_agents_json( + dir.path(), + &serde_json::json!([ + folded_definition_json(folded_slug, "Solo", "You are Solo.", "openai"), + standalone_agent_json("Solo", &pubkey, Some("You are Solo.")), + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let records = load_typed(dir.path()); + assert_eq!(records.len(), 2); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(folded_slug)); + assert_eq!(instance.runtime, None); + assert_eq!(instance.agent_command_override, None); + assert_eq!(instance.agent_command, "goose"); +} + +#[test] +fn backfill_does_not_adopt_definition_for_relay_mesh_agent() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "7".repeat(64); + let mut standalone = standalone_with_legacy_defaults("Solo", &pubkey, "You are Solo."); + standalone["relay_mesh"] = serde_json::json!({ "model_ref": "Qwen3" }); + write_agents_json( + dir.path(), + &serde_json::json!([ + folded_definition_json("non-mesh-persona", "Solo", "You are Solo.", "openai"), + standalone, + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let records = load_typed(dir.path()); + assert_eq!(records.len(), 3); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(pubkey.as_str())); + assert_eq!( + instance + .relay_mesh + .as_ref() + .map(|config| config.model_ref.as_str()), + Some("Qwen3") + ); +} + +#[test] +fn backfill_does_not_adopt_ambiguous_matching_definitions() { + let dir = tempfile::tempdir().unwrap(); + let ambiguous_pubkey = "4".repeat(64); + let clean_pubkey = "8".repeat(64); + write_agents_json( + dir.path(), + &serde_json::json!([ + folded_definition_json("legacy-persona-one", "Solo", "You are Solo.", "openai"), + folded_definition_json("legacy-persona-two", "Solo", "You are Solo.", "openai"), + standalone_with_legacy_defaults("Solo", &ambiguous_pubkey, "You are Solo."), + standalone_with_legacy_defaults("Other", &clean_pubkey, "You are Other."), + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1, + "ambiguous record skipped while unrelated record proceeds" + ); + let records = load_typed(dir.path()); + assert_eq!( + records.len(), + 5, + "ambiguous matches must not manufacture another definition" + ); + let ambiguous = records + .iter() + .find(|record| record.pubkey == ambiguous_pubkey) + .unwrap(); + assert_eq!(ambiguous.persona_id, None); + let clean = records + .iter() + .find(|record| record.pubkey == clean_pubkey) + .unwrap(); + assert_eq!(clean.persona_id.as_deref(), Some(clean_pubkey.as_str())); +} + +#[test] +fn backfill_adopts_definition_without_overwriting_instance_behavior() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "5".repeat(64); + let folded_slug = "legacy-defaults"; + let mut standalone = standalone_with_legacy_defaults("Solo", &pubkey, "You are Solo."); + standalone["respond_to"] = serde_json::json!("anyone"); + standalone["parallelism"] = serde_json::json!(4); + write_agents_json( + dir.path(), + &serde_json::json!([ + folded_definition_json(folded_slug, "Solo", "You are Solo.", "openai"), + standalone, + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let records = load_typed(dir.path()); + assert_eq!(records.len(), 2); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(folded_slug)); + assert_eq!(instance.respond_to.as_str(), "anyone"); + assert_eq!(instance.parallelism, 4); +} + +#[test] +fn backfill_does_not_adopt_conflicting_explicit_behavior_defaults() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "b".repeat(64); + let mut definition = + folded_definition_json("allowlist-persona", "Solo", "You are Solo.", "openai"); + definition["definition_respond_to"] = serde_json::json!("allowlist"); + definition["definition_respond_to_allowlist"] = serde_json::json!(["a".repeat(64)]); + definition["definition_parallelism"] = serde_json::json!(1); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition, + standalone_with_legacy_defaults("Solo", &pubkey, "You are Solo."), + ]), + ); + + assert_eq!( + backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + 1 + ); + let records = load_typed(dir.path()); + assert_eq!(records.len(), 3); + let instance = records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); + assert_eq!(instance.persona_id.as_deref(), Some(pubkey.as_str())); +} + #[test] fn backfill_links_standalone_agent_to_manufactured_definition() { let dir = tempfile::tempdir().unwrap();