diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b912169801..636a5bc2bb 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -182,6 +182,21 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +/// Buzz community agent catalog entry (parameterized replaceable, owner-authored). +/// +/// Addressed by `(pubkey, kind, d_tag)` where `d_tag` is the source persona's +/// stable local id. Unlike [`KIND_PERSONA`], readers intentionally query this +/// kind across every community member: a `published` head contains an explicit +/// public projection plus a verified same-relay agent-snapshot reference, while +/// an `unpublished` head removes that coordinate from discovery. +/// +/// Privacy contract: catalog entries and their referenced snapshots are +/// community-readable plaintext. Publishers MUST use an explicit allowlist +/// projection that excludes private keys, auth tags, environment variables, +/// machine-local runtime state, and response allowlists. Optional memory is +/// included only at the user-selected `none`, `core`, or `everything` level. +pub const KIND_PERSONA_CATALOG: u32 = 30178; + // NIP-56 reporting /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). /// @@ -510,6 +525,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PERSONA_CATALOG, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -708,6 +724,7 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000– const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA_CATALOG)); // 30178 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ca529d1db6..9c6b70593b 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -27,9 +27,9 @@ use buzz_core::kind::{ KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PERSONA_CATALOG, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, + KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -200,7 +200,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM - | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT + | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_PERSONA_CATALOG | KIND_TEAM | KIND_MANAGED_AGENT | super::push_lease::KIND_PUSH_LEASE => { Ok(Scope::UsersWrite) } @@ -406,6 +406,9 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_AGENT_PROFILE // NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag). | KIND_PERSONA + // Community catalog publications (30178): owner-authored, but read + // across all members and keyed by the source persona's stable id. + | KIND_PERSONA_CATALOG // NIP-AP: team (30176) + managed-agent (30177) definitions: owner-authored, // keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. | KIND_TEAM @@ -1066,6 +1069,254 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> { Ok(()) } +/// Validate the public envelope and plaintext projection of a community catalog +/// entry before it can replace the current kind:30178 head. +/// +/// The relay deliberately rejects unknown fields here. This is stricter than +/// the private owner projection (`kind:30175`) because every catalog entry and +/// referenced snapshot is readable by the community. The explicit allowlist +/// prevents accidental additions such as env vars, auth tags, or response +/// allowlists from silently becoming public metadata. +fn validate_persona_catalog_envelope(event: &Event) -> Result<(), String> { + use serde_json::{Map, Value}; + + const MAX_SOURCE_ID_LEN: usize = 128; + const MAX_SOURCE_UPDATED_AT_LEN: usize = 64; + const MAX_AGENT_SNAPSHOT_JSON_BYTES: u64 = 5 * 1024 * 1024; + + fn one_tag<'a>(event: &'a Event, name: &str) -> Result<&'a str, String> { + let values: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].as_str()) + }) + .collect(); + if values.len() != 1 { + return Err(format!( + "persona catalog event must have exactly one `{name}` tag (got {})", + values.len() + )); + } + Ok(values[0]) + } + + fn optional_one_tag<'a>(event: &'a Event, name: &str) -> Result, String> { + let values: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].as_str()) + }) + .collect(); + if values.len() > 1 { + return Err(format!( + "persona catalog event must have at most one `{name}` tag (got {})", + values.len() + )); + } + Ok(values.first().copied()) + } + + fn object<'a>(value: &'a Value, label: &str) -> Result<&'a Map, String> { + value + .as_object() + .ok_or_else(|| format!("persona catalog `{label}` must be an object")) + } + + fn reject_unknown( + object: &Map, + allowed: &[&str], + label: &str, + ) -> Result<(), String> { + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!( + "persona catalog `{label}` contains unsupported field `{key}`" + )); + } + Ok(()) + } + + fn required_string<'a>( + object: &'a Map, + key: &str, + label: &str, + ) -> Result<&'a str, String> { + object + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("persona catalog `{label}.{key}` must be a non-empty string")) + } + + fn optional_string_or_null( + object: &Map, + key: &str, + label: &str, + ) -> Result<(), String> { + if object + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_string()) + { + return Err(format!( + "persona catalog `{label}.{key}` must be a string or null" + )); + } + Ok(()) + } + + let source_id = one_tag(event, "d")?; + if source_id.is_empty() + || source_id.len() > MAX_SOURCE_ID_LEN + || source_id.chars().any(char::is_control) + { + return Err(format!( + "persona catalog `d` tag must be 1-{MAX_SOURCE_ID_LEN} non-control characters" + )); + } + + let status = one_tag(event, "status")?; + if status != "published" && status != "unpublished" { + return Err("persona catalog `status` must be `published` or `unpublished`".to_string()); + } + let source_updated_at = one_tag(event, "source_updated_at")?; + if source_updated_at.is_empty() || source_updated_at.len() > MAX_SOURCE_UPDATED_AT_LEN { + return Err(format!( + "persona catalog `source_updated_at` must be 1-{MAX_SOURCE_UPDATED_AT_LEN} characters" + )); + } + let memory_tag = optional_one_tag(event, "memory")?; + + let parsed: Value = serde_json::from_str(&event.content) + .map_err(|_| "persona catalog content must be valid JSON".to_string())?; + let root = object(&parsed, "content")?; + + if root.get("format").and_then(Value::as_str) != Some("buzz-persona-catalog") + || root.get("version").and_then(Value::as_u64) != Some(1) + || root.get("status").and_then(Value::as_str) != Some(status) + || root.get("sourcePersonaId").and_then(Value::as_str) != Some(source_id) + || root.get("sourceUpdatedAt").and_then(Value::as_str) != Some(source_updated_at) + { + return Err("persona catalog content does not match its signed envelope".to_string()); + } + + if status == "unpublished" { + reject_unknown( + root, + &[ + "format", + "version", + "status", + "sourcePersonaId", + "sourceUpdatedAt", + ], + "content", + )?; + if memory_tag.is_some() { + return Err( + "unpublished persona catalog events must not carry a `memory` tag".to_string(), + ); + } + return Ok(()); + } + + reject_unknown( + root, + &[ + "format", + "version", + "status", + "sourcePersonaId", + "sourceUpdatedAt", + "memoryLevel", + "agent", + "snapshot", + ], + "content", + )?; + + let memory = memory_tag.ok_or_else(|| { + "published persona catalog events must carry exactly one `memory` tag".to_string() + })?; + if !matches!(memory, "none" | "core" | "everything") + || root.get("memoryLevel").and_then(Value::as_str) != Some(memory) + { + return Err( + "persona catalog memory level must be `none`, `core`, or `everything` and match content" + .to_string(), + ); + } + + let agent = object( + root.get("agent") + .ok_or_else(|| "persona catalog content is missing `agent`".to_string())?, + "agent", + )?; + reject_unknown( + agent, + &[ + "displayName", + "avatarUrl", + "systemPrompt", + "runtime", + "model", + "provider", + ], + "agent", + )?; + required_string(agent, "displayName", "agent")?; + if !agent.get("systemPrompt").is_some_and(Value::is_string) { + return Err("persona catalog `agent.systemPrompt` must be a string".to_string()); + } + for key in ["avatarUrl", "runtime", "model", "provider"] { + optional_string_or_null(agent, key, "agent")?; + } + + let snapshot = object( + root.get("snapshot") + .ok_or_else(|| "persona catalog content is missing `snapshot`".to_string())?, + "snapshot", + )?; + reject_unknown( + snapshot, + &["url", "sha256", "size", "type", "fileName"], + "snapshot", + )?; + required_string(snapshot, "url", "snapshot")?; + let sha256 = required_string(snapshot, "sha256", "snapshot")?; + if sha256.len() != 64 + || !sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err("persona catalog `snapshot.sha256` must be 64 lowercase hex chars".to_string()); + } + snapshot + .get("size") + .and_then(Value::as_u64) + .filter(|size| (1..=MAX_AGENT_SNAPSHOT_JSON_BYTES).contains(size)) + .ok_or_else(|| { + "persona catalog `snapshot.size` must be within the 5 MiB agent JSON limit".to_string() + })?; + if snapshot.get("type").and_then(Value::as_str) != Some("application/json") { + return Err("persona catalog snapshot must use `application/json`".to_string()); + } + let filename = required_string(snapshot, "fileName", "snapshot")?; + if !filename.to_ascii_lowercase().ends_with(".agent.json") + || filename.contains('/') + || filename.contains('\\') + { + return Err( + "persona catalog `snapshot.fileName` must be a plain `.agent.json` filename" + .to_string(), + ); + } + + Ok(()) +} + /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// /// Checks: @@ -2025,6 +2276,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_PERSONA_CATALOG { + validate_persona_catalog_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -2808,6 +3064,7 @@ mod tests { KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_PERSONA, + KIND_PERSONA_CATALOG, KIND_TEAM, KIND_MANAGED_AGENT, KIND_AGENT_TURN_METRIC, @@ -2893,6 +3150,17 @@ mod tests { assert!(!requires_h_channel_scope(KIND_PERSONA)); } + #[test] + fn persona_catalog_is_in_scope_allowlist_and_global_only() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PERSONA_CATALOG, &dummy).unwrap(), + Scope::UsersWrite, + ); + assert!(is_global_only_kind(KIND_PERSONA_CATALOG)); + assert!(!requires_h_channel_scope(KIND_PERSONA_CATALOG)); + } + #[test] fn team_and_managed_agent_are_in_scope_allowlist() { let dummy = make_dummy_event(); @@ -3534,6 +3802,89 @@ mod tests { assert!(err.contains("`d` tag"), "got: {err}"); } + fn make_persona_catalog(status: &str, memory: Option<&str>, content: &str) -> Event { + let mut tags = vec![ + vec!["d", "persona-1"], + vec!["status", status], + vec!["source_updated_at", "2026-07-23T00:00:00Z"], + ]; + if let Some(memory) = memory { + tags.push(vec!["memory", memory]); + } + let tag_refs: Vec> = tags; + let borrowed: Vec<&[&str]> = tag_refs.iter().map(Vec::as_slice).collect(); + make_event_with_tags(KIND_PERSONA_CATALOG, content, &borrowed) + } + + fn valid_published_catalog_content() -> String { + serde_json::json!({ + "format": "buzz-persona-catalog", + "version": 1, + "status": "published", + "sourcePersonaId": "persona-1", + "sourceUpdatedAt": "2026-07-23T00:00:00Z", + "memoryLevel": "none", + "agent": { + "displayName": "Reviewer", + "avatarUrl": null, + "systemPrompt": "Review changes.", + "runtime": "goose", + "model": "claude", + "provider": null + }, + "snapshot": { + "url": "https://relay.example/media/snapshot", + "sha256": "a".repeat(64), + "size": 512, + "type": "application/json", + "fileName": "reviewer.agent.json" + } + }) + .to_string() + } + + #[test] + fn persona_catalog_accepts_public_allowlist_projection() { + let content = valid_published_catalog_content(); + let event = make_persona_catalog("published", Some("none"), &content); + assert!(validate_persona_catalog_envelope(&event).is_ok()); + } + + #[test] + fn persona_catalog_rejects_secret_or_allowlist_fields() { + let mut content: serde_json::Value = + serde_json::from_str(&valid_published_catalog_content()).unwrap(); + content["agent"]["envVars"] = serde_json::json!({"ANTHROPIC_API_KEY": "secret"}); + let event = make_persona_catalog("published", Some("none"), &content.to_string()); + let error = validate_persona_catalog_envelope(&event).unwrap_err(); + assert!( + error.contains("unsupported field `envVars`"), + "got: {error}" + ); + } + + #[test] + fn persona_catalog_accepts_unpublished_replacement() { + let content = serde_json::json!({ + "format": "buzz-persona-catalog", + "version": 1, + "status": "unpublished", + "sourcePersonaId": "persona-1", + "sourceUpdatedAt": "2026-07-23T00:00:00Z" + }) + .to_string(); + let event = make_persona_catalog("unpublished", None, &content); + assert!(validate_persona_catalog_envelope(&event).is_ok()); + } + + #[test] + fn persona_catalog_rejects_memory_mismatch() { + let content = valid_published_catalog_content(); + let event = make_persona_catalog("published", Some("everything"), &content); + let error = validate_persona_catalog_envelope(&event).unwrap_err(); + assert!(error.contains("memory level"), "got: {error}"); + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index c182986cba..91429846f9 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -610,6 +610,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, @@ -659,6 +660,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, @@ -704,6 +706,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index f2593ff9e0..734d8f5dc8 100644 --- a/desktop/src-tauri/src/commands/media_snapshot_png.rs +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -158,6 +158,7 @@ mod tests { version: 1, definition: AgentSnapshotDefinition { name: "Tree Trunks".to_string(), + source_is_builtin: false, system_prompt: Some("You are a helpful agent.".to_string()), runtime: Some("goose".to_string()), model: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e4d8a5d1bc..a3ba731875 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -432,6 +432,7 @@ mod png_body_tests { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition { name: "Agent".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index ac5c0eace6..a444794dd4 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -13,7 +13,7 @@ use tauri::{AppHandle, Emitter, State}; use crate::{ app_state::AppState, managed_agents::{ - agent_snapshot::{decode_snapshot_json, decode_snapshot_png, MemoryLevel}, + agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel}, load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, @@ -50,6 +50,13 @@ pub(super) fn reject_legacy_persona_filename(file_name: &str) -> Result<(), Stri pub struct AgentSnapshotImportPreview { /// Agent display name from the snapshot. pub display_name: String, + /// Whether the exported source definition was built in. This is display + /// metadata only; confirmed imports are always independent custom agents. + pub is_builtin: bool, + /// Preferred model from the exported definition. + pub model: Option, + /// Preferred runtime from the exported definition. + pub runtime: Option, /// System prompt, if any. pub system_prompt: Option, /// Effective avatar: data URL if present, otherwise the source URL fallback. @@ -262,32 +269,41 @@ pub async fn preview_agent_snapshot_import( reject_legacy_persona_filename(&file_name)?; let snapshot = decode_snapshot_from_bytes(&file_bytes)?; - let memory_level = match snapshot.memory.level { - MemoryLevel::None => "none", - MemoryLevel::Core => "core", - MemoryLevel::Everything => "everything", - } - .to_string(); - - Ok(AgentSnapshotImportPreview { - display_name: snapshot.profile.display_name.clone(), - system_prompt: snapshot.definition.system_prompt.clone(), - // Effective avatar: data URL wins; URL fallback if no data URL. - avatar_url: snapshot - .profile - .avatar_data_url - .clone() - .or_else(|| snapshot.profile.avatar_url.clone()), - memory_level, - memory_entry_count: snapshot.memory.entries.len(), - source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), - has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), - }) + Ok(build_agent_snapshot_import_preview(&snapshot)) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } +pub(crate) fn build_agent_snapshot_import_preview( + snapshot: &AgentSnapshot, +) -> AgentSnapshotImportPreview { + let memory_level = match snapshot.memory.level { + MemoryLevel::None => "none", + MemoryLevel::Core => "core", + MemoryLevel::Everything => "everything", + } + .to_string(); + + AgentSnapshotImportPreview { + display_name: snapshot.profile.display_name.clone(), + is_builtin: snapshot.definition.source_is_builtin, + model: snapshot.definition.model.clone(), + runtime: snapshot.definition.runtime.clone(), + system_prompt: snapshot.definition.system_prompt.clone(), + // Effective avatar: data URL wins; URL fallback if no data URL. + avatar_url: snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()), + memory_level, + memory_entry_count: snapshot.memory.entries.len(), + source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), + has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), + } +} + // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── /// Import a `buzz-agent-snapshot v1` file as a brand-new agent. diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b6..333ec5aa63 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -1,6 +1,7 @@ use super::import::{ - decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior, - AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, + build_agent_snapshot_import_preview, decode_snapshot_from_bytes, + reject_legacy_persona_filename, resolve_snapshot_import_behavior, AgentSnapshotImportResult, + MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, }; use super::*; use crate::managed_agents::{ @@ -94,6 +95,7 @@ fn make_snapshot( version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "Test Agent".to_string(), + source_is_builtin: false, system_prompt: Some("You are helpful.".to_string()), runtime: None, model: None, @@ -551,6 +553,22 @@ fn import_preview_flags_non_empty_source_allowlist() { ); } +#[test] +fn import_preview_includes_exported_definition_metadata() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.definition.source_is_builtin = true; + snapshot.definition.model = Some("claude-opus-4-5".to_string()); + snapshot.definition.runtime = Some("goose".to_string()); + let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap(); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + + let preview = build_agent_snapshot_import_preview(&decoded); + + assert!(preview.is_builtin); + assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5")); + assert_eq!(preview.runtime.as_deref(), Some("goose")); +} + // ── Import: resolve_snapshot_import_behavior — the production selection path // // All tests below call `resolve_snapshot_import_behavior` directly. This is @@ -614,6 +632,14 @@ fn import_non_allowlist_mode_preserved_when_keep_false() { ); } +#[test] +fn import_catalog_owner_only_without_allowlist_succeeds() { + let minted = resolve_snapshot_import_behavior(Some("owner-only"), &[], None, false).unwrap(); + + assert_eq!(minted.respond_to, RespondTo::OwnerOnly); + assert!(minted.respond_to_allowlist.is_empty()); +} + /// Non-allowlist mode with a non-empty list and keep=true: preserve mode + list. /// The toggle WAS shown (list is non-empty) so keep_allowlist applies. #[test] diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc61830..c91d63459e 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -13,6 +13,7 @@ fn member(name: &str) -> AgentSnapshot { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: AgentSnapshotDefinition { name: name.to_string(), + source_is_builtin: false, system_prompt: Some(format!("{name} prompt")), runtime: Some("goose".to_string()), model: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index b0bf8f5991..fff7494e92 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -31,7 +31,11 @@ //! - lineage ids: `persona_id`, `team_id`, `source_team`, `source_team_persona_slug`, //! `persona_source_version` //! - internal bookkeeping: `start_on_app_launch`, -//! `auto_restart_on_config_change`, `is_builtin` +//! `auto_restart_on_config_change` +//! +//! The portable `sourceIsBuiltIn` hint preserves how the exported definition +//! should be described in an import preview. It never grants built-in status +//! to the newly imported definition. //! //! These exclusions are enforced by construction (only explicit fields are //! placed into `AgentSnapshotDefinition`) and asserted by unit tests. @@ -87,6 +91,10 @@ pub enum MemoryLevel { #[serde(rename_all = "camelCase")] pub struct AgentSnapshotDefinition { pub name: String, + /// Portable source classification for import-preview metadata. Imported + /// definitions are still created as custom agents with fresh identities. + #[serde(default)] + pub source_is_builtin: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub system_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -191,6 +199,7 @@ pub fn build_snapshot( .display_name .clone() .unwrap_or_else(|| record.name.clone()), + source_is_builtin: record.is_builtin, system_prompt: record.system_prompt.clone(), runtime: record.runtime.clone(), model: record.model.clone(), @@ -913,6 +922,7 @@ mod tests { let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); assert_eq!(snapshot.definition.name, "Test Agent Display"); + assert!(!snapshot.definition.source_is_builtin); assert_eq!( snapshot.definition.system_prompt.as_deref(), Some("You are a test agent.") diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index b0d874dc78..fc7e9449ed 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -254,10 +254,7 @@ pub fn ensure_persona_is_active( .ok_or_else(|| format!("agent {persona_id} not found"))?; if !persona.is_active { - return Err(format!( - "{} is not in My Agents. Choose it from Agent Catalog first.", - persona.display_name - )); + return Err(format!("{} is not in My Agents.", persona.display_name)); } Ok(()) diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index e924345e8b..1598f26067 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -171,10 +171,7 @@ fn ensure_persona_is_active_rejects_inactive_personas() { let err = ensure_persona_is_active(&[persona], "builtin:fizz").unwrap_err(); - assert_eq!( - err, - "Fizz is not in My Agents. Choose it from Agent Catalog first." - ); + assert_eq!(err, "Fizz is not in My Agents."); } #[test] diff --git a/desktop/src/features/agents/assets/agent-outline.svg b/desktop/src/features/agents/assets/agent-outline.svg new file mode 100644 index 0000000000..b89f4c61c9 --- /dev/null +++ b/desktop/src/features/agents/assets/agent-outline.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs index 7fa72f4f3e..62e809bdb5 100644 --- a/desktop/src/features/agents/lib/catalog.test.mjs +++ b/desktop/src/features/agents/lib/catalog.test.mjs @@ -2,11 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - getCatalogPersonas, - getCatalogSelectionState, getLibraryPersonas, getPersonaLabelsById, - getPersonaLibraryState, isCatalogPersonaSelected, } from "./catalog.ts"; @@ -25,62 +22,6 @@ function createPersona(id, displayName, overrides = {}) { }; } -test("getCatalogPersonas keeps built-ins visible whether selected or not", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("custom:builder", "Builder"), - ]; - - assert.deepEqual( - getCatalogPersonas(personas).map((persona) => persona.id), - ["builtin:fizz"], - ); -}); - -test("getCatalogSelectionState keeps built-in selection rules in one place", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getCatalogSelectionState(personas); - - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], - ); - assert.deepEqual( - state.selectedCatalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], - ); - assert.deepEqual( - state.unselectedCatalogPersonas.map((persona) => persona.id), - [], - ); -}); - -test("getCatalogPersonas keeps chooser order stable when selection changes", () => { - const inactive = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("builtin:reviewer", "Reviewer", { - isBuiltIn: true, - isActive: true, - }), - ]; - const active = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("builtin:reviewer", "Reviewer", { - isBuiltIn: true, - isActive: false, - }), - ]; - - assert.deepEqual( - getCatalogPersonas(inactive).map((persona) => persona.id), - getCatalogPersonas(active).map((persona) => persona.id), - ); -}); - test("isCatalogPersonaSelected treats active catalog personas as selected", () => { assert.equal( isCatalogPersonaSelected( @@ -118,25 +59,6 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => { }); }); -test("getPersonaLibraryState keeps the working library and full catalog in one place", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getPersonaLibraryState(personas); - - assert.deepEqual( - state.libraryPersonas.map((persona) => persona.id), - ["builtin:fizz", "custom:builder"], - ); - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], - ); - assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz"); -}); - test("getLibraryPersonas keeps active custom personas even when catalog entries are similar", () => { const avatarUrl = "https://example.test/coordinator.png"; const personas = [ diff --git a/desktop/src/features/agents/lib/catalog.ts b/desktop/src/features/agents/lib/catalog.ts index 226aafca5e..fabc0af87e 100644 --- a/desktop/src/features/agents/lib/catalog.ts +++ b/desktop/src/features/agents/lib/catalog.ts @@ -1,17 +1,5 @@ import type { AgentPersona } from "@/shared/api/types"; -export type CatalogSelectionState = { - catalogPersonas: AgentPersona[]; - selectedCatalogPersonas: AgentPersona[]; - unselectedCatalogPersonas: AgentPersona[]; -}; - -export type PersonaLibraryState = { - catalogPersonas: AgentPersona[]; - libraryPersonas: AgentPersona[]; - personaLabelsById: Record; -}; - export function isPersonaActive(persona: AgentPersona) { return persona.isActive; } @@ -24,62 +12,12 @@ export function getLibraryPersonas(personas: readonly AgentPersona[]) { return getActivePersonas(personas); } -export function isPersonaVisibleInCatalog( - persona: AgentPersona, - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return persona.isBuiltIn || sharedCatalogPersonaIds.has(persona.id); -} - -export function getCatalogPersonas( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return personas - .filter((persona) => - isPersonaVisibleInCatalog(persona, sharedCatalogPersonaIds), - ) - .sort((left, right) => left.displayName.localeCompare(right.displayName)); -} - export function isCatalogPersonaSelected(persona: AgentPersona) { return persona.isActive; } -export function getCatalogSelectionState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): CatalogSelectionState { - const catalogPersonas = getCatalogPersonas(personas, sharedCatalogPersonaIds); - - return { - catalogPersonas, - selectedCatalogPersonas: catalogPersonas.filter(isCatalogPersonaSelected), - unselectedCatalogPersonas: catalogPersonas.filter( - (persona) => !isCatalogPersonaSelected(persona), - ), - }; -} - export function getPersonaLabelsById(personas: readonly AgentPersona[]) { return Object.fromEntries( personas.map((persona) => [persona.id, persona.displayName]), ); } - -export function getPersonaLibraryState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): PersonaLibraryState { - const libraryPersonas = getLibraryPersonas(personas); - const { catalogPersonas } = getCatalogSelectionState( - personas, - sharedCatalogPersonaIds, - ); - - return { - catalogPersonas, - libraryPersonas, - personaLabelsById: getPersonaLabelsById(personas), - }; -} diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs deleted file mode 100644 index 9439d4a36e..0000000000 --- a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { clearLegacyPersonaCatalogVisibility } from "./legacyPersonaCatalogVisibility.ts"; - -test("clearLegacyPersonaCatalogVisibility removes the retired preference", () => { - const removedKeys = []; - - clearLegacyPersonaCatalogVisibility({ - removeItem(key) { - removedKeys.push(key); - }, - }); - - assert.deepEqual(removedKeys, ["buzz-persona-catalog-visibility-v1"]); -}); - -test("clearLegacyPersonaCatalogVisibility ignores unavailable storage", () => { - assert.doesNotThrow(() => clearLegacyPersonaCatalogVisibility(null)); - assert.doesNotThrow(() => - clearLegacyPersonaCatalogVisibility({ - removeItem() { - throw new Error("storage unavailable"); - }, - }), - ); -}); diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts deleted file mode 100644 index 38b2d5d974..0000000000 --- a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts +++ /dev/null @@ -1,28 +0,0 @@ -const LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = - "buzz-persona-catalog-visibility-v1"; - -/** - * Removes the retired custom-persona catalog preference so it cannot resurface - * agents after the visibility control has been removed. - */ -export function clearLegacyPersonaCatalogVisibility( - storage?: Pick | null, -) { - let targetStorage = storage; - if (targetStorage === undefined) { - if (typeof window === "undefined") return; - - try { - targetStorage = window.localStorage; - } catch { - return; - } - } - if (!targetStorage) return; - - try { - targetStorage.removeItem(LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY); - } catch { - // Catalog cleanup is best-effort and should not block the agents view. - } -} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs new file mode 100644 index 0000000000..c3db378587 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + catalogPersonasFromPublications, + catalogPublicationsFromEvents, + sanitizeCatalogSnapshotBytes, +} from "./personaCatalogRelay.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); +const SNAPSHOT = { + url: `https://relay.example/media/${"c".repeat(64)}`, + sha256: "c".repeat(64), + size: 512, + type: "application/json", + fileName: "reviewer.agent.json", +}; + +function catalogEvent({ + createdAt, + id, + owner = ALICE, + sourcePersonaId = "reviewer", + status = "published", + memoryLevel = "none", + avatarUrl = null, +}) { + const sourceUpdatedAt = `2026-07-23T00:00:0${createdAt}.000Z`; + const content = + status === "published" + ? { + format: "buzz-persona-catalog", + version: 1, + status, + sourcePersonaId, + sourceUpdatedAt, + memoryLevel, + agent: { + displayName: "Relay Reviewer", + avatarUrl, + systemPrompt: "Review changes.", + runtime: "goose", + model: "claude", + provider: null, + }, + snapshot: SNAPSHOT, + } + : { + format: "buzz-persona-catalog", + version: 1, + status, + sourcePersonaId, + sourceUpdatedAt, + }; + return { + id, + pubkey: owner, + created_at: createdAt, + kind: 30178, + tags: [ + ["d", sourcePersonaId], + ["status", status], + ["source_updated_at", sourceUpdatedAt], + ...(status === "published" ? [["memory", memoryLevel]] : []), + ], + content: JSON.stringify(content), + sig: "sig", + }; +} + +test("a catalog publication from Alice is discoverable by Bob", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + const personas = catalogPersonasFromPublications(publications, [], BOB); + + assert.equal(personas.length, 1); + assert.equal(personas[0].displayName, "Relay Reviewer"); + assert.equal(personas[0].isActive, false); + assert.equal(personas[0].catalogSource.ownerPubkey, ALICE); + assert.equal(personas[0].catalogSource.isOwn, false); + assert.deepEqual(personas[0].catalogSource.snapshot, SNAPSHOT); +}); + +test("the newest unpublished head removes an older publication from discovery", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "published" }), + catalogEvent({ createdAt: 2, id: "unpublished", status: "unpublished" }), + ]); + + assert.equal(publications.length, 1); + assert.equal(publications[0].status, "unpublished"); + assert.deepEqual(catalogPersonasFromPublications(publications, [], BOB), []); +}); + +test("catalog coordinates remain independent across authors", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "alice", owner: ALICE }), + catalogEvent({ createdAt: 1, id: "bob", owner: BOB }), + ]); + + assert.equal(publications.length, 2); + assert.equal( + catalogPersonasFromPublications(publications, [], BOB).length, + 2, + ); +}); + +test("equal-second catalog heads use the relay's lowest-id tie-break", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ + createdAt: 1, + id: "b".repeat(64), + status: "published", + }), + catalogEvent({ + createdAt: 1, + id: "a".repeat(64), + status: "unpublished", + }), + ]); + + assert.equal(publications.length, 1); + assert.equal(publications[0].status, "unpublished"); +}); + +test("an invalid canonical head does not resurrect an older publication", () => { + const invalidHead = { + ...catalogEvent({ createdAt: 2, id: "a".repeat(64) }), + content: "{}", + }; + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "older-valid" }), + invalidHead, + ]); + + assert.deepEqual(publications, []); +}); + +test("catalog avatars accept only bounded http or https URLs", () => { + assert.equal( + catalogPublicationsFromEvents([ + catalogEvent({ + createdAt: 1, + id: "safe-avatar", + avatarUrl: "https://relay.example/avatar.png", + }), + ]).length, + 1, + ); + for (const [index, avatarUrl] of [ + "data:image/png;base64,abc", + "javascript:alert(1)", + "ftp://relay.example/avatar.png", + `https://relay.example/${"a".repeat(2_049)}`, + ].entries()) { + assert.equal( + catalogPublicationsFromEvents([ + catalogEvent({ + createdAt: index + 1, + id: `unsafe-avatar-${index}`, + sourcePersonaId: `unsafe-avatar-${index}`, + avatarUrl, + }), + ]).length, + 0, + ); + } +}); + +test("catalog snapshot sanitization strips secrets and response allowlists", () => { + const source = { + format: "buzz-agent-snapshot", + version: 1, + definition: { + name: "Reviewer", + systemPrompt: "Review changes.", + runtime: "goose", + model: "claude", + provider: "anthropic", + respondTo: "allowlist", + respondToAllowlist: [BOB], + namePool: ["Reviewer"], + envVars: { ANTHROPIC_API_KEY: "secret" }, + privateKeyNsec: "nsec-secret", + authTag: "auth-secret", + }, + profile: { displayName: "Reviewer" }, + memory: { level: "none", entries: [] }, + relayUrl: "wss://private.example", + }; + + const sanitized = JSON.parse( + new TextDecoder().decode( + Uint8Array.from( + sanitizeCatalogSnapshotBytes( + Array.from(new TextEncoder().encode(JSON.stringify(source))), + "none", + ), + ), + ), + ); + + assert.equal(sanitized.definition.systemPrompt, "Review changes."); + assert.equal(sanitized.definition.respondTo, "owner-only"); + assert.equal("respondToAllowlist" in sanitized.definition, false); + assert.equal("envVars" in sanitized.definition, false); + assert.equal("privateKeyNsec" in sanitized.definition, false); + assert.equal("authTag" in sanitized.definition, false); + assert.equal("relayUrl" in sanitized, false); +}); + +test("selected core memory survives the public allowlist projection", () => { + const source = { + format: "buzz-agent-snapshot", + version: 1, + definition: { name: "Reviewer", systemPrompt: "Review changes." }, + profile: { displayName: "Reviewer" }, + memory: { + level: "core", + entries: [{ slug: "core", body: "Prefers concise findings." }], + }, + }; + const sanitized = JSON.parse( + new TextDecoder().decode( + Uint8Array.from( + sanitizeCatalogSnapshotBytes( + Array.from(new TextEncoder().encode(JSON.stringify(source))), + "core", + ), + ), + ), + ); + + assert.deepEqual(sanitized.memory, source.memory); +}); + +test("catalog snapshot sanitization rejects memory beyond the selected level", () => { + const source = { + format: "buzz-agent-snapshot", + version: 1, + definition: { name: "Reviewer", systemPrompt: "Review changes." }, + profile: { displayName: "Reviewer" }, + memory: { + level: "core", + entries: [ + { slug: "core", body: "Public core instructions." }, + { slug: "mem/private", body: "Must not escape a core-only share." }, + ], + }, + }; + + assert.throws( + () => + sanitizeCatalogSnapshotBytes( + Array.from(new TextEncoder().encode(JSON.stringify(source))), + "core", + ), + /outside the selected sharing level/u, + ); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts new file mode 100644 index 0000000000..40965d2754 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -0,0 +1,630 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent, uploadMediaBytes } from "@/shared/api/tauri"; +import { + encodeAgentSnapshotForSend, + type SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; +import type { + AgentPersona, + ManagedAgent, + RelayEvent, +} from "@/shared/api/types"; +import { KIND_PERSONA_CATALOG } from "@/shared/constants/kinds"; + +const CATALOG_FORMAT = "buzz-persona-catalog"; +const CATALOG_VERSION = 1; +const MAX_CATALOG_EVENTS = 1_000; +const MAX_SNAPSHOT_JSON_BYTES = 5 * 1024 * 1024; + +type CatalogStatus = "published" | "unpublished"; + +export type CatalogSnapshotReference = { + url: string; + sha256: string; + size: number; + type: "application/json"; + fileName: string; +}; + +export type CatalogPersonaShareLevel = "not-shared" | SnapshotMemoryLevel; + +type CatalogAgentProjection = { + displayName: string; + avatarUrl: string | null; + systemPrompt: string; + runtime: string | null; + model: string | null; + provider: string | null; +}; + +type PublishedCatalogContent = { + format: typeof CATALOG_FORMAT; + version: typeof CATALOG_VERSION; + status: "published"; + sourcePersonaId: string; + sourceUpdatedAt: string; + memoryLevel: SnapshotMemoryLevel; + agent: CatalogAgentProjection; + snapshot: CatalogSnapshotReference; +}; + +type UnpublishedCatalogContent = { + format: typeof CATALOG_FORMAT; + version: typeof CATALOG_VERSION; + status: "unpublished"; + sourcePersonaId: string; + sourceUpdatedAt: string; +}; + +type CatalogContent = PublishedCatalogContent | UnpublishedCatalogContent; + +export type PersonaCatalogPublication = { + eventId: string; + ownerPubkey: string; + sourcePersonaId: string; + sourceUpdatedAt: string; + createdAt: number; + status: CatalogStatus; + memoryLevel: SnapshotMemoryLevel | null; + agent: CatalogAgentProjection | null; + snapshot: CatalogSnapshotReference | null; +}; + +export type CatalogPersona = AgentPersona & { + catalogSource: { + eventId: string; + ownerPubkey: string; + isOwn: boolean; + sourcePersonaId: string; + sourceUpdatedAt: string; + memoryLevel: SnapshotMemoryLevel; + snapshot: CatalogSnapshotReference; + }; +}; + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringOrNull(value: unknown): string | null | undefined { + return typeof value === "string" || value === null ? value : undefined; +} + +function extractTag(event: RelayEvent, name: string): string | null { + const matches = event.tags.filter( + (tag) => tag[0] === name && typeof tag[1] === "string", + ); + return matches.length === 1 ? (matches[0]?.[1] ?? null) : null; +} + +function isMemoryLevel(value: unknown): value is SnapshotMemoryLevel { + return value === "none" || value === "core" || value === "everything"; +} + +function isSafeHttpUrl(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + /[\s()]/u.test(value) + ) { + return false; + } + try { + const parsed = new URL(value); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +} + +function isSafeAvatarUrl(value: unknown): value is string | null { + return ( + value === null || + (typeof value === "string" && value.length <= 2_048 && isSafeHttpUrl(value)) + ); +} + +function isSafeSnapshotReference( + value: unknown, +): value is CatalogSnapshotReference { + if (!isObject(value)) return false; + const fileName = value.fileName; + const sha256 = value.sha256; + const size = value.size; + const url = value.url; + return ( + isSafeHttpUrl(url) && + typeof sha256 === "string" && + /^[0-9a-f]{64}$/u.test(sha256) && + typeof size === "number" && + Number.isSafeInteger(size) && + size > 0 && + size <= MAX_SNAPSHOT_JSON_BYTES && + value.type === "application/json" && + typeof fileName === "string" && + fileName.toLowerCase().endsWith(".agent.json") && + !fileName.includes("/") && + !fileName.includes("\\") + ); +} + +function parseCatalogContent(event: RelayEvent): CatalogContent | null { + let parsed: unknown; + try { + parsed = JSON.parse(event.content); + } catch { + return null; + } + if (!isObject(parsed)) return null; + + const sourcePersonaId = extractTag(event, "d"); + const status = extractTag(event, "status"); + const sourceUpdatedAt = extractTag(event, "source_updated_at"); + if ( + event.kind !== KIND_PERSONA_CATALOG || + parsed.format !== CATALOG_FORMAT || + parsed.version !== CATALOG_VERSION || + parsed.status !== status || + parsed.sourcePersonaId !== sourcePersonaId || + parsed.sourceUpdatedAt !== sourceUpdatedAt || + !sourcePersonaId || + !sourceUpdatedAt || + (status !== "published" && status !== "unpublished") + ) { + return null; + } + + if (status === "unpublished") { + return { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status, + sourcePersonaId, + sourceUpdatedAt, + }; + } + + const memoryLevel = extractTag(event, "memory"); + if ( + !isMemoryLevel(memoryLevel) || + parsed.memoryLevel !== memoryLevel || + !isObject(parsed.agent) || + typeof parsed.agent.displayName !== "string" || + parsed.agent.displayName.trim().length === 0 || + typeof parsed.agent.systemPrompt !== "string" || + !isSafeAvatarUrl(parsed.agent.avatarUrl) || + stringOrNull(parsed.agent.runtime) === undefined || + stringOrNull(parsed.agent.model) === undefined || + stringOrNull(parsed.agent.provider) === undefined || + !isSafeSnapshotReference(parsed.snapshot) + ) { + return null; + } + + return { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status, + sourcePersonaId, + sourceUpdatedAt, + memoryLevel, + agent: { + displayName: parsed.agent.displayName, + avatarUrl: parsed.agent.avatarUrl as string | null, + systemPrompt: parsed.agent.systemPrompt, + runtime: parsed.agent.runtime as string | null, + model: parsed.agent.model as string | null, + provider: parsed.agent.provider as string | null, + }, + snapshot: parsed.snapshot, + }; +} + +/** + * Collapse relay results to one latest head per `(author, sourcePersonaId)`. + * The relay already applies NIP-33 replacement, but doing this client-side + * makes discovery deterministic against older relays and test fixtures. + */ +export function catalogPublicationsFromEvents( + events: readonly RelayEvent[], +): PersonaCatalogPublication[] { + const sorted = [...events].sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + ); + const seenCoordinates = new Set(); + const publications: PersonaCatalogPublication[] = []; + + for (const event of sorted) { + const sourcePersonaId = extractTag(event, "d"); + if (!sourcePersonaId) continue; + const coordinate = `${event.pubkey.toLowerCase()}:${sourcePersonaId}`; + if (seenCoordinates.has(coordinate)) continue; + seenCoordinates.add(coordinate); + + const content = parseCatalogContent(event); + if (!content) continue; + publications.push({ + eventId: event.id, + ownerPubkey: event.pubkey.toLowerCase(), + sourcePersonaId: content.sourcePersonaId, + sourceUpdatedAt: content.sourceUpdatedAt, + createdAt: event.created_at, + status: content.status, + memoryLevel: content.status === "published" ? content.memoryLevel : null, + agent: content.status === "published" ? content.agent : null, + snapshot: content.status === "published" ? content.snapshot : null, + }); + } + + return publications; +} + +export async function fetchPersonaCatalogPublications(): Promise< + PersonaCatalogPublication[] +> { + const events = await relayClient.fetchEvents({ + kinds: [KIND_PERSONA_CATALOG], + limit: MAX_CATALOG_EVENTS, + }); + return catalogPublicationsFromEvents(events); +} + +export function catalogPersonasFromPublications( + publications: readonly PersonaCatalogPublication[], + localPersonas: readonly AgentPersona[], + currentPubkey: string | null | undefined, +): CatalogPersona[] { + const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; + return publications + .filter( + ( + publication, + ): publication is PersonaCatalogPublication & { + status: "published"; + memoryLevel: SnapshotMemoryLevel; + agent: CatalogAgentProjection; + snapshot: CatalogSnapshotReference; + } => + publication.status === "published" && + publication.memoryLevel !== null && + publication.agent !== null && + publication.snapshot !== null, + ) + .map((publication) => { + const ownLocalPersona = + publication.ownerPubkey === normalizedCurrentPubkey + ? localPersonas.find( + (persona) => persona.id === publication.sourcePersonaId, + ) + : undefined; + const persona: CatalogPersona = { + id: + publication.ownerPubkey === normalizedCurrentPubkey + ? publication.sourcePersonaId + : `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, + displayName: publication.agent.displayName, + avatarUrl: publication.agent.avatarUrl, + systemPrompt: publication.agent.systemPrompt, + runtime: publication.agent.runtime, + model: publication.agent.model, + provider: publication.agent.provider, + namePool: [], + isBuiltIn: false, + isActive: ownLocalPersona?.isActive ?? false, + sourceTeam: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: publication.sourceUpdatedAt, + updatedAt: publication.sourceUpdatedAt, + catalogSource: { + eventId: publication.eventId, + ownerPubkey: publication.ownerPubkey, + isOwn: publication.ownerPubkey === normalizedCurrentPubkey, + sourcePersonaId: publication.sourcePersonaId, + sourceUpdatedAt: publication.sourceUpdatedAt, + memoryLevel: publication.memoryLevel, + snapshot: publication.snapshot, + }, + }; + return persona; + }) + .sort((left, right) => left.displayName.localeCompare(right.displayName)); +} + +export function isCatalogPersona( + persona: AgentPersona, +): persona is CatalogPersona { + return "catalogSource" in persona && isObject(persona.catalogSource); +} + +export function ownCatalogPublication( + publications: readonly PersonaCatalogPublication[], + ownerPubkey: string | null | undefined, + sourcePersonaId: string, +): PersonaCatalogPublication | null { + if (!ownerPubkey) return null; + const normalizedOwner = ownerPubkey.toLowerCase(); + return ( + publications.find( + (publication) => + publication.ownerPubkey === normalizedOwner && + publication.sourcePersonaId === sourcePersonaId, + ) ?? null + ); +} + +function copyOptionalString( + source: JsonObject, + target: JsonObject, + key: string, +): void { + if (typeof source[key] === "string") target[key] = source[key]; +} + +function copyOptionalNumber( + source: JsonObject, + target: JsonObject, + key: string, +): void { + if ( + typeof source[key] === "number" && + Number.isFinite(source[key]) && + source[key] >= 0 + ) { + target[key] = source[key]; + } +} + +/** + * Rebuild a catalog snapshot from a strict public allowlist. + * + * The normal portable snapshot already excludes identity keys, auth tags, + * environment variables, relay URLs, commands, and runtime state. Catalog + * publication applies a second boundary: response allowlists are also removed + * because they disclose source-community pubkeys and are not portable. + */ +export function sanitizeCatalogSnapshotBytes( + fileBytes: readonly number[], + expectedMemoryLevel: SnapshotMemoryLevel, +): number[] { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(Uint8Array.from(fileBytes))); + } catch { + throw new Error("Couldn’t prepare this agent for the community catalog."); + } + if ( + !isObject(parsed) || + parsed.format !== "buzz-agent-snapshot" || + parsed.version !== 1 || + !isObject(parsed.definition) || + !isObject(parsed.profile) || + !isObject(parsed.memory) || + parsed.memory.level !== expectedMemoryLevel || + !Array.isArray(parsed.memory.entries) + ) { + throw new Error("The generated agent snapshot is invalid."); + } + + const definition: JsonObject = {}; + copyOptionalString(parsed.definition, definition, "name"); + if (typeof parsed.definition.sourceIsBuiltIn === "boolean") { + definition.sourceIsBuiltIn = parsed.definition.sourceIsBuiltIn; + } + for (const key of ["systemPrompt", "runtime", "model", "provider"]) { + copyOptionalString(parsed.definition, definition, key); + } + if (parsed.definition.respondTo === "allowlist") { + definition.respondTo = "owner-only"; + } else if ( + parsed.definition.respondTo === "owner-only" || + parsed.definition.respondTo === "anyone" + ) { + definition.respondTo = parsed.definition.respondTo; + } + for (const key of [ + "parallelism", + "idleTimeoutSeconds", + "maxTurnDurationSeconds", + ]) { + copyOptionalNumber(parsed.definition, definition, key); + } + if ( + Array.isArray(parsed.definition.namePool) && + parsed.definition.namePool.every((value) => typeof value === "string") + ) { + definition.namePool = [...parsed.definition.namePool]; + } + + const profile: JsonObject = {}; + for (const key of ["displayName", "about", "avatarDataUrl", "avatarUrl"]) { + copyOptionalString(parsed.profile, profile, key); + } + if ( + typeof profile.displayName !== "string" || + profile.displayName.length === 0 + ) { + throw new Error("The generated agent snapshot has no display name."); + } + + const entries = parsed.memory.entries.map((entry) => { + if ( + !isObject(entry) || + typeof entry.slug !== "string" || + entry.slug.length === 0 || + typeof entry.body !== "string" + ) { + throw new Error("The generated agent snapshot has invalid memory."); + } + return { slug: entry.slug, body: entry.body }; + }); + const duplicateSlugs = + new Set(entries.map((entry) => entry.slug)).size !== entries.length; + const invalidMemorySelection = + duplicateSlugs || + (expectedMemoryLevel === "none" && entries.length > 0) || + (expectedMemoryLevel === "core" && + (entries.length > 1 || entries.some((entry) => entry.slug !== "core"))) || + (expectedMemoryLevel === "everything" && + entries.some( + (entry) => entry.slug !== "core" && !entry.slug.startsWith("mem/"), + )); + if (invalidMemorySelection) { + throw new Error( + "The generated agent snapshot includes memory outside the selected sharing level.", + ); + } + + const sanitized = { + format: "buzz-agent-snapshot", + version: 1, + definition, + profile, + memory: { + level: expectedMemoryLevel, + entries, + }, + }; + const bytes = Array.from(new TextEncoder().encode(JSON.stringify(sanitized))); + if (bytes.length > MAX_SNAPSHOT_JSON_BYTES) { + throw new Error( + "This agent is too large for the community catalog. Share less memory and try again.", + ); + } + return bytes; +} + +function publicAvatarUrl(avatarUrl: string | null): string | null { + return isSafeAvatarUrl(avatarUrl) ? avatarUrl : null; +} + +function monotonicCreatedAt(previousCreatedAt?: number | null): number { + return Math.max(Math.floor(Date.now() / 1_000), (previousCreatedAt ?? 0) + 1); +} + +function requirePublishedCatalogEvent( + event: RelayEvent, +): PersonaCatalogPublication { + const publication = catalogPublicationsFromEvents([event])[0]; + if (!publication) { + throw new Error("The relay returned an invalid catalog publication."); + } + return publication; +} + +export async function publishPersonaToCatalog(input: { + persona: AgentPersona; + memoryLevel: SnapshotMemoryLevel; + linkedAgentPubkey: string | null; + previousCreatedAt?: number | null; +}): Promise { + if (input.persona.isBuiltIn) { + throw new Error("Built-in agents can’t be published to the catalog."); + } + if (input.memoryLevel !== "none" && !input.linkedAgentPubkey) { + throw new Error("Start this agent before sharing its memory."); + } + + const encoded = await encodeAgentSnapshotForSend( + input.persona.id, + input.memoryLevel, + "json", + input.linkedAgentPubkey, + ); + const snapshotBytes = sanitizeCatalogSnapshotBytes( + encoded.fileBytes, + input.memoryLevel, + ); + const descriptor = await uploadMediaBytes(snapshotBytes, encoded.fileName); + if ( + !/^[0-9a-f]{64}$/u.test(descriptor.sha256) || + descriptor.size !== snapshotBytes.length || + !descriptor.url + ) { + throw new Error("The relay returned an invalid catalog snapshot receipt."); + } + + const content: PublishedCatalogContent = { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status: "published", + sourcePersonaId: input.persona.id, + sourceUpdatedAt: input.persona.updatedAt, + memoryLevel: input.memoryLevel, + agent: { + displayName: input.persona.displayName, + avatarUrl: publicAvatarUrl(input.persona.avatarUrl), + systemPrompt: input.persona.systemPrompt, + runtime: input.persona.runtime, + model: input.persona.model, + provider: input.persona.provider, + }, + snapshot: { + url: descriptor.url, + sha256: descriptor.sha256, + size: descriptor.size, + type: "application/json", + fileName: encoded.fileName, + }, + }; + const event = await signRelayEvent({ + kind: KIND_PERSONA_CATALOG, + content: JSON.stringify(content), + createdAt: monotonicCreatedAt(input.previousCreatedAt), + tags: [ + ["d", input.persona.id], + ["status", "published"], + ["source_updated_at", input.persona.updatedAt], + ["memory", input.memoryLevel], + ], + }); + const published = await relayClient.publishEvent( + event, + "Timed out publishing this agent to the catalog.", + "Failed to publish this agent to the catalog.", + ); + return requirePublishedCatalogEvent(published); +} + +export async function unpublishPersonaFromCatalog(input: { + persona: AgentPersona; + previousCreatedAt?: number | null; +}): Promise { + const content: UnpublishedCatalogContent = { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status: "unpublished", + sourcePersonaId: input.persona.id, + sourceUpdatedAt: input.persona.updatedAt, + }; + const event = await signRelayEvent({ + kind: KIND_PERSONA_CATALOG, + content: JSON.stringify(content), + createdAt: monotonicCreatedAt(input.previousCreatedAt), + tags: [ + ["d", input.persona.id], + ["status", "unpublished"], + ["source_updated_at", input.persona.updatedAt], + ], + }); + const published = await relayClient.publishEvent( + event, + "Timed out removing this agent from the catalog.", + "Failed to remove this agent from the catalog.", + ); + return requirePublishedCatalogEvent(published); +} + +export function linkedAgentPubkeyForPersona( + personaId: string, + managedAgents: readonly ManagedAgent[], +): string | null { + return ( + managedAgents.find((agent) => agent.personaId === personaId)?.pubkey ?? null + ); +} diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts new file mode 100644 index 0000000000..890cd2d198 --- /dev/null +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -0,0 +1,107 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchPersonaCatalogPublications, + publishPersonaToCatalog, + unpublishPersonaFromCatalog, + type PersonaCatalogPublication, +} from "@/features/agents/lib/personaCatalogRelay"; +import { relayClient } from "@/shared/api/relayClient"; +import { KIND_PERSONA_CATALOG } from "@/shared/constants/kinds"; + +export function personaCatalogQueryKey(communityId: string | null) { + return ["persona-catalog", communityId] as const; +} + +export function usePersonaCatalogQuery(communityId: string | null) { + return useQuery({ + enabled: communityId !== null, + queryKey: personaCatalogQueryKey(communityId), + queryFn: fetchPersonaCatalogPublications, + staleTime: 30_000, + refetchInterval: 120_000, + }); +} + +export function usePersonaCatalogLiveUpdates(communityId: string | null): void { + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!communityId) return; + let disposed = false; + let dispose: (() => Promise) | null = null; + + void relayClient + .subscribeLive({ kinds: [KIND_PERSONA_CATALOG], limit: 0 }, () => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }) + .then((unsubscribe) => { + if (disposed) { + void unsubscribe(); + } else { + dispose = unsubscribe; + } + }) + .catch((error) => { + console.error( + "Failed to subscribe to the community agent catalog", + error, + ); + }); + + const unsubscribeReconnect = relayClient.subscribeToReconnects(() => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }); + + return () => { + disposed = true; + unsubscribeReconnect(); + if (dispose) void dispose(); + }; + }, [communityId, queryClient]); +} + +export function usePublishPersonaCatalogMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: publishPersonaToCatalog, + onSuccess: (publication) => { + queryClient.setQueryData( + personaCatalogQueryKey(communityId), + (current) => [ + publication, + ...(current ?? []).filter( + (candidate) => + candidate.ownerPubkey !== publication.ownerPubkey || + candidate.sourcePersonaId !== publication.sourcePersonaId, + ), + ], + ); + }, + }); +} + +export function useUnpublishPersonaCatalogMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: unpublishPersonaFromCatalog, + onSuccess: (publication) => { + queryClient.setQueryData( + personaCatalogQueryKey(communityId), + (current) => [ + publication, + ...(current ?? []).filter( + (candidate) => + candidate.ownerPubkey !== publication.ownerPubkey || + candidate.sourcePersonaId !== publication.sourcePersonaId, + ), + ], + ); + }, + }); +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 7556a79ba2..f48d210777 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -8,7 +8,6 @@ import type { UpdatePersonaInput, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; @@ -35,6 +34,7 @@ import { AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, BLOCK_BUILD_HIDDEN_PROVIDER_IDS, + buildPersonaRuntimeDropdownOptions, CUSTOM_PROVIDER_DROPDOWN_VALUE, computeLocalModeGate, formatRuntimeOptionLabel, @@ -49,7 +49,6 @@ import { PERSONA_FIELD_SHELL_CLASS, PERSONA_LABEL_OPTIONAL_CLASS, shouldClearKnownModelForSelectionScope, - sortPersonaRuntimes, } from "./agentConfigOptions"; import { RequiredFieldLabel } from "./agentConfigControls"; import { @@ -82,6 +81,7 @@ import { } from "./agentAiConfigurationPolicy"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; +import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter"; type AgentDefinitionDialogProps = { open: boolean; @@ -96,13 +96,20 @@ type AgentDefinitionDialogProps = { onOpenChange: (open: boolean) => void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, + options: AgentDefinitionSubmitOptions, ) => Promise; + /** Publishes saved changes when the edited agent is shared in the catalog. */ + publishCatalogUpdatesOnSave?: boolean; /** Rendered below the form fields in create mode only ("Where to run"). */ createRunSection?: React.ReactNode; /** Extra create-mode submit gate (e.g. incomplete provider config). */ createSubmitBlocked?: boolean; }; +export type AgentDefinitionSubmitOptions = { + publishCatalogUpdates: boolean; +}; + const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, ease: [0.23, 1, 0.32, 1], @@ -120,6 +127,7 @@ export function AgentDefinitionDialog({ runtimesLoading = false, onOpenChange, onSubmit, + publishCatalogUpdatesOnSave = false, createRunSection, createSubmitBlocked = false, }: AgentDefinitionDialogProps) { @@ -157,6 +165,7 @@ export function AgentDefinitionDialog({ const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); + const [hasUserChanges, setHasUserChanges] = React.useState(false); const { globalConfig, inheritedDefaults: { @@ -211,6 +220,7 @@ export function AgentDefinitionDialog({ // Advanced always starts collapsed and only changes from its toggle. setShowAdvancedFields(false); setIsAvatarUploadPending(false); + setHasUserChanges(false); isRuntimeAutoSeededRef.current = false; hasSeededForOpenRef.current = false; }, [initialValues, open]); @@ -296,6 +306,7 @@ export function AgentDefinitionDialog({ behaviorSeedRef.current = emptyPersonaBehaviorDraft; setShowAdvancedFields(false); setIsAvatarUploadPending(false); + setHasUserChanges(false); // isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the // [initialValues, open] effect resets both when the dialog re-opens. } @@ -347,14 +358,19 @@ export function AgentDefinitionDialog({ }; if ("id" in initialValues) { - await onSubmit({ - id: initialValues.id, - ...baseInput, - }); + await onSubmit( + { + id: initialValues.id, + ...baseInput, + }, + { + publishCatalogUpdates: publishCatalogUpdatesOnSave && hasUserChanges, + }, + ); return; } - await onSubmit(baseInput); + await onSubmit(baseInput, { publishCatalogUpdates: false }); } function handleSubmitForm(event: React.FormEvent) { @@ -381,6 +397,7 @@ export function AgentDefinitionDialog({ enabled: open, }); function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) { + setHasUserChanges(true); setAiConfigurationMode(nextMode); setIsCustomProviderEditing(false); setIsCustomModelEditing(false); @@ -552,44 +569,14 @@ export function AgentDefinitionDialog({ const showCustomProviderInput = llmProviderFieldVisible && isCustomProviderEditing; const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE; - const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimes), - [runtimes], - ); - const blankRuntimeOptionLabel = runtimesLoading - ? "Loading harnesses..." - : isCreateMode - ? "Choose a harness" - : "No preference (use app default)"; - const runtimeDropdownOptions: PersonaDropdownOption[] = [ - ...(!isCreateMode - ? [ - { - label: blankRuntimeOptionLabel, - value: NO_RUNTIME_DROPDOWN_VALUE, - }, - ] - : []), - ...sortedRuntimes.map((candidate) => ({ - disabled: - isCreateMode && - defaultRuntime !== null && - candidate.availability !== "available", - label: `${formatRuntimeOptionLabel(candidate)}${ - isCreateMode && candidate.id === defaultRuntime?.id ? " (default)" : "" - }`, - value: candidate.id, - })), - ]; - if ( - runtime.trim().length > 0 && - !runtimeDropdownOptions.some((option) => option.value === runtime) - ) { - runtimeDropdownOptions.push({ - label: `${runtime.trim()} (current)`, - value: runtime.trim(), + const { blankRuntimeOptionLabel, runtimeDropdownOptions } = + buildPersonaRuntimeDropdownOptions({ + defaultRuntimeId: defaultRuntime?.id, + isCreateMode, + runtime, + runtimes, + runtimesLoading, }); - } const runtimeSummaryLabel = selectedRuntime ? formatRuntimeOptionLabel(selectedRuntime) : runtime.trim() || "Not configured"; @@ -679,6 +666,7 @@ export function AgentDefinitionDialog({ } function handleRuntimeDropdownChange(nextValue: string) { + setHasUserChanges(true); const nextRuntime = nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue; // The user made an explicit choice — no longer auto-seeded. @@ -697,6 +685,7 @@ export function AgentDefinitionDialog({ } function handleProviderDropdownChange(nextValue: string) { + setHasUserChanges(true); const nextProvider = nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue; if (nextProvider === "relay-mesh" && runtime !== "buzz-agent") { @@ -714,6 +703,7 @@ export function AgentDefinitionDialog({ } function handleModelDropdownChange(nextValue: string) { + setHasUserChanges(true); applySelection( selectionOnModelDropdownChange(selection, { nextValue, @@ -740,42 +730,38 @@ export function AgentDefinitionDialog({ headerClassName="pb-2" title={title} footer={ -
- - -
+ handleOpenChange(false)} + publishesCatalogUpdates={ + publishCatalogUpdatesOnSave && hasUserChanges + } + submitBlockReason={null} + submitLabel={submitLabel} + /> } >
setHasUserChanges(true)} onSubmit={handleSubmitForm} > setAvatarUrl("")} + onClearAvatar={() => { + setHasUserChanges(true); + setAvatarUrl(""); + }} onUploadPendingChange={setIsAvatarUploadPending} - onSelectAvatar={setAvatarUrl} + onSelectAvatar={(nextAvatarUrl) => { + setHasUserChanges(true); + setAvatarUrl(nextAvatarUrl); + }} />
@@ -1012,7 +998,10 @@ export function AgentDefinitionDialog({ model={model} modelTuningRuntimeId={runtime} namePoolText={namePoolText} - onBehaviorDraftChange={setBehaviorDraft} + onBehaviorDraftChange={(nextBehaviorDraft) => { + setHasUserChanges(true); + setBehaviorDraft(nextBehaviorDraft); + }} onEnvVarsChange={setEnvVars} onNamePoolTextChange={setNamePoolText} provider={effectiveProvider} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx new file mode 100644 index 0000000000..92428ad95c --- /dev/null +++ b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/shared/ui/button"; + +type AgentDefinitionDialogFooterProps = { + canSubmit: boolean; + isAvatarUploadPending: boolean; + isPending: boolean; + onCancel: () => void; + publishesCatalogUpdates: boolean; + submitBlockReason: string | null; + submitLabel: string; +}; + +export function AgentDefinitionDialogFooter({ + canSubmit, + isAvatarUploadPending, + isPending, + onCancel, + publishesCatalogUpdates, + submitBlockReason, + submitLabel, +}: AgentDefinitionDialogFooterProps) { + return ( +
+
+ {submitBlockReason ? ( +

+ {submitBlockReason} +

+ ) : null} + {publishesCatalogUpdates ? ( +

+ This agent is in the community catalog. Your changes will be + published when you save. +

+ ) : null} +
+ +
+ + +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx new file mode 100644 index 0000000000..50109143cd --- /dev/null +++ b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/shared/lib/cn"; + +export function AgentDefinitionMetadata({ + className, + isBuiltIn, + model, + runtime, +}: { + className?: string; + isBuiltIn: boolean; + model: string | null; + runtime: string | null; +}) { + const items = [ + { + label: "Type", + value: isBuiltIn ? "Built-in agent" : "Custom agent", + }, + { + label: "Preferred model", + value: model ?? "Use app default", + }, + { + label: "Preferred runtime", + value: runtime ?? "Use app default", + }, + ]; + + return ( +
+
+ {items.map((item, index) => ( +
0 && + "border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70", + )} + key={item.label} + > +

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentDialog.tsx b/desktop/src/features/agents/ui/AgentDialog.tsx index 02a6d0e64a..f5be3cc7e8 100644 --- a/desktop/src/features/agents/ui/AgentDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDialog.tsx @@ -11,7 +11,10 @@ import type { AgentCreateIntent } from "./agentCreateIntent"; import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog"; import { createPersonaDialogState } from "./personaDialogState"; -import { AgentDefinitionDialog } from "./AgentDefinitionDialog"; +import { + AgentDefinitionDialog, + type AgentDefinitionSubmitOptions, +} from "./AgentDefinitionDialog"; import { WhereToRunSection } from "./WhereToRunSection"; import { canSubmitWhereToRun, @@ -64,7 +67,9 @@ type AgentDialogDefinitionEditProps = { onOpenChange: (open: boolean) => void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, + options: AgentDefinitionSubmitOptions, ) => Promise; + publishCatalogUpdatesOnSave?: boolean; }; type AgentDialogProps = diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx index ad1219310d..4a9584dfb9 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -15,6 +15,8 @@ import { } from "@/shared/ui/dialog"; import { Separator } from "@/shared/ui/separator"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; + // ── Types ───────────────────────────────────────────────────────────────────── type ImportPhase = "preview" | "confirming" | "result"; @@ -164,6 +166,12 @@ function PreviewBody({ ) : null}
+ +

A new agent will be created with a fresh keypair. The imported agent is independent of the source — identity never travels. diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 77d9e82060..def2bedc06 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { OctagonX } from "lucide-react"; +import { OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, subscribeSnapshotImport, @@ -21,7 +21,10 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; -import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; +import { + AGENT_CARD_GRID_COLUMNS_CLASS, + UnifiedAgentsSection, +} from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -71,11 +74,14 @@ export function AgentsView() { const runningAgentCount = agents.managedAgents.filter((agent) => isManagedAgentActive(agent), ).length; - // Show the resolved effective model, not just the structured `model` field: - // most providers persist the model as a provider env var (e.g. DATABRICKS_MODEL) - // or inherit a baked build default, leaving `globalConfig.model` null. - const configuredGlobalModel = inheritedDefaults.model.value; - + const hasSavedAgentDefaults = Boolean( + globalConfig.preferred_runtime?.trim() || + globalConfig.provider?.trim() || + globalConfig.model?.trim() || + Object.values(globalConfig.env_vars).some( + (value) => value.trim().length > 0, + ), + ); // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable React.useEffect(() => { // Consume a snapshot import that was enqueued before navigation (e.g. from @@ -107,18 +113,23 @@ export function AgentsView() { return ( <>

-
+
{runningAgentCount > 0 ? ( @@ -136,11 +147,10 @@ export function AgentsView() { ) : null}
} - className="mx-auto w-full max-w-[996px]" description="Set up and manage your agents." title="Agents" /> -
+
0} personas={personas.libraryPersonas} personasError={ personas.personasQuery.error instanceof Error @@ -187,10 +196,8 @@ export function AgentsView() { } isPersonasLoading={personas.personasQuery.isLoading} isPersonasPending={personas.isPending} - onCreatePersona={() => { - openUnifiedCreate(); - }} - onChooseCatalog={personas.openCatalog} + onCreatePersona={openUnifiedCreate} + onDiscoverPersonas={personas.openCatalog} onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} onSharePersona={personas.openShare} @@ -315,8 +322,22 @@ export function AgentsView() { personas.setPersonaDialogState(null); } }} - onSubmit={personas.handleSubmit} + onSubmit={(input, options) => + personas.handleSubmit( + input, + undefined, + undefined, + undefined, + options, + ) + } open={personas.personaDialogState !== null} + publishCatalogUpdatesOnSave={ + "id" in personas.personaDialogState.initialValues && + personas.sharedCatalogPersonaIdSet.has( + personas.personaDialogState.initialValues.id, + ) + } submitLabel={personas.personaDialogState.submitLabel} title={personas.personaDialogState.title} /> @@ -342,8 +363,23 @@ export function AgentsView() { ) : null} {personas.personaToShare ? ( { + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + void personas.setPersonaCatalogShareLevel( + shareTarget.persona, + shareLevel, + shareTarget.linkedAgentPubkey, + ); + }} onExport={() => { const shareTarget = personas.personaToShare; if (!shareTarget) return; @@ -355,6 +391,14 @@ export function AgentsView() { personas.setPersonaToShare(null); } }} + onPublishCatalogUpdates={() => { + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + void personas.publishPersonaCatalogUpdates( + shareTarget.persona, + shareTarget.linkedAgentPubkey, + ); + }} open={personas.personaToShare !== null} persona={personas.personaToShare.persona} /> @@ -402,8 +446,8 @@ export function AgentsView() { {personas.isCatalogDialogOpen ? ( { personas.clearFeedback("catalog"); }} diff --git a/desktop/src/features/agents/ui/CreateIdentityCard.tsx b/desktop/src/features/agents/ui/CreateIdentityCard.tsx index 70d063098b..4fdd6db26f 100644 --- a/desktop/src/features/agents/ui/CreateIdentityCard.tsx +++ b/desktop/src/features/agents/ui/CreateIdentityCard.tsx @@ -6,7 +6,7 @@ import { cn } from "@/shared/lib/cn"; type CreateIdentityCardProps = React.ButtonHTMLAttributes & { ariaLabel: string; dataTestId: string; - label: string; + label?: string; }; export const CreateIdentityCard = React.forwardRef< @@ -30,7 +30,9 @@ export const CreateIdentityCard = React.forwardRef< > - {label} + {label ? ( + {label} + ) : null} ); diff --git a/desktop/src/features/agents/ui/PersonaAddedBy.tsx b/desktop/src/features/agents/ui/PersonaAddedBy.tsx index 66e5ee31f9..3cdec29104 100644 --- a/desktop/src/features/agents/ui/PersonaAddedBy.tsx +++ b/desktop/src/features/agents/ui/PersonaAddedBy.tsx @@ -2,13 +2,17 @@ import { cn } from "@/shared/lib/cn"; type PersonaAddedByProps = { className?: string; + label?: string; }; -export function PersonaAddedBy({ className }: PersonaAddedByProps) { +export function PersonaAddedBy({ + className, + label = "You", +}: PersonaAddedByProps) { return (

Added by{" "} - You + {label}

); } diff --git a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx index 6aab3096cb..34d8fd8b16 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx @@ -11,6 +11,7 @@ import { SheetTitle, } from "@/shared/ui/sheet"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge"; import { getPersonaCatalogDetailSelectionCopy, @@ -121,30 +122,11 @@ export function PersonaCatalogDetailsSheet({

) : null} -
-
-

- Type -

-

Built-in agent

-
-
-

- Preferred model -

-

- {persona.model ?? "Use app default"} -

-
-
-

- Preferred runtime -

-

- {persona.runtime ?? "Use app default"} -

-
-
+

diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index 0d6b5583ff..ba76d6e4ed 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; +import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; @@ -11,6 +12,8 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Markdown } from "@/shared/ui/markdown"; import { Skeleton } from "@/shared/ui/skeleton"; +import agentOutlineUrl from "../assets/agent-outline.svg"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; import { PersonaAddedBy } from "./PersonaAddedBy"; import { personaCatalogCopy } from "./personaLibraryCopy"; @@ -28,7 +31,7 @@ type PersonaCatalogDialogProps = { }; const agentInstructionMarkdownClassName = [ - "mt-3 leading-6 text-muted-foreground [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", + "mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", "[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground", "[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground", "[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground", @@ -100,7 +103,7 @@ export function PersonaCatalogDialog({

+
+ +

+ {personaCatalogCopy.emptyCatalogTitle} +

+

+ {personaCatalogCopy.emptyCatalogDescription} +

+
+
+ ); + } + return (
@@ -200,9 +228,9 @@ function PersonaCatalogChooser({
-
+
{isLoading ? : null} @@ -211,19 +239,6 @@ function PersonaCatalogChooser({ ) : null} - {!isLoading && personas.length === 0 && !error ? ( -
-
-

- {personaCatalogCopy.emptyCatalogTitle} -

-

- {personaCatalogCopy.emptyCatalogDescription} -

-
-
- ) : null} - {error ? (

{error.message} @@ -263,7 +278,7 @@ function PersonaCatalogChooser({ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { return ( -

+
{persona.displayName} - {persona.isBuiltIn ? null : } + {persona.isBuiltIn ? null : ( + + )}
- -
+

Agent instruction

@@ -309,36 +322,6 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { ); } -function PersonaCatalogMetaGroup({ - items, -}: { - items: { label: string; value: string }[]; -}) { - return ( -
-
- {items.map((item, index) => ( -
0 && - "border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70", - )} - key={item.label} - > -

- {item.label} -

-

- {item.value} -

-
- ))} -
-
- ); -} - function PersonaCatalogListSkeleton() { return (
diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index b6d3fafd3c..c8e7d471b9 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { AlertCircle, + BookUser, Check, ChevronRight, Download, @@ -11,6 +12,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks"; +import type { CatalogPersonaShareLevel } from "@/features/agents/lib/personaCatalogRelay"; import { useOpenDmMutation, useUpsertCachedChannel, @@ -51,15 +53,20 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; import { useSnapshotSendController } from "./useSnapshotSendController"; type PersonaShareDialogProps = { + catalogShareLevel: CatalogPersonaShareLevel; + hasCatalogUpdates: boolean; isPending: boolean; linkedAgentPubkey: string | null; + onCatalogShareLevelChange: (shareLevel: CatalogPersonaShareLevel) => void; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; + onPublishCatalogUpdates: () => void; persona: AgentPersona; }; type SnapshotShareDialogProps = { + afterLink?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -109,6 +116,20 @@ type PendingMemoryShare = { recipientNames?: string[]; }; +function buildSnapshotShareLevels(itemLabel: "Agent" | "Team") { + return [ + { value: "none" as const, label: `${itemLabel} only` }, + { + value: "core" as const, + label: `${itemLabel} + core memory`, + }, + { + value: "everything" as const, + label: `${itemLabel} + all memories`, + }, + ]; +} + function formatRecipientAudience(names: readonly string[]): string { if (names.length === 0) return "The people you selected"; if (names.length === 1) return names[0] ?? "The person you selected"; @@ -231,6 +252,7 @@ function ShareLevelControl({ } export function SnapshotShareDialog({ + afterLink, displayName, encodeSnapshot, hasMemoryOptions, @@ -298,17 +320,7 @@ export function SnapshotShareDialog({ const itemLabel = snapshotKind === "team" ? "team" : "agent"; const itemLabelTitle = snapshotKind === "team" ? "Team" : "Agent"; const shareLevels = React.useMemo( - () => [ - { value: "none" as const, label: `${itemLabelTitle} only` }, - { - value: "core" as const, - label: `${itemLabelTitle} + core memory`, - }, - { - value: "everything" as const, - label: `${itemLabelTitle} + all memories`, - }, - ], + () => buildSnapshotShareLevels(itemLabelTitle), [itemLabelTitle], ); const getEncodedSnapshot = React.useCallback( @@ -611,6 +623,7 @@ export function SnapshotShareDialog({ value={linkShareLevel} />
+ {afterLink ?
{afterLink}
: null} | null>(null); + const catalogShareLevels = React.useMemo( + () => [ + { value: "not-shared", label: "Not shared" }, + ...buildSnapshotShareLevels("Agent").filter( + ({ value }) => linkedAgentPubkey || value === "none", + ), + ], + [linkedAgentPubkey], + ); const encodeSnapshot = React.useCallback( async (memoryLevel: SnapshotMemoryLevel) => encodeSnapshotMutation.mutateAsync({ @@ -741,17 +769,107 @@ export function PersonaShareDialog({ ); return ( - + <> + + + + +
+

Share to catalog

+

+ Anyone in this community can find and use a copy. Catalog data + is plaintext; secrets and response allowlists are never + included. +

+
+
+ {catalogShareLevel !== "not-shared" && hasCatalogUpdates ? ( + + ) : null} + { + const shareLevel = nextValue as CatalogPersonaShareLevel; + if (shareLevel === "core" || shareLevel === "everything") { + setPendingCatalogMemoryLevel(shareLevel); + } else { + onCatalogShareLevelChange(shareLevel); + } + }} + options={catalogShareLevels} + testId="persona-share-catalog-access" + value={catalogShareLevel} + /> +
+ + ) + } + displayName={persona.displayName} + encodeSnapshot={encodeSnapshot} + hasMemoryOptions={linkedAgentPubkey !== null} + isPending={isPending} + onExport={onExport} + onOpenChange={onOpenChange} + onReset={encodeSnapshotMutation.reset} + open={open} + snapshotKind="agent" + testIdPrefix="persona-share" + /> + { + if (!nextOpen) setPendingCatalogMemoryLevel(null); + }} + open={pendingCatalogMemoryLevel !== null} + > + + + + Publish memories to the catalog? + + + The selected memory will be stored as plaintext community data. + Anyone in this community can read it and keep a copy, even if you + stop sharing the agent later. + + + + + + + + + + + + + ); } diff --git a/desktop/src/features/agents/ui/TeamIdentityCard.tsx b/desktop/src/features/agents/ui/TeamIdentityCard.tsx index 824b206604..19596b76d6 100644 --- a/desktop/src/features/agents/ui/TeamIdentityCard.tsx +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -115,6 +115,7 @@ function TeamAvatarRow({ }) { const visiblePersonas = personas.slice(0, MAX_VISIBLE_MEMBER_AVATARS); const overflowCount = Math.max(0, memberCount - visiblePersonas.length); + const stackItemCount = visiblePersonas.length + (overflowCount > 0 ? 1 : 0); if (visiblePersonas.length === 0 && overflowCount === 0) { return ( @@ -130,16 +131,26 @@ function TeamAvatarRow({
{visiblePersonas.map((persona, index) => ( - + ))} {overflowCount > 0 ? ( - - +{overflowCount} - +
0 ? "-ml-5" : ""} + style={{ zIndex: stackItemCount }} + > + + +{overflowCount} + +
) : null}
@@ -148,25 +159,39 @@ function TeamAvatarRow({ function TeamAvatarItem({ index, + isFollowedByAnother, persona, }: { index: number; + isFollowedByAnother: boolean; persona: AgentPersona; }) { const avatarUrl = persona.avatarUrl?.trim() ?? null; return ( -
+
0 ? "-ml-5" : ""}`} + data-team-member-avatar="avatar" + style={{ + zIndex: index + 1, + ...(isFollowedByAnother && { + mask: "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", + WebkitMask: + "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", + }), + }} + > {avatarUrl ? ( ) : ( - + - - Import team snapshot + Import diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index fe792b0c39..006d3ed6c5 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -40,7 +40,6 @@ type UnifiedAgentsSectionProps = { onOpenPersonaProfile: (persona: AgentPersona) => void; onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; - canChooseCatalog: boolean; personas: AgentPersona[]; personasError: Error | null; personaFeedbackErrorMessage: string | null; @@ -48,7 +47,7 @@ type UnifiedAgentsSectionProps = { isPersonasLoading: boolean; isPersonasPending: boolean; onCreatePersona: () => void; - onChooseCatalog: () => void; + onDiscoverPersonas: () => void; onDuplicatePersona: (persona: AgentPersona) => void; onEditPersona: (persona: AgentPersona) => void; onSharePersona: ( @@ -61,7 +60,9 @@ type UnifiedAgentsSectionProps = { }; const AGENT_CARD_COLUMN_CLASS = "w-full"; -const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; +export const AGENT_CARD_GRID_COLUMNS_CLASS = + "grid-cols-[repeat(auto-fill,minmax(220px,240px))]"; +const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3`; export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const { @@ -78,7 +79,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onOpenPersonaProfile, onStartAgent, onStartPersona, - canChooseCatalog, personas, personasError, personaFeedbackErrorMessage, @@ -86,7 +86,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { isPersonasLoading, isPersonasPending, onCreatePersona, - onChooseCatalog, + onDiscoverPersonas, onDuplicatePersona, onEditPersona, onSharePersona, @@ -179,11 +179,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ); })}
@@ -419,50 +418,37 @@ function firstAvatarUrl( } function NewAgentCard({ - canChooseCatalog, - isPersonasPending, - openFilePicker, - onChooseCatalog, - onCreatePersona, + isPending, + onCreate, + onDiscover, + onImport, }: { - canChooseCatalog: boolean; - isPersonasPending: boolean; - openFilePicker: () => void; - onChooseCatalog: () => void; - onCreatePersona: () => void; + isPending: boolean; + onCreate: () => void; + onDiscover: () => void; + onImport: () => void; }) { return ( - + event.preventDefault()} > - - Create from scratch + + Create agent + + + Discover agents - {canChooseCatalog ? ( - - Choose from catalog - - ) : null} - Import agent snapshot + Import diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cb..1313d2cec4 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -426,6 +426,60 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { return `${runtime.label}${suffix}`; } +export function buildPersonaRuntimeDropdownOptions({ + defaultRuntimeId, + isCreateMode, + runtime, + runtimes, + runtimesLoading, +}: { + defaultRuntimeId?: string; + isCreateMode: boolean; + runtime: string; + runtimes: AcpRuntimeCatalogEntry[]; + runtimesLoading: boolean; +}): { + blankRuntimeOptionLabel: string; + runtimeDropdownOptions: PersonaDropdownOption[]; +} { + const blankRuntimeOptionLabel = runtimesLoading + ? "Loading harnesses..." + : isCreateMode + ? "Choose a harness" + : "No preference (use app default)"; + const runtimeDropdownOptions: PersonaDropdownOption[] = [ + ...(!isCreateMode + ? [ + { + label: blankRuntimeOptionLabel, + value: NO_RUNTIME_DROPDOWN_VALUE, + }, + ] + : []), + ...sortPersonaRuntimes(runtimes).map((candidate) => ({ + disabled: + isCreateMode && + defaultRuntimeId !== undefined && + candidate.availability !== "available", + label: `${formatRuntimeOptionLabel(candidate)}${ + isCreateMode && candidate.id === defaultRuntimeId ? " (default)" : "" + }`, + value: candidate.id, + })), + ]; + const currentRuntime = runtime.trim(); + if ( + currentRuntime.length > 0 && + !runtimeDropdownOptions.some((option) => option.value === currentRuntime) + ) { + runtimeDropdownOptions.push({ + label: `${currentRuntime} (current)`, + value: currentRuntime, + }); + } + return { blankRuntimeOptionLabel, runtimeDropdownOptions }; +} + function runtimeAvailabilitySortRank( availability: AcpRuntimeCatalogEntry["availability"], ) { diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts index 53c5e7a16f..79ddad1c3c 100644 --- a/desktop/src/features/agents/ui/personaLibraryCopy.ts +++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts @@ -14,14 +14,13 @@ export const personaLibraryCopy = { export const personaCatalogCopy = { title: "Agent Catalog", - description: "Browse built-in agents and add them to My Agents.", + description: "Browse agents shared to this relay.", dialogTitle: "Agent Catalog", - dialogDescription: "Browse built-in agents and add them to My Agents.", + dialogDescription: "Browse agents shared to this relay.", emptyTitle: "You're all set", emptyDescription: "Everything in Agent Catalog is already in My Agents.", - emptyCatalogDescription: - "New agents will show up here when the app ships more options.", - emptyCatalogTitle: "No agents in the catalog yet", + emptyCatalogDescription: "Shared agents will appear here.", + emptyCatalogTitle: "No agents are being shared", detailsAction: "View details", selectAction: "Choose", deselectAction: "Deselect", diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 54535d121c..b620998480 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -17,9 +17,27 @@ import { type AgentSnapshotImportPreview, type AgentSnapshotImportResult, } from "@/features/agents/hooks"; -import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; -import { clearLegacyPersonaCatalogVisibility } from "@/features/agents/lib/legacyPersonaCatalogVisibility"; +import { + getLibraryPersonas, + getPersonaLabelsById, +} from "@/features/agents/lib/catalog"; +import { + type CatalogPersonaShareLevel, + catalogPersonasFromPublications, + isCatalogPersona, + linkedAgentPubkeyForPersona, + ownCatalogPublication, +} from "@/features/agents/lib/personaCatalogRelay"; +import { + usePersonaCatalogLiveUpdates, + usePersonaCatalogQuery, + usePublishPersonaCatalogMutation, + useUnpublishPersonaCatalogMutation, +} from "@/features/agents/lib/usePersonaCatalogRelay"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { fetchSnapshotBytes } from "@/shared/api/tauriMedia"; import type { SnapshotFormat, SnapshotMemoryLevel, @@ -51,7 +69,15 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const communityId = activeCommunity?.id ?? null; const personasQuery = usePersonasQuery(); + const catalogQuery = usePersonaCatalogQuery(communityId); + usePersonaCatalogLiveUpdates(communityId); + const publishCatalogMutation = usePublishPersonaCatalogMutation(communityId); + const unpublishCatalogMutation = + useUnpublishPersonaCatalogMutation(communityId); const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] = React.useState(false); const acpRuntimesQuery = useAcpRuntimesQuery({ @@ -101,9 +127,19 @@ export function usePersonaActions() { React.useState(false); const personas = personasQuery.data ?? []; - React.useEffect(() => { - clearLegacyPersonaCatalogVisibility(); - }, []); + const publications = catalogQuery.data ?? []; + const sharedCatalogPersonaIdSet = React.useMemo(() => { + const ownerPubkey = identityQuery.data?.pubkey?.toLowerCase(); + return new Set( + publications + .filter( + (publication) => + publication.ownerPubkey === ownerPubkey && + publication.status === "published", + ) + .map((publication) => publication.sourcePersonaId), + ); + }, [identityQuery.data?.pubkey, publications]); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -112,8 +148,21 @@ export function usePersonaActions() { ), [acpRuntimesQuery.data], ); - const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo( - () => getPersonaLibraryState(personas), + const catalogPersonas = React.useMemo( + () => + catalogPersonasFromPublications( + publications, + personas, + identityQuery.data?.pubkey, + ), + [identityQuery.data?.pubkey, personas, publications], + ); + const libraryPersonas = React.useMemo( + () => getLibraryPersonas(personas), + [personas], + ); + const personaLabelsById = React.useMemo( + () => getPersonaLabelsById(personas), [personas], ); @@ -130,6 +179,7 @@ export function usePersonaActions() { intent?: AgentCreateIntent, backendIntent?: BackendIntent | null, targetChannel?: Pick | null, + options?: { publishCatalogUpdates?: boolean }, ): Promise { if (isPersonaSubmitPending) { return false; @@ -139,7 +189,10 @@ export function usePersonaActions() { setIsPersonaSubmitPending(true); try { if ("id" in input) { - await updatePersonaMutation.mutateAsync(input); + const updatedPersona = await updatePersonaMutation.mutateAsync(input); + if (options?.publishCatalogUpdates) { + await publishPersonaCatalogUpdates(updatedPersona); + } setPersonaNoticeMessage(`Updated ${input.displayName}.`); } else { const runtime = availableRuntimes.find( @@ -223,6 +276,17 @@ export function usePersonaActions() { async function handleDelete(persona: AgentPersona) { clearFeedback("library"); try { + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + if (publication?.status === "published") { + await unpublishCatalogMutation.mutateAsync({ + persona, + previousCreatedAt: publication.createdAt, + }); + } await deletePersonaMutation.mutateAsync(persona.id); setPersonaNoticeMessage(`Deleted ${persona.displayName}.`); setPersonaToDelete(null); @@ -240,7 +304,47 @@ export function usePersonaActions() { ) { clearFeedback(surface); try { - await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + if (active && isCatalogPersona(persona)) { + const isOwnPublication = + persona.catalogSource.ownerPubkey === + identityQuery.data?.pubkey?.toLowerCase(); + const ownLocalPersona = isOwnPublication + ? personas.find( + (candidate) => + candidate.id === persona.catalogSource.sourcePersonaId, + ) + : undefined; + + if (ownLocalPersona) { + await setPersonaActiveMutation.mutateAsync({ + id: ownLocalPersona.id, + active: true, + }); + } else { + const snapshot = persona.catalogSource.snapshot; + const fileBytes = await fetchSnapshotBytes({ + url: snapshot.url, + filename: snapshot.fileName, + expectedSha256: snapshot.sha256, + expectedSize: snapshot.size, + }); + const result = await confirmSnapshotImportMutation.mutateAsync({ + fileBytes, + // Catalog publication strips the source response allowlist, but + // fail closed at import too if a malicious entry bypassed it. + keepAllowlist: false, + }); + void queryClient.invalidateQueries({ queryKey: personasQueryKey }); + void queryClient.invalidateQueries({ + queryKey: managedAgentsQueryKey, + }); + void queryClient.invalidateQueries({ + queryKey: ["user-profile", result.newPubkey.toLowerCase()], + }); + } + } else { + await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + } setPersonaNoticeMessage( active ? `Selected ${persona.displayName} for My Agents.` @@ -386,6 +490,118 @@ export function usePersonaActions() { ); } + function getPersonaCatalogShareLevel( + persona: AgentPersona, + ): CatalogPersonaShareLevel { + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + if ( + publication?.status !== "published" || + publication.memoryLevel === null + ) { + return "not-shared"; + } + return publication.memoryLevel; + } + + async function setPersonaCatalogShareLevel( + persona: AgentPersona, + shareLevel: CatalogPersonaShareLevel, + linkedAgentPubkey: string | null, + ): Promise { + if (persona.isBuiltIn) return; + + clearFeedback("library"); + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + try { + if (shareLevel === "not-shared") { + await unpublishCatalogMutation.mutateAsync({ + persona, + previousCreatedAt: publication?.createdAt, + }); + setPersonaNoticeMessage( + `${persona.displayName} is no longer discoverable in the community catalog.`, + ); + } else { + await publishCatalogMutation.mutateAsync({ + persona, + memoryLevel: shareLevel, + linkedAgentPubkey, + previousCreatedAt: publication?.createdAt, + }); + setPersonaNoticeMessage( + `Published ${persona.displayName} to the community catalog.`, + ); + } + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? error.message + : "Failed to update catalog sharing.", + ); + } + } + + function hasPersonaCatalogUpdates(persona: AgentPersona) { + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + return ( + publication?.status === "published" && + publication.sourceUpdatedAt !== persona.updatedAt + ); + } + + async function publishPersonaCatalogUpdates( + persona: AgentPersona, + linkedAgentPubkey?: string | null, + ): Promise { + if (persona.isBuiltIn) return false; + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + if ( + publication?.status !== "published" || + publication.memoryLevel === null + ) { + return false; + } + const managedAgents = + queryClient.getQueryData(managedAgentsQueryKey) ?? []; + try { + await publishCatalogMutation.mutateAsync({ + persona, + memoryLevel: publication.memoryLevel, + linkedAgentPubkey: + linkedAgentPubkey ?? + linkedAgentPubkeyForPersona(persona.id, managedAgents), + previousCreatedAt: publication.createdAt, + }); + setPersonaNoticeMessage( + `Published updates to ${persona.displayName} in the community catalog.`, + ); + return true; + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? `${persona.displayName} was saved, but its catalog update failed: ${error.message}` + : `${persona.displayName} was saved, but its catalog update failed.`, + ); + return false; + } + } + const isPending = isPersonaSubmitPending || createPersonaMutation.isPending || @@ -395,10 +611,13 @@ export function usePersonaActions() { setPersonaActiveMutation.isPending || exportAgentSnapshotMutation.isPending || previewSnapshotImportMutation.isPending || - confirmSnapshotImportMutation.isPending; + confirmSnapshotImportMutation.isPending || + publishCatalogMutation.isPending || + unpublishCatalogMutation.isPending; return { personasQuery, + catalogQuery, acpRuntimesQuery, createPersonaMutation, updatePersonaMutation, @@ -431,6 +650,11 @@ export function usePersonaActions() { personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, + getPersonaCatalogShareLevel, + setPersonaCatalogShareLevel, + hasPersonaCatalogUpdates, + publishPersonaCatalogUpdates, + sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, snapshotImportResult, diff --git a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs index 8503a79349..d4e0b1d5f3 100644 --- a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs +++ b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs @@ -12,6 +12,9 @@ import test from "node:test"; function makePreview(overrides = {}) { return { displayName: "Test Agent", + isBuiltIn: false, + model: null, + runtime: null, systemPrompt: "You are helpful.", avatarUrl: null, memoryLevel: "none", diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index bd3be92407..aa5bc8ed9e 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -172,6 +172,10 @@ export async function encodeAgentSnapshotForSend( /** Preview returned by `preview_agent_snapshot_import` before any write. */ export type AgentSnapshotImportPreview = { displayName: string; + /** Source classification shown in the preview; imports remain custom. */ + isBuiltIn: boolean; + model: string | null; + runtime: string | null; systemPrompt: string | null; /** Effective avatar: data URL if present, source URL fallback otherwise. */ avatarUrl: string | null; diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ef3234f4c5..ab8923b1b8 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -52,6 +52,9 @@ export const KIND_CHANNEL_SORT = 30078; export const KIND_PERSONA = 30175; export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; +// Community-visible catalog publications. Unlike KIND_PERSONA, clients query +// this kind across every author in the active community. +export const KIND_PERSONA_CATALOG = 30178; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_AGENT_TURN_METRIC = 44200; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a7d58e70cc..7c2376008e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -35,6 +35,7 @@ import { KIND_HUDDLE_STARTED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_PERSONA_CATALOG, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, @@ -105,6 +106,7 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; sourceTeam?: string | null; envVars?: Record; @@ -112,6 +114,8 @@ type MockPersonaSeed = { model?: string | null; provider?: string | null; namePool?: string[]; + respondTo?: "owner-only" | "allowlist" | "anyone"; + respondToAllowlist?: string[]; }; type MockTeamSeed = { @@ -206,6 +210,8 @@ type E2eConfig = { * (`list/start/stop/restart_managed_agent_runtime`). */ managedAgentRuntimes?: MockManagedAgentRuntimeSeed[]; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number; @@ -2156,12 +2162,17 @@ function resetMockPersonas(config?: E2eConfig) { model: persona.model ?? null, provider: persona.provider ?? null, name_pool: persona.namePool ?? [], + respond_to: persona.respondTo ?? null, + respond_to_allowlist: + persona.respondTo === "allowlist" + ? [...(persona.respondToAllowlist ?? [])] + : [], is_builtin: false, is_active: persona.isActive ?? true, source_team: persona.sourceTeam ?? null, env_vars: { ...(persona.envVars ?? {}) }, created_at: now, - updated_at: now, + updated_at: persona.updatedAt ?? now, }); } } @@ -2756,6 +2767,7 @@ const mockChannels: MockChannel[] = [ const mockMessages = new Map(); const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; +const mockPersonaCatalogEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; @@ -2786,6 +2798,16 @@ function resetMockSaveSubscriptions(config: E2eConfig | undefined) { })); } +function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) { + mockPersonaCatalogEvents.length = 0; + for (const event of config?.mock?.personaCatalogEvents ?? []) { + mockPersonaCatalogEvents.push({ + ...event, + tags: event.tags.map((tag) => [...tag]), + }); + } +} + // Mesh-compute mock state — TEST-ONLY. // // This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__` @@ -7333,6 +7355,9 @@ async function handleUpdatePersona(args: { applyMockPersonaBehavior(persona, args.input.behavior); persona.updated_at = new Date().toISOString(); + for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) { + callback(); + } return { ...persona }; } @@ -7401,9 +7426,7 @@ function ensureMockPersonaIsActive(personaId: string) { throw new Error(`agent ${personaId} not found`); } if (!persona.is_active) { - throw new Error( - `${persona.display_name} is not in My Agents. Choose it from Agent Catalog first.`, - ); + throw new Error(`${persona.display_name} is not in My Agents.`); } } @@ -8092,6 +8115,35 @@ async function resolveMockUploadDescriptors( ]; } +async function resolveMockUploadDescriptorForBytes( + args: { data: number[]; filename?: string | null }, + config: E2eConfig | undefined, +): Promise { + const configured = config?.mock?.uploadDescriptors; + if (configured !== undefined) { + const descriptors = await resolveMockUploadDescriptors(config); + const descriptor = descriptors[0]; + if (!descriptor) throw new Error("mock upload returned no descriptor"); + return descriptor; + } + + const bytes = Uint8Array.from(args.data); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const sha256 = Array.from(new Uint8Array(digest), (value) => + value.toString(16).padStart(2, "0"), + ).join(""); + const filename = args.filename ?? "upload.bin"; + const isAgentJson = filename.toLowerCase().endsWith(".agent.json"); + return { + url: `https://mock.relay/media/${sha256}${isAgentJson ? ".json" : ".bin"}`, + sha256, + size: bytes.length, + type: isAgentJson ? "application/json" : "application/octet-stream", + uploaded: Math.floor(Date.now() / 1000), + filename, + }; +} + async function handleSendChannelMessage( args: { channelId: string; @@ -8779,6 +8831,19 @@ function sendToMockSocket(args: { return; } + if (filter.kinds?.includes(KIND_PERSONA_CATALOG)) { + const authors = filter.authors?.map((author) => author.toLowerCase()); + const sourceIds = filter["#d"]; + for (const event of mockPersonaCatalogEvents) { + if (authors && !authors.includes(event.pubkey.toLowerCase())) continue; + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (sourceIds && (!sourceId || !sourceIds.includes(sourceId))) continue; + sendWsText(socket.handler, ["EVENT", subId, event]); + } + sendWsText(socket.handler, ["EOSE", subId]); + return; + } + // Project queries: NIP-34 kinds, or kind:1 comments scoped by repo `a` // tag (PR/issue discussions, approvals, review requests). if ( @@ -8875,6 +8940,31 @@ function sendToMockSocket(args: { return; } + if (event.kind === KIND_PERSONA_CATALOG) { + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!sourceId) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + "invalid: persona catalog event missing d tag.", + ]); + return; + } + const existingIndex = mockPersonaCatalogEvents.findIndex( + (candidate) => + candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && + candidate.tags.some((tag) => tag[0] === "d" && tag[1] === sourceId), + ); + if (existingIndex >= 0) { + mockPersonaCatalogEvents.splice(existingIndex, 1); + } + mockPersonaCatalogEvents.push(event); + emitMockGlobalEvent(event); + sendWsText(socket.handler, ["OK", event.id, true, ""]); + return; + } + if (event.kind === 20001) { const status = event.content; if (status === "online" || status === "away" || status === "offline") { @@ -8993,6 +9083,7 @@ export function maybeInstallE2eTauriMocks() { resetMockWorkflows(); resetMockMesh(); resetMockUserStatuses(); + resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); mockWebsocketSendMutexWedged = false; @@ -10147,8 +10238,8 @@ export function maybeInstallE2eTauriMocks() { // Specs assert invocation via __BUZZ_E2E_COMMANDS__. return true; case "encode_agent_snapshot_for_send": { - // Return a minimal PNG-shaped payload so the send flow can proceed - // through upload_media_bytes without a real Rust encode step. + // Return the requested wire format so both message sharing (PNG) and + // community catalog publication (JSON) exercise their real branches. // Optional encodeDelayMs lets specs observe the "preparing" phase before // the upload begins. const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0; @@ -10157,6 +10248,46 @@ export function maybeInstallE2eTauriMocks() { window.setTimeout(resolve, encodeDelayMs), ); } + const input = payload as { + id: string; + memoryLevel: "none" | "core" | "everything"; + format: "json" | "png"; + }; + if (input.format === "json") { + const persona = mockPersonas.find( + (candidate) => candidate.id === input.id, + ); + const snapshot = { + format: "buzz-agent-snapshot", + version: 1, + definition: { + name: persona?.display_name ?? "E2E Agent", + sourceIsBuiltIn: persona?.is_builtin ?? false, + systemPrompt: persona?.system_prompt ?? "", + runtime: persona?.runtime ?? null, + model: persona?.model ?? null, + provider: persona?.provider ?? null, + respondTo: persona?.respond_to ?? null, + respondToAllowlist: persona?.respond_to_allowlist ?? [], + namePool: persona?.name_pool ?? [], + }, + profile: { + displayName: persona?.display_name ?? "E2E Agent", + avatarUrl: persona?.avatar_url ?? null, + }, + memory: { + level: input.memoryLevel, + entries: [], + }, + }; + const fileBytes = Array.from( + new TextEncoder().encode(JSON.stringify(snapshot)), + ); + return { + fileBytes, + fileName: "e2e-agent.agent.json", + }; + } return { fileBytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], fileName: "e2e-agent.agent.png", @@ -10166,6 +10297,9 @@ export function maybeInstallE2eTauriMocks() { // Return a minimal preview — no writes performed. return { displayName: "Imported Agent", + isBuiltIn: true, + model: "claude-opus-4-5", + runtime: "goose", systemPrompt: null, avatarUrl: null, memoryLevel: "none", @@ -10677,7 +10811,10 @@ export function maybeInstallE2eTauriMocks() { case "pick_and_upload_image": return (await resolveMockUploadDescriptors(activeConfig))[0] ?? null; case "upload_media_bytes": - return (await resolveMockUploadDescriptors(activeConfig))[0]; + return resolveMockUploadDescriptorForBytes( + payload as { data: number[]; filename?: string | null }, + activeConfig, + ); case "fetch_media_bytes": { // The real command fetches relay media through Rust reqwest and // replies with raw bytes (`tauri::ipc::Response` → ArrayBuffer). In diff --git a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts index 8f4c7d1478..a92efcd40a 100644 --- a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts +++ b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts @@ -18,7 +18,7 @@ async function openCreateDialog(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill("Test Agent"); } diff --git a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts index a20c649df4..9ce80593f1 100644 --- a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts +++ b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts @@ -271,6 +271,13 @@ test("recipient_import_navigates_to_agents_and_opens_preview", async ({ // Decoded display name must appear. await expect(dialog).toContainText("Imported Agent"); + const metadata = dialog.getByTestId("agent-definition-metadata"); + await expect(metadata).toContainText("Type"); + await expect(metadata).toContainText("Built-in agent"); + await expect(metadata).toContainText("Preferred model"); + await expect(metadata).toContainText("claude-opus-4-5"); + await expect(metadata).toContainText("Preferred runtime"); + await expect(metadata).toContainText("goose"); }); // ── Confirm imports the agent ───────────────────────────────────────────────── diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 3c0c60d935..bb4b1cbf12 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1,8 +1,59 @@ import { expect, test } from "@playwright/test"; +import type { RelayEvent } from "@/shared/api/types"; + import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +const DEFAULT_MOCK_OWNER_PUBKEY = "deadbeef".repeat(8); + +function createCatalogEvent(input: { + ownerPubkey: string; + sourcePersonaId: string; + sourceUpdatedAt: string; + displayName: string; + systemPrompt: string; + createdAt?: number; +}): RelayEvent { + const snapshot = { + url: `https://relay.example/media/${"c".repeat(64)}`, + sha256: "c".repeat(64), + size: 512, + type: "application/json", + fileName: `${input.sourcePersonaId}.agent.json`, + } as const; + return { + id: "1".repeat(64), + pubkey: input.ownerPubkey, + created_at: input.createdAt ?? 1_721_750_400, + kind: 30178, + tags: [ + ["d", input.sourcePersonaId], + ["status", "published"], + ["source_updated_at", input.sourceUpdatedAt], + ["memory", "none"], + ], + content: JSON.stringify({ + format: "buzz-persona-catalog", + version: 1, + status: "published", + sourcePersonaId: input.sourcePersonaId, + sourceUpdatedAt: input.sourceUpdatedAt, + memoryLevel: "none", + agent: { + displayName: input.displayName, + avatarUrl: null, + systemPrompt: input.systemPrompt, + runtime: null, + model: null, + provider: null, + }, + snapshot, + }), + sig: "2".repeat(128), + }; +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); @@ -32,7 +83,9 @@ async function gotoApp(page: import("@playwright/test").Page) { async function openPersonaCatalog(page: import("@playwright/test").Page) { await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Choose from catalog" }).click(); + await page + .getByRole("menuitem", { exact: true, name: "Discover agents" }) + .click(); } async function getCatalogOrder(page: import("@playwright/test").Page) { @@ -50,12 +103,19 @@ async function selectCatalogPersona( await page.getByTestId(`persona-catalog-list-item-${personaId}`).click(); } -async function useCatalogPersona( +async function sharePersonaToCatalog( page: import("@playwright/test").Page, - personaId: string, + displayName: string, ) { + await page.getByLabel(`Open actions for ${displayName}`).click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await page.getByTestId("persona-share-catalog-access").click(); await page - .getByTestId(`persona-catalog-use-agent-target-${personaId}`) + .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .click(); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) .click(); } @@ -154,78 +214,70 @@ async function invokeTauriExpectError( ); } -test("built-in personas are used from the catalog dialog", async ({ page }) => { +test("catalog hides built-ins and shows the shared-agent empty state", async ({ + page, +}) => { await page.setViewportSize({ width: 1280, height: 420 }); + await installMockBridge(page, { + activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("agents-library-personas")).toBeVisible(); - await openPersonaCatalog(page); - await expect(page.getByTestId("persona-catalog-dialog")).toContainText( - "Fizz", - ); for (const personaName of ["Fizz", "Honey", "Bumble"]) { - await expect(page.getByTestId("persona-catalog-dialog")).toContainText( + await expect(page.getByTestId("agents-library-personas")).toContainText( personaName, ); } - for (const retiredPersonaName of [ - "Product Strategist", - "Implementation Partner", - "QA Reviewer", - "Work Coordinator", - "Support Guide", - "Experiment Designer", - ]) { + + await openPersonaCatalog(page); + for (const personaName of ["Fizz", "Honey", "Bumble"]) { await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - retiredPersonaName, + personaName, ); } await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible(); - await expect( - page.getByTestId("persona-catalog-dialog-scroll-area"), - ).toBeVisible(); - await expect( - page.getByTestId("persona-catalog-dialog-scroll-area"), - ).toHaveCSS("overflow-y", "auto"); - const catalogScrollAreaMetrics = await page - .getByTestId("persona-catalog-dialog-scroll-area") - .evaluate((element) => ({ - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - })); - expect(catalogScrollAreaMetrics.clientHeight).toBeGreaterThan(0); - expect(catalogScrollAreaMetrics.scrollHeight).toBeGreaterThanOrEqual( - catalogScrollAreaMetrics.clientHeight, - ); await expect(page.getByTestId("persona-catalog-dialog-body")).toBeVisible(); - await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - "Done", - ); - await expect(page.getByRole("tooltip")).toHaveCount(0); - const initialCatalogOrder = await getCatalogOrder(page); - - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); + const emptyState = page.getByTestId("persona-catalog-empty-state"); + await expect(emptyState).toContainText("No agents are being shared"); await expect( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Selected Fizz for My Agents." }), + emptyState.getByTestId("persona-catalog-empty-agent-artwork"), ).toBeVisible(); - - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", - ); await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toHaveText("Added to My Agents"); + page.locator('[data-testid^="persona-catalog-list-item-"]'), + ).toHaveCount(0); await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toBeDisabled(); - await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - "Delete", + page.getByTestId("persona-catalog-use-agent-target"), + ).toHaveCount(0); + + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await page.getByLabel("Open actions for Fizz").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(page.getByTestId("persona-share-catalog")).toHaveCount(0); + await expect(page.getByTestId("persona-share-catalog-access")).toHaveCount(0); +}); + +test("catalog empty state remains available after reopening", async ({ + page, +}) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible(); + + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await expect(page.getByTestId("persona-catalog-dialog")).not.toBeVisible(); + await openPersonaCatalog(page); + await expect(page.getByTestId("persona-catalog-empty-state")).toContainText( + "No agents are being shared", ); - await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder); }); test("built-in persona edits persist", async ({ page }) => { @@ -269,7 +321,9 @@ test("agent avatar emoji picker scrolls inside its popover", async ({ await gotoApp(page); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page + .getByRole("menuitem", { exact: true, name: "Create agent" }) + .click(); await expect(page.getByTestId("persona-dialog")).toBeVisible(); await page.getByLabel("Add avatar").click(); @@ -306,70 +360,313 @@ test("agent avatar emoji picker scrolls inside its popover", async ({ .toBeGreaterThan(before); }); -test("agent catalog can reopen from the populated library header", async ({ +test("the new agent card offers create, discover, and import", async ({ page, }) => { + await installMockBridge(page, { + activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"], + personas: [ + { + id: "custom:code-reviewer", + displayName: "Code Reviewer", + systemPrompt: "Review code changes.", + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", - ); + const newAgentCard = page.getByTestId("new-agent-card"); + await expect(newAgentCard).toHaveText(""); + await expect(newAgentCard.locator(".lucide-plus")).toBeVisible(); - await page.keyboard.press("Escape"); - await openPersonaCatalog(page); + const personaCards = page.locator('[data-testid^="persona-agent-row-"]'); + await expect(personaCards.first()).toBeVisible(); + const headerBox = await page + .getByRole("heading", { level: 1, name: "Agents" }) + .locator("../..") + .boundingBox(); + const cardBoxes = await personaCards.evaluateAll((cards) => + cards.map((card) => { + const box = card.getBoundingClientRect(); + return { right: box.right, top: box.top }; + }), + ); + const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top)); + const rightmostFirstRowCard = Math.max( + ...cardBoxes + .filter(({ top }) => Math.abs(top - firstRowTop) < 1) + .map(({ right }) => right), + ); + expect(headerBox).not.toBeNull(); + expect( + Math.abs( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard, + ), + ).toBeLessThan(1); + await newAgentCard.click(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Create agent" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Discover agents" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Import" }), + ).toBeVisible(); + await page + .getByRole("menuitem", { exact: true, name: "Discover agents" }) + .click(); await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible(); - await selectCatalogPersona(page, "builtin:fizz"); + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await newAgentCard.click(); + await page + .getByRole("menuitem", { exact: true, name: "Create agent" }) + .click(); + + const dialog = page.getByTestId("persona-dialog"); + await expect(dialog).toBeVisible(); await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toBeDisabled(); + dialog.getByTestId("import-agent-snapshot-dialog-action"), + ).toHaveCount(0); + await expect(dialog).not.toContainText("Enter a name for this agent."); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await newAgentCard.click(); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("menuitem", { exact: true, name: "Import" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + buffer: Buffer.from("{}"), + mimeType: "application/json", + name: "imported.agent.json", + }); + await expect(page.getByTestId("agent-snapshot-import-dialog")).toBeVisible(); }); -test("agent catalog chooser order stays stable when selection changes", async ({ +test("the new team card offers create and import", async ({ page }) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const newTeamCard = page.getByTestId("new-team-card"); + await expect(newTeamCard).toHaveText(""); + await expect(newTeamCard.locator(".lucide-plus")).toBeVisible(); + + await newTeamCard.click(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Create team" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Import" }), + ).toBeVisible(); +}); + +test("team cards use the thread-style overlapping avatar stack", async ({ page, }) => { + const personaIds = ["custom:design", "custom:build", "custom:ship"]; + await installMockBridge(page, { + personas: [ + { + avatarUrl: "/onboarding/starter-team/fizz.png", + id: personaIds[0], + displayName: "Design", + systemPrompt: "You design interfaces.", + }, + { + id: personaIds[1], + displayName: "Build", + systemPrompt: "You build interfaces.", + }, + { + id: personaIds[2], + displayName: "Ship", + systemPrompt: "You ship interfaces.", + }, + ], + teams: [ + { + name: "Product crew", + personaIds, + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - const before = await getCatalogOrder(page); + const stack = page.getByLabel("Product crew member avatars"); + const avatars = stack.locator('[data-team-member-avatar="avatar"]'); + await expect(avatars).toHaveCount(3); + await expect(avatars.nth(1)).toHaveClass(/-ml-5/); + await expect(avatars.nth(2)).toHaveClass(/-ml-5/); + + const boxes = await avatars.evaluateAll((elements) => + elements.map((element) => { + const box = element.getBoundingClientRect(); + return { left: box.left, right: box.right }; + }), + ); + expect(boxes[1]?.left).toBeLessThan(boxes[0]?.right ?? 0); + expect(boxes[2]?.left).toBeLessThan(boxes[1]?.right ?? 0); + await expect(avatars.first()).not.toHaveCSS("mask-image", "none"); + await expect(avatars.last()).toHaveCSS("mask-image", "none"); + const avatarSurfaceStyles = await avatars + .locator(":scope > *") + .evaluateAll((elements) => + elements.map((element) => { + const styles = getComputedStyle(element); + const hasVisibleShadow = [ + ...styles.boxShadow.matchAll(/rgba?\(([^)]+)\)/g), + ].some((match) => { + if (match[0].startsWith("rgb(")) return true; + const channels = match[1]?.split(/[\s,/]+/).filter(Boolean) ?? []; + return Number(channels.at(-1)) > 0; + }); + return { + borderWidth: styles.borderTopWidth, + hasVisibleShadow, + }; + }), + ); + expect(avatarSurfaceStyles).toEqual([ + { borderWidth: "0px", hasVisibleShadow: false }, + { borderWidth: "0px", hasVisibleShadow: false }, + { borderWidth: "0px", hasVisibleShadow: false }, + ]); +}); - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); +test("agent defaults stays in the header without an actions menu", async ({ + page, +}) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + auth_status: { status: "logged_in" }, + availability: "available", + avatar_url: "", + binary_path: "/usr/local/bin/codex", + can_auto_install: false, + command: "codex", + default_args: [], + id: "codex", + install_hint: "", + install_instructions_url: "https://example.com", + label: "Codex", + login_hint: null, + mcp_command: null, + node_required: false, + underlying_cli_path: null, + }, + ], + globalAgentConfig: { + env_vars: {}, + model: "gpt-5.5[high]", + preferred_runtime: "codex", + provider: null, + }, + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await expect(page.getByTestId("agent-header-actions-button")).toHaveCount(0); await expect( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Selected Fizz for My Agents." }), - ).toBeVisible(); + page.getByRole("menuitem", { name: "Import agent" }), + ).toHaveCount(0); + + const defaultsButton = page.getByTestId("agent-defaults-button"); + await expect(defaultsButton).toHaveText("Agent defaults"); + await defaultsButton.click(); + const defaultsDialog = page.getByTestId("agent-ai-defaults-dialog"); + await expect(defaultsDialog).toBeVisible(); + await expect( + defaultsDialog.getByTestId("global-agent-default-harness"), + ).toHaveAttribute("data-value", "codex"); + await expect( + defaultsDialog.getByTestId("global-agent-default-harness"), + ).toContainText("Codex"); + await expect( + defaultsDialog.getByTestId("global-agent-model"), + ).toHaveAttribute("data-value", "gpt-5.5[high]"); + await expect(defaultsDialog.getByTestId("global-agent-model")).toContainText( + "gpt-5.5[high]", + ); + await page.keyboard.press("Escape"); + await expect(defaultsDialog).toHaveCount(0); +}); + +test("unconfigured agent defaults use the setup label", async ({ page }) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await expect(page.getByTestId("agent-defaults-button")).toHaveText( + "Set agent defaults", + ); +}); + +test("agent catalog chooser order stays stable when selection changes", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:builder", + displayName: "Builder", + systemPrompt: "Build the requested change.", + }, + { + id: "custom:reviewer", + displayName: "Reviewer", + systemPrompt: "Review the requested change.", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await sharePersonaToCatalog(page, "Builder"); + await sharePersonaToCatalog(page, "Reviewer"); + await openPersonaCatalog(page); + const before = await getCatalogOrder(page); + await selectCatalogPersona(page, "custom:reviewer"); expect(await getCatalogOrder(page)).toEqual(before); }); test("catalog detail pane shows the full persona details", async ({ page }) => { + const personaId = "custom:researcher"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Researcher", + 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, "builtin:fizz"); + await selectCatalogPersona(page, personaId); const useAgentTarget = page.getByTestId( - "persona-catalog-use-agent-target-builtin:fizz", + `persona-catalog-use-agent-target-${personaId}`, ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Fizz", + "Researcher", + ); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Added by You", ); - await expect( - page.getByTestId("persona-catalog-detail-pane"), - ).not.toContainText("Added by You"); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "You are Fizz.", + "Research the question and cite the evidence.", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Built-in agent", + "Custom agent", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( "Preferred model", @@ -382,14 +679,10 @@ test("catalog detail pane shows the full persona details", async ({ page }) => { ); await expect(useAgentTarget).toHaveAttribute( "aria-label", - "Add Fizz from Agent Catalog", - ); - await expect(useAgentTarget).toHaveText("Add agent"); - - await useAgentTarget.click(); - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", + "Researcher is already in My Agents", ); + await expect(useAgentTarget).toHaveText("Added to My Agents"); + await expect(useAgentTarget).toBeDisabled(); }); type AgentShareCommand = { command: string; payload: unknown }; @@ -576,6 +869,7 @@ test("custom personas share with people and keep export separate", async ({ const linkIcon = page.getByTestId("persona-share-link-icon"); const linkCopy = page.getByTestId("persona-share-link-copy"); const linkDivider = page.getByTestId("persona-share-link-divider"); + const catalogSection = page.getByTestId("persona-share-catalog"); const staticLinkAccess = page.getByTestId("persona-share-link-access"); await waitForAnimations(page); const [ @@ -584,6 +878,7 @@ test("custom personas share with people and keep export separate", async ({ linkIconBox, linkCopyBox, linkDividerBox, + catalogSectionBox, staticLinkAccessBox, ] = await Promise.all([ linkRow.boundingBox(), @@ -591,6 +886,7 @@ test("custom personas share with people and keep export separate", async ({ linkIcon.boundingBox(), linkCopy.boundingBox(), linkDivider.boundingBox(), + catalogSection.boundingBox(), staticLinkAccess.boundingBox(), ]); const sendDescriptionBox = await sendDescription.boundingBox(); @@ -598,7 +894,10 @@ test("custom personas share with people and keep export separate", async ({ (sendDescriptionBox?.height ?? 0) + 30, ); expect(initialCopyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 23, + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0) + 23, + ); + expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( + (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 7, ); expect( Math.abs( @@ -608,7 +907,7 @@ test("custom personas share with people and keep export separate", async ({ ), ).toBeLessThanOrEqual(1); expect(linkDividerBox?.y ?? 0).toBeGreaterThan( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), ); expect(linkDividerBox?.y ?? 0).toBeLessThan(initialCopyLinkButtonBox?.y ?? 0); expect( @@ -623,20 +922,6 @@ test("custom personas share with people and keep export separate", async ({ (staticLinkAccessBox?.height ?? 0) / 2), ), ).toBeLessThanOrEqual(1); - const shareMainCardForLinkSpacing = page.getByTestId( - "persona-share-main-card", - ); - const shareMainCardForLinkSpacingBox = - await shareMainCardForLinkSpacing.boundingBox(); - const gapAboveCopyLink = - (initialCopyLinkButtonBox?.y ?? 0) - - ((linkDividerBox?.y ?? 0) + (linkDividerBox?.height ?? 0)); - const gapBelowCopyLink = - (shareMainCardForLinkSpacingBox?.y ?? 0) + - (shareMainCardForLinkSpacingBox?.height ?? 0) - - ((initialCopyLinkButtonBox?.y ?? 0) + - (initialCopyLinkButtonBox?.height ?? 0)); - expect(Math.abs(gapAboveCopyLink - gapBelowCopyLink)).toBeLessThanOrEqual(1); await expect(copyLinkButton).toHaveClass( /border.*bg-background.*border-border/, ); @@ -667,7 +952,6 @@ test("custom personas share with people and keep export separate", async ({ ).toHaveCount(0); await expect(shareDialog.getByText("Memories")).toHaveCount(0); await expect(shareDialog.getByText("File format")).toHaveCount(0); - await expect(page.getByText("Show in my catalog")).toHaveCount(0); const shareMainCard = page.getByTestId("persona-share-main-card"); const exportAgentRow = page.getByTestId("persona-share-export"); await expect(exportAgentRow).toHaveText("Export agent"); @@ -700,6 +984,9 @@ test("custom personas share with people and keep export separate", async ({ expect(exportAgentRowShadow).toBe(shareMainCardShadow); expect(exportAgentRowShadow).not.toBe("none"); await expect(exportAgentRow).toHaveCSS("position", "relative"); + expect(exportAgentRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0) + 12, + ); await expect(page.getByTestId("agent-snapshot-export-dialog")).toHaveCount(0); await exportAgentRow.click(); @@ -1034,6 +1321,293 @@ test("custom personas share with people and keep export separate", async ({ await expect(shareDialog).toHaveCount(0); }); +test("custom personas can be shared to the relay catalog", async ({ page }) => { + const personaId = "custom:catalog-analyst"; + await installMockBridge(page, { + globalAgentConfig: { + env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" }, + provider: "anthropic", + model: "claude-opus-4-5", + }, + personas: [ + { + id: personaId, + displayName: "Catalog Analyst", + respondTo: "allowlist", + respondToAllowlist: [TEST_IDENTITIES.alice.pubkey], + systemPrompt: `## Design System And Styling + +- For design-system changes, check the local guidance in \`DESIGN.md\`, \`docs/color-token-mapping.md\`, \`src/shared/ui/AGENTS.md\`, and \`src/features/design-system/AGENTS.md\` before judging the implementation. +- Check every changed visual surface in both light and dark mode. Missing dark-mode support is a review issue, not visual polish. +- Review the selected changes and explain whether \`git diff --cached --name-only --some-extremely-long-inline-option-that-must-wrap\` stays inside the catalog detail column. + +\`\`\`text +This deliberately long fenced-code example must not establish the minimum width of the full custom-agent instruction document or force earlier prose outside the catalog detail pane. +\`\`\` + +| Before | After | Why | +| --- | --- | --- | +| \`transition: all 300ms\` | \`transition: transform 200ms ease-out\` | Specify exact properties so a wide instruction table stays independently scrollable without expanding the full catalog detail pane. | +| \`transform: scale(0)\` | \`transform: scale(0.95); opacity: 0\` | Preserve physicality while keeping the shared agent instructions inside their container. |`, + }, + ], + }); + await gotoApp(page); + await page.evaluate(() => { + document.documentElement.style.fontSize = "24px"; + }); + + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + const catalogAccess = page.getByTestId("persona-share-catalog-access"); + const shareDialog = page.getByTestId("persona-share-dialog"); + const shareMainCard = shareDialog.getByTestId("persona-share-main-card"); + const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); + const catalogSection = shareDialog.getByTestId("persona-share-catalog"); + const publishCatalogUpdatesButton = page.getByTestId( + "persona-share-publish-catalog-updates", + ); + await expect( + shareMainCard.getByTestId("persona-share-catalog"), + ).toBeVisible(); + await expect(catalogSection).toContainText("Share to catalog"); + await expect(catalogSection).toContainText( + "Anyone in this community can find and use a copy.", + ); + await expect(catalogSection).toContainText( + "Catalog data is plaintext; secrets and response allowlists are never included.", + ); + const [copyLinkButtonBox, catalogSectionBox, shareMainCardBox] = + await Promise.all([ + copyLinkButton.boundingBox(), + catalogSection.boundingBox(), + shareMainCard.boundingBox(), + ]); + expect(copyLinkButtonBox?.y ?? 0).toBeGreaterThan( + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), + ); + expect( + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), + ).toBeLessThanOrEqual( + (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), + ); + await expect(catalogAccess).toHaveText("Not shared"); + await expect(publishCatalogUpdatesButton).toHaveCount(0); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Agent only", + ]); + await page + .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .click(); + await expect(catalogAccess).toHaveText("Agent only"); + await expect(publishCatalogUpdatesButton).toHaveCount(0); + const uploadCommand = (await readAgentShareCommands(page)).find( + (entry) => entry.command === "upload_media_bytes", + ); + const uploadedSnapshot = JSON.parse( + new TextDecoder().decode( + Uint8Array.from( + (uploadCommand?.payload as { data?: number[] } | undefined)?.data ?? [], + ), + ), + ); + expect(uploadedSnapshot.definition.respondTo).toBe("owner-only"); + expect(uploadedSnapshot.definition).not.toHaveProperty("respondToAllowlist"); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toContainText("Catalog Analyst"); + await selectCatalogPersona(page, personaId); + const catalogDialog = page.getByTestId("persona-catalog-dialog"); + const catalogDetailPane = page.getByTestId("persona-catalog-detail-pane"); + await expect(catalogDetailPane).toContainText("Design System And Styling"); + await expect(catalogDialog).toBeVisible(); + await expect(catalogDetailPane).toBeVisible(); + await waitForAnimations(page); + const [catalogDialogRight, catalogDetailPaneRight] = await Promise.all([ + catalogDialog.evaluate((element) => element.getBoundingClientRect().right), + catalogDetailPane.evaluate( + (element) => element.getBoundingClientRect().right, + ), + ]); + expect(catalogDetailPaneRight).toBeLessThanOrEqual(catalogDialogRight); + expect( + await catalogDetailPane.evaluate( + (element) => element.scrollWidth - element.clientWidth, + ), + ).toBeLessThanOrEqual(1); + const catalogInstruction = catalogDetailPane.locator(".message-markdown"); + expect( + await catalogInstruction.evaluate( + (element) => element.scrollWidth - element.clientWidth, + ), + ).toBeLessThanOrEqual(1); + await page.keyboard.press("Escape"); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + const editDialog = page.getByTestId("persona-dialog"); + const catalogPublishNotice = editDialog.getByTestId( + "persona-dialog-catalog-publish-notice", + ); + await expect(catalogPublishNotice).toHaveCount(0); + await expect( + editDialog.getByRole("button", { name: "Save and publish" }), + ).toHaveCount(0); + await expect( + editDialog.getByRole("button", { name: "Save changes" }), + ).toBeVisible(); + await editDialog + .getByLabel("Agent instructions") + .fill("Review the latest catalog changes."); + await expect(catalogPublishNotice).toHaveText( + "This agent is in the community catalog. Your changes will be published when you save.", + ); + await expect( + editDialog.getByRole("button", { name: "Save changes" }), + ).toHaveCount(0); + await editDialog.getByRole("button", { name: "Save and publish" }).click(); + await expect(editDialog).toHaveCount(0); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(catalogAccess).toHaveText("Agent only"); + await expect(publishCatalogUpdatesButton).toHaveCount(0); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(catalogAccess).toHaveText("Agent only"); + await expect(publishCatalogUpdatesButton).toHaveCount(0); + await catalogAccess.click(); + await page + .getByRole("menuitemradio", { name: "Not shared", exact: true }) + .click(); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toHaveCount(0); +}); + +test("catalog owners can publish local updates to an existing relay entry", async ({ + page, +}) => { + const personaId = "custom:catalog-updates"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Catalog Updates", + systemPrompt: "The locally updated instructions.", + updatedAt: "2026-07-23T17:00:00.000Z", + }, + ], + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: DEFAULT_MOCK_OWNER_PUBKEY, + sourcePersonaId: personaId, + sourceUpdatedAt: "2026-07-23T16:00:00.000Z", + displayName: "Catalog Updates", + systemPrompt: "The previously published instructions.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await page.getByLabel("Open actions for Catalog Updates").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + const catalogAccess = page.getByTestId("persona-share-catalog-access"); + const publishButton = page.getByTestId( + "persona-share-publish-catalog-updates", + ); + await expect(catalogAccess).toHaveText("Agent only"); + await expect(publishButton).toBeVisible(); + const [catalogAccessBox, publishButtonBox] = await Promise.all([ + catalogAccess.boundingBox(), + publishButton.boundingBox(), + ]); + expect( + (publishButtonBox?.x ?? 0) + (publishButtonBox?.width ?? 0), + ).toBeLessThan(catalogAccessBox?.x ?? 0); + + await publishButton.click(); + await expect(publishButton).toHaveCount(0); + await expect(catalogAccess).toHaveText("Agent only"); +}); + +test("a community member can discover and add another member's catalog agent", async ({ + page, +}) => { + const personaId = "shared-reviewer"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + sourceUpdatedAt: "2026-07-23T16:00:00.000Z", + displayName: "Alice’s Reviewer", + systemPrompt: "Review changes for the whole community.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + const remoteEntry = page.getByTestId( + `persona-catalog-list-item-${remoteCatalogId}`, + ); + await expect(remoteEntry).toContainText("Alice’s Reviewer"); + await remoteEntry.click(); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Added by Community member", + ); + + await page + .getByRole("button", { + name: "Add Alice’s Reviewer from Agent Catalog", + }) + .click(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMANDS__?: string[]; + } + ).__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "confirm_agent_snapshot_import", + ).length ?? 0, + ), + ) + .toBe(1); +}); + test("share access controls include the selected memories", async ({ page, }) => { @@ -1085,6 +1659,7 @@ test("share access controls include the selected memories", async ({ (element) => element.getBoundingClientRect().height, ); const linkAccess = shareDialog.getByLabel("What to include in the link"); + const catalogAccess = shareDialog.getByLabel("What to share in the catalog"); const recipientField = page.getByTestId("persona-share-recipient-field"); const emptyRecipientFieldBox = await recipientField.boundingBox(); await expect(shareDialog.getByTestId("persona-share-send")).toHaveCount(0); @@ -1096,6 +1671,15 @@ test("share access controls include the selected memories", async ({ await expect(linkAccess).toHaveCSS("text-decoration-line", "none"); await expect(linkAccess).toHaveCSS("padding-left", "8px"); await expect(linkAccess).toHaveCSS("padding-right", "8px"); + await expect(catalogAccess).toHaveText("Not shared"); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Agent only", + "Agent + core memory", + "Agent + all memories", + ]); + await page.keyboard.press("Escape"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); const [linkAccessBox, copyLinkButtonBox] = await Promise.all([ linkAccess.boundingBox(), @@ -1571,19 +2155,16 @@ test("inactive built-ins cannot be used to create teams", async ({ page }) => { }, }); - expect(error).toBe( - "Honey is not in My Agents. Choose it from Agent Catalog first.", - ); + expect(error).toBe("Honey is not in My Agents."); }); test("built-in removal failures show up from My Agents", async ({ page }) => { + await installMockBridge(page, { + activePersonaIds: ["builtin:honey"], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:honey"); - await useCatalogPersona(page, "builtin:honey"); - await invokeTauri(page, "create_team", { input: { name: "Honeys", @@ -1591,7 +2172,6 @@ test("built-in removal failures show up from My Agents", async ({ page }) => { }, }); - await page.keyboard.press("Escape"); await page.getByLabel("Open actions for Honey").click(); await page.getByRole("menuitem", { name: "Delete" }).click(); diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index d9b3214e64..379aa17c65 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -32,7 +32,7 @@ async function openCreateDialog(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill("Test Agent"); } @@ -712,7 +712,7 @@ test.describe("global agent config screenshots", () => { await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({ timeout: 10_000, diff --git a/desktop/tests/e2e/persona-env-vars.spec.ts b/desktop/tests/e2e/persona-env-vars.spec.ts index 53efa09a0e..1e9b077a82 100644 --- a/desktop/tests/e2e/persona-env-vars.spec.ts +++ b/desktop/tests/e2e/persona-env-vars.spec.ts @@ -267,10 +267,10 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({ }) => { await gotoApp(page); - // Open the Agents view, click New > New agent to open the persona dialog. + // Open the Agents view, then choose Create agent from the new-agent menu. await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); // Scope all env-vars queries to the dialog: AgentDefaultsSettingsCard // also renders an EnvVarsEditor in the background settings pane (introduced @@ -315,7 +315,7 @@ test("persona model options follow the selected LLM provider", async ({ await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); const provider = page.locator("#persona-runtime"); await page.getByRole("tab", { name: "Customize for this agent" }).click(); diff --git a/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts index 38d1df0914..508b123d7f 100644 --- a/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts +++ b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts @@ -36,7 +36,7 @@ async function openNewPersonaDialog(page: import("@playwright/test").Page) { }); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); const dialog = page.getByTestId("persona-dialog"); await expect(dialog).toBeVisible({ timeout: 8_000 }); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index d5ae2046db..4a930e4651 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -141,7 +141,7 @@ test("Buzz shared compute explains automatic model selection", async ({ }); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await chooseSharedComputeProvider(page); await expect @@ -170,7 +170,7 @@ test("create agent persists Buzz shared compute with auto model", async ({ await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill(agentName); await chooseSharedComputeProvider(page); @@ -214,7 +214,7 @@ test("create agent supports parallelism and system prompt overrides", async ({ await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill(agentName); await page diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 41fdeae6fe..3203010b23 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import type { ChannelTemplate } from "../../src/shared/api/types"; +import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; export const TEST_IDENTITIES = { @@ -88,6 +88,7 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; sourceTeam?: string | null; envVars?: Record; @@ -103,6 +104,8 @@ type MockPersonaSeed = { /** Provider pinned on the persona. Leave empty for Codex/Claude runtimes. */ provider?: string | null; namePool?: string[]; + respondTo?: "owner-only" | "allowlist" | "anyone"; + respondToAllowlist?: string[]; }; type MockTeamSeed = { @@ -211,6 +214,8 @@ type MockBridgeOptions = { | "stopped"; }>; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number;