From 5e69a11a376e9386c14f049785f96592551a26ab Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 22:23:21 -0700 Subject: [PATCH] [agents2] preserve picture and description in ordinary profiles Store optional picture and about fields on Agents2 identities and publish them with the name. Track accepted metadata so updates and clears publish even without a rename. Preserve compatibility with older rows and publication receipts. Implemented with goose AI assistance. Human acceptance: green confirmed working. Signed-off-by: Bradley Axen --- crates/agent-controller/src/app_agent.rs | 27 +++++++- .../agent-controller/src/app_agent/tests.rs | 67 +++++++++++++++++++ src-tauri/src/app_agents.rs | 10 ++- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index e07c14580..7b5e0f134 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -31,6 +31,11 @@ pub struct AppAgent { pub agent_type: String, #[serde(default)] pub name: String, + /// Public profile metadata, published as kind-0 picture and about. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, /// The last profile its community accepted. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, @@ -43,6 +48,10 @@ pub struct AppAgent { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Published { pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, pub created_at: u64, } impl AppAgent { @@ -90,11 +99,23 @@ impl AppAgent { let tags = self.attested(key, tags)?; key.signed(kind, content, tags) } + /// Whether the community accepted this row's current public metadata. + pub fn profile_is_current(&self) -> bool { + self.profile.as_ref().is_some_and(|last| { + last.name == self.name && last.picture == self.picture && last.about == self.about + }) + } /// Its profile as this row describes it, replacing the last one accepted. - /// No other writer exists: only this device holds the key. pub fn profile_event(&self, key: &Secret) -> Result { valid_name(&self.name)?; - let content = serde_json::json!({ "name": self.name, "bot": true }).to_string(); + let mut content = serde_json::json!({ "name": self.name, "bot": true }); + if let Some(picture) = &self.picture { + content["picture"] = picture.as_str().into(); + } + if let Some(about) = &self.about { + content["about"] = about.as_str().into(); + } + let content = content.to_string(); let tags = self.attested(key, vec![])?; key.sign_event_after( 0, @@ -299,6 +320,8 @@ impl AppAgents { auth, agent_type: prepared.agent_type, name: prepared.name, + picture: None, + about: None, profile: None, deleted: false, }; diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index 219ee8a2d..2b4518878 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -122,6 +122,8 @@ fn renames_and_profiles_replace_the_last_accepted_profile() { let future = first["created_at"].as_u64().unwrap() + 100; let published = |created_at| Published { name: "Ada".into(), + picture: None, + about: None, created_at, }; agents.published(&agent.pubkey, published(future)).unwrap(); @@ -324,3 +326,68 @@ fn concurrent_writers_on_one_file_keep_every_identity() { saved.sort(); assert_eq!(saved, made); } + +#[test] +fn saved_public_profile_survives_updates_and_is_signed() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (agents, agent) = created(dir.path(), &credentials); + let mut saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&agents.path).unwrap()).unwrap(); + saved[0]["picture"] = "https://relay.example/media/a.png".into(); + saved[0]["about"] = "Reviews pull requests.\nKeeps changes small.".into(); + // Existing receipts predate picture/about tracking. + saved[0]["profile"] = serde_json::json!({"name": "Ada", "createdAt": 500}); + std::fs::write(&agents.path, saved.to_string()).unwrap(); + agents.rename(&agent.pubkey, "Bea").unwrap(); + let reopened = AppAgents::open(agents.path.clone()); + let agent = reopened.get(&agent.pubkey).unwrap(); + let event = agent + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + let content: serde_json::Value = + serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content, + serde_json::json!({ + "name": "Bea", "bot": true, + "picture": "https://relay.example/media/a.png", + "about": "Reviews pull requests.\nKeeps changes small.", + }) + ); + assert!(!agent.profile_is_current()); + reopened + .published( + &agent.pubkey, + Published { + name: agent.name.clone(), + picture: agent.picture.clone(), + about: agent.about.clone(), + created_at: event["created_at"].as_u64().unwrap(), + }, + ) + .unwrap(); + assert!(reopened.get(&agent.pubkey).unwrap().profile_is_current()); + let accepted = std::fs::read(&reopened.path).unwrap(); + // Each metadata-only change/clear must publish, even without a rename. + for field in ["picture", "about"] { + for replacement in [Some("https://relay.example/changed"), None] { + let mut changed: serde_json::Value = serde_json::from_slice(&accepted).unwrap(); + changed[0][field] = serde_json::json!(replacement); + std::fs::write(&reopened.path, changed.to_string()).unwrap(); + let changed = reopened.get(&agent.pubkey).unwrap(); + assert!(!changed.profile_is_current()); + let event = changed + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + let content: serde_json::Value = + serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content.get(field).and_then(serde_json::Value::as_str), + replacement + ); + } + } + let event: nostr::event::Event = serde_json::from_value(event).unwrap(); + event.verify().unwrap(); +} diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index 9fc68db66..049466bd1 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -336,8 +336,8 @@ pub(crate) async fn app_agent_publish( post(&agent, &key, signed).await } -/// Publishes the agent's profile if its name differs from the last one its -/// community accepted. Safe to call any time; one runs per agent at a time. +/// Publishes changed public metadata. Safe to call any time; one publication +/// runs per agent at a time and records only what its community accepted. #[tauri::command] pub(crate) async fn app_agent_publish_profile( state: tauri::State<'_, AppAgentHost>, @@ -346,7 +346,7 @@ pub(crate) async fn app_agent_publish_profile( let turn = state.profile_turn(&pubkey); let _turn = turn.lock().await; let agent = state.agent(pubkey).await?; - if agent.profile.as_ref().map(|last| &last.name) == Some(&agent.name) { + if agent.profile_is_current() { return Ok(()); } let (agent, key) = state.key(agent).await?; @@ -361,6 +361,8 @@ pub(crate) async fn app_agent_publish_profile( &agent.pubkey, Published { name: agent.name, + picture: agent.picture, + about: agent.about, created_at, }, ) @@ -590,6 +592,8 @@ mod tests { auth: "[]".into(), agent_type: "p/t".into(), name: "Ada".into(), + picture: None, + about: None, profile: None, deleted: false, };