@@ -695,17 +695,25 @@ pub async fn create_managed_agent(
695695}
696696
697697/// Data needed for background profile reconciliation after agent start.
698- struct ProfileReconcileData {
699- private_key_nsec : String ,
700- name : String ,
701- relay_url : String ,
702- /// Expected avatar URL for the published profile. Resolved at start from the
703- /// record's persisted `avatar_url` (the exact URL published at creation),
704- /// falling back to persona/command derivation only for pre-existing records
705- /// that have no stored value — so old records still self-heal without
706- /// regressing a user-overridden avatar.
707- avatar_url : Option < String > ,
708- auth_tag : Option < String > ,
698+ pub ( crate ) struct ProfileReconcileData {
699+ pub ( crate ) private_key_nsec : String ,
700+ pub ( crate ) name : String ,
701+ pub ( crate ) relay_url : String ,
702+ /// Expected avatar URL for the published profile. `None` for legacy records
703+ /// that predate the `avatar_url` field — these will be backfilled from the
704+ /// relay's existing kind:0 profile on first reconciliation.
705+ pub ( crate ) avatar_url : Option < String > ,
706+ pub ( crate ) auth_tag : Option < String > ,
707+ /// The agent's pubkey (hex). Needed to update the persisted record during
708+ /// avatar backfill migration.
709+ pub ( crate ) pubkey : String ,
710+ /// The agent's command (e.g. "goose"). Used as fallback when no profile
711+ /// exists on the relay during avatar backfill.
712+ pub ( crate ) agent_command : String ,
713+ /// Persona ID if this agent was created from a persona. Used during avatar
714+ /// backfill to recover the correct avatar from the persona record when the
715+ /// relay profile has been corrupted.
716+ pub ( crate ) persona_id : Option < String > ,
709717}
710718
711719#[ tauri:: command]
@@ -745,14 +753,15 @@ pub async fn start_managed_agent(
745753
746754 let record = find_managed_agent_mut ( & mut records, & pubkey) ?;
747755
748- let expected_avatar = reconcile_avatar ( record. avatar_url . as_deref ( ) , & record. agent_command ) ;
749-
750756 let reconcile = ProfileReconcileData {
751757 private_key_nsec : record. private_key_nsec . clone ( ) ,
752758 name : record. name . clone ( ) ,
753759 relay_url : record. relay_url . clone ( ) ,
754- avatar_url : expected_avatar ,
760+ avatar_url : record . avatar_url . clone ( ) ,
755761 auth_tag : record. auth_tag . clone ( ) ,
762+ pubkey : record. pubkey . clone ( ) ,
763+ agent_command : record. agent_command . clone ( ) ,
764+ persona_id : record. persona_id . clone ( ) ,
756765 } ;
757766
758767 let target = if record. backend == BackendKind :: Local {
@@ -812,15 +821,17 @@ pub async fn start_managed_agent(
812821 // ── Profile reconciliation (fire-and-forget) ────────────────────────────
813822 // On successful start, spawn a background task to ensure the agent's kind:0
814823 // profile is published on the relay. This self-heals cases where the initial
815- // profile sync at creation time failed silently.
824+ // profile sync at creation time failed silently. For legacy records (pre-PR-921)
825+ // with no persisted avatar, this also backfills the avatar from the relay.
816826 if result. is_ok ( ) {
817827 let reconcile_pubkey = pubkey. clone ( ) ;
818828 let reconcile_app = app. clone ( ) ;
819829 tauri:: async_runtime:: spawn ( async move {
820830 use tauri:: Manager ;
821831 let state = reconcile_app. state :: < AppState > ( ) ;
822832 if let Err ( e) =
823- reconcile_agent_profile ( & state, & reconcile_pubkey, & reconcile_data) . await
833+ reconcile_agent_profile ( & state, & reconcile_app, & reconcile_pubkey, & reconcile_data)
834+ . await
824835 {
825836 eprintln ! (
826837 "sprout-desktop: profile reconciliation failed for agent {reconcile_pubkey}: {e}"
@@ -832,29 +843,95 @@ pub async fn start_managed_agent(
832843 result
833844}
834845
846+ /// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no
847+ /// stored `avatar_url`).
848+ ///
849+ /// Priority: the persona's avatar wins, because the old reconciliation code
850+ /// could have overwritten the relay's kind:0 `picture` with the command default
851+ /// — making the relay an unreliable source for persona-backed agents. Only fall
852+ /// back to the relay's `picture`, then the command icon, for agents with no
853+ /// persona avatar to recover from.
854+ fn resolve_legacy_avatar (
855+ persona_avatar : Option < String > ,
856+ relay_picture : Option < String > ,
857+ agent_command : & str ,
858+ ) -> String {
859+ persona_avatar
860+ . or ( relay_picture)
861+ . or_else ( || managed_agent_avatar_url ( agent_command) )
862+ . unwrap_or_default ( )
863+ }
864+
835865/// Reconcile an agent's kind:0 profile on the relay.
836866///
837867/// Queries the relay for the agent's existing profile and re-publishes if missing
838868/// or stale (display_name or picture mismatch). This is fire-and-forget — errors
839869/// are returned to the caller for logging but never block agent startup.
840870///
871+ /// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function
872+ /// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar
873+ /// over the relay's `picture`, since the old code may have corrupted the relay
874+ /// profile — and persists the updated record. After backfill, normal
875+ /// reconciliation proceeds.
876+ ///
841877/// Query and publish both target the agent's stored `relay_url` so that, under
842878/// an active workspace relay override, reconciliation reads and writes the same
843879/// relay the agent's profile actually lives on.
844- async fn reconcile_agent_profile (
880+ pub ( crate ) async fn reconcile_agent_profile (
845881 state : & AppState ,
882+ app : & AppHandle ,
846883 agent_pubkey : & str ,
847884 data : & ProfileReconcileData ,
848885) -> Result < ( ) , String > {
849886 use crate :: relay:: { query_agent_profile, sync_managed_agent_profile} ;
850887
851- // Compare against the avatar persisted at creation time — never re-derive it.
852- let expected_avatar = data. avatar_url . as_deref ( ) ;
853-
854- // Query the same relay the profile is published to (the stored relay_url).
888+ // Query the relay for the agent's existing kind:0 profile.
855889 let existing = query_agent_profile ( state, & data. relay_url , agent_pubkey) . await ?;
856890
857- if !profile_needs_sync ( existing. as_ref ( ) , & data. name , expected_avatar) {
891+ // Resolve the expected avatar — backfilling for legacy records that have no
892+ // stored avatar_url yet.
893+ let expected_avatar = match data. avatar_url . as_deref ( ) {
894+ Some ( url) => url. to_string ( ) ,
895+ None => {
896+ // Legacy record: the relay profile may have been corrupted by the
897+ // old reconciliation code (it overwrote the persona avatar with the
898+ // command default), so the persona record is the authoritative source.
899+ let persona_avatar = data. persona_id . as_ref ( ) . and_then ( |pid| {
900+ load_personas ( app)
901+ . ok ( ) ?
902+ . into_iter ( )
903+ . find ( |p| p. id == * pid) ?
904+ . avatar_url
905+ } ) ;
906+
907+ let backfilled = resolve_legacy_avatar (
908+ persona_avatar,
909+ existing. as_ref ( ) . and_then ( |info| info. picture . clone ( ) ) ,
910+ & data. agent_command ,
911+ ) ;
912+
913+ // Persist the backfilled avatar so this migration only runs once.
914+ if !backfilled. is_empty ( ) {
915+ let _store_guard = state
916+ . managed_agents_store_lock
917+ . lock ( )
918+ . map_err ( |e| e. to_string ( ) ) ?;
919+ let mut records = load_managed_agents ( app) ?;
920+ if let Some ( record) = records. iter_mut ( ) . find ( |r| r. pubkey == data. pubkey ) {
921+ record. avatar_url = Some ( backfilled. clone ( ) ) ;
922+ save_managed_agents ( app, & records) ?;
923+ }
924+ }
925+
926+ backfilled
927+ }
928+ } ;
929+
930+ if expected_avatar. is_empty ( ) {
931+ return Ok ( ( ) ) ;
932+ }
933+
934+ if !profile_needs_sync ( existing. as_ref ( ) , & data. name , Some ( & expected_avatar) ) {
858935 return Ok ( ( ) ) ;
859936 }
860937
@@ -866,7 +943,7 @@ async fn reconcile_agent_profile(
866943 & data. relay_url ,
867944 & agent_keys,
868945 & data. name ,
869- expected_avatar,
946+ Some ( & expected_avatar) ,
870947 data. auth_tag . as_deref ( ) ,
871948 )
872949 . await
@@ -890,18 +967,6 @@ fn profile_needs_sync(
890967 }
891968}
892969
893- /// Resolve the avatar a managed agent's profile should reconcile against.
894- /// Stored value (persisted at creation) wins; legacy records that predate the
895- /// field (`stored == None`) fall back to the command-based derivation — the
896- /// same source the create path used. Persona config is never consulted: doing
897- /// so diverges from what was published and overwrites user intent on restart.
898- fn reconcile_avatar ( stored : Option < & str > , agent_command : & str ) -> Option < String > {
899- match stored {
900- Some ( url) => Some ( url. to_string ( ) ) ,
901- None => managed_agent_avatar_url ( agent_command) ,
902- }
903- }
904-
905970#[ tauri:: command]
906971pub fn stop_managed_agent (
907972 pubkey : String ,
@@ -1182,26 +1247,40 @@ mod tests {
11821247 assert ! ( profile_needs_sync( Some ( & existing) , "Duncan" , None ) ) ;
11831248 }
11841249
1185- /// Legacy records (`avatar_url: None`) must reconcile against
1186- /// `managed_agent_avatar_url(agent_command)` — never persona config —
1187- /// matching what the original create path published.
11881250 #[ test]
1189- fn reconcile_avatar_legacy_record_uses_command_not_persona ( ) {
1190- let resolved = reconcile_avatar ( None , "goose" ) ;
1191-
1192- assert_eq ! ( resolved, managed_agent_avatar_url ( "goose" ) ) ;
1193- assert ! (
1194- resolved . is_some ( ) ,
1195- "goose command should have a known avatar"
1251+ fn legacy_avatar_prefers_persona_over_corrupted_relay_picture ( ) {
1252+ // The regression: the relay picture was overwritten with the command
1253+ // default. The persona avatar must win so the correct avatar is restored.
1254+ let resolved = resolve_legacy_avatar (
1255+ Some ( "https://x/persona.png" . to_string ( ) ) ,
1256+ Some ( "https://x/default-icon.png" . to_string ( ) ) ,
1257+ "goose" ,
11961258 ) ;
1259+
1260+ assert_eq ! ( resolved, "https://x/persona.png" ) ;
1261+ }
1262+
1263+ #[ test]
1264+ fn legacy_avatar_falls_back_to_relay_picture_without_persona ( ) {
1265+ let resolved =
1266+ resolve_legacy_avatar ( None , Some ( "https://x/relay.png" . to_string ( ) ) , "goose" ) ;
1267+
1268+ assert_eq ! ( resolved, "https://x/relay.png" ) ;
1269+ }
1270+
1271+ #[ test]
1272+ fn legacy_avatar_falls_back_to_command_icon_when_no_persona_or_relay ( ) {
1273+ use crate :: managed_agents:: managed_agent_avatar_url;
1274+
1275+ let resolved = resolve_legacy_avatar ( None , None , "goose" ) ;
1276+
1277+ assert_eq ! ( resolved, managed_agent_avatar_url( "goose" ) . unwrap( ) ) ;
11971278 }
11981279
1199- /// New records persist their avatar at creation; the stored value is used
1200- /// verbatim, never falling back to command derivation.
12011280 #[ test]
1202- fn reconcile_avatar_stored_value_wins ( ) {
1203- let resolved = reconcile_avatar ( Some ( "https://custom/avatar.png" ) , "goose ") ;
1281+ fn legacy_avatar_empty_when_nothing_resolves ( ) {
1282+ let resolved = resolve_legacy_avatar ( None , None , "totally-unknown-command ") ;
12041283
1205- assert_eq ! ( resolved. as_deref ( ) , Some ( "https://custom/avatar.png" ) ) ;
1284+ assert ! ( resolved. is_empty ( ) ) ;
12061285 }
12071286}
0 commit comments