@@ -377,7 +377,7 @@ pub async fn create_managed_agent(
377377 } ;
378378
379379 // ── Phase 3: save record (sync lock) ───────────────────────────────────────
380- let agent = {
380+ let ( agent, resolved_avatar_url ) = {
381381 let _store_guard = state
382382 . managed_agents_store_lock
383383 . lock ( )
@@ -453,13 +453,26 @@ pub async fn create_managed_agent(
453453 Some ( ( pack_path, slug. to_owned ( ) ) )
454454 } ) ;
455455
456+ // Resolve the avatar URL once at creation and persist it on the record.
457+ // This is the same logic the original publish used (user input, else
458+ // command-based fallback) — storing it lets reconciliation compare
459+ // against what was actually published instead of re-deriving it.
460+ let resolved_avatar_url = input
461+ . avatar_url
462+ . as_deref ( )
463+ . map ( str:: trim)
464+ . filter ( |value| !value. is_empty ( ) )
465+ . map ( str:: to_string)
466+ . or_else ( || managed_agent_avatar_url ( & agent_command) ) ;
467+
456468 let record = crate :: managed_agents:: ManagedAgentRecord {
457469 pubkey : pubkey. clone ( ) ,
458470 name : name. clone ( ) ,
459471 persona_id : requested_persona_id. clone ( ) ,
460472 private_key_nsec : private_key_nsec. clone ( ) ,
461473 auth_tag : auth_tag. clone ( ) ,
462474 relay_url : resolved_relay_url. clone ( ) ,
475+ avatar_url : resolved_avatar_url. clone ( ) ,
463476 acp_command : input
464477 . acp_command
465478 . as_deref ( )
@@ -534,7 +547,10 @@ pub async fn create_managed_agent(
534547 . iter ( )
535548 . find ( |record| record. pubkey == pubkey)
536549 . ok_or_else ( || "created agent disappeared unexpectedly" . to_string ( ) ) ?;
537- build_managed_agent_summary ( & app, record, & runtimes) ?
550+ (
551+ build_managed_agent_summary ( & app, record, & runtimes) ?,
552+ resolved_avatar_url,
553+ )
538554 } ;
539555
540556 // ── Phase 3b: local spawn (async preflight outside store lock) ───────────
@@ -571,19 +587,14 @@ pub async fn create_managed_agent(
571587 try_regenerate_nest ( & app) ;
572588
573589 // ── Phase 4: sync agent profile on relay (async, outside lock) ───────────
574- let avatar_url = input
575- . avatar_url
576- . as_deref ( )
577- . map ( str:: trim)
578- . filter ( |value| !value. is_empty ( ) )
579- . map ( str:: to_string)
580- . or_else ( || managed_agent_avatar_url ( agent. agent_command . as_str ( ) ) ) ;
590+ // Use the avatar persisted on the record so the published profile and any
591+ // later reconciliation agree on the same value.
581592 let profile_sync_error = ( sync_managed_agent_profile (
582593 & state,
583594 & resolved_relay_url,
584595 & agent_keys,
585596 & name,
586- avatar_url . as_deref ( ) ,
597+ resolved_avatar_url . as_deref ( ) ,
587598 auth_tag. as_deref ( ) ,
588599 )
589600 . await )
@@ -665,6 +676,20 @@ pub async fn create_managed_agent(
665676 } )
666677}
667678
679+ /// Data needed for background profile reconciliation after agent start.
680+ struct ProfileReconcileData {
681+ private_key_nsec : String ,
682+ name : String ,
683+ relay_url : String ,
684+ /// Expected avatar URL for the published profile. Resolved at start from the
685+ /// record's persisted `avatar_url` (the exact URL published at creation),
686+ /// falling back to persona/command derivation only for pre-existing records
687+ /// that have no stored value — so old records still self-heal without
688+ /// regressing a user-overridden avatar.
689+ avatar_url : Option < String > ,
690+ auth_tag : Option < String > ,
691+ }
692+
668693#[ tauri:: command]
669694pub async fn start_managed_agent (
670695 pubkey : String ,
@@ -684,7 +709,8 @@ pub async fn start_managed_agent(
684709 }
685710
686711 // Collect backend info under lock; async preflight/spawn happens below.
687- let target = {
712+ // Also snapshot profile reconciliation data for the background task.
713+ let ( target, reconcile_data) = {
688714 let _store_guard = state
689715 . managed_agents_store_lock
690716 . lock ( )
@@ -701,18 +727,30 @@ pub async fn start_managed_agent(
701727
702728 let record = find_managed_agent_mut ( & mut records, & pubkey) ?;
703729
704- if record. backend == BackendKind :: Local {
730+ let expected_avatar = reconcile_avatar ( record. avatar_url . as_deref ( ) , & record. agent_command ) ;
731+
732+ let reconcile = ProfileReconcileData {
733+ private_key_nsec : record. private_key_nsec . clone ( ) ,
734+ name : record. name . clone ( ) ,
735+ relay_url : record. relay_url . clone ( ) ,
736+ avatar_url : expected_avatar,
737+ auth_tag : record. auth_tag . clone ( ) ,
738+ } ;
739+
740+ let target = if record. backend == BackendKind :: Local {
705741 StartTarget :: Local
706742 } else {
707743 StartTarget :: Provider {
708744 backend : record. backend . clone ( ) ,
709745 cached_binary_path : record. provider_binary_path . clone ( ) ,
710746 agent_json : build_deploy_payload ( & app, record) ?,
711747 }
712- }
748+ } ;
749+
750+ ( target, reconcile)
713751 } ;
714752
715- match target {
753+ let result = match target {
716754 StartTarget :: Local => {
717755 start_local_agent_with_preflight ( & app, & state, & pubkey, & owner_hex, false ) . await
718756 }
@@ -751,6 +789,98 @@ pub async fn start_managed_agent(
751789 StartTarget :: Provider { backend, .. } => Err ( format ! (
752790 "agent {pubkey} has unsupported backend kind: {backend:?}"
753791 ) ) ,
792+ } ;
793+
794+ // ── Profile reconciliation (fire-and-forget) ────────────────────────────
795+ // On successful start, spawn a background task to ensure the agent's kind:0
796+ // profile is published on the relay. This self-heals cases where the initial
797+ // profile sync at creation time failed silently.
798+ if result. is_ok ( ) {
799+ let reconcile_pubkey = pubkey. clone ( ) ;
800+ let reconcile_app = app. clone ( ) ;
801+ tauri:: async_runtime:: spawn ( async move {
802+ use tauri:: Manager ;
803+ let state = reconcile_app. state :: < AppState > ( ) ;
804+ if let Err ( e) =
805+ reconcile_agent_profile ( & state, & reconcile_pubkey, & reconcile_data) . await
806+ {
807+ eprintln ! (
808+ "sprout-desktop: profile reconciliation failed for agent {reconcile_pubkey}: {e}"
809+ ) ;
810+ }
811+ } ) ;
812+ }
813+
814+ result
815+ }
816+
817+ /// Reconcile an agent's kind:0 profile on the relay.
818+ ///
819+ /// Queries the relay for the agent's existing profile and re-publishes if missing
820+ /// or stale (display_name or picture mismatch). This is fire-and-forget — errors
821+ /// are returned to the caller for logging but never block agent startup.
822+ ///
823+ /// Query and publish both target the agent's stored `relay_url` so that, under
824+ /// an active workspace relay override, reconciliation reads and writes the same
825+ /// relay the agent's profile actually lives on.
826+ async fn reconcile_agent_profile (
827+ state : & AppState ,
828+ agent_pubkey : & str ,
829+ data : & ProfileReconcileData ,
830+ ) -> Result < ( ) , String > {
831+ use crate :: relay:: { query_agent_profile, sync_managed_agent_profile} ;
832+
833+ // Compare against the avatar persisted at creation time — never re-derive it.
834+ let expected_avatar = data. avatar_url . as_deref ( ) ;
835+
836+ // Query the same relay the profile is published to (the stored relay_url).
837+ let existing = query_agent_profile ( state, & data. relay_url , agent_pubkey) . await ?;
838+
839+ if !profile_needs_sync ( existing. as_ref ( ) , & data. name , expected_avatar) {
840+ return Ok ( ( ) ) ;
841+ }
842+
843+ let agent_keys = Keys :: parse ( & data. private_key_nsec )
844+ . map_err ( |e| format ! ( "failed to parse agent keys: {e}" ) ) ?;
845+
846+ sync_managed_agent_profile (
847+ state,
848+ & data. relay_url ,
849+ & agent_keys,
850+ & data. name ,
851+ expected_avatar,
852+ data. auth_tag . as_deref ( ) ,
853+ )
854+ . await
855+ }
856+
857+ /// Decide whether a published profile is missing or stale relative to the
858+ /// expected name and avatar. A missing profile always needs sync; a present
859+ /// one is stale when either the display name or picture diverges.
860+ fn profile_needs_sync (
861+ existing : Option < & crate :: relay:: AgentProfileInfo > ,
862+ expected_name : & str ,
863+ expected_avatar : Option < & str > ,
864+ ) -> bool {
865+ match existing {
866+ None => true ,
867+ Some ( info) => {
868+ let name_matches = info. display_name . as_deref ( ) == Some ( expected_name) ;
869+ let picture_matches = info. picture . as_deref ( ) == expected_avatar;
870+ !name_matches || !picture_matches
871+ }
872+ }
873+ }
874+
875+ /// Resolve the avatar a managed agent's profile should reconcile against.
876+ /// Stored value (persisted at creation) wins; legacy records that predate the
877+ /// field (`stored == None`) fall back to the command-based derivation — the
878+ /// same source the create path used. Persona config is never consulted: doing
879+ /// so diverges from what was published and overwrites user intent on restart.
880+ fn reconcile_avatar ( stored : Option < & str > , agent_command : & str ) -> Option < String > {
881+ match stored {
882+ Some ( url) => Some ( url. to_string ( ) ) ,
883+ None => managed_agent_avatar_url ( agent_command) ,
754884 }
755885}
756886
@@ -969,4 +1099,91 @@ mod tests {
9691099 } )
9701100 ) ;
9711101 }
1102+
1103+ fn profile ( name : Option < & str > , picture : Option < & str > ) -> crate :: relay:: AgentProfileInfo {
1104+ crate :: relay:: AgentProfileInfo {
1105+ display_name : name. map ( str:: to_string) ,
1106+ picture : picture. map ( str:: to_string) ,
1107+ }
1108+ }
1109+
1110+ #[ test]
1111+ fn profile_needs_sync_when_missing ( ) {
1112+ assert ! ( profile_needs_sync( None , "Duncan" , Some ( "https://x/a.png" ) ) ) ;
1113+ }
1114+
1115+ #[ test]
1116+ fn profile_needs_sync_when_name_diverges ( ) {
1117+ let existing = profile ( Some ( "Stilgar" ) , Some ( "https://x/a.png" ) ) ;
1118+ assert ! ( profile_needs_sync(
1119+ Some ( & existing) ,
1120+ "Duncan" ,
1121+ Some ( "https://x/a.png" )
1122+ ) ) ;
1123+ }
1124+
1125+ #[ test]
1126+ fn profile_needs_sync_when_picture_diverges ( ) {
1127+ let existing = profile ( Some ( "Duncan" ) , Some ( "https://x/old.png" ) ) ;
1128+ assert ! ( profile_needs_sync(
1129+ Some ( & existing) ,
1130+ "Duncan" ,
1131+ Some ( "https://x/new.png" )
1132+ ) ) ;
1133+ }
1134+
1135+ #[ test]
1136+ fn profile_in_sync_when_name_and_picture_match ( ) {
1137+ let existing = profile ( Some ( "Duncan" ) , Some ( "https://x/a.png" ) ) ;
1138+ assert ! ( !profile_needs_sync(
1139+ Some ( & existing) ,
1140+ "Duncan" ,
1141+ Some ( "https://x/a.png" )
1142+ ) ) ;
1143+ }
1144+
1145+ #[ test]
1146+ fn profile_in_sync_when_both_avatars_absent ( ) {
1147+ let existing = profile ( Some ( "Duncan" ) , None ) ;
1148+ assert ! ( !profile_needs_sync( Some ( & existing) , "Duncan" , None ) ) ;
1149+ }
1150+
1151+ #[ test]
1152+ fn profile_needs_sync_when_existing_name_is_none ( ) {
1153+ let existing = profile ( None , Some ( "https://x/a.png" ) ) ;
1154+ assert ! ( profile_needs_sync(
1155+ Some ( & existing) ,
1156+ "Duncan" ,
1157+ Some ( "https://x/a.png" ) ,
1158+ ) ) ;
1159+ }
1160+
1161+ #[ test]
1162+ fn profile_needs_sync_when_expected_avatar_absent_but_published ( ) {
1163+ let existing = profile ( Some ( "Duncan" ) , Some ( "https://x/a.png" ) ) ;
1164+ assert ! ( profile_needs_sync( Some ( & existing) , "Duncan" , None ) ) ;
1165+ }
1166+
1167+ /// Legacy records (`avatar_url: None`) must reconcile against
1168+ /// `managed_agent_avatar_url(agent_command)` — never persona config —
1169+ /// matching what the original create path published.
1170+ #[ test]
1171+ fn reconcile_avatar_legacy_record_uses_command_not_persona ( ) {
1172+ let resolved = reconcile_avatar ( None , "goose" ) ;
1173+
1174+ assert_eq ! ( resolved, managed_agent_avatar_url( "goose" ) ) ;
1175+ assert ! (
1176+ resolved. is_some( ) ,
1177+ "goose command should have a known avatar"
1178+ ) ;
1179+ }
1180+
1181+ /// New records persist their avatar at creation; the stored value is used
1182+ /// verbatim, never falling back to command derivation.
1183+ #[ test]
1184+ fn reconcile_avatar_stored_value_wins ( ) {
1185+ let resolved = reconcile_avatar ( Some ( "https://custom/avatar.png" ) , "goose" ) ;
1186+
1187+ assert_eq ! ( resolved. as_deref( ) , Some ( "https://custom/avatar.png" ) ) ;
1188+ }
9721189}
0 commit comments