diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 13bcb5d4efa..3dd3a823f05 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -66,6 +66,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: Some("persona-1".to_string()), @@ -127,6 +128,7 @@ fn agent_record() -> ManagedAgentRecord { fn persona_with_model(model: &str) -> AgentDefinition { AgentDefinition { + description: None, id: "persona-1".to_string(), display_name: "Persona".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index db0573acd7c..0b6bf22a6a9 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -506,6 +506,7 @@ mod real_relay_tests { &agent, "Agent Probe", None, + None, Some(&auth_tag), ) .await diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index d79e40bd20b..7c382a663b2 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -428,29 +428,20 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { ) .expect("sample managed agent record"); - let persona = crate::managed_agents::AgentDefinition { - id: "persona-1".to_string(), - display_name: "Persona".to_string(), - avatar_url: None, - system_prompt: "You are a persona.".to_string(), - runtime: Some("goose".to_string()), - model: Some("persona-model".to_string()), - provider: Some("anthropic".to_string()), - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - team_catalog_source: None, - env_vars: BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: "".to_string(), - updated_at: "".to_string(), - }; + let persona: crate::managed_agents::AgentDefinition = serde_json::from_str( + r#"{ + "id": "persona-1", + "display_name": "Persona", + "system_prompt": "You are a persona.", + "runtime": "goose", + "model": "persona-model", + "provider": "anthropic", + "is_active": true, + "created_at": "", + "updated_at": "" + }"#, + ) + .expect("sample persona"); // agent_model_discovery_config is the single helper get_agent_models // consumes — the stale record bytes must lose to the persona's current diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index bb045b81a24..68f54f58ad6 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -244,8 +244,16 @@ pub async fn update_managed_agent( .avatar_url .clone() .or_else(|| managed_agent_avatar_url(&effective_command)); + let about = crate::managed_agents::record_effective_description(record, &personas); let auth_tag = record.auth_tag.clone(); - Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) + Some(( + agent_keys, + relay_url, + display_name, + avatar_url, + about, + auth_tag, + )) } else { None }; @@ -291,13 +299,14 @@ pub async fn update_managed_agent( // A rename is committed only when profile sync succeeds; otherwise restore // the complete pre-edit record so Desktop and the relay keep one // authoritative name. - if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { + if let Some((agent_keys, relay_url, display_name, avatar_url, about, auth_tag)) = sync_params { if let Err(sync_error) = sync_managed_agent_profile( &state, &relay_url, &agent_keys, &display_name, avatar_url.as_deref(), + about.as_deref(), auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index acee23f2f39..cc8bc08da46 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -15,7 +15,7 @@ use crate::{ CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, - relay::{relay_ws_url_with_override, sync_managed_agent_profile}, + relay::relay_ws_url_with_override, util::now_iso, }; @@ -486,7 +486,7 @@ pub async fn create_managed_agent( }; // ── Phase 3: save record (sync lock) ─────────────────────────────────────── - let (agent, resolved_avatar_url) = { + let (agent, resolved_avatar_url, profile_about) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -637,10 +637,10 @@ pub async fn create_managed_agent( input.parallelism, linked_persona.as_ref(), )?; - let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), + description: None, persona_id: requested_persona_id.clone(), team_id, private_key_nsec: private_key_nsec.clone(), @@ -739,9 +739,12 @@ pub async fn create_managed_agent( // before any .await — owner-authored, every agent (Will's ruling: no // is_builtin/persona-membership gate). retain_managed_agent_pending(&app, &state, record); + // Effective owner-authored description for the kind:0 `about`. + let profile_about = crate::managed_agents::record_effective_description(record, &personas); ( summarize_from_disk(&app, record, &runtimes)?, resolved_avatar_url, + profile_about, ) }; @@ -781,20 +784,16 @@ pub async fn create_managed_agent( // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── // Use the avatar persisted on the record so the published profile and any // later reconciliation agree on the same value. - let profile_relay_url = crate::relay::effective_agent_relay_url( - &resolved_relay_url, - &relay_ws_url_with_override(&state), - ); - let mut profile_sync_error = (sync_managed_agent_profile( + let mut profile_sync_error = profile::publish_agent_profile_with_about( &state, - &profile_relay_url, + &resolved_relay_url, &agent_keys, &name, resolved_avatar_url.as_deref(), + profile_about.as_deref(), auth_tag.as_deref(), ) - .await) - .err(); + .await; profile_sync_error = super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await; diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 16a1538c753..193a1fb0344 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -40,6 +40,11 @@ pub(crate) struct ProfileReconcileData { /// backfill to recover the correct avatar from the persona record when the /// relay profile has been corrupted. pub(crate) persona_id: Option, + /// Expected kind:0 `about` — the agent's effective public description + /// (owner-authored when present; see + /// `managed_agents::record_effective_description`). `None` publishes an + /// about-less profile. + pub(crate) about: Option, } /// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no @@ -96,6 +101,7 @@ pub(crate) fn profile_reconcile_data( pubkey: record.pubkey.clone(), agent_command: crate::managed_agents::record_agent_command(record, personas), persona_id: record.persona_id.clone(), + about: crate::managed_agents::record_effective_description(record, personas), } } @@ -254,7 +260,12 @@ pub(crate) async fn reconcile_agent_profile( Some(expected_avatar) }; - if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { + if !profile_needs_sync( + existing.as_ref(), + &data.name, + expected_avatar.as_deref(), + data.about.as_deref(), + ) { return Ok(ProfileReconcileOutcome::Reconciled); } @@ -274,6 +285,7 @@ pub(crate) async fn reconcile_agent_profile( &agent_keys, &data.name, expected_avatar.as_deref(), + data.about.as_deref(), data.auth_tag.as_deref(), ) .await?; @@ -281,23 +293,84 @@ pub(crate) async fn reconcile_agent_profile( } /// Decide whether a published profile is missing or stale relative to the -/// expected name and avatar. A missing profile always needs sync; a present -/// one is stale when either the display name or picture diverges. +/// expected name, avatar, and about. A missing profile always needs sync; a +/// present one is stale when the display name, picture, or about diverges. +/// For about, `None` and the empty string are treated as equal so an +/// about-less profile never triggers a pointless republish loop. pub(super) fn profile_needs_sync( existing: Option<&crate::relay::AgentProfileInfo>, expected_name: &str, expected_avatar: Option<&str>, + expected_about: Option<&str>, ) -> bool { match existing { None => true, Some(info) => { let name_matches = info.display_name.as_deref() == Some(expected_name); let picture_matches = info.picture.as_deref() == expected_avatar; - !name_matches || !picture_matches + let about_matches = info.about.as_deref().unwrap_or("") == expected_about.unwrap_or(""); + !name_matches || !picture_matches || !about_matches } } } +/// Publish a managed agent's kind:0 profile with the authored public +/// description as `about`, resolving the effective +/// relay URL from the record's stored value. Returns the sync error (if any) +/// rather than failing the caller — profile publish is best-effort in the +/// create and snapshot-import flows that share this helper. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn publish_agent_profile_with_about( + state: &AppState, + record_relay_url: &str, + agent_keys: &nostr::Keys, + display_name: &str, + avatar_url: Option<&str>, + about: Option<&str>, + auth_tag: Option<&str>, +) -> Option { + let relay_url = crate::relay::effective_agent_relay_url( + record_relay_url, + &relay_ws_url_with_override(state), + ); + crate::relay::sync_managed_agent_profile( + state, + &relay_url, + agent_keys, + display_name, + avatar_url, + about, + auth_tag, + ) + .await + .err() +} + +/// Publish a fresh persona-backed agent's kind:0 profile, computing the +/// effective public `about` from the persona itself. +/// Shared by flows in files at the size ratchet (snapshot import). +pub(crate) async fn publish_persona_profile( + state: &AppState, + record_relay_url: &str, + agent_keys: &nostr::Keys, + display_name: &str, + avatar_url: Option<&str>, + persona: &crate::managed_agents::AgentDefinition, + auth_tag: Option<&str>, +) -> Option { + let about = crate::managed_agents::effective_agent_description(persona.description.as_deref()); + publish_agent_profile_with_about( + state, + record_relay_url, + agent_keys, + display_name, + avatar_url, + about.as_deref(), + auth_tag, + ) + .await +} + // Async so the blocking body (disk reads/writes + process termination) runs off // the main UI thread via spawn_blocking. State is re-derived from the owned // AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send). diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 17fadea82f3..ef71321bedf 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -9,6 +9,7 @@ fn bare_agent_record( use crate::managed_agents::{BackendKind, RespondTo}; use std::collections::BTreeMap; ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: persona_id.map(str::to_string), @@ -70,6 +71,7 @@ fn bare_agent_record( fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { use std::collections::BTreeMap; AgentDefinition { + description: None, id: id.to_string(), display_name: "Test Persona".to_string(), avatar_url: None, @@ -314,15 +316,29 @@ fn created_avatar_uses_command_fallback_without_input_or_persona() { } fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProfileInfo { + profile_with_about(name, picture, None) +} + +fn profile_with_about( + name: Option<&str>, + picture: Option<&str>, + about: Option<&str>, +) -> crate::relay::AgentProfileInfo { crate::relay::AgentProfileInfo { display_name: name.map(str::to_string), picture: picture.map(str::to_string), + about: about.map(str::to_string), } } #[test] fn profile_needs_sync_when_missing() { - assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png"))); + assert!(profile_needs_sync( + None, + "Duncan", + Some("https://x/a.png"), + None + )); } // ── resolve_reconcile_relay: deferred-task relay pinning ──────────────────── @@ -352,7 +368,7 @@ fn unpinned_reconcile_relay_resolves_the_execution_time_workspace() { #[test] fn profile_needs_sync_when_missing_even_without_expected_avatar() { - assert!(profile_needs_sync(None, "Duncan", None)); + assert!(profile_needs_sync(None, "Duncan", None, None)); } #[test] @@ -361,7 +377,8 @@ fn profile_needs_sync_when_name_diverges() { assert!(profile_needs_sync( Some(&existing), "Duncan", - Some("https://x/a.png") + Some("https://x/a.png"), + None )); } @@ -371,7 +388,8 @@ fn profile_needs_sync_when_picture_diverges() { assert!(profile_needs_sync( Some(&existing), "Duncan", - Some("https://x/new.png") + Some("https://x/new.png"), + None )); } @@ -381,14 +399,15 @@ fn profile_in_sync_when_name_and_picture_match() { assert!(!profile_needs_sync( Some(&existing), "Duncan", - Some("https://x/a.png") + Some("https://x/a.png"), + None )); } #[test] fn profile_in_sync_when_both_avatars_absent() { let existing = profile(Some("Duncan"), None); - assert!(!profile_needs_sync(Some(&existing), "Duncan", None)); + assert!(!profile_needs_sync(Some(&existing), "Duncan", None, None)); } #[test] @@ -398,13 +417,50 @@ fn profile_needs_sync_when_existing_name_is_none() { Some(&existing), "Duncan", Some("https://x/a.png"), + None, )); } #[test] fn profile_needs_sync_when_expected_avatar_absent_but_published() { let existing = profile(Some("Duncan"), Some("https://x/a.png")); - assert!(profile_needs_sync(Some(&existing), "Duncan", None)); + assert!(profile_needs_sync(Some(&existing), "Duncan", None, None)); +} + +#[test] +fn profile_needs_sync_when_about_diverges() { + let existing = profile_with_about(Some("Duncan"), None, Some("Old description.")); + assert!(profile_needs_sync( + Some(&existing), + "Duncan", + None, + Some("New description.") + )); +} + +#[test] +fn profile_needs_sync_when_expected_about_absent_but_published() { + let existing = profile_with_about(Some("Duncan"), None, Some("Stale description.")); + assert!(profile_needs_sync(Some(&existing), "Duncan", None, None)); +} + +#[test] +fn profile_in_sync_when_about_matches() { + let existing = profile_with_about(Some("Duncan"), None, Some("A helpful desktop agent.")); + assert!(!profile_needs_sync( + Some(&existing), + "Duncan", + None, + Some("A helpful desktop agent.") + )); +} + +#[test] +fn profile_in_sync_when_about_none_equals_published_empty_string() { + // None vs "" must be treated as equal — otherwise every reconcile of an + // about-less agent would republish forever. + let existing = profile_with_about(Some("Duncan"), None, Some("")); + assert!(!profile_needs_sync(Some(&existing), "Duncan", None, None)); } #[test] diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 14c7c196b2b..517e333b293 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -23,14 +23,10 @@ //! uses (global config < persona < agent record) and never leaves Rust. //! It is never logged. -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, State}; - use super::super::export_util::save_bytes_with_dialog; use super::snapshot::{ - memory_entries_from_listing, parse_memory_level, resolve_from_lists, - validate_snapshot_encode_size, + materialize_snapshot_description, memory_entries_from_listing, parse_memory_level, + resolve_from_lists, validate_snapshot_encode_size, }; use crate::{ app_state::AppState, @@ -47,6 +43,9 @@ use crate::{ save_global_agent_config, validate_global_config, }, }; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; /// The Buzz card frame template — Tyler's gold-honeycomb base. Generation /// input only: it never participates in the snapshot manifest, PNG chunk, @@ -553,7 +552,8 @@ pub async fn mint_agent_card( let definitions = load_agent_definitions(&app)?; let (record, is_definition) = resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; - + let mut record = record; + materialize_snapshot_description(&mut record, is_definition, &definitions); let global = load_global_agent_config(&app).unwrap_or_default(); let personas = load_personas(&app).unwrap_or_default(); let persona_env = record diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index 91616b225cf..2f19d1256e1 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -13,7 +13,7 @@ use crate::{ util::now_iso, }; -use super::{pending, retain_persona_pending, trim_optional, trim_required}; +use super::{normalize_description, pending, retain_persona_pending, trim_optional, trim_required}; #[tauri::command] pub async fn create_persona( @@ -29,6 +29,7 @@ pub async fn create_persona( // exact string before the ACP harness executes it. let system_prompt = input.system_prompt.clone(); validate_agent_definition_text(&display_name, &system_prompt)?; + let description = normalize_description(input.description)?; let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); @@ -58,6 +59,7 @@ pub async fn create_persona( id: Uuid::new_v4().to_string(), display_name, avatar_url, + description, system_prompt, runtime, model, diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 189d2676c49..6a10a1f9ee2 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -17,6 +17,7 @@ fn make_agent( runtime_pid: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: pubkey.to_string(), name: "Test Agent".to_string(), persona_id: persona_id.map(str::to_string), diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 2080630742d..b4438b67b7a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -452,7 +452,9 @@ fn validate_inbound_persona_definition(persona: &AgentDefinition) -> Result<(), &persona.display_name, &persona.system_prompt, ) - .map_err(|error| format!("Inbound persona definition is unsafe: {error}")) + .map_err(|error| format!("Inbound persona definition is unsafe: {error}"))?; + crate::managed_agents::validate_agent_description_text(persona.description.as_deref()) + .map_err(|error| format!("Inbound persona definition is unsafe: {error}")) } fn validate_inbound_managed_agent_definition( @@ -685,6 +687,7 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefi Some(local) => { local.display_name = inbound.display_name; local.avatar_url = inbound.avatar_url; + local.description = inbound.description; local.system_prompt = inbound.system_prompt; local.runtime = inbound.runtime; local.model = inbound.model; diff --git a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs index a5ca5cd9b5d..390e4850773 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs @@ -28,6 +28,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index ab932437553..e90df637314 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -10,6 +10,7 @@ const UUID: &str = "11111111-2222-3333-4444-555555555555"; // sadscan:disable sq /// IS its UUID id. Carries env_vars + source_team that must survive a patch. fn local_in_app() -> AgentDefinition { AgentDefinition { + description: None, id: UUID.to_string(), display_name: "Local".to_string(), avatar_url: None, @@ -38,6 +39,7 @@ fn local_in_app() -> AgentDefinition { /// slug = Some(d-tag), empty env_vars, source_team None. fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + description: None, id: d_tag.to_string(), display_name: display_name.to_string(), avatar_url: Some("https://example.com/a.png".to_string()), @@ -161,6 +163,7 @@ const AGENT_PUBKEY: &str = "agentpubkeyhex00000000000000000000000000000000000000 /// event must NEVER be able to overwrite. fn local_agent() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: AGENT_PUBKEY.to_string(), name: "Local Agent".to_string(), persona_id: Some("persona-local".to_string()), diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 81371e72ed0..ac43a4719ab 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -26,6 +26,38 @@ fn trim_optional(value: Option) -> Option { }) } +/// Validate the raw authored bytes before applying storage normalization. +/// This ordering is security-relevant: prohibited edge characters must be +/// rejected, never made invisible by trimming. +fn normalize_description(value: Option) -> Result, String> { + crate::managed_agents::validate_agent_description_text(value.as_deref())?; + Ok(trim_optional(value)) +} + +#[cfg(test)] +mod description_normalization_tests { + use super::normalize_description; + + #[test] + fn trims_visible_whitespace_and_collapses_blank_to_none() { + assert_eq!( + normalize_description(Some(" A careful agent. ".to_string())).unwrap(), + Some("A careful agent.".to_string()) + ); + assert_eq!( + normalize_description(Some(" ".to_string())).unwrap(), + None + ); + } + + #[test] + fn rejects_prohibited_characters_at_the_edges_before_trimming() { + for value in ["\nA careful agent.", "A careful agent.\n", "\u{feff}Agent"] { + assert!(normalize_description(Some(value.to_string())).is_err()); + } + } +} + mod pending; pub(in crate::commands) use pending::retain_persona_pending; pub(in crate::commands) use pending::retain_persona_pending_at; diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 30e2ec266db..3e4fabbcf5b 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -181,6 +181,9 @@ pub(super) fn prepare_persona_publication_at( &scoped_persona.display_name, &scoped_persona.system_prompt, )?; + crate::managed_agents::validate_agent_description_text( + scoped_persona.description.as_deref(), + )?; } let event = build_persona_event(&scoped_persona)? .custom_created_at(monotonic_created_at( @@ -307,6 +310,7 @@ mod tests { fn persona() -> AgentDefinition { AgentDefinition { + description: None, id: "catalog-reviewer".to_string(), display_name: "Catalog Reviewer".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index fa492b338b5..331ec9d0d70 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -146,6 +146,7 @@ mod tests { fn persona() -> AgentDefinition { AgentDefinition { + description: None, id: "catalog-reviewer".to_string(), display_name: "Catalog Reviewer".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e63..c996c7ee2ea 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -56,6 +56,25 @@ pub(crate) fn resolve_from_lists<'a>( Err(format!("agent {id:?} not found")) } +/// Materialize persona-owned display metadata onto a cloned instance for +/// portable snapshot construction. Keyless definition records already carry +/// their own description. +pub(crate) fn materialize_snapshot_description( + record: &mut ManagedAgentRecord, + is_definition: bool, + definitions: &[ManagedAgentRecord], +) { + if is_definition { + return; + } + if let Some(persona_id) = record.persona_id.as_deref() { + record.description = definitions + .iter() + .find(|definition| definition.slug.as_deref() == Some(persona_id)) + .and_then(|definition| definition.description.clone()); + } +} + /// Validate that `memory_source_pubkey` is an appropriate source for a /// memory-bearing snapshot export. /// @@ -250,6 +269,7 @@ pub(crate) async fn materialize_snapshot_bytes( let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions) .map(|(r, is_def)| (r.clone(), is_def))?; let mut def_record = def_record; + materialize_snapshot_description(&mut def_record, is_definition, &definitions); // A snapshot is a verbatim portable copy of the effective runtime, // provider, and model configuration, not a pointer to the sender's // machine-wide defaults. This does not translate or substitute values diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index ff2b4535294..55a64db59bc 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), slug: Some(slug.to_string()), name: slug.to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 729222d3831..0ad466fc1ad 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -21,7 +21,7 @@ use crate::{ load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, - relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, + relay::{effective_agent_relay_url, relay_ws_url_with_override}, util::now_iso, }; @@ -557,12 +557,14 @@ pub async fn confirm_agent_snapshot_import( let now = now_iso(); let persona_id = uuid::Uuid::new_v4().to_string(); - // Build persona from snapshot definition. let persona = AgentDefinition { id: persona_id.clone(), display_name: display_name.clone(), avatar_url: effective_avatar.clone(), + description: crate::managed_agents::effective_agent_description( + snapshot.profile.about.as_deref(), + ), system_prompt: snapshot .definition .system_prompt @@ -592,13 +594,16 @@ pub async fn confirm_agent_snapshot_import( // Enqueue the kind:30175 persona event via the retention path. super::super::pending::retain_persona_pending(&app, &state, &persona); - // Build the managed agent record — no machine-local commands, no // secrets, no lineage from the snapshot. let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, + // Linked definitions remain the sole description authority. Do + // not persist a second instance copy that can go stale after an + // edit or survive a later definition deletion. + description: None, slug: None, persona_id: Some(persona_id.clone()), private_key_nsec: private_key_nsec.clone(), @@ -680,16 +685,16 @@ pub async fn confirm_agent_snapshot_import( // ── Phase 3b: publish kind:0 profile (async, outside lock) ─────────────── let relay_url = effective_agent_relay_url(&record.relay_url, &relay_ws_url_with_override(&state)); - let profile_sync_error = sync_managed_agent_profile( + let profile_sync_error = crate::commands::agents::publish_persona_profile( &state, - &relay_url, + &record.relay_url, &agent_keys, &display_name, effective_avatar.as_deref(), + &persona, auth_tag.as_deref(), ) - .await - .err(); + .await; // ── Phase 4: restore memory (async, outside lock) ───────────────────────── let memory_total = snapshot.memory.entries.len(); diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 6292a4dd258..abf4bef443d 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; /// persona_id. fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), slug: Some(slug.to_string()), name: slug.to_string(), @@ -90,6 +91,17 @@ fn make_instance(pubkey: &str, persona_id: &str) -> ManagedAgentRecord { } } +#[test] +fn linked_instance_snapshot_materializes_the_definition_description() { + let mut definition = make_definition("reviewer"); + definition.description = Some("Reviews changes.".to_string()); + let mut instance = make_instance("agent-pubkey", "reviewer"); + + materialize_snapshot_description(&mut instance, false, std::slice::from_ref(&definition)); + + assert_eq!(instance.description, definition.description); +} + /// Build a minimal valid AgentSnapshot for import tests. fn make_snapshot( memory_level: MemoryLevel, diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index f9b09b4bbb4..46d0c8a99dc 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -14,7 +14,7 @@ use crate::{ util::now_iso, }; -use super::{pending, retain_persona_pending, trim_optional, trim_required}; +use super::{normalize_description, pending, retain_persona_pending, trim_optional, trim_required}; #[cfg(test)] mod name_propagation_tests; @@ -54,8 +54,72 @@ fn propagate_persona_name_rename( renamed } -/// Profile sync params collected under the store lock for async relay publish. -type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option, Option)>; +#[derive(Debug, PartialEq, Eq)] +struct LinkedProfileUpdate { + /// Whether this update changed bytes in the managed-agent record. + record_changed: bool, + /// Whether this instance needs a complete kind:0 replacement event. + profile_sync_required: bool, + /// Avatar to publish with the complete kind:0 replacement event. + profile_avatar: Option, +} + +/// Apply the persisted portion of a persona identity edit to one linked +/// instance and resolve the avatar for the complete kind:0 replacement. +/// +/// Description-only edits deliberately leave the record unchanged, but still +/// need a non-empty avatar projection for legacy records whose `avatar_url` +/// has not yet been backfilled. The persona avatar is authoritative there; +/// the effective command icon is the final fallback. +fn prepare_linked_profile_update( + record: &mut ManagedAgentRecord, + persona: &AgentDefinition, + renamed: bool, + avatar_changed: bool, + about_changed: bool, +) -> LinkedProfileUpdate { + let mut record_changed = renamed; + if avatar_changed { + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(persona), + record.agent_command_override.as_deref(), + ); + record.avatar_url = persona + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + record_changed = true; + } + + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(persona), + record.agent_command_override.as_deref(), + ); + let profile_avatar = record + .avatar_url + .clone() + .or_else(|| persona.avatar_url.clone()) + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + + LinkedProfileUpdate { + record_changed, + profile_sync_required: record_changed || about_changed, + profile_avatar, + } +} + +/// Profile sync params collected under the store lock for async relay publish: +/// (agent keys, relay url, display name, avatar url, kind:0 about, auth tag). +type ProfileSyncParams = Vec<( + nostr::Keys, + String, + String, + Option, + Option, + Option, +)>; #[tauri::command] pub async fn update_persona( @@ -96,6 +160,7 @@ pub(super) async fn update_persona_with( let display_name = trim_required(&input.display_name, "Display name")?; let system_prompt = input.system_prompt.clone(); validate_agent_definition_text(&display_name, &system_prompt)?; + let description = normalize_description(input.description)?; let avatar_url = trim_optional(input.avatar_url); let runtime = trim_optional(input.runtime); let model = trim_optional(input.model); @@ -116,9 +181,17 @@ pub(super) async fn update_persona_with( let avatar_changed = persona.avatar_url != avatar_url; let name_changed = persona.display_name != display_name; let old_display_name = persona.display_name.clone(); + // The kind:0 `about` is the authored description, so a + // description edit changes what should be published. + let old_about = + crate::managed_agents::effective_agent_description(persona.description.as_deref()); + let new_about = + crate::managed_agents::effective_agent_description(description.as_deref()); + let about_changed = old_about != new_about; persona.display_name = display_name; persona.avatar_url = avatar_url; + persona.description = description; persona.system_prompt = system_prompt; persona.runtime = runtime; persona.model = model; @@ -142,9 +215,12 @@ pub(super) async fn update_persona_with( let retained = retain(&app, &state, &result)?; try_regenerate_nest(&app); - // If the avatar or display_name changed, propagate to linked agent - // records and collect relay profile sync params for the async phase. - let sync_params: ProfileSyncParams = if avatar_changed || name_changed { + // If the avatar, display_name, or effective description changed, + // propagate to linked agent records and collect relay profile sync + // params for the async phase. An about-only change touches no + // record bytes but still republishes each linked kind:0 profile. + let sync_params: ProfileSyncParams = if avatar_changed || name_changed || about_changed + { let mut records = load_managed_agents(&app)?; let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; @@ -169,28 +245,17 @@ pub(super) async fn update_persona_with( if record.persona_id.as_deref() != Some(&result.id) { continue; } - let mut record_changed = renamed.contains(&record.pubkey); - - if avatar_changed { - // Update the persisted avatar so reconciliation on next - // start agrees with what we're about to publish. - // When the persona avatar is cleared, fall back to the - // command-default icon so the record never stores `None` - // (which reconcile_agent_profile treats as "un-migrated"). - let effective_cmd = effective_agent_command( - record.persona_id.as_deref(), - std::slice::from_ref(&result), - record.agent_command_override.as_deref(), - ); - record.avatar_url = result - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&effective_cmd)); - record_changed = true; - } + let was_renamed = renamed.contains(&record.pubkey); + let update = prepare_linked_profile_update( + record, + &result, + was_renamed, + avatar_changed, + about_changed, + ); - if record_changed { - agents_modified = true; + agents_modified = agents_modified || update.record_changed; + if update.profile_sync_required { if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { let relay_url = crate::relay::effective_agent_relay_url( &record.relay_url, @@ -200,7 +265,8 @@ pub(super) async fn update_persona_with( agent_keys, relay_url, record.name.clone(), - record.avatar_url.clone(), + update.profile_avatar, + new_about.clone(), record.auth_tag.clone(), )); } @@ -231,19 +297,23 @@ pub(super) async fn update_persona_with( .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; - // Phase 2: await relay profile sync for linked agents whose avatar or - // display_name was just updated. We await (rather than fire-and-forget) + // Phase 2: await relay profile sync for linked agents whose avatar, + // display_name, or effective description (kind:0 about) was just + // updated. We await (rather than fire-and-forget) // so the frontend cache invalidation that follows the mutation settlement // sees the fresh relay profile. Best-effort — failures are logged, not surfaced. if !profile_sync_params.is_empty() { let state = app.state::(); - for (agent_keys, relay_url, display_name, avatar_url, auth_tag) in profile_sync_params { + for (agent_keys, relay_url, display_name, avatar_url, about, auth_tag) in + profile_sync_params + { if let Err(e) = crate::relay::sync_managed_agent_profile( &state, &relay_url, &agent_keys, &display_name, avatar_url.as_deref(), + about.as_deref(), auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index edef958cef8..7aedcb25ef5 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -5,6 +5,7 @@ use super::*; fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: format!("pubkey-{name}"), name: name.to_string(), persona_id: Some(persona_id.to_string()), @@ -138,6 +139,52 @@ fn test_rename_only_affects_linked_persona() { ); } +#[test] +fn description_only_update_syncs_without_mutating_record_and_preserves_legacy_persona_avatar() { + let mut record = agent("persona-1", "Paul", Some("Paul")); + record.avatar_url = None; + record.slug = Some("persona-1".to_string()); + let before = record.clone(); + let mut persona = record + .clone() + .to_definition_view() + .expect("test record projects to a definition"); + persona.id = "persona-1".to_string(); + persona.avatar_url = Some("https://example.com/paul.png".to_string()); + + let update = prepare_linked_profile_update(&mut record, &persona, false, false, true); + + assert!(update.profile_sync_required, "about-only edits must sync"); + assert!( + !update.record_changed, + "about-only edits must not write the agent store" + ); + assert_eq!( + record, before, + "description-only edits leave instance bytes untouched" + ); + assert_eq!( + update.profile_avatar.as_deref(), + Some("https://example.com/paul.png"), + "complete kind:0 replacement must not clear a legacy agent avatar" + ); +} + +#[test] +fn unchanged_identity_needs_neither_store_write_nor_profile_sync() { + let mut record = agent("persona-1", "Paul", Some("Paul")); + record.slug = Some("persona-1".to_string()); + let persona = record + .clone() + .to_definition_view() + .expect("test record projects to a definition"); + + let update = prepare_linked_profile_update(&mut record, &persona, false, false, false); + + assert!(!update.record_changed); + assert!(!update.profile_sync_required); +} + #[test] fn test_rename_renames_all_matching_instances_in_one_pass() { // Several instances may carry the definition name (multi-instance deploys diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 26f6450c568..9c57ce12b53 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -122,6 +122,9 @@ fn definition_from_snapshot( id: Uuid::new_v4().to_string(), display_name: member.profile.display_name.trim().to_string(), avatar_url: effective_avatar(member), + description: crate::managed_agents::effective_agent_description( + member.profile.about.as_deref(), + ), system_prompt: member.definition.system_prompt.clone().unwrap_or_default(), runtime: member.definition.runtime.clone(), model: member.definition.model.clone(), @@ -559,6 +562,10 @@ pub async fn confirm_team_snapshot_import( pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, + // Linked definitions remain the sole description authority. Do + // not persist a second instance copy that can go stale after an + // edit or survive a later definition deletion. + description: None, slug: None, persona_id: Some(definition.id.clone()), private_key_nsec: private_key_nsec.clone(), @@ -771,12 +778,15 @@ pub async fn confirm_team_snapshot_import( let relay_url = effective_agent_relay_url(&m.record.relay_url, &relay_ws); // Phase 4: profile sync (best-effort). + let profile_about = + crate::managed_agents::effective_agent_description(m.definition.description.as_deref()); let profile_sync_error = sync_managed_agent_profile( &state, &relay_url, &m.agent_keys, &m.display_name, m.effective_avatar.as_deref(), + profile_about.as_deref(), m.auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index b1c93a283ec..13c7f6ae810 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -55,6 +55,7 @@ fn snapshot(members: Vec) -> TeamSnapshot { fn team_export_round_trip_preserves_team_and_excludes_member_memory() { let definitions = vec![ AgentDefinition { + description: Some("A careful reviewer.".to_string()), id: "alice".to_string(), display_name: "Alice".to_string(), avatar_url: None, @@ -78,6 +79,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { updated_at: "now".to_string(), }, AgentDefinition { + description: None, id: "bob".to_string(), display_name: "Bob".to_string(), avatar_url: None, @@ -136,6 +138,11 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { assert_eq!(decoded.team.description.as_deref(), Some("Reviews changes")); assert_eq!(decoded.team.instructions.as_deref(), Some("Be thorough.")); assert_eq!(decoded.members.len(), 2); + assert_eq!( + decoded.members[0].profile.about.as_deref(), + Some("A careful reviewer.") + ); + assert_eq!(decoded.members[1].profile.about, None); assert!(decoded.members.iter().all(|member| { member.memory.level == MemoryLevel::None && member.memory.entries.is_empty() })); @@ -144,6 +151,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { #[test] fn team_export_with_instance_and_memory_level_uses_supplied_entries() { let definitions = vec![AgentDefinition { + description: None, id: "alice".to_string(), display_name: "Alice".to_string(), avatar_url: None, @@ -185,6 +193,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { // Build a fake instance record tied to this team+persona. let instance = ManagedAgentRecord { + description: None, pubkey: "a".repeat(64), name: "Alice".to_string(), display_name: None, @@ -298,6 +307,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { #[test] fn team_import_definitions_are_built_for_all_members() { let mut memory_bearing = member("Alice"); + memory_bearing.profile.about = Some(" A careful reviewer. ".to_string()); memory_bearing.memory = AgentSnapshotMemory { level: MemoryLevel::Everything, entries: vec![AgentSnapshotMemoryEntry { @@ -337,6 +347,11 @@ fn team_import_definitions_are_built_for_all_members() { && definition.respond_to_allowlist.is_empty() })); assert_eq!(definitions[0].system_prompt, "Alice prompt"); + assert_eq!( + definitions[0].description.as_deref(), + Some("A careful reviewer.") + ); + assert_eq!(definitions[1].description, None); } #[test] diff --git a/desktop/src-tauri/src/commands/teams/adopt/apply.rs b/desktop/src-tauri/src/commands/teams/adopt/apply.rs index f3e0bc708a4..d52e71aeee1 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/apply.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/apply.rs @@ -437,6 +437,9 @@ fn member_copy( Ok(AgentDefinition { id: Uuid::new_v4().to_string(), display_name: member.display_name.clone(), + // Team catalog members carry no public description; an adopted copy + // starts without one. + description: None, avatar_url: member.avatar_url.clone(), system_prompt: member.system_prompt.clone().unwrap_or_default(), runtime: member.runtime.clone(), diff --git a/desktop/src-tauri/src/commands/teams/adopt/tests.rs b/desktop/src-tauri/src/commands/teams/adopt/tests.rs index bd30cdacc24..2235bd0b2b9 100644 --- a/desktop/src-tauri/src/commands/teams/adopt/tests.rs +++ b/desktop/src-tauri/src/commands/teams/adopt/tests.rs @@ -23,6 +23,7 @@ fn persona(id: &str, prompt: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: prompt.to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/teams/pending/tests.rs b/desktop/src-tauri/src/commands/teams/pending/tests.rs index 941f725c50b..7f4d31a6535 100644 --- a/desktop/src-tauri/src/commands/teams/pending/tests.rs +++ b/desktop/src-tauri/src/commands/teams/pending/tests.rs @@ -14,6 +14,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/commands/teams/sharing/tests.rs b/desktop/src-tauri/src/commands/teams/sharing/tests.rs index 71f841d5803..a6e5a7d2d77 100644 --- a/desktop/src-tauri/src/commands/teams/sharing/tests.rs +++ b/desktop/src-tauri/src/commands/teams/sharing/tests.rs @@ -16,6 +16,7 @@ fn member(id: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: "One".to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 0e718079a30..29e74cfb506 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -109,6 +109,7 @@ async fn boundary_sync_managed_agent_profile_blocks_ncryptsec() { &format!("agent {NCRYPTSEC}"), None, None, + None, ) .await .unwrap_err(); diff --git a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs index 8d370285739..5fcf66a4588 100644 --- a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs +++ b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs @@ -14,6 +14,7 @@ fn member(id: &str, prompt: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: prompt.to_string(), runtime: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_description.rs b/desktop/src-tauri/src/managed_agents/agent_description.rs new file mode 100644 index 00000000000..af0a406404e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_description.rs @@ -0,0 +1,154 @@ +//! Effective public agent description — the Rust twin of +//! `desktop/src/features/agents/lib/agentDescription.ts`. +//! +//! The desktop publishes an agent's effective description as the `about` +//! field of its kind:0 profile event. Only the owner-authored +//! `AgentDefinition.description` publishes; a blank description publishes an +//! empty `about`, exactly as before the field existed. + +use super::{AgentDefinition, ManagedAgentRecord}; + +/// The description to publish for an agent: the authored `description`, +/// trimmed, when non-empty; otherwise `None`. +/// +/// TS twin: `effectiveAgentDescription` in `lib/agentDescription.ts`. +pub(crate) fn effective_agent_description(description: Option<&str>) -> Option { + let authored = description.map(str::trim).unwrap_or(""); + if authored.is_empty() { + return None; + } + Some(authored.to_string()) +} + +/// Effective description for a managed-agent record's kind:0 profile. +/// +/// A persona-linked instance publishes its linked definition's authored +/// description — the definition is the authority for identity metadata, +/// matching how the card face resolves it. A missing linked definition yields +/// no description rather than reviving a stale instance copy. Only a +/// definition-less instance falls back to its own record field. +pub(crate) fn record_effective_description( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], +) -> Option { + if let Some(persona_id) = record.persona_id.as_deref() { + return personas + .iter() + .find(|persona| persona.id == persona_id) + .and_then(|persona| effective_agent_description(persona.description.as_deref())); + } + effective_agent_description(record.description.as_deref()) +} + +// Tests mirror `lib/agentDescription.test.mjs` case-for-case so the Rust +// publish path and the TS display path cannot drift silently. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authored_description_wins() { + assert_eq!( + effective_agent_description(Some("Reviews desktop PRs.")).as_deref(), + Some("Reviews desktop PRs.") + ); + } + + #[test] + fn authored_description_is_trimmed() { + assert_eq!( + effective_agent_description(Some(" Reviews desktop PRs. ")).as_deref(), + Some("Reviews desktop PRs.") + ); + } + + #[test] + fn blank_and_none_descriptions_yield_none() { + assert_eq!(effective_agent_description(None), None); + assert_eq!(effective_agent_description(Some("")), None); + assert_eq!(effective_agent_description(Some(" ")), None); + } + + fn record_with(description: Option<&str>, persona_id: Option<&str>) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("sample record"); + record.description = description.map(str::to_string); + record.persona_id = persona_id.map(str::to_string); + record + } + + fn persona_with(id: &str, description: Option<&str>) -> AgentDefinition { + let mut persona: AgentDefinition = serde_json::from_str( + r#"{ + "id": "placeholder", + "display_name": "Helper", + "system_prompt": "You help.", + "is_builtin": false, + "is_active": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z" + }"#, + ) + .expect("sample persona"); + persona.id = id.to_string(); + persona.description = description.map(str::to_string); + persona + } + + #[test] + fn linked_record_publishes_the_definition_description() { + let record = record_with(Some("record-level"), Some("p1")); + let personas = vec![persona_with("p1", Some("Definition description."))]; + assert_eq!( + record_effective_description(&record, &personas).as_deref(), + Some("Definition description.") + ); + } + + #[test] + fn linked_record_with_blank_definition_description_publishes_none() { + let record = record_with(Some("record-level"), Some("p1")); + let personas = vec![persona_with("p1", None)]; + assert_eq!(record_effective_description(&record, &personas), None); + } + + #[test] + fn definition_less_record_falls_back_to_its_own_description() { + let record = record_with(Some("Record description."), None); + assert_eq!( + record_effective_description(&record, &[]).as_deref(), + Some("Record description.") + ); + } + + #[test] + fn dangling_persona_link_does_not_revive_a_stale_record_description() { + let record = record_with(Some("Stale imported description."), Some("missing")); + assert_eq!(record_effective_description(&record, &[]), None); + } + + #[test] + fn no_description_anywhere_yields_none() { + let record = record_with(None, None); + assert_eq!(record_effective_description(&record, &[]), None); + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index ce30dcae851..85f34260ce7 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -164,6 +164,7 @@ mod tests { fn sample_agent() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agentpubkeyhex".to_string(), name: "Test Agent".to_string(), persona_id: Some("persona-1".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 4b734ce1591..abe48e49fa8 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -226,7 +226,7 @@ pub fn build_snapshot( .display_name .clone() .unwrap_or_else(|| record.name.clone()), - about: None, // kind:0 `about` not yet surfaced in ManagedAgentRecord + about: super::effective_agent_description(record.description.as_deref()), avatar_data_url, avatar_url: avatar_url_ref, }; @@ -419,6 +419,8 @@ pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> .unwrap_or_default(), ) .map_err(|error| format!("Snapshot definition is unsafe: {error}"))?; + super::validate_agent_description_text(snapshot.profile.about.as_deref()) + .map_err(|error| format!("Snapshot description is unsafe: {error}"))?; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8fd631b5b5b..131966409b0 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -366,6 +366,7 @@ mod tests { /// pubkey/nsec pair matters here. fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey, name: "Locked Test".to_string(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index 02b4151da3f..31dc365a775 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; /// relevant to snapshot export are filled; the rest use defaults. fn minimal_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "deadbeef".to_string(), name: "Test Agent".to_string(), display_name: Some("Test Agent Display".to_string()), @@ -598,9 +599,14 @@ fn definition_fields_present_in_snapshot() { #[test] fn profile_fields_present_in_snapshot() { - let record = minimal_record(); + let mut record = minimal_record(); + record.description = Some(" A careful test agent. ".to_string()); let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); assert_eq!(snapshot.profile.display_name, "Test Agent Display"); + assert_eq!( + snapshot.profile.about.as_deref(), + Some("A careful test agent.") + ); // No bytes → should fall back to avatar_url assert_eq!( snapshot.profile.avatar_url.as_deref(), @@ -609,6 +615,16 @@ fn profile_fields_present_in_snapshot() { assert!(snapshot.profile.avatar_data_url.is_none()); } +#[test] +fn snapshot_rejects_unsafe_or_overlong_description() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.profile.about = Some("unsafe\u{200b}description".to_string()); + assert!(validate_snapshot(&snapshot).is_err()); + + snapshot.profile.about = Some("a".repeat(281)); + assert!(validate_snapshot(&snapshot).is_err()); +} + #[test] fn avatar_inlined_when_under_size_limit() { let record = minimal_record(); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 5fe86e9cf8d..f598888c60b 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -65,6 +65,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { fn test_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "test".to_string(), name: "Test Agent".to_string(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/definition_validation.rs b/desktop/src-tauri/src/managed_agents/definition_validation.rs index e063eb85cd8..17e75d7bdac 100644 --- a/desktop/src-tauri/src/managed_agents/definition_validation.rs +++ b/desktop/src-tauri/src/managed_agents/definition_validation.rs @@ -10,6 +10,8 @@ use std::sync::LazyLock; const MAX_DISPLAY_NAME_CHARS: usize = 128; const MAX_SYSTEM_PROMPT_BYTES: usize = 64 * 1024; +/// Cap for the optional public agent description. +pub(crate) const MAX_AGENT_DESCRIPTION_CHARS: usize = 280; const EMOJI_VARIATION_SELECTOR: char = '\u{FE0F}'; const ZERO_WIDTH_JOINER: char = '\u{200D}'; @@ -41,6 +43,23 @@ pub(crate) fn validate_agent_definition_text( validate_visible_text(system_prompt, "Agent instructions", true) } +/// Validate an optional public agent description: max 280 characters and the +/// same visible-text policy as the other definition fields (invisible, bidi, +/// and control characters are rejected, not stripped). `None` and the empty +/// string are both valid — the description is optional. +pub(crate) fn validate_agent_description_text(description: Option<&str>) -> Result<(), String> { + let Some(description) = description else { + return Ok(()); + }; + let description_chars = description.chars().count(); + if description_chars > MAX_AGENT_DESCRIPTION_CHARS { + return Err(format!( + "Description is too long ({description_chars} characters, max {MAX_AGENT_DESCRIPTION_CHARS})" + )); + } + validate_visible_text(description, "Description", false) +} + /// Validate the human-reviewed definition text carried by a managed agent. /// /// Definition-linked agents resolve their executable prompt through the @@ -243,6 +262,37 @@ mod tests { assert!(validate_agent_definition_text("Reviewer", &"a".repeat(64 * 1024 + 1)).is_err()); } + #[test] + fn description_accepts_none_empty_and_plain_text() { + assert!(validate_agent_description_text(None).is_ok()); + assert!(validate_agent_description_text(Some("")).is_ok()); + assert!(validate_agent_description_text(Some("Buttercup, a software engineer 🐝")).is_ok()); + assert!( + validate_agent_description_text(Some(&"a".repeat(MAX_AGENT_DESCRIPTION_CHARS))).is_ok() + ); + } + + #[test] + fn description_rejects_over_280_chars() { + assert!(validate_agent_description_text(Some( + &"a".repeat(MAX_AGENT_DESCRIPTION_CHARS + 1) + )) + .is_err()); + } + + #[test] + fn description_rejects_invisible_bidi_and_control_characters() { + for character in ['\u{200B}', '\u{202E}', '\u{2066}', '\0', '\r', '\u{0007}'] { + for description in [ + format!("A helpful{character}agent"), + format!("{character}A helpful agent"), + format!("A helpful agent{character}"), + ] { + assert!(validate_agent_description_text(Some(&description)).is_err()); + } + } + } + #[test] fn definition_less_managed_agent_validates_its_own_name_and_prompt() { assert!(validate_managed_agent_definition_text( diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index ff5cfc34725..577a780d6ca 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -167,9 +167,9 @@ fn classifies_cli_missing_when_adapter_found_but_cli_absent() { assert_eq!(cmd.as_deref(), Some("codex-acp")); assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); } - fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { + description: None, id: id.to_string(), display_name: id.to_string(), avatar_url: None, @@ -204,14 +204,14 @@ fn effective_agent_command_explicit_override_wins() { ); } -/// Minimal record for `record_agent_command` tests. Only the resolution -/// inputs (runtime / persona_id / agent_command_override) vary. +/// Minimal record for `record_agent_command` tests; only resolution inputs vary. fn record_with( runtime: Option<&str>, persona_id: Option<&str>, override_cmd: Option<&str>, ) -> crate::managed_agents::types::ManagedAgentRecord { crate::managed_agents::types::ManagedAgentRecord { + description: None, pubkey: String::new(), name: "r".to_string(), persona_id: persona_id.map(str::to_string), diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 080a8fbb987..1ed44ace946 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -8,6 +8,7 @@ fn definition( prompt: &str, ) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: "Test Definition".to_string(), avatar_url: None, @@ -40,6 +41,7 @@ fn record( ) -> ManagedAgentRecord { use crate::managed_agents::{BackendKind, RespondTo}; ManagedAgentRecord { + description: None, pubkey: "agent-pk".to_string(), name: "Agent".to_string(), persona_id: persona_id.map(str::to_string), diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 9d090787c7f..5f39b7b75f2 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -299,6 +299,7 @@ fn default_global_config_serializes_all_fields() { fn bare_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), persona_id: None, @@ -360,6 +361,7 @@ fn bare_record() -> ManagedAgentRecord { fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: "Test Persona".to_string(), avatar_url: None, @@ -622,6 +624,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { record.persona_id = Some("p1".to_string()); let persona = AgentDefinition { + description: None, id: "p1".to_string(), display_name: "Goose persona".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c005e8858b7..8ff68a209fb 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -8,6 +8,8 @@ pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_ac pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; +mod agent_description; +pub(crate) use agent_description::{effective_agent_description, record_effective_description}; mod backend; pub(crate) mod claude_config; pub(crate) mod config_bridge; @@ -56,7 +58,8 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { pub use backend::*; pub(crate) use definition_validation::{ - validate_agent_definition_text, validate_managed_agent_definition_text, validate_visible_text, + validate_agent_definition_text, validate_agent_description_text, + validate_managed_agent_definition_text, validate_visible_text, }; pub use discovery::*; pub use env_vars::*; diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index c6056d4b839..c712b2525d4 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -11,6 +11,7 @@ const TEST_RELAY: &str = "ws://example.com:3000"; fn make_persona(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: display_name.to_string(), avatar_url: None, @@ -37,6 +38,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), name: name.to_string(), persona_id: persona_id.map(|s| s.to_string()), diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index 27ee19eb67a..f0806c8bc04 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -64,6 +64,7 @@ mod tests { fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: String::new(), name: "r".to_string(), persona_id: None, @@ -129,6 +130,7 @@ mod tests { ) -> crate::managed_agents::types::AgentDefinition { use crate::managed_agents::types::AgentDefinition; AgentDefinition { + description: None, id: id.to_string(), display_name: String::new(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 619122d9164..fa80b456a07 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -92,6 +92,14 @@ pub struct PersonaEventContent { pub respond_to_allowlist: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub parallelism: Option, + /// Optional short, PUBLIC description (max 280 chars). Appended after the + /// pre-existing fields so records without one serialize byte-identically + /// to the pre-description era — existing content bytes and event ids are + /// unchanged. EXCLUDED from [`persona_content_hash`]: description is + /// display metadata, not spawn-relevant config, so a description-only edit + /// must not badge linked instances as needing a restart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } /// Derive the d-tag (persona slug) from a `AgentDefinition`. @@ -229,6 +237,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result String { use sha2::{Digest, Sha256}; - let json = serde_json::to_vec(content).unwrap_or_default(); + let hashed = PersonaEventContent { + description: None, + ..content.clone() + }; + let json = serde_json::to_vec(&hashed).unwrap_or_default(); let digest = Sha256::digest(&json); hex::encode(digest) } @@ -522,6 +540,7 @@ pub fn persona_event_content(record: &AgentDefinition) -> PersonaEventContent { respond_to: record.respond_to.clone(), respond_to_allowlist: record.respond_to_allowlist.clone(), parallelism: record.parallelism, + description: record.description.clone(), } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index ffbb575224d..9367ad463e2 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -5,6 +5,7 @@ use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; /// state right after creation, before any snapshot apply. pub(super) fn sample_record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "p".repeat(64), name: "agent".into(), persona_id: Some("test-persona".into()), @@ -144,6 +145,7 @@ fn preview_passes_through_unchanged_when_persona_missing() { pub(super) fn sample_persona() -> AgentDefinition { AgentDefinition { + description: None, id: "test-persona".to_string(), display_name: "Test Persona".to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -319,6 +321,7 @@ fn content_matches_nip_ap_vector() { const VECTOR: &str = r#"{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}"#; let content = PersonaEventContent { + description: None, display_name: "Test Agent".to_string(), system_prompt: Some("You are a test assistant.".to_string()), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -372,6 +375,7 @@ fn content_matches_nip_ap_vector() { // signed content, so a second implementer following the spec computes // the same NIP-01 id. let record = AgentDefinition { + description: None, id: "test-agent".to_string(), display_name: "Test Agent".to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), @@ -404,6 +408,7 @@ fn content_matches_nip_ap_vector() { #[test] fn round_trip_minimal_persona() { let record = AgentDefinition { + description: None, id: "minimal".to_string(), display_name: "Minimal".to_string(), avatar_url: None, @@ -502,6 +507,7 @@ fn behavioral_defaults_survive_record_round_trip() { #[test] fn quad_absent_definition_hash_stable_across_activation() { let record = AgentDefinition { + description: None, id: "quad-absent".to_string(), display_name: "Test".to_string(), avatar_url: None, @@ -547,6 +553,7 @@ fn quad_absent_definition_hash_stable_across_activation() { /// way `persona_from_event` maps fields, without needing a signed event. fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDefinition { AgentDefinition { + description: content.description, id: "staged".to_string(), display_name: content.display_name, avatar_url: content.avatar_url, @@ -574,6 +581,7 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef #[test] fn persona_content_hash_is_deterministic() { let content = PersonaEventContent { + description: None, display_name: "Test".to_string(), avatar_url: None, system_prompt: Some("Hello".to_string()), @@ -594,6 +602,7 @@ fn persona_content_hash_is_deterministic() { #[test] fn persona_content_hash_changes_on_edit() { let content1 = PersonaEventContent { + description: None, display_name: "Test".to_string(), avatar_url: None, system_prompt: Some("Hello".to_string()), @@ -613,6 +622,42 @@ fn persona_content_hash_changes_on_edit() { ); } +/// `description` is public display metadata, deliberately excluded from +/// `persona_content_hash`: two contents differing only in description must +/// hash identically, so a description-only edit never flips the +/// "restart required" drift badge on linked instances. +#[test] +fn description_change_does_not_change_content_hash() { + let without = PersonaEventContent { + description: None, + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: Some("Hello".to_string()), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + }; + let mut with = without.clone(); + with.description = Some("A friendly test agent.".to_string()); + assert_eq!( + persona_content_hash(&without), + persona_content_hash(&with), + "description must not participate in the content hash" + ); + + let mut edited = with.clone(); + edited.description = Some("A different description.".to_string()); + assert_eq!( + persona_content_hash(&with), + persona_content_hash(&edited), + "description-only edits must not change the content hash" + ); +} + // ── PersonaSnapshot.runtime ─────────────────────────────────────────────── /// (b) The snapshot carries the persona's runtime VERBATIM — including None, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 3c8a40231d4..094d0a1a478 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -124,6 +124,7 @@ fn built_in_persona_records(now: &str) -> Vec { id: persona.id.to_string(), display_name: persona.display_name.to_string(), avatar_url: persona.avatar_url.map(|s| s.to_string()), + description: None, system_prompt: persona.system_prompt.to_string(), runtime: persona.runtime.map(|s| s.to_string()), model: persona.model.map(|s| s.to_string()), diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 1fd8c3bccff..a52f6aa3b19 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -8,6 +8,7 @@ use crate::managed_agents::AgentDefinition; fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { + description: None, id: id.to_string(), display_name: display_name.to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 909b97d652d..9af3c989f49 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1473,9 +1473,9 @@ mod tests { "BUZZ_AGENT_MODEL".to_string(), "claude-opus-4-5".to_string(), ); - // Minimal record: only the fields resolve_effective_agent_env reads. let record = crate::managed_agents::types::ManagedAgentRecord { + description: None, pubkey: "test-pubkey".to_string(), name: "test-agent".to_string(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index a225f492d33..881ac99237a 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -454,6 +454,10 @@ pub async fn restore_managed_agents_on_launch( pubkey: record.pubkey.clone(), agent_command: effective_command, persona_id: record.persona_id.clone(), + about: crate::managed_agents::record_effective_description( + record, + &reconcile_personas, + ), }, )) }) diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index ec78cc14efa..05e11fc4cdf 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -36,6 +36,7 @@ pub(super) fn fixture( auth_tag: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "p".into(), name: "n".into(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 24fad1461c5..b0c93289709 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -265,7 +265,6 @@ fn build_env_rejects_empty_allowlist_in_allowlist_mode() { } // ── persona fixture helpers ───────────────────────────────────────── - fn persona_with_provider( id: &str, prompt: &str, @@ -273,6 +272,7 @@ fn persona_with_provider( provider: Option<&str>, ) -> crate::managed_agents::AgentDefinition { crate::managed_agents::AgentDefinition { + description: None, id: id.to_string(), display_name: id.to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index bcd93da851e..28a86aa5792 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -42,6 +42,7 @@ fn snap(record: &ManagedAgentRecord) -> serde_json::Value { fn record() -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: "p".repeat(64), name: "agent".into(), persona_id: None, @@ -103,6 +104,7 @@ fn record() -> ManagedAgentRecord { fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { AgentDefinition { + description: None, id: id.into(), display_name: id.into(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs index e0ae5fc37aa..8f9d68245de 100644 --- a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs +++ b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs @@ -7,6 +7,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: display_name.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: Some("goose".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 32fe39531d5..fdeb54c4f27 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -254,6 +254,7 @@ mod tests { /// Build a minimal `ManagedAgentRecord` for use as a team member. fn agent_record(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: format!("{name}-pubkey"), name: name.to_string(), display_name: Some(format!("{name} Display")), diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 98816a07e33..342dc59d52d 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -167,6 +167,7 @@ fn validate_team_deletion_rejects_built_ins() { fn managed_agent(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + description: None, pubkey: name.to_string(), name: name.to_string(), persona_id: None, @@ -455,6 +456,7 @@ fn catalog_copy(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: String::new(), runtime: None, @@ -694,6 +696,7 @@ fn catalog_persona(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { AgentDefinition { id: id.to_string(), display_name: id.to_string(), + description: None, avatar_url: None, system_prompt: "Do the work.".to_string(), runtime: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 7d4b43f01d8..b3c9d4b53ca 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -17,6 +17,11 @@ pub struct AgentDefinition { pub id: String, pub display_name: String, pub avatar_url: Option, + /// Optional short, PUBLIC description (max 280 chars), shown on the + /// agent's card/profile and carried on the public kind:30175 persona + /// event. EXCLUDED from `persona_content_hash` (no restart badge). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, pub system_prompt: String, /// Preferred ACP runtime ID (e.g., 'goose', 'claude', 'codex'). Determines which agent binary /// Buzz spawns. When deploying from this persona, this runtime is pre-selected in the UI. @@ -146,6 +151,7 @@ impl AgentDefinition { respond_to: RespondTo::default(), respond_to_allowlist: Vec::new(), display_name: Some(self.display_name), + description: self.description, slug: Some(self.id), runtime: self.runtime, name_pool: self.name_pool, @@ -180,6 +186,7 @@ impl ManagedAgentRecord { .clone() .unwrap_or_else(|| self.name.clone()), avatar_url: self.avatar_url.clone(), + description: self.description.clone(), system_prompt: self.system_prompt.clone().unwrap_or_default(), runtime: self.runtime.clone(), model: self.model.clone(), @@ -366,6 +373,13 @@ pub struct ManagedAgentRecord { /// from `AgentDefinition.display_name` (unified agent model, Phase 1A). #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, + /// Optional short, PUBLIC agent description. Keyless definition records + /// carry the authored value; persona-linked instances leave it absent and + /// resolve through their definition so a second copy cannot drift. + /// Display metadata only (never spawn-relevant, never part of the persona + /// content hash). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, /// Stable definition slug — the former `AgentDefinition.id`. Key-less /// records (definitions not yet instantiated) publish kind:30175 at /// `d_tag = slug`, preserving the pre-merge event coordinates. `None` for diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 3e1afff2561..a7b379ac838 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -76,6 +76,9 @@ pub fn apply_persona_behavior( pub struct CreatePersonaRequest { pub display_name: String, pub avatar_url: Option, + /// Optional short, PUBLIC description (max 280 chars). + #[serde(default)] + pub description: Option, pub system_prompt: String, #[serde(default)] pub runtime: Option, @@ -103,6 +106,10 @@ pub struct UpdatePersonaRequest { pub id: String, pub display_name: String, pub avatar_url: Option, + /// Optional short, PUBLIC description (max 280 chars). The dialog always + /// sends the current value, so absent and empty both clear it. + #[serde(default)] + pub description: Option, pub system_prompt: String, #[serde(default)] pub runtime: Option, @@ -269,6 +276,7 @@ mod tests { fn record_without_quad() -> AgentDefinition { AgentDefinition { + description: None, id: "p-1".to_string(), display_name: "Test".to_string(), avatar_url: None, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 5299eb4ecca..0918ab2c65c 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -487,6 +487,7 @@ fn sample_agent_record() -> ManagedAgentRecord { fn sample_persona() -> AgentDefinition { AgentDefinition { + description: None, id: "custom:helper".to_string(), display_name: "Helper".to_string(), avatar_url: Some("https://example.com/a.png".to_string()), diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 5bc8a6e432c..2573ce2d566 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -25,6 +25,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati }, ]; let definition = crate::managed_agents::AgentDefinition { + description: None, id: "builtin:fizz".to_string(), display_name: "Fizz".to_string(), avatar_url: Some(old_fizz.to_string()), diff --git a/desktop/src-tauri/src/persona_catalog.rs b/desktop/src-tauri/src/persona_catalog.rs index 5d1717d67c3..c04afb64b4c 100644 --- a/desktop/src-tauri/src/persona_catalog.rs +++ b/desktop/src-tauri/src/persona_catalog.rs @@ -16,7 +16,8 @@ use std::sync::LazyLock; use tauri::State; use crate::{ - app_state::AppState, managed_agents::validate_agent_definition_text, + app_state::AppState, + managed_agents::{validate_agent_definition_text, validate_agent_description_text}, native_relay_client::NativeRelayClient, }; @@ -47,6 +48,8 @@ pub(crate) struct PersonaCatalogPublication { struct CatalogAgentProjection { display_name: String, avatar_url: Option, + /// Optional public description (max 280 chars, visible-text policy). + description: Option, system_prompt: String, runtime: Option, model: Option, @@ -223,6 +226,16 @@ fn parse_agent(content: &str) -> Option { .unwrap_or_default() .to_string(); validate_agent_definition_text(&display_name, &system_prompt).ok()?; + // Untrusted boundary: a description that fails the shared 280-char + + // visible-text policy rejects the whole entry rather than being stripped, + // matching how the other definition fields are handled. + let raw_description = match object.get("description") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => return None, + }; + validate_agent_description_text(raw_description.as_deref()).ok()?; + let description = raw_description.filter(|value| !value.trim().is_empty()); let respond_to = match object.get("respond_to").and_then(Value::as_str) { Some("allowlist") => Some("owner-only".to_string()), @@ -252,6 +265,7 @@ fn parse_agent(content: &str) -> Option { .and_then(Value::as_str) .filter(|value| safe_avatar(value)) .map(ToOwned::to_owned), + description, system_prompt, runtime: optional_string(object.get("runtime")), model: optional_string(object.get("model")), diff --git a/desktop/src-tauri/src/persona_catalog_tests.rs b/desktop/src-tauri/src/persona_catalog_tests.rs index d3175ef9807..64cb1ce2114 100644 --- a/desktop/src-tauri/src/persona_catalog_tests.rs +++ b/desktop/src-tauri/src/persona_catalog_tests.rs @@ -127,6 +127,31 @@ fn parser_rejects_malformed_and_invisible_definition_text() { ] { assert!(parse_agent(&content).is_none()); } + // A description that violates the shared visible-text policy or the + // 280-char cap rejects the whole entry — never silently stripped. + for bad_description in [ + "hidden\u{200b}text".to_string(), + "description\n".to_string(), + "a".repeat(281), + ] { + let mut content = valid_content("Reviewer"); + content["description"] = json!(bad_description); + assert!(parse_agent(&content.to_string()).is_none()); + } + for malformed_description in [json!(7), json!([]), json!({})] { + let mut content = valid_content("Reviewer"); + content["description"] = malformed_description; + assert!(parse_agent(&content.to_string()).is_none()); + } + let mut content = valid_content("Reviewer"); + content["description"] = json!("A careful reviewer."); + assert_eq!( + parse_agent(&content.to_string()) + .unwrap() + .description + .as_deref(), + Some("A careful reviewer.") + ); let visible = parse_agent( &json!({ "display_name": "Reviewer 🐝", @@ -204,6 +229,7 @@ fn serialized_catalog_matches_the_typescript_contract() { agent: CatalogAgentProjection { display_name: "Ada".into(), avatar_url: Some("https://example.com/a.png".into()), + description: Some("A kind agent.".into()), system_prompt: "be kind".into(), runtime: Some("acp".into()), model: Some("m1".into()), @@ -222,6 +248,7 @@ fn serialized_catalog_matches_the_typescript_contract() { "agent": { "displayName": "Ada", "avatarUrl": "https://example.com/a.png", + "description": "A kind agent.", "systemPrompt": "be kind", "runtime": "acp", "model": "m1", diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index f408ef2afda..676b9656ff2 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -477,9 +477,10 @@ fn build_profile_event( agent_keys: &nostr::Keys, display_name: &str, avatar_url: Option<&str>, + about: Option<&str>, auth_tag_json: Option<&str>, ) -> Result { - let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?; + let builder = crate::events::build_profile(Some(display_name), None, avatar_url, about, None)?; let builder = if let Some(tag_json) = auth_tag_json { // Bridge nostr 0.37 PublicKey → nostr 0.36 PublicKey via hex encoding. @@ -511,18 +512,22 @@ fn build_profile_event( /// Sync a managed agent's kind:0 profile event to the relay using NIP-98 auth. /// /// The agent signs its own profile event and the NIP-98 HTTP-auth event, so no -/// API token is required. +/// API token is required. `about` carries the agent's authored public +/// description (see `managed_agents::record_effective_description`); the +/// relay treats kind:0 +/// fields as absolute, so passing `None` clears any previously published about. pub async fn sync_managed_agent_profile( state: &AppState, relay_url: &str, agent_keys: &nostr::Keys, display_name: &str, avatar_url: Option<&str>, + about: Option<&str>, auth_tag: Option<&str>, // NIP-OA auth tag JSON ) -> Result<(), String> { crate::relay_admission::wait_for_rate_limit().await; // Build a signed kind:0 profile event (with optional NIP-OA auth tag). - let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; + let event = build_profile_event(agent_keys, display_name, avatar_url, about, auth_tag)?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "agent profile sync")?; @@ -563,8 +568,9 @@ pub async fn sync_managed_agent_profile( /// backend — always the active workspace relay — so the query targets the host /// the profile is actually published to. /// -/// Returns the parsed profile content (display_name, picture) if a kind:0 event -/// exists for the given pubkey, or `None` if no profile is published. +/// Returns the parsed profile content (display_name, picture, about) if a +/// kind:0 event exists for the given pubkey, or `None` if no profile is +/// published. pub async fn query_agent_profile( state: &AppState, relay_url: &str, @@ -595,6 +601,10 @@ pub async fn query_agent_profile( .get("picture") .and_then(|v| v.as_str()) .map(str::to_string), + about: content + .get("about") + .and_then(|v| v.as_str()) + .map(str::to_string), })) } @@ -603,6 +613,8 @@ pub async fn query_agent_profile( pub struct AgentProfileInfo { pub display_name: Option, pub picture: Option, + /// Published public description (kind:0 `about`). + pub about: Option, } // ── Signed-event submission ───────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/relay/tests.rs b/desktop/src-tauri/src/relay/tests.rs index 4ae39249328..0fcbc891b79 100644 --- a/desktop/src-tauri/src/relay/tests.rs +++ b/desktop/src-tauri/src/relay/tests.rs @@ -569,7 +569,7 @@ fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { fn profile_event_with_valid_auth_tag() { let agent_keys = nostr::Keys::generate(); let tag_json = make_valid_auth_tag(&agent_keys); - let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) + let event = build_profile_event(&agent_keys, "TestBot", None, None, Some(&tag_json)) .expect("should succeed with a valid auth tag"); // Exactly one "auth" tag must be present. @@ -587,7 +587,7 @@ fn profile_event_with_valid_auth_tag() { #[test] fn profile_event_without_auth_tag() { let agent_keys = nostr::Keys::generate(); - let event = build_profile_event(&agent_keys, "TestBot", None, None) + let event = build_profile_event(&agent_keys, "TestBot", None, None, None) .expect("should succeed without an auth tag"); // No "auth" tags should be present. @@ -601,12 +601,41 @@ fn profile_event_without_auth_tag() { assert_eq!(event.kind, nostr::Kind::Metadata); } +#[test] +fn profile_event_includes_about_when_description_present() { + let agent_keys = nostr::Keys::generate(); + let event = build_profile_event( + &agent_keys, + "TestBot", + None, + Some("A meticulous code reviewer."), + None, + ) + .expect("should succeed with an about"); + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("kind:0 content is JSON"); + assert_eq!( + content.get("about").and_then(|v| v.as_str()), + Some("A meticulous code reviewer.") + ); +} + +#[test] +fn profile_event_omits_about_when_absent() { + let agent_keys = nostr::Keys::generate(); + let event = build_profile_event(&agent_keys, "TestBot", None, None, None) + .expect("should succeed without an about"); + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("kind:0 content is JSON"); + assert!(content.get("about").is_none()); +} + #[test] fn profile_event_rejects_invalid_auth_tag() { let agent_keys = nostr::Keys::generate(); // Structurally valid JSON array but with a bogus signature — verification must fail. let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); - let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); + let result = build_profile_event(&agent_keys, "TestBot", None, None, Some(&bad_json)); assert!(result.is_err(), "should reject an invalid auth tag"); assert!( result.unwrap_err().contains("verification failed"), diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9df8c164db..14d23a342a9 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -236,7 +236,38 @@ with a TypeScript lookup table or an id comparison in a component. mid-conversation effort control without a plan ruling. The archived live-effort machinery lives on `archive/claude-config-gaps-live-effort` for reference only. -12. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** +15. **The persona `description` is public display metadata.** It is optional, + capped at 280 characters, and validated through the shared visible-text + policy (`validate_agent_description_text` in `definition_validation.rs`) + on the raw authored bytes at create/update, snapshot import, publication, + inbound sync, and the untrusted catalog parser — rejected, never stripped. + It is deliberately EXCLUDED from `persona_content_hash` + (`description_change_does_not_change_content_hash`), so a description-only + edit never flips the restart badge on linked instances. Only the AUTHORED + description exists — there is deliberately no derived/generated fallback; + a blank description publishes an empty kind:0 `about`, exactly as before + the field existed. Agent and team snapshots carry the authored description + in the member profile's `about` and validate it before import. The trim/empty + resolution exists twice and must stay in + sync (port changes in the same PR): `lib/agentDescription.ts` + (`effectiveAgentDescription`) feeds display surfaces, and its Rust twin + (`managed_agents/agent_description.rs`, `effective_agent_description` / + `record_effective_description`) feeds the publish path, where + `profile_needs_sync` compares `about` (None == empty) so description edits + reconcile instead of being clobbered. Persona-linked instances do not own a + second description copy; snapshot export materializes the definition value + only into the portable snapshot, and a dangling link resolves no description + rather than reviving stale instance metadata. The agents-page card face shows the + authored description as its second line, falling back to the model label + when none exists (`UnifiedAgentsSection.tsx` composes it; + `AgentIdentityCard` takes a presentational `subtitle`). The community catalog + shows the same authored description before consent: a clamped two-line list + subtitle for scanning and the full safely wrapped value in persona detail. + The dialog field + lives in `ui/AgentDescriptionField.tsx` (`AgentIdentityFields`), not + inline in the over-1000-line dialogs. + +16. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** The compiled owner-only capability applies when Desktop starts or deploys a managed agent. Independently operated relay agents with NIP-OA ownership remain eligible in every build when their verified owner's signed @@ -250,7 +281,7 @@ with a TypeScript lookup table or an id comparison in a component. refresh only local persona/team/managed-agent caches; they must never invalidate the remote relay directory. -15. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. +17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. ## The tests that enforce this @@ -289,6 +320,11 @@ with a TypeScript lookup table or an id comparison in a component. acceptance coverage for readiness, failure states, defaults, session-draft restoration, zero-write Skip, Next save failure/retry, navigation, and successful-empty vs failed optional-model discovery. +- `desktop/tests/e2e/agents.spec.ts` — community catalog descriptions remain + visible in the list and full detail before Add agent, including long + unbroken Unicode text without horizontal overflow. +- `lib/agentDescription.test.mjs` — authored-description resolution: trim, + blank/missing → null. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. - Rust: persona sharing/retention tests pin relay+owner scoping, durable enqueue errors, relay rejection/unavailability, and accepted publication. diff --git a/desktop/src/features/agents/lib/agentDescription.test.mjs b/desktop/src/features/agents/lib/agentDescription.test.mjs new file mode 100644 index 00000000000..52e9d65a53a --- /dev/null +++ b/desktop/src/features/agents/lib/agentDescription.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentDescriptionCharacterCount, + clampAgentDescription, + effectiveAgentDescription, +} from "./agentDescription.ts"; + +test("description character count matches Rust Unicode scalar counting", () => { + assert.equal(agentDescriptionCharacterCount("a🐝é"), 3); + assert.equal(agentDescriptionCharacterCount("🐝".repeat(280)), 280); +}); + +test("description clamp preserves a useful prefix for over-cap pastes", () => { + assert.equal(clampAgentDescription("a".repeat(300)), "a".repeat(280)); + assert.equal( + clampAgentDescription(`${"a".repeat(279)}🐝extra`), + `${"a".repeat(279)}🐝`, + ); +}); + +test("an authored description wins", () => { + assert.equal( + effectiveAgentDescription({ description: "Reviews desktop PRs." }), + "Reviews desktop PRs.", + ); +}); + +test("an authored description is trimmed", () => { + assert.equal( + effectiveAgentDescription({ description: " Reviews desktop PRs. " }), + "Reviews desktop PRs.", + ); +}); + +test("blank, whitespace-only, and missing descriptions yield null", () => { + assert.equal(effectiveAgentDescription({ description: "" }), null); + assert.equal(effectiveAgentDescription({ description: " " }), null); + assert.equal(effectiveAgentDescription({ description: null }), null); + assert.equal(effectiveAgentDescription({}), null); +}); diff --git a/desktop/src/features/agents/lib/agentDescription.ts b/desktop/src/features/agents/lib/agentDescription.ts new file mode 100644 index 00000000000..7a1b8ae2c0c --- /dev/null +++ b/desktop/src/features/agents/lib/agentDescription.ts @@ -0,0 +1,29 @@ +import type { AgentPersona } from "@/shared/api/types"; + +/** Hard cap on a public agent description, mirroring the Rust validator. */ +export const MAX_AGENT_DESCRIPTION_CHARS = 280; + +/** Count Unicode scalar values, matching Rust's `str::chars().count()`. */ +export function agentDescriptionCharacterCount(value: string): number { + return Array.from(value).length; +} + +/** Clamp pasted/inserted text to the Rust description cap by Unicode scalar. */ +export function clampAgentDescription(value: string): string { + return Array.from(value).slice(0, MAX_AGENT_DESCRIPTION_CHARS).join(""); +} + +/** + * The description to display for a persona: the authored `description`, + * trimmed, when non-empty; otherwise `null`. + * + * Rust twin: `effective_agent_description` in + * `managed_agents/agent_description.rs`, which resolves the same value on + * the kind:0 `about` publish path — keep both in sync. + */ +export function effectiveAgentDescription( + persona: Partial>, +): string | null { + const authored = persona.description?.trim() ?? ""; + return authored.length > 0 ? authored : null; +} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 63a357e4487..928920f9a32 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -10,6 +10,8 @@ export type CatalogPersonaShareLevel = "not-shared" | "none"; type CatalogAgentProjection = { displayName: string; avatarUrl: string | null; + /** Optional public description (validated server-side; max 280 chars). */ + description: string | null; systemPrompt: string; runtime: string | null; model: string | null; @@ -69,6 +71,7 @@ function publicationToPersona( `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, displayName: publication.agent.displayName, avatarUrl: publication.agent.avatarUrl, + description: publication.agent.description ?? null, systemPrompt: publication.agent.systemPrompt, runtime: publication.agent.runtime, model: publication.agent.model, diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 81033f7d928..c0968ad0e9d 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -11,6 +11,7 @@ import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { AgentCreationPreview } from "./AgentCreationPreview"; +import { AgentIdentityFields } from "./AgentDescriptionField"; import { PersonaDropdownField } from "./PersonaDropdownField"; import type { EnvVarsValue } from "./EnvVarsEditor"; import { PersonaAdvancedFields } from "./PersonaAdvancedFields"; @@ -139,6 +140,7 @@ export function AgentDefinitionDialog({ }: AgentDefinitionDialogProps) { const runtimesLoading = runtimeCatalogStatus === "loading"; const [displayName, setDisplayName] = React.useState(""); + const [descriptionDraft, setDescriptionDraft] = React.useState(""); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); const aiDefaultsTriggerRef = React.useRef(null); const [avatarUrl, setAvatarUrl] = React.useState(""); @@ -205,6 +207,7 @@ export function AgentDefinitionDialog({ } setDisplayName(initialValues.displayName); + setDescriptionDraft(initialValues.description ?? ""); setAvatarUrl(initialValues.avatarUrl ?? ""); setSystemPrompt(initialValues.systemPrompt); setRuntime(initialValues.runtime ?? ""); @@ -357,6 +360,8 @@ export function AgentDefinitionDialog({ : undefined; const baseInput = { displayName: displayName.trim(), + // Empty string → null happens in the API wrapper (normalizeDescription). + description: descriptionDraft, avatarUrl: avatarUrl.trim() || undefined, systemPrompt: systemPrompt, runtime: runtimeForSubmit, @@ -759,33 +764,13 @@ export function AgentDefinitionDialog({ />
-
- -
- setDisplayName(event.target.value)} - placeholder="Fizz" - value={displayName} - /> -
-
+
+ {description ? ( +

+ {description} +

+ ) : null} + void; }) { const title = persona.displayName; - const modelLabel = resolveAgentCardModelLabel({ - agent, - personaModel: persona.model, - provider: persona.provider, - defaultModel, - }); + // Card face second line: the authored description when one exists; + // otherwise fall back to the model label as before. + const subtitle = + effectiveAgentDescription(persona) ?? + resolveAgentCardModelLabel({ + agent, + personaModel: persona.model, + provider: persona.provider, + defaultModel, + }); const isActive = agent ? isManagedAgentActive(agent) : false; const profileQuery = useUserProfileQuery(agent?.pubkey); const avatarUrl = agent @@ -312,7 +317,7 @@ function AgentPersonaCard({ avatarUrl={avatarUrl} dataTestId={`persona-agent-row-${persona.id}`} label={title} - modelLabel={modelLabel} + subtitle={subtitle} onClick={() => { // The card's main click always opens the PERSONA target, never an // explicit pubkey. A pubkey target is durable in the panel, so a pick @@ -394,12 +399,16 @@ function StandaloneAgentCard({ avatarUrl={profileQuery.data?.avatarUrl} dataTestId={`managed-agent-${agent.pubkey}`} label={title} - modelLabel={resolveAgentCardModelLabel({ - agent, - personaModel: null, - provider: agent.provider, - defaultModel, - })} + subtitle={ + // Definition-less instance: no authored description exists, so fall + // back to the model label. + resolveAgentCardModelLabel({ + agent, + personaModel: null, + provider: agent.provider, + defaultModel, + }) + } onClick={() => { onOpenAgentProfile( agent.pubkey, diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index b786bf5573d..aab59803ddb 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -75,6 +75,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () => id: "persona-1", displayName: "Solo", avatarUrl: "avatar://solo", + description: "Reviews desktop changes.", systemPrompt: "Be direct.", runtime: "provider-a", model: "model-a", @@ -88,6 +89,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () => assert.deepEqual(state.initialValues, { displayName: "Solo copy", avatarUrl: "avatar://solo", + description: "Reviews desktop changes.", systemPrompt: "Be direct.", runtime: "provider-a", model: "model-a", @@ -128,6 +130,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => { id: "persona-2", displayName: "Kit", avatarUrl: null, + description: "Finds unusual solutions.", systemPrompt: "Keep it weird.", runtime: null, model: null, @@ -145,6 +148,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => { id: "persona-2", displayName: "Kit", avatarUrl: "", + description: "Finds unusual solutions.", systemPrompt: "Keep it weird.", runtime: undefined, model: undefined, diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index e09e647b9f4..a686dbd6827 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -63,6 +63,7 @@ export function duplicatePersonaDialogState( initialValues: { displayName: `${persona.displayName} copy`, avatarUrl: persona.avatarUrl ?? "", + description: persona.description ?? undefined, systemPrompt: persona.systemPrompt, runtime: persona.runtime ?? undefined, model: persona.model ?? undefined, @@ -121,6 +122,7 @@ export function editPersonaDialogState( id: persona.id, displayName: persona.displayName, avatarUrl: persona.avatarUrl ?? "", + description: persona.description ?? undefined, systemPrompt: persona.systemPrompt, runtime: persona.runtime ?? undefined, model: persona.model ?? undefined, diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 268d336eaa5..7948c474c91 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -319,6 +319,7 @@ export function usePersonaActions() { updatedPersona = await createPersonaMutation.mutateAsync({ displayName: persona.displayName, avatarUrl: persona.avatarUrl ?? undefined, + description: persona.description ?? undefined, systemPrompt: persona.systemPrompt, runtime: persona.runtime ?? undefined, model: persona.model ?? undefined, diff --git a/desktop/src/shared/api/personaTypes.ts b/desktop/src/shared/api/personaTypes.ts new file mode 100644 index 00000000000..f18e9fe96b9 --- /dev/null +++ b/desktop/src/shared/api/personaTypes.ts @@ -0,0 +1,98 @@ +// Persona (agent definition) wire types, split out of `types.ts` to keep that +// file inside the repo-wide size ratchet. Consumers import these through +// `@/shared/api/types`, which re-exports everything here. +import type { RespondToMode } from "./types"; + +export type AgentPersona = { + id: string; + displayName: string; + avatarUrl: string | null; + /** + * Optional short, PUBLIC description (max 280 chars), shown on the agent's + * card and profile. Excluded from the persona content hash (no restart + * badge). Null means no owner-authored description. + */ + description: string | null; + systemPrompt: string; + /** Preferred ACP runtime ID (e.g. "goose", "claude"). */ + runtime: string | null; + /** Opaque, harness-specific model identifier string. Buzz stores and passes through without interpretation. */ + model: string | null; + /** LLM inference provider (e.g. "databricks", "anthropic"). Injected as the runtime's provider env var at spawn time. */ + provider: string | null; + namePool: string[]; + isBuiltIn: boolean; + isActive: boolean; + /** Whether this persona is discoverable in the active community catalog. */ + shared: boolean; + /** Team ID if this persona was imported from a team directory. Team personas are non-editable. */ + sourceTeam?: string | null; + /** + * Set only on a local copy of another owner's shared catalog entry. A copy + * carries a fresh local `id`, so this coordinate is the only thing that can + * answer "is this catalog entry already added" without minting a duplicate. + */ + catalogSource?: CatalogSourceCoordinate | null; + /** Agent environment variables, layered after desktop parent and persona values. */ + envVars: Record; + /** NIP-AP behavioral defaults (wire shape). Null/empty = unset. */ + respondTo: RespondToMode | null; + respondToAllowlist: string[]; + parallelism: number | null; + createdAt: string; + updatedAt: string; +}; + +/** + * A catalog publication's coordinate: the owner who published it and the + * `d`-tag identifying the persona within that owner's catalog. Mirrors the + * backend `CatalogSource`. + */ +export type CatalogSourceCoordinate = { + ownerPubkey: string; + personaId: string; +}; + +/** + * NIP-AP behavioral group for a definition: absent preserves the stored group + * for legacy callers; present replaces it as a unit. Mirrors `PersonaBehaviorRequest`. + */ +export type PersonaBehaviorInput = { + respondTo?: RespondToMode; + respondToAllowlist?: string[]; + parallelism?: number; +}; + +export type CreatePersonaInput = { + displayName: string; + avatarUrl?: string; + /** Optional short, PUBLIC description (max 280 chars). Empty string clears. */ + description?: string | null; + systemPrompt: string; + runtime?: string; + model?: string; + provider?: string; + namePool?: string[]; + envVars?: Record; + behavior?: PersonaBehaviorInput; + /** + * Set when this persona is a copy of another owner's shared catalog entry, + * so the catalog can tell an already-added foreign entry from a new one. + */ + catalogSource?: CatalogSourceCoordinate; +}; + +export type UpdatePersonaInput = { + id: string; + displayName: string; + avatarUrl?: string; + /** Optional short, PUBLIC description (max 280 chars). Empty string clears. */ + description?: string | null; + systemPrompt: string; + runtime?: string; + model?: string; + provider?: string; + namePool?: string[]; + envVars?: Record; + behavior?: PersonaBehaviorInput; +}; diff --git a/desktop/src/shared/api/tauriPersonas.test.mjs b/desktop/src/shared/api/tauriPersonas.test.mjs index 13fe66e4382..b93f61b9107 100644 --- a/desktop/src/shared/api/tauriPersonas.test.mjs +++ b/desktop/src/shared/api/tauriPersonas.test.mjs @@ -28,3 +28,12 @@ test("fromRawPersona maps source_team to sourceTeam", () => { assert.equal(persona.sourceTeam, "team-research"); }); + +test("fromRawPersona maps authored description and defaults absence to null", () => { + assert.equal( + fromRawPersona(rawPersona({ description: "A careful analyst." })) + .description, + "A careful analyst.", + ); + assert.equal(fromRawPersona(rawPersona()).description, null); +}); diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index 3cd9734ae26..d1619daea4f 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -10,6 +10,8 @@ export type RawPersona = { id: string; display_name: string; avatar_url: string | null; + /** Optional short, PUBLIC description (max 280 chars). */ + description?: string | null; system_prompt: string; runtime?: string | null; model?: string | null; @@ -40,6 +42,7 @@ export function fromRawPersona(persona: RawPersona): AgentPersona { id: persona.id, displayName: persona.display_name, avatarUrl: persona.avatar_url, + description: persona.description ?? null, systemPrompt: persona.system_prompt, runtime: persona.runtime ?? null, model: persona.model ?? null, @@ -64,6 +67,22 @@ export function fromRawPersona(persona: RawPersona): AgentPersona { }; } +/** + * Normalize only the unambiguous empty/absent cases for the wire. The trusted + * Rust boundary validates the authored bytes before applying trim/empty + * storage normalization. + */ +function normalizeDescription( + description: string | null | undefined, +): string | null { + if (description === null || description === undefined || description === "") { + return null; + } + // Preserve the authored bytes for the Rust boundary to validate. Trimming + // here could turn a prohibited edge control into apparently valid text. + return description; +} + export async function listPersonas(): Promise { return (await invokeTauri("list_personas")).map(fromRawPersona); } @@ -76,6 +95,7 @@ export async function createPersona( input: { displayName: input.displayName, avatarUrl: input.avatarUrl, + description: normalizeDescription(input.description), systemPrompt: input.systemPrompt, runtime: input.runtime, model: input.model, @@ -95,6 +115,7 @@ function updatePersonaPayload(input: UpdatePersonaInput) { id: input.id, displayName: input.displayName, avatarUrl: input.avatarUrl, + description: normalizeDescription(input.description), systemPrompt: input.systemPrompt, runtime: input.runtime, model: input.model, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 7528998592d..4e8a1b93eed 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -703,89 +703,16 @@ export type UpdateManagedAgentInput = { */ respondToAllowlist?: string[]; }; -export type AgentPersona = { - id: string; - displayName: string; - avatarUrl: string | null; - systemPrompt: string; - /** Preferred ACP runtime ID (e.g. "goose", "claude"). */ - runtime: string | null; - /** Opaque, harness-specific model identifier string. Buzz stores and passes through without interpretation. */ - model: string | null; - /** LLM inference provider (e.g. "databricks", "anthropic"). Injected as the runtime's provider env var at spawn time. */ - provider: string | null; - namePool: string[]; - isBuiltIn: boolean; - isActive: boolean; - /** Whether this persona is discoverable in the active community catalog. */ - shared: boolean; - /** Team ID if this persona was imported from a team directory. Team personas are non-editable. */ - sourceTeam?: string | null; - /** - * Set only on a local copy of another owner's shared catalog entry. A copy - * carries a fresh local `id`, so this coordinate is the only thing that can - * answer "is this catalog entry already added" without minting a duplicate. - */ - catalogSource?: CatalogSourceCoordinate | null; - /** Agent environment variables, layered after desktop parent and persona values. */ - envVars: Record; - /** NIP-AP behavioral defaults (wire shape). Null/empty = unset. */ - respondTo: RespondToMode | null; - respondToAllowlist: string[]; - parallelism: number | null; - createdAt: string; - updatedAt: string; -}; - -/** - * A catalog publication's coordinate: the owner who published it and the - * `d`-tag identifying the persona within that owner's catalog. Mirrors the - * backend `CatalogSource`. - */ -export type CatalogSourceCoordinate = { - ownerPubkey: string; - personaId: string; -}; - -/** - * NIP-AP behavioral group for a definition: absent preserves the stored group - * for legacy callers; present replaces it as a unit. Mirrors `PersonaBehaviorRequest`. - */ -export type PersonaBehaviorInput = { - respondTo?: RespondToMode; - respondToAllowlist?: string[]; - parallelism?: number; -}; - -export type CreatePersonaInput = { - displayName: string; - avatarUrl?: string; - systemPrompt: string; - runtime?: string; - model?: string; - provider?: string; - namePool?: string[]; - envVars?: Record; - behavior?: PersonaBehaviorInput; - /** - * Set when this persona is a copy of another owner's shared catalog entry, - * so the catalog can tell an already-added foreign entry from a new one. - */ - catalogSource?: CatalogSourceCoordinate; -}; - -export type UpdatePersonaInput = { - id: string; - displayName: string; - avatarUrl?: string; - systemPrompt: string; - runtime?: string; - model?: string; - provider?: string; - namePool?: string[]; - envVars?: Record; - behavior?: PersonaBehaviorInput; -}; +// Persona (agent definition) types live in a sibling module to keep this +// file inside the repo-wide size ratchet; re-exported so import paths +// (`@/shared/api/types`) are unchanged. +export type { + AgentPersona, + CatalogSourceCoordinate, + CreatePersonaInput, + PersonaBehaviorInput, + UpdatePersonaInput, +} from "./personaTypes"; // ── Team types ──────────────────────────────────────────────────────────────── export type { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9c12ebef4fe..d45f6d6fb0b 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -989,6 +989,7 @@ type RawPersona = { id: string; display_name: string; avatar_url: string | null; + description?: string | null; system_prompt: string; runtime?: string | null; model?: string | null; @@ -3388,6 +3389,7 @@ function mockPersonaCatalogPublications() { ); }); }; + const rawDescription = content.description; if ( typeof displayName !== "string" || !displayName.trim() || @@ -3395,7 +3397,13 @@ function mockPersonaCatalogPublications() { typeof systemPrompt !== "string" || new TextEncoder().encode(systemPrompt).length > 64 * 1024 || !hasValidVisibleText(displayName, false) || - !hasValidVisibleText(systemPrompt, true) + !hasValidVisibleText(systemPrompt, true) || + (rawDescription !== undefined && + rawDescription !== null && + typeof rawDescription !== "string") || + (typeof rawDescription === "string" && + ([...rawDescription].length > 280 || + !hasValidVisibleText(rawDescription, false))) ) continue; publications.push({ @@ -3406,6 +3414,7 @@ function mockPersonaCatalogPublications() { agent: { displayName, avatarUrl: optionalString(content.avatar_url), + description: optionalString(rawDescription), systemPrompt, runtime: optionalString(content.runtime), model: optionalString(content.model), @@ -8642,6 +8651,7 @@ async function handleCreatePersona(args: { input: { displayName: string; avatarUrl?: string; + description?: string | null; systemPrompt: string; runtime?: string; model?: string; @@ -8656,6 +8666,7 @@ async function handleCreatePersona(args: { id: crypto.randomUUID(), display_name: args.input.displayName.trim(), avatar_url: args.input.avatarUrl?.trim() || null, + description: args.input.description?.trim() || null, system_prompt: args.input.systemPrompt.trim(), runtime: args.input.runtime?.trim() || null, model: args.input.model?.trim() || null, @@ -8688,6 +8699,7 @@ type MockUpdatePersonaInput = { id: string; displayName: string; avatarUrl?: string; + description?: string | null; systemPrompt: string; runtime?: string; model?: string; @@ -8721,6 +8733,7 @@ async function applyMockPersonaUpdate( } persona.display_name = input.displayName.trim(); persona.avatar_url = input.avatarUrl?.trim() || null; + persona.description = input.description?.trim() || null; persona.system_prompt = input.systemPrompt.trim(); persona.runtime = input.runtime?.trim() || null; persona.model = input.model?.trim() || null; @@ -8826,6 +8839,7 @@ function upsertMockPersonaEvent( display_name: persona.display_name, system_prompt: persona.system_prompt, avatar_url: persona.avatar_url, + description: persona.description ?? null, runtime: persona.runtime ?? null, model: persona.model ?? null, provider: persona.provider ?? null, diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index b72cb393440..f4119435f0c 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -20,6 +20,7 @@ function createCatalogEvent(input: { createdAt?: number; shared?: boolean; avatarUrl?: string; + description?: string; }): RelayEvent { const ownerPrivateKey = input.ownerPrivateKey ?? @@ -43,6 +44,7 @@ function createCatalogEvent(input: { display_name: input.displayName, system_prompt: input.systemPrompt, avatar_url: input.avatarUrl ?? null, + description: input.description ?? null, runtime: null, model: null, provider: null, @@ -309,6 +311,7 @@ test("built-in persona edits persist", async ({ page }) => { const dialog = page.getByTestId("persona-dialog"); await dialog.getByLabel("Agent name").fill("My Fizz"); + await dialog.getByLabel("Description").fill("Helps teams ship reliably."); await dialog.getByLabel("Agent instruction").fill("User-edited instructions"); await dialog.getByRole("button", { name: "Save changes" }).click(); @@ -316,13 +319,22 @@ test("built-in persona edits persist", async ({ page }) => { await expect(page.getByTestId("agents-library-personas")).toContainText( "My Fizz", ); + await expect( + page.getByTestId("persona-agent-row-builtin:fizz"), + ).toContainText("Helps teams ship reliably."); const personas = await invokeTauri< - Array<{ id: string; display_name: string; system_prompt: string }> + Array<{ + id: string; + display_name: string; + description: string | null; + system_prompt: string; + }> >(page, "list_personas"); expect( personas.find((persona) => persona.id === "builtin:fizz"), ).toMatchObject({ display_name: "My Fizz", + description: "Helps teams ship reliably.", system_prompt: "User-edited instructions", }); }); @@ -836,34 +848,55 @@ test("agent catalog chooser order stays stable when selection changes", async ({ expect(await getCatalogOrder(page)).toEqual(before); }); -test("catalog detail pane shows the full persona details", async ({ page }) => { - const personaId = "custom:researcher"; - await seedActiveIdentity(page, TEST_IDENTITIES.tyler); +test("catalog detail pane shows the full persona details before Add agent", async ({ + page, +}) => { + const personaId = "remote-researcher"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + const description = `Maps evidence across systems: ${"界".repeat(180)}`; await installMockBridge(page, { - personas: [ - { - id: personaId, - displayName: "Researcher", + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + displayName: "Alice’s Researcher", + description, systemPrompt: "Research the question and cite the evidence.", - }, + }), ], }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await sharePersonaToCatalog(page, "Researcher"); await openPersonaCatalog(page); - await selectCatalogPersona(page, personaId); + const catalogRow = page.getByTestId( + `community-catalog-agent-${remoteCatalogId}`, + ); + await expect(catalogRow).toContainText("Alice’s Researcher"); + const rowDescription = page.getByTestId( + `community-catalog-agent-description-${remoteCatalogId}`, + ); + await expect(rowDescription).toHaveText(description); + await expect(rowDescription).toHaveCSS("overflow", "hidden"); + await catalogRow.click(); + const useAgentTarget = page.getByTestId( - `community-catalog-use-agent-${personaId}`, + `community-catalog-use-agent-${remoteCatalogId}`, ); + const detailDescription = page.getByTestId("persona-catalog-description"); await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( - "Researcher", + "Alice’s Researcher", ); await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( - "Added by You", + "Added by alice", ); + await expect(detailDescription).toHaveText(description); + const detailWidth = await detailDescription.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + expect(detailWidth.scrollWidth).toBeLessThanOrEqual(detailWidth.clientWidth); await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Research the question and cite the evidence.", ); @@ -881,10 +914,10 @@ test("catalog detail pane shows the full persona details", async ({ page }) => { ); await expect(useAgentTarget).toHaveAttribute( "aria-label", - "Researcher is already in My Agents", + "Add Alice’s Researcher from Community Catalog", ); - await expect(useAgentTarget).toHaveText("Added to My Agents"); - await expect(useAgentTarget).toBeDisabled(); + await expect(useAgentTarget).toHaveText("Add agent"); + await expect(useAgentTarget).toBeEnabled(); }); type AgentShareCommand = { command: string; payload: unknown };