diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 5b781f63f6f..f8ee32dfab3 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -98,7 +98,6 @@ const overrides = new Map([ // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for // retry-safety. Load-bearing reviewer-required change; queued to split. // Consolidation removed the legacy persona-card import/export codecs. - ["src-tauri/src/commands/personas/mod.rs", 984], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so // the two p-gate filters can't drift) plus two guard unit tests. The file was @@ -257,7 +256,11 @@ const overrides = new Map([ // (#2680) to indicate runtimes that need a separate CLI install. // +6: ManagedAgent.runtime record-level pin + JSDoc so the harness delete // confirmation can count referencing agents (review fix for #2773). - ["src/shared/api/types.ts", 1058], + // +21: CatalogSourceCoordinate + the `catalogSource` fields on AgentPersona + // and CreatePersonaInput. The coordinate is the only identifier a catalog + // copy keeps, so it is what stops the catalog re-offering "Add" for an + // already-added foreign entry. Queued to split. + ["src/shared/api/types.ts", 1079], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); @@ -398,7 +401,9 @@ const overrides = new Map([ // the full (major, minor, patch) triple instead of a bare major, plus // below-the-floor and uncomparable-version (partial / prerelease) classification // regressions for the fail-closed parse. - ["src-tauri/src/managed_agents/discovery/tests.rs", 1922], + // +2 (1922 -> 1924): the AgentDefinition and ManagedAgentRecord fixtures each + // set the new mandatory `catalog_source` field. + ["src-tauri/src/managed_agents/discovery/tests.rs", 1924], // identity-import-keyring: the identity resolution state machine's behavioral // matrix (46 tests over FakeIdentityStore — probe × marker × file cells, // adoption / read-back-corruption / marker-failure arms, recovery-mode @@ -593,7 +598,9 @@ const overrides = new Map([ // computing had_* so stale materialized snapshot bytes can never be tagged // BuzzExplicit and shadow the definition/global fallthrough; the dead // persona-model re-tag branch replaced; two new regression tests added. - ["src-tauri/src/commands/agent_config.rs", 1110], + // +2 (1110 -> 1112): the agent_record and persona_with_model test fixtures + // each set the new mandatory `catalog_source` field. + ["src-tauri/src/commands/agent_config.rs", 1112], // codex-install-auto-restart review-fixes: should_restart_after_install // takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy // cache tests replaced with 6 pure availability_drift predicate tests; diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 461fed5dbd4..5a26f0f6450 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -21,8 +21,7 @@ use crate::{ /// Subset of the goose file config exposed to the frontend for gate evaluation. /// -/// Only the fields the dialog gate needs — not the full `RuntimeConfigSurface`. -/// The gate uses this to know which requirements are already satisfied in the +/// Only the fields the dialog gate needs. This tracks which requirements are already satisfied in the /// harness config file, so it can show "Set in goose config" rather than /// surfacing a false missing-key marker. #[derive(Debug, Serialize)] @@ -685,8 +684,10 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -709,8 +710,10 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index d98460109f5..b65f2409005 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -394,8 +394,10 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 16272ac28b3..0758fc3aac5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,8 +6,8 @@ use crate::{ managed_agents::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - load_teams, managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, - provider_deploy, resolve_provider_binary, save_managed_agents, start_managed_agent_process, + load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy, + resolve_provider_binary, save_managed_agents, start_managed_agent_process, stop_managed_agent_process, stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, @@ -18,8 +18,7 @@ use crate::{ util::now_iso, }; -/// Read the workspace owner's pubkey hex from app state without holding the -/// lock for longer than necessary. Used to populate `BUZZ_ACP_AGENT_OWNER` +/// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. pub(super) fn workspace_owner_hex(state: &AppState) -> Result { let keys = state.keys.lock().map_err(|e| e.to_string())?; @@ -48,12 +47,12 @@ pub(super) fn retain_managed_agent_pending( use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db}; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let keys = state.signing_keys()?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; // Shared engine with the boot-time reconcile: projection content diff // (no republish for runtime-only churn) + monotonic created_at bump // past the retained head (NIP-AP step 3). - retain_agent_record(&conn, &keys, record).map(|_| ()) + retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ()) })(); if let Err(e) = result { eprintln!("buzz-desktop: agent-retain: {e}"); @@ -89,15 +88,12 @@ pub(super) fn tombstone_managed_agent_pending( const KIND_DELETE: u32 = 5; let result = (|| -> Result<(), String> { - let (owner_pubkey, event) = { - let keys = state.signing_keys()?; - let owner_pubkey = keys.public_key().to_hex(); - let event = build_agent_delete(agent_pubkey, &owner_pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; - (owner_pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_delete(agent_pubkey, &owner_pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; retain_event( &conn, @@ -183,13 +179,10 @@ pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, a use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let (owner_pubkey, event) = { - let keys = state.signing_keys()?; - let owner_pubkey = keys.public_key().to_hex(); - let event = build_agent_archive_request(&keys, agent_pubkey)?; - (owner_pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey)?; + let conn = open_retention_db(&scope.db_path)?; retain_event( &conn, &RetainedEvent { @@ -904,8 +897,10 @@ pub async fn create_managed_agent( name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index e32fc1cfe4b..03389d1d18b 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -53,8 +53,10 @@ fn bare_agent_record( name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, @@ -75,8 +77,10 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 016865878e0..d3b1a9499dc 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 f2593ff9e0f..734d8f5dc8a 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/create.rs b/desktop/src-tauri/src/commands/personas/create.rs new file mode 100644 index 00000000000..c00de1c6da1 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -0,0 +1,85 @@ +//! The persona creation command surface, split from `mod.rs` (file-size cap) +//! as the sibling of [`super::update`]. + +use tauri::AppHandle; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + managed_agents::{ + apply_persona_behavior, load_personas, save_personas, try_regenerate_nest, AgentDefinition, + CatalogSource, CreatePersonaRequest, + }, + util::now_iso, +}; + +use super::{pending, retain_persona_pending, trim_optional, trim_required}; + +#[tauri::command] +pub async fn create_persona( + input: CreatePersonaRequest, + app: AppHandle, +) -> Result { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let display_name = trim_required(&input.display_name, "Display name")?; + // System prompt optional: core memory is auto-injected. Empty is valid. + let system_prompt = input.system_prompt.trim().to_string(); + let avatar_url = trim_optional(input.avatar_url); + let runtime = trim_optional(input.runtime); + let model = trim_optional(input.model); + let provider = trim_optional(input.provider); + // Normalized before the store is touched: a coordinate that can't match + // a publication is worse than no coordinate, because it silently + // re-enables the duplicate add it exists to prevent. + let catalog_source = input + .catalog_source + .map(CatalogSource::normalized) + .transpose()?; + let now = now_iso(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut personas = load_personas(&app)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); + let name_pool: Vec = input + .name_pool + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + crate::managed_agents::validate_user_env_keys(&input.env_vars)?; + let mut persona = AgentDefinition { + id: Uuid::new_v4().to_string(), + display_name, + avatar_url, + system_prompt, + runtime, + model, + provider, + name_pool, + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source, + env_vars: input.env_vars, + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: now.clone(), + updated_at: now, + }; + apply_persona_behavior(&mut persona, input.behavior)?; + personas.push(persona.clone()); + save_personas(&app, &personas)?; + retain_persona_pending(&app, &state, &persona); + try_regenerate_nest(&app); + Ok(persona) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 316af5f72d0..8ff7cfbd9bd 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -61,8 +61,10 @@ fn make_agent( name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs new file mode 100644 index 00000000000..d7ffecef2d6 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -0,0 +1,450 @@ +//! Inbound relay → local store reconciliation for persona/team/managed-agent +//! projections and their NIP-09 tombstones. Extracted from the parent module to +//! keep it under the file-size cap. + +use tauri::{AppHandle, Emitter, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + agent_events::ManagedAgentEventContent, load_personas, persona_events::persona_d_tag, + save_personas, team_events::TeamEventContent, try_regenerate_nest, AgentDefinition, + ManagedAgentRecord, TeamRecord, + }, + util::now_iso, +}; + +#[cfg(test)] +mod inbound_tests; + +/// Apply an inbound kind:30175 persona event from the relay onto the local +/// store. The frontend's live subscription invokes this per event for our own +/// authored coordinate so Device B inherits Device A's edits. +/// +/// Retention is a sync channel that writes INTO `personas.json`, never an +/// authoritative read source — `load_personas` is untouched, so every agent +/// keeps resolving its persona by UUID and keeps its provider keys. +/// +/// MATCH KEY (single source of truth, both directions): an inbound event +/// matches the local record whose `persona_d_tag(record)` equals the event's +/// d-tag. Reusing the same derivation the outbound path uses guarantees the +/// inbound key can never drift from the outbound key — in particular, an +/// in-app persona (`source_team_persona_slug == None`) whose d-tag IS its +/// `id` matches its existing UUID row instead of minting a duplicate. +/// +/// On match: patch ONLY the projected fields; preserve local `id`, `env_vars`, +/// `source_team`, and `created_at`. On no match: insert the parsed record as-is +/// — `persona_from_event` already sets `id = d_tag`, so an in-app persona reuses +/// its d-tag as the id and a re-received event stays idempotent (no duplicate). +/// +/// The retention store decides whether the inbound event wins over a pending +/// local edit (`retain_inbound_event`): `personas.json` is only patched when the +/// retain reports [`InboundOutcome::Applied`], so an equal-second collision with +/// a pending local edit leaves the local record — and its queued publish — +/// untouched. +/// +/// `arrival_relay_url` is the relay the calling subscription is bound to. The +/// retention store this event belongs to is decided by the community that +/// DELIVERED it, not by whichever community happens to be active when the +/// reconcile runs — a workspace switch in flight would otherwise file community +/// A's event into community B's scoped database. An event whose arrival relay is +/// no longer the active scope is dropped: it was already durable in its own +/// community's store when it arrived there, and that community's next boot +/// reconcile refetches it. +#[tauri::command] +pub async fn reconcile_inbound_persona_event( + event_json: String, + arrival_relay_url: String, + app: AppHandle, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, app) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +fn reconcile_inbound_persona_event_blocking( + event_json: String, + arrival_relay_url: String, + app: AppHandle, +) -> Result<(), String> { + use crate::managed_agents::{ + agent_events::managed_agent_content_from_event, + load_managed_agents, load_teams, + persona_events::persona_from_event, + retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, + save_managed_agents, save_teams, + team_events::team_content_from_event, + }; + use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; + use nostr::JsonUtil; + + let state = app.state::(); + let event = parse_verified_inbound_event(&event_json)?; + + // The live filter subscribes to 30175/30176/30177 (upserts) plus kind:5 + // (NIP-09 deletions). d-tags are NOT unique across kinds, so every path + // below dispatches on kind FIRST and only ever touches its own store — a + // cross-kind d-tag collision can never link a team to a persona or agent. + let kind = event.kind.as_u16() as u32; + + // kind:5 deletion: a tombstone removes the local record at the coordinate + // in its `a` tag (`::`). Handled before the + // upsert dispatch because its coordinate and retention key differ. + if kind == KIND_DELETION { + return reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state); + } + + if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { + return Ok(()); + } + + // The d-tag identifies the record within its kind. Persona derives it from + // the parsed record (`persona_d_tag`); team/agent carry it as the event's + // d-tag directly. The persona is parsed once here and reused in the apply + // branch below — team/agent content is parsed in-branch since their d-tag + // comes from the event tag, not the content. + let inbound_persona = (kind == KIND_PERSONA) + .then(|| persona_from_event(&event)) + .transpose()?; + let d_tag = match &inbound_persona { + Some(persona) => persona_d_tag(persona), + None => event_d_tag(&event)?, + }; + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Resolve inbound vs. any pending local edit before touching the store, in + // the scope the event ARRIVED on. A workspace switch since arrival leaves + // this event to its own community's store — dropping it here is what keeps + // community A's head out of community B's database. + let Some(scope) = crate::managed_agents::retention::arrival_retention_scope( + &app, + &state, + &arrival_relay_url, + )? + else { + return Ok(()); + }; + let conn = open_retention_db(&scope.db_path)?; + let outcome = retain_inbound_event( + &conn, + &RetainedEvent { + kind, + pubkey: event.pubkey.to_hex(), + d_tag: d_tag.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + )?; + if outcome == InboundOutcome::Skipped { + return Ok(()); + } + + match kind { + KIND_PERSONA => { + let mut personas = load_personas(&app)?; + // `inbound_persona` is `Some` for KIND_PERSONA (set above). + apply_inbound_persona( + &mut personas, + inbound_persona.expect("persona parsed above"), + ); + save_personas(&app, &personas)?; + } + KIND_TEAM => { + let mut teams = load_teams(&app)?; + apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?); + save_teams(&app, &teams)?; + } + KIND_MANAGED_AGENT => { + let mut agents = load_managed_agents(&app)?; + apply_inbound_managed_agent( + &mut agents, + &d_tag, + managed_agent_content_from_event(&event)?, + ); + save_managed_agents(&app, &agents)?; + } + _ => unreachable!("kind gated above"), + } + try_regenerate_nest(&app); + + // Signal the live UI to refetch agents data — inbound relay events otherwise + // land on disk silently, leaving the Agents tab stale until restart. + let _ = app.emit("agents-data-changed", ()); + + Ok(()) +} + +/// Parse an inbound wire event and enforce the signature gate. Everything +/// downstream trusts `event.pubkey` (ownership routing, tombstone scoping, +/// behavioral-quad application), so a forged pubkey must die here — the +/// TS-side owner filter reads the same attacker-controlled field and is no +/// defense. +fn parse_verified_inbound_event(event_json: &str) -> Result { + use nostr::JsonUtil; + let event = nostr::Event::from_json(event_json) + .map_err(|e| format!("failed to parse inbound event: {e}"))?; + event + .verify() + .map_err(|e| format!("inbound event failed signature verification: {e}"))?; + Ok(event) +} + +/// Parse a NIP-09 `a`-tag coordinate `::` into its +/// target kind and d-tag. Returns `None` if the tag is absent or malformed, so +/// the caller no-ops on a tombstone it can't route. +fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { + event.tags.iter().find_map(|tag| { + let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); + if values.first() != Some(&"a") { + return None; + } + let coord = values.get(1)?; + // `::` — d_tag may itself contain ':' so split at + // most twice and keep the remainder as the d_tag. + let mut parts = coord.splitn(3, ':'); + let kind: u32 = parts.next()?.parse().ok()?; + let owner = parts.next()?; + // NIP-09 scoping: only the record's author may tombstone it. The + // signature gate upstream proves `event.pubkey`; requiring the + // coordinate owner to match closes the other half — a validly + // signed kind:5 naming ANOTHER owner's coordinate must no-op. + if owner != event.pubkey.to_hex() { + return None; + } + let d_tag = parts.next()?; + Some((kind, d_tag.to_string())) + }) +} + +/// Apply an inbound kind:5 NIP-09 deletion: remove the local record at the +/// tombstone's target coordinate, scoped per-kind. Mirrors the upsert spine — +/// arrival-scoped retention resolution under the store lock, then a per-kind +/// store mutation — but removes rather than patches. Unknown/malformed +/// coordinates no-op, as does a tombstone whose arrival community is no longer +/// active. +fn reconcile_inbound_tombstone( + event: &nostr::Event, + arrival_relay_url: &str, + app: &AppHandle, + state: &AppState, +) -> Result<(), String> { + use crate::managed_agents::{ + load_managed_agents, load_teams, + retention::{ + open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, + RetainedEvent, + }, + save_managed_agents, save_teams, + }; + use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; + use nostr::JsonUtil; + + let Some((target_kind, target_d_tag)) = parse_deletion_coordinate(event) else { + return Ok(()); // no routable coordinate — nothing to delete + }; + if !matches!(target_kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { + return Ok(()); // deletion for a kind we don't track locally + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Resolve against the retained tombstone row (keyed by the target + // coordinate, F2c) so a re-received tombstone or one older than a pending + // local edit is a no-op. Scoped to the arrival community, so a workspace + // switch since arrival drops the tombstone instead of retaining it — and + // deleting a record — in the wrong community's store. + let Some(scope) = + crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)? + else { + return Ok(()); + }; + let conn = open_retention_db(&scope.db_path)?; + let outcome = retain_inbound_event( + &conn, + &RetainedEvent { + kind: KIND_DELETION, + pubkey: event.pubkey.to_hex(), + d_tag: tombstone_retention_d_tag(target_kind, &target_d_tag), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + )?; + if outcome == InboundOutcome::Skipped { + return Ok(()); + } + + // Remove the local record using the SAME per-kind match rule the apply fns + // use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`. + match target_kind { + KIND_PERSONA => { + let mut personas = load_personas(app)?; + personas.retain(|record| persona_d_tag(record) != target_d_tag); + save_personas(app, &personas)?; + } + KIND_TEAM => { + let mut teams = load_teams(app)?; + teams.retain(|record| record.id != target_d_tag); + save_teams(app, &teams)?; + } + KIND_MANAGED_AGENT => { + let mut agents = load_managed_agents(app)?; + agents.retain(|record| record.pubkey != target_d_tag); + save_managed_agents(app, &agents)?; + } + _ => unreachable!("target kind gated above"), + } + try_regenerate_nest(app); + + // Refresh the live UI on inbound deletion — a removal is as user-visible as + // an upsert and the Agents tab must drop the tombstoned record without restart. + let _ = app.emit("agents-data-changed", ()); + + Ok(()) +} + +/// Extract the `d` tag value from an event, the match key for team (= team id) +/// and managed-agent (= agent pubkey) inbound reconcile. +fn event_d_tag(event: &nostr::Event) -> Result { + event + .tags + .iter() + .find_map(|tag| { + let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); + (values.first() == Some(&"d")) + .then(|| values.get(1).map(|s| s.to_string())) + .flatten() + }) + .ok_or_else(|| "inbound event missing d-tag".to_string()) +} + +/// Merge a parsed inbound persona into the local set: patch the matching record +/// in place, or push it when none matches. +/// +/// The match key is `persona_d_tag` — the same derivation the outbound path +/// uses — so the inbound and outbound keys can never drift. On match, only the +/// projected fields are overwritten; local `id`, `env_vars`, `source_team`, and +/// `created_at` survive. On no match, the parsed record is inserted as-is; since +/// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as +/// the id and a re-received event stays idempotent (no duplicate row). +fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefinition) { + let d_tag = persona_d_tag(&inbound); + match personas + .iter_mut() + .find(|record| persona_d_tag(record) == d_tag) + { + Some(local) => { + local.display_name = inbound.display_name; + local.avatar_url = inbound.avatar_url; + local.system_prompt = inbound.system_prompt; + local.runtime = inbound.runtime; + local.model = inbound.model; + local.provider = inbound.provider; + local.name_pool = inbound.name_pool; + local.respond_to = inbound.respond_to; + local.respond_to_allowlist = inbound.respond_to_allowlist; + local.parallelism = inbound.parallelism; + local.shared = inbound.shared; + local.updated_at = inbound.updated_at; + } + None => personas.push(inbound), + } +} + +/// Merge an inbound kind:30177 managed-agent projection into the local set. +/// +/// Matches the local record whose `pubkey` equals the event's d-tag (the d-tag +/// IS the agent pubkey — see `build_agent_event`). On match, overwrite ONLY the +/// 10 projected fields; every secret (`private_key_nsec`, `auth_tag`, +/// `env_vars`, `backend`), the harness pins (`agent_command`, +/// `agent_command_override`), and all runtime/local fields are preserved +/// untouched. The projection type carries none of them, so they cannot be +/// reached here even if a foreign event tried to inject them. +/// +/// No match is a no-op: managed agents carry device-local secrets and are never +/// minted from a relay event — an agent that does not already exist locally has +/// no secret key to run with, so inserting a secretless shell would be useless +/// and misleading. This diverges from the persona path, which DOES insert on no +/// match (personas are secretless definitions). Flagged in the reconcile docs. +fn apply_inbound_managed_agent( + agents: &mut [ManagedAgentRecord], + d_tag: &str, + inbound: ManagedAgentEventContent, +) { + if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { + local.name = inbound.name; + // Mirror of the slimmed writer (agent_event_content): a + // definition-linked event omits the definition quad because those + // fields resolve through the kind:30175 definition — absent means + // "not carried", never "clear". Definition-less events still carry + // the quad and apply it unconditionally (including clears). + let definition_linked = inbound.persona_id.is_some(); + local.persona_id = inbound.persona_id; + if !definition_linked { + local.system_prompt = inbound.system_prompt; + local.model = inbound.model; + local.provider = inbound.provider; + local.persona_source_version = inbound.persona_source_version; + } + local.parallelism = inbound.parallelism; + local.respond_to = inbound.respond_to; + local.respond_to_allowlist = inbound.respond_to_allowlist; + } +} + +/// Merge an inbound kind:30176 team projection into the local set. +/// +/// Matches the local record whose `id` equals the event's d-tag (the d-tag IS +/// the team id — see `build_team_event`). On match, overwrite ONLY the three +/// shared fields (`name`, `description`, `persona_ids`); install-specific local +/// fields (`source_dir`, `is_symlink`, `symlink_target`, `is_builtin`, +/// `version`, `created_at`) are preserved. On no match, insert a fresh record +/// reusing the d-tag as the id so a re-received event stays idempotent — +/// symmetric to the persona path, since a team (like a persona) is a secretless +/// definition that another device may legitimately learn about from the relay. +fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamEventContent) { + match teams.iter_mut().find(|record| record.id == d_tag) { + Some(local) => { + local.name = inbound.name; + local.description = inbound.description; + // `None` means the event came from a client that predates + // always-publish — its true value is unknown, so preserve + // local. Only `Some` (including the explicit-clear variants) + // overwrites. See `TeamEventContent` for the wire rules. + if let Some(instructions) = inbound.instructions { + local.instructions = instructions; + } + if let Some(persona_ids) = inbound.persona_ids { + local.persona_ids = persona_ids; + } + } + None => teams.push(TeamRecord { + id: d_tag, + name: inbound.name, + description: inbound.description, + // Fresh insert has no local value to preserve; `None` from a + // pre-fix client simply means no known value. + instructions: inbound.instructions.unwrap_or_default(), + persona_ids: inbound.persona_ids.unwrap_or_default(), + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: now_iso(), + updated_at: now_iso(), + }), + } +} diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs similarity index 99% rename from desktop/src-tauri/src/commands/personas/inbound_tests.rs rename to desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1000e48b70c..1005a83432d 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -20,8 +20,10 @@ fn local_in_app() -> AgentDefinition { name_pool: vec!["Local".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: Some("team-1".to_string()), source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::from([("API_KEY".to_string(), "secret".to_string())]), respond_to: None, respond_to_allowlist: Vec::new(), @@ -45,8 +47,10 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { name_pool: vec!["Remote".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: Some(d_tag.to_string()), + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -203,8 +207,10 @@ fn local_agent() -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 2f1f292f5eb..66f7296a251 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -1,16 +1,12 @@ -use tauri::{AppHandle, Emitter, Manager}; -use uuid::Uuid; +use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - agent_events::ManagedAgentEventContent, apply_persona_behavior, current_instance_id, - delete_agent_key, effective_agent_command, load_managed_agents, load_personas, load_teams, - managed_agent_avatar_url, persona_events::persona_d_tag, save_managed_agents, - save_personas, stop_managed_agent_process, sync_managed_agent_processes, - team_events::TeamEventContent, try_regenerate_nest, validate_persona_activation_change, - validate_persona_deletion, AgentDefinition, CreatePersonaRequest, ManagedAgentRecord, - TeamRecord, UpdatePersonaRequest, + current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams, + save_managed_agents, save_personas, stop_managed_agent_process, + sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change, + validate_persona_deletion, AgentDefinition, ManagedAgentRecord, }, util::now_iso, }; @@ -33,298 +29,35 @@ fn trim_optional(value: Option) -> Option { mod pending; pub(in crate::commands) use pending::retain_persona_pending; pub(super) use pending::tombstone_persona_pending; +mod create; +pub use create::create_persona; +mod sharing; +pub use sharing::set_persona_shared; +pub use sharing::update_persona_and_publish; +mod update; +pub use update::update_persona; +mod inbound; +pub use inbound::reconcile_inbound_persona_event; #[tauri::command] pub async fn list_personas(app: AppHandle) -> Result, String> { use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - load_personas(&app) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - -#[tauri::command] -pub async fn create_persona( - input: CreatePersonaRequest, - app: AppHandle, -) -> Result { - use tauri::Manager; - tokio::task::spawn_blocking(move || { - let state = app.state::(); - let display_name = trim_required(&input.display_name, "Display name")?; - // System prompt optional: core memory is auto-injected. Empty is valid. - let system_prompt = input.system_prompt.trim().to_string(); - let avatar_url = trim_optional(input.avatar_url); - let runtime = trim_optional(input.runtime); - let model = trim_optional(input.model); - let provider = trim_optional(input.provider); - let now = now_iso(); let _store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; - let name_pool: Vec = input - .name_pool - .into_iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - crate::managed_agents::validate_user_env_keys(&input.env_vars)?; - let mut persona = AgentDefinition { - id: Uuid::new_v4().to_string(), - display_name, - avatar_url, - system_prompt, - runtime, - model, - provider, - name_pool, - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: input.env_vars, - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: now.clone(), - updated_at: now, - }; - apply_persona_behavior(&mut persona, input.behavior)?; - personas.push(persona.clone()); - save_personas(&app, &personas)?; - retain_persona_pending(&app, &state, &persona); - try_regenerate_nest(&app); - Ok(persona) + pending::project_active_persona_sharing(&app, &state, &mut personas); + Ok(personas) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } -/// Return value of the `update_persona` command. Uses flatten so all -/// `AgentDefinition` fields appear at the top level of the JSON response — -/// backward-compatible with callers that already destructure a raw persona object. -#[derive(Debug, serde::Serialize)] -pub struct UpdatePersonaResult { - #[serde(flatten)] - persona: AgentDefinition, -} - -/// Propagate a persona definition's display_name rename to linked agent instances. -/// Only instances whose current `name` equals `old_display_name` are updated; -/// pool-named instances (e.g. "Birch", "Compass") keep their individualised name. -/// Updates both `record.name` (relay display name) and `record.display_name`. -/// Returns the pubkeys of the records that were renamed. -fn propagate_persona_name_rename( - records: &mut [ManagedAgentRecord], - persona_id: &str, - old_display_name: &str, - new_display_name: &str, -) -> Vec { - let mut renamed = Vec::new(); - for record in records.iter_mut() { - if record.persona_id.as_deref() != Some(persona_id) { - continue; - } - if record.name != old_display_name { - continue; // pool-named instance — keep its individualised name - } - record.name = new_display_name.to_string(); - record.display_name = Some(new_display_name.to_string()); - renamed.push(record.pubkey.clone()); - } - renamed -} - -#[tauri::command] -pub async fn update_persona( - input: UpdatePersonaRequest, - app: AppHandle, -) -> Result { - use tauri::Manager; - - /// Profile sync params collected under the store lock for async relay publish. - type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option, Option)>; - - // Phase 1: synchronous save (persona record + linked agent avatar updates) - let (result, profile_sync_params) = tokio::task::spawn_blocking({ - let app = app.clone(); - move || -> Result<(AgentDefinition, ProfileSyncParams), String> { - let state = app.state::(); - let display_name = trim_required(&input.display_name, "Display name")?; - let system_prompt = input.system_prompt.clone(); - let avatar_url = trim_optional(input.avatar_url); - let runtime = trim_optional(input.runtime); - let model = trim_optional(input.model); - let provider = trim_optional(input.provider); - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - let persona = personas - .iter_mut() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("agent {} not found", input.id))?; - - // Track what changed so we can propagate to linked agent records. - let avatar_changed = persona.avatar_url != avatar_url; - let name_changed = persona.display_name != display_name; - let old_display_name = persona.display_name.clone(); - - persona.display_name = display_name; - persona.avatar_url = avatar_url; - persona.system_prompt = system_prompt; - persona.runtime = runtime; - persona.model = model; - persona.provider = provider; - persona.name_pool = input - .name_pool - .into_iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - persona.env_vars = env_vars; - } - apply_persona_behavior(persona, input.behavior)?; - persona.updated_at = now_iso(); - - let result = persona.clone(); - save_personas(&app, &personas)?; - - retain_persona_pending(&app, &state, &result); - try_regenerate_nest(&app); - - // If the avatar or display_name changed, propagate to linked agent - // records and collect relay profile sync params for the async phase. - let sync_params: ProfileSyncParams = if avatar_changed || name_changed { - let mut records = load_managed_agents(&app)?; - let mut params: ProfileSyncParams = Vec::new(); - let mut agents_modified = false; - let workspace_relay = crate::relay::relay_ws_url_with_override(&state); - - // Propagate the display_name rename to instances that still - // carry the old definition display_name (pool-named instances - // keep their individualised name) in one pass; the loop below - // only decides which records need a relay profile sync. - let renamed: Vec = if name_changed { - propagate_persona_name_rename( - &mut records, - &result.id, - &old_display_name, - &result.display_name, - ) - } else { - Vec::new() - }; - - for record in records.iter_mut() { - if record.persona_id.as_deref() != Some(&result.id) { - continue; - } - let mut record_changed = renamed.contains(&record.pubkey); - - if avatar_changed { - // Update the persisted avatar so reconciliation on next - // start agrees with what we're about to publish. - // When the persona avatar is cleared, fall back to the - // command-default icon so the record never stores `None` - // (which reconcile_agent_profile treats as "un-migrated"). - let effective_cmd = effective_agent_command( - record.persona_id.as_deref(), - std::slice::from_ref(&result), - record.agent_command_override.as_deref(), - ); - record.avatar_url = result - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&effective_cmd)); - record_changed = true; - } - - if record_changed { - agents_modified = true; - if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { - let relay_url = crate::relay::effective_agent_relay_url( - &record.relay_url, - &workspace_relay, - ); - params.push(( - agent_keys, - relay_url, - record.name.clone(), - record.avatar_url.clone(), - record.auth_tag.clone(), - )); - } - } - } - - if agents_modified { - save_managed_agents(&app, &records)?; - // Keep retained kind:30177 identity records in lockstep with - // the rename (#2423): `record.name` is part of the published - // identity projection, so skipping this strands the relay on - // the stale name→pubkey binding until the next boot reconcile. - // Avatar-only edits are excluded — the avatar is not in the - // projection, so retaining would be a guaranteed no-op. - for record in records.iter().filter(|r| renamed.contains(&r.pubkey)) { - super::agents::retain_managed_agent_pending(&app, &state, record); - } - } - - params - } else { - Vec::new() - }; - - Ok((result, sync_params)) - } - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))??; - - // Phase 2: await relay profile sync for linked agents whose avatar or - // display_name was just updated. We await (rather than fire-and-forget) - // so the frontend cache invalidation that follows the mutation settlement - // sees the fresh relay profile. Best-effort — failures are logged, not surfaced. - if !profile_sync_params.is_empty() { - let state = app.state::(); - for (agent_keys, relay_url, display_name, avatar_url, auth_tag) in profile_sync_params { - if let Err(e) = crate::relay::sync_managed_agent_profile( - &state, - &relay_url, - &agent_keys, - &display_name, - avatar_url.as_deref(), - auth_tag.as_deref(), - ) - .await - { - eprintln!("buzz-desktop: relay profile sync failed after persona update: {e}"); - } - } - } - - Ok(UpdatePersonaResult { persona: result }) -} - #[cfg(test)] mod delete_cascade_tests; -#[cfg(test)] -mod inbound_tests; -#[cfg(test)] -mod name_propagation_tests; /// Return pubkeys of every managed agent whose definition is the given persona. /// @@ -519,403 +252,6 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { .map_err(|e| format!("spawn_blocking failed: {e}"))? } -/// Apply an inbound kind:30175 persona event from the relay onto the local -/// store. The frontend's live subscription invokes this per event for our own -/// authored coordinate so Device B inherits Device A's edits. -/// -/// Retention is a sync channel that writes INTO `personas.json`, never an -/// authoritative read source — `load_personas` is untouched, so every agent -/// keeps resolving its persona by UUID and keeps its provider keys. -/// -/// MATCH KEY (single source of truth, both directions): an inbound event -/// matches the local record whose `persona_d_tag(record)` equals the event's -/// d-tag. Reusing the same derivation the outbound path uses guarantees the -/// inbound key can never drift from the outbound key — in particular, an -/// in-app persona (`source_team_persona_slug == None`) whose d-tag IS its -/// `id` matches its existing UUID row instead of minting a duplicate. -/// -/// On match: patch ONLY the projected fields; preserve local `id`, `env_vars`, -/// `source_team`, and `created_at`. On no match: insert the parsed record as-is -/// — `persona_from_event` already sets `id = d_tag`, so an in-app persona reuses -/// its d-tag as the id and a re-received event stays idempotent (no duplicate). -/// -/// The retention store decides whether the inbound event wins over a pending -/// local edit (`retain_inbound_event`): `personas.json` is only patched when the -/// retain reports [`InboundOutcome::Applied`], so an equal-second collision with -/// a pending local edit leaves the local record — and its queued publish — -/// untouched. -#[tauri::command] -pub async fn reconcile_inbound_persona_event( - event_json: String, - app: AppHandle, -) -> Result<(), String> { - tokio::task::spawn_blocking(move || reconcile_inbound_persona_event_blocking(event_json, app)) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - -fn reconcile_inbound_persona_event_blocking( - event_json: String, - app: AppHandle, -) -> Result<(), String> { - use crate::managed_agents::{ - agent_events::managed_agent_content_from_event, - load_managed_agents, load_teams, managed_agents_base_dir, - persona_events::persona_from_event, - retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, - save_managed_agents, save_teams, - team_events::team_content_from_event, - }; - use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; - use nostr::JsonUtil; - - let state = app.state::(); - let event = parse_verified_inbound_event(&event_json)?; - - // The live filter subscribes to 30175/30176/30177 (upserts) plus kind:5 - // (NIP-09 deletions). d-tags are NOT unique across kinds, so every path - // below dispatches on kind FIRST and only ever touches its own store — a - // cross-kind d-tag collision can never link a team to a persona or agent. - let kind = event.kind.as_u16() as u32; - - // kind:5 deletion: a tombstone removes the local record at the coordinate - // in its `a` tag (`::`). Handled before the - // upsert dispatch because its coordinate and retention key differ. - if kind == KIND_DELETION { - return reconcile_inbound_tombstone(&event, &app, &state); - } - - if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { - return Ok(()); - } - - // The d-tag identifies the record within its kind. Persona derives it from - // the parsed record (`persona_d_tag`); team/agent carry it as the event's - // d-tag directly. The persona is parsed once here and reused in the apply - // branch below — team/agent content is parsed in-branch since their d-tag - // comes from the event tag, not the content. - let inbound_persona = (kind == KIND_PERSONA) - .then(|| persona_from_event(&event)) - .transpose()?; - let d_tag = match &inbound_persona { - Some(persona) => persona_d_tag(persona), - None => event_d_tag(&event)?, - }; - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - - // Resolve inbound vs. any pending local edit before touching the store. - let conn = open_retention_db(&managed_agents_base_dir(&app)?.join("retention.db"))?; - let outcome = retain_inbound_event( - &conn, - &RetainedEvent { - kind, - pubkey: event.pubkey.to_hex(), - d_tag: d_tag.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, - )?; - if outcome == InboundOutcome::Skipped { - return Ok(()); - } - - match kind { - KIND_PERSONA => { - let mut personas = load_personas(&app)?; - // `inbound_persona` is `Some` for KIND_PERSONA (set above). - apply_inbound_persona( - &mut personas, - inbound_persona.expect("persona parsed above"), - ); - save_personas(&app, &personas)?; - } - KIND_TEAM => { - let mut teams = load_teams(&app)?; - apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?); - save_teams(&app, &teams)?; - } - KIND_MANAGED_AGENT => { - let mut agents = load_managed_agents(&app)?; - apply_inbound_managed_agent( - &mut agents, - &d_tag, - managed_agent_content_from_event(&event)?, - ); - save_managed_agents(&app, &agents)?; - } - _ => unreachable!("kind gated above"), - } - try_regenerate_nest(&app); - - // Signal the live UI to refetch agents data — inbound relay events otherwise - // land on disk silently, leaving the Agents tab stale until restart. - let _ = app.emit("agents-data-changed", ()); - - Ok(()) -} - -/// Parse an inbound wire event and enforce the signature gate. Everything -/// downstream trusts `event.pubkey` (ownership routing, tombstone scoping, -/// behavioral-quad application), so a forged pubkey must die here — the -/// TS-side owner filter reads the same attacker-controlled field and is no -/// defense. -fn parse_verified_inbound_event(event_json: &str) -> Result { - use nostr::JsonUtil; - let event = nostr::Event::from_json(event_json) - .map_err(|e| format!("failed to parse inbound event: {e}"))?; - event - .verify() - .map_err(|e| format!("inbound event failed signature verification: {e}"))?; - Ok(event) -} - -/// Parse a NIP-09 `a`-tag coordinate `::` into its -/// target kind and d-tag. Returns `None` if the tag is absent or malformed, so -/// the caller no-ops on a tombstone it can't route. -fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { - event.tags.iter().find_map(|tag| { - let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); - if values.first() != Some(&"a") { - return None; - } - let coord = values.get(1)?; - // `::` — d_tag may itself contain ':' so split at - // most twice and keep the remainder as the d_tag. - let mut parts = coord.splitn(3, ':'); - let kind: u32 = parts.next()?.parse().ok()?; - let owner = parts.next()?; - // NIP-09 scoping: only the record's author may tombstone it. The - // signature gate upstream proves `event.pubkey`; requiring the - // coordinate owner to match closes the other half — a validly - // signed kind:5 naming ANOTHER owner's coordinate must no-op. - if owner != event.pubkey.to_hex() { - return None; - } - let d_tag = parts.next()?; - Some((kind, d_tag.to_string())) - }) -} - -/// Apply an inbound kind:5 NIP-09 deletion: remove the local record at the -/// tombstone's target coordinate, scoped per-kind. Mirrors the upsert spine — -/// retention resolution under the store lock, then a per-kind store mutation — -/// but removes rather than patches. Unknown/malformed coordinates no-op. -fn reconcile_inbound_tombstone( - event: &nostr::Event, - app: &AppHandle, - state: &AppState, -) -> Result<(), String> { - use crate::managed_agents::{ - load_managed_agents, load_teams, managed_agents_base_dir, - retention::{ - open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, - RetainedEvent, - }, - save_managed_agents, save_teams, - }; - use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; - use nostr::JsonUtil; - - let Some((target_kind, target_d_tag)) = parse_deletion_coordinate(event) else { - return Ok(()); // no routable coordinate — nothing to delete - }; - if !matches!(target_kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { - return Ok(()); // deletion for a kind we don't track locally - } - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - - // Resolve against the retained tombstone row (keyed by the target - // coordinate, F2c) so a re-received tombstone or one older than a pending - // local edit is a no-op. - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let outcome = retain_inbound_event( - &conn, - &RetainedEvent { - kind: KIND_DELETION, - pubkey: event.pubkey.to_hex(), - d_tag: tombstone_retention_d_tag(target_kind, &target_d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, - )?; - if outcome == InboundOutcome::Skipped { - return Ok(()); - } - - // Remove the local record using the SAME per-kind match rule the apply fns - // use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`. - match target_kind { - KIND_PERSONA => { - let mut personas = load_personas(app)?; - personas.retain(|record| persona_d_tag(record) != target_d_tag); - save_personas(app, &personas)?; - } - KIND_TEAM => { - let mut teams = load_teams(app)?; - teams.retain(|record| record.id != target_d_tag); - save_teams(app, &teams)?; - } - KIND_MANAGED_AGENT => { - let mut agents = load_managed_agents(app)?; - agents.retain(|record| record.pubkey != target_d_tag); - save_managed_agents(app, &agents)?; - } - _ => unreachable!("target kind gated above"), - } - try_regenerate_nest(app); - - // Refresh the live UI on inbound deletion — a removal is as user-visible as - // an upsert and the Agents tab must drop the tombstoned record without restart. - let _ = app.emit("agents-data-changed", ()); - - Ok(()) -} - -/// Extract the `d` tag value from an event, the match key for team (= team id) -/// and managed-agent (= agent pubkey) inbound reconcile. -fn event_d_tag(event: &nostr::Event) -> Result { - event - .tags - .iter() - .find_map(|tag| { - let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); - (values.first() == Some(&"d")) - .then(|| values.get(1).map(|s| s.to_string())) - .flatten() - }) - .ok_or_else(|| "inbound event missing d-tag".to_string()) -} - -/// Merge a parsed inbound persona into the local set: patch the matching record -/// in place, or push it when none matches. -/// -/// The match key is `persona_d_tag` — the same derivation the outbound path -/// uses — so the inbound and outbound keys can never drift. On match, only the -/// projected fields are overwritten; local `id`, `env_vars`, `source_team`, and -/// `created_at` survive. On no match, the parsed record is inserted as-is; since -/// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as -/// the id and a re-received event stays idempotent (no duplicate row). -fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefinition) { - let d_tag = persona_d_tag(&inbound); - match personas - .iter_mut() - .find(|record| persona_d_tag(record) == d_tag) - { - Some(local) => { - local.display_name = inbound.display_name; - local.avatar_url = inbound.avatar_url; - local.system_prompt = inbound.system_prompt; - local.runtime = inbound.runtime; - local.model = inbound.model; - local.provider = inbound.provider; - local.name_pool = inbound.name_pool; - local.respond_to = inbound.respond_to; - local.respond_to_allowlist = inbound.respond_to_allowlist; - local.parallelism = inbound.parallelism; - local.updated_at = inbound.updated_at; - } - None => personas.push(inbound), - } -} - -/// Merge an inbound kind:30177 managed-agent projection into the local set. -/// -/// Matches the local record whose `pubkey` equals the event's d-tag (the d-tag -/// IS the agent pubkey — see `build_agent_event`). On match, overwrite ONLY the -/// 10 projected fields; every secret (`private_key_nsec`, `auth_tag`, -/// `env_vars`, `backend`), the harness pins (`agent_command`, -/// `agent_command_override`), and all runtime/local fields are preserved -/// untouched. The projection type carries none of them, so they cannot be -/// reached here even if a foreign event tried to inject them. -/// -/// No match is a no-op: managed agents carry device-local secrets and are never -/// minted from a relay event — an agent that does not already exist locally has -/// no secret key to run with, so inserting a secretless shell would be useless -/// and misleading. This diverges from the persona path, which DOES insert on no -/// match (personas are secretless definitions). Flagged in the reconcile docs. -fn apply_inbound_managed_agent( - agents: &mut [ManagedAgentRecord], - d_tag: &str, - inbound: ManagedAgentEventContent, -) { - if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { - local.name = inbound.name; - // Mirror of the slimmed writer (agent_event_content): a - // definition-linked event omits the definition quad because those - // fields resolve through the kind:30175 definition — absent means - // "not carried", never "clear". Definition-less events still carry - // the quad and apply it unconditionally (including clears). - let definition_linked = inbound.persona_id.is_some(); - local.persona_id = inbound.persona_id; - if !definition_linked { - local.system_prompt = inbound.system_prompt; - local.model = inbound.model; - local.provider = inbound.provider; - local.persona_source_version = inbound.persona_source_version; - } - local.parallelism = inbound.parallelism; - local.respond_to = inbound.respond_to; - local.respond_to_allowlist = inbound.respond_to_allowlist; - } -} - -/// Merge an inbound kind:30176 team projection into the local set. -/// -/// Matches the local record whose `id` equals the event's d-tag (the d-tag IS -/// the team id — see `build_team_event`). On match, overwrite ONLY the three -/// shared fields (`name`, `description`, `persona_ids`); install-specific local -/// fields (`source_dir`, `is_symlink`, `symlink_target`, `is_builtin`, -/// `version`, `created_at`) are preserved. On no match, insert a fresh record -/// reusing the d-tag as the id so a re-received event stays idempotent — -/// symmetric to the persona path, since a team (like a persona) is a secretless -/// definition that another device may legitimately learn about from the relay. -fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamEventContent) { - match teams.iter_mut().find(|record| record.id == d_tag) { - Some(local) => { - local.name = inbound.name; - local.description = inbound.description; - // `None` means the event came from a client that predates - // always-publish — its true value is unknown, so preserve - // local. Only `Some` (including the explicit-clear variants) - // overwrites. See `TeamEventContent` for the wire rules. - if let Some(instructions) = inbound.instructions { - local.instructions = instructions; - } - if let Some(persona_ids) = inbound.persona_ids { - local.persona_ids = persona_ids; - } - } - None => teams.push(TeamRecord { - id: d_tag, - name: inbound.name, - description: inbound.description, - // Fresh insert has no local value to preserve; `None` from a - // pre-fix client simply means no known value. - instructions: inbound.instructions.unwrap_or_default(), - persona_ids: inbound.persona_ids.unwrap_or_default(), - is_builtin: false, - source_dir: None, - is_symlink: false, - symlink_target: None, - version: None, - created_at: now_iso(), - updated_at: now_iso(), - }), - } -} - #[tauri::command] pub async fn set_persona_active( id: String, diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 4d887ca39e9..a4003329bca 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -5,7 +5,17 @@ use tauri::AppHandle; use crate::app_state::AppState; -use crate::managed_agents::AgentDefinition; +use crate::managed_agents::{ + retention::{RetainedEvent, RetentionScope}, + AgentDefinition, +}; + +pub(super) struct PreparedPersonaPublication { + pub scope: RetentionScope, + pub event: nostr::Event, + pub retained: RetainedEvent, + pub persona: AgentDefinition, +} /// Retain a freshly authored persona event in the local store, flagged for /// relay sync. Called inside a command's `managed_agents_store_lock`-held body @@ -16,58 +26,162 @@ use crate::managed_agents::AgentDefinition; /// newer-or-equal guard. `pending_sync = 1` enqueues it for the flush loop, /// which is the sole publisher. Best-effort: a failure here is logged and /// swallowed so a retention hiccup never blocks the disk-authoritative write. +/// The explicit catalog toggle uses [`prepare_persona_publication`] directly +/// so its durable enqueue failure reaches the UI. /// /// Unlike `retain_managed_agent_pending`, this has no projection-equality /// short-circuit: personas have no start/stop runtime churn, so a republish -/// only happens on a genuine create/update/delete user edit (`set_persona_active` -/// does not retain, so the local-only `is_active` toggle never republishes, and -/// a byte-identical user-save republish is harmlessly NIP-33-replaced). The -/// guard is intentionally omitted. +/// only happens on a genuine create/update/delete/share user edit +/// (`set_persona_active` does not retain, so the local-only `is_active` toggle +/// never republishes, while `set_persona_shared` must retain because the tag is +/// relay-authoritative). A byte-identical user-save republish is harmlessly +/// NIP-33-replaced. The guard is intentionally omitted. pub(in crate::commands) fn retain_persona_pending( app: &AppHandle, state: &AppState, persona: &AgentDefinition, ) { + if let Err(e) = prepare_persona_publication(app, state, persona, None) { + eprintln!("buzz-desktop: persona-retain: {e}"); + } +} + +/// Build, sign, and durably retain a persona event in the active relay+owner +/// scope. +/// +/// Ordinary definition writes pass `None` and preserve the scoped head's +/// exact share tag. The explicit share toggle passes `Some(shared)`. Returning +/// the retained event lets that command immediately await relay acceptance +/// without rebuilding or re-signing a different NIP-33 head. +pub(super) fn prepare_persona_publication( + app: &AppHandle, + state: &AppState, + persona: &AgentDefinition, + shared_override: Option, +) -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let (event, retained, persona) = prepare_persona_publication_at( + &scope.db_path, + &scope.owner_keys, + persona, + shared_override, + )?; + Ok(PreparedPersonaPublication { + scope, + event, + retained, + persona, + }) +} + +fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool { + use buzz_core_pkg::kind::persona_event_is_shared; + use nostr::JsonUtil; + + row.and_then(|retained| nostr::Event::from_json(&retained.raw_event).ok()) + .is_some_and(|event| persona_event_is_shared(&event)) +} + +/// Project each persona's catalog visibility from the active relay+owner +/// scope's retained head. +/// +/// Infallible by design. The scope needs `signing_keys()`, which fails for the +/// whole process whenever the identity is lost or the keyring is locked, and a +/// propagated error there would break listing, creating, and updating EVERY +/// agent. Share state is a view projection, so an unresolvable scope degrades +/// to "not shared" — the safe direction: it can under-report visibility but can +/// never present an unshared persona as published. The durable share state +/// lives in the retention head, so nothing is lost: the true value reappears +/// once the identity is signable again. +pub(super) fn project_active_persona_sharing( + app: &AppHandle, + state: &AppState, + personas: &mut [AgentDefinition], +) { + let scope = crate::managed_agents::retention::active_retention_scope(app, state); + project_scoped_persona_sharing(scope, personas); +} + +fn project_scoped_persona_sharing( + scope: Result, + personas: &mut [AgentDefinition], +) { + let projected = scope.and_then(|scope| { + project_persona_sharing_at( + &scope.db_path, + &scope.owner_keys.public_key().to_hex(), + personas, + ) + }); + if let Err(error) = projected { + eprintln!("buzz-desktop: persona-share-projection unavailable, reporting every agent as unshared: {error}"); + for persona in personas { + persona.shared = false; + } + } +} + +fn project_persona_sharing_at( + db_path: &std::path::Path, + owner_pubkey: &str, + personas: &mut [AgentDefinition], +) -> Result<(), String> { + use crate::managed_agents::{ + persona_events::persona_d_tag, + retention::{get_retained_event, open_retention_db}, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + + let conn = open_retention_db(db_path)?; + for persona in personas { + if persona.is_builtin { + persona.shared = false; + continue; + } + let retained = + get_retained_event(&conn, KIND_PERSONA, owner_pubkey, &persona_d_tag(persona))?; + persona.shared = retained_persona_is_shared(retained.as_ref()); + } + Ok(()) +} + +pub(super) fn prepare_persona_publication_at( + db_path: &std::path::Path, + keys: &nostr::Keys, + persona: &AgentDefinition, + shared_override: Option, +) -> Result<(nostr::Event, RetainedEvent, AgentDefinition), String> { use crate::managed_agents::{ - managed_agents_base_dir, persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; use buzz_core_pkg::kind::KIND_PERSONA; use nostr::JsonUtil; - let result = (|| -> Result<(), String> { - let d_tag = persona_d_tag(persona); - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - // Monotonic created_at: read the retained head for this coordinate - // and bump past it (NIP-AP step 3) so a same-second edit supersedes. - let prior = - get_retained_event(&conn, KIND_PERSONA, &keys.public_key().to_hex(), &d_tag)? - .map(|row| row.created_at); - let event = build_persona_event(persona)? - .custom_created_at(monotonic_created_at(prior)) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona event: {e}"))?; - (keys.public_key().to_hex(), event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_PERSONA, - pubkey, - d_tag, - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: persona-retain: {e}"); - } + let d_tag = persona_d_tag(persona); + let pubkey = keys.public_key().to_hex(); + let conn = open_retention_db(db_path)?; + let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?; + let mut scoped_persona = persona.clone(); + scoped_persona.shared = + shared_override.unwrap_or_else(|| retained_persona_is_shared(existing.as_ref())); + let event = build_persona_event(&scoped_persona)? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign persona event: {e}"))?; + let retained = RetainedEvent { + kind: KIND_PERSONA, + pubkey, + d_tag, + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }; + retain_event(&conn, &retained)?; + Ok((event, retained, scoped_persona)) } /// Purge a deleted persona's pending row and enqueue a NIP-09 tombstone, both @@ -88,7 +202,6 @@ pub(in crate::commands) fn tombstone_persona_pending( d_tag: &str, ) { use crate::managed_agents::{ - managed_agents_base_dir, persona_events::build_persona_delete, retention::{ delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, @@ -101,15 +214,12 @@ pub(in crate::commands) fn tombstone_persona_pending( const KIND_DELETE: u32 = 5; let result = (|| -> Result<(), String> { - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - let event = build_persona_delete(d_tag, &pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; - (pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_persona_delete(d_tag, &pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; // Purge the persona row first so an unpublished edit can never resurrect // it after the tombstone publishes. delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; @@ -132,3 +242,158 @@ pub(in crate::commands) fn tombstone_persona_pending( eprintln!("buzz-desktop: persona-tombstone: {e}"); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, scoped_retention_db_path, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + use std::collections::BTreeMap; + + fn persona() -> AgentDefinition { + AgentDefinition { + id: "catalog-reviewer".to_string(), + display_name: "Catalog Reviewer".to_string(), + avatar_url: None, + system_prompt: "Review the catalog.".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-27T00:00:00Z".to_string(), + updated_at: "2026-07-27T00:00:00Z".to_string(), + } + } + + #[test] + fn share_state_and_pending_heads_are_scoped_by_relay_and_owner() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let community_a = scoped_retention_db_path(dir.path(), "wss://a.example", &owner); + let community_b = scoped_retention_db_path(dir.path(), "wss://b.example", &owner); + std::fs::create_dir_all(community_a.parent().unwrap()).unwrap(); + + let (_, _, shared_in_a) = + prepare_persona_publication_at(&community_a, &keys, &persona(), Some(true)).unwrap(); + assert!(shared_in_a.shared); + + let (_, _, unshared_in_b) = + prepare_persona_publication_at(&community_b, &keys, &persona(), None).unwrap(); + assert!(!unshared_in_b.shared); + + let mut edited = persona(); + edited.system_prompt = "Review the latest catalog.".to_string(); + let (_, _, edited_in_a) = + prepare_persona_publication_at(&community_a, &keys, &edited, None).unwrap(); + assert!( + edited_in_a.shared, + "ordinary edits preserve only the active scope's share choice" + ); + + let conn_a = open_retention_db(&community_a).unwrap(); + let conn_b = open_retention_db(&community_b).unwrap(); + assert!(retained_persona_is_shared( + get_retained_event(&conn_a, KIND_PERSONA, &owner, "catalog-reviewer") + .unwrap() + .as_ref() + )); + assert!(!retained_persona_is_shared( + get_retained_event(&conn_b, KIND_PERSONA, &owner, "catalog-reviewer") + .unwrap() + .as_ref() + )); + } + + /// A `shared = true` persona plus the scope that says so. + fn shared_persona_scope(dir: &std::path::Path) -> (RetentionScope, Vec) { + let keys = nostr::Keys::generate(); + let db_path = scoped_retention_db_path(dir, "wss://a.example", &keys.public_key().to_hex()); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap(); + ( + RetentionScope { + db_path, + relay_url: "wss://a.example".to_string(), + owner_keys: keys, + }, + vec![persona()], + ) + } + + #[test] + fn test_resolvable_scope_projects_the_retained_share_state() { + let dir = tempfile::tempdir().unwrap(); + let (scope, mut personas) = shared_persona_scope(dir.path()); + + project_scoped_persona_sharing(Ok(scope), &mut personas); + + assert!(personas[0].shared); + } + + #[test] + fn test_recovery_mode_identity_projects_unshared_instead_of_failing() { + let dir = tempfile::tempdir().unwrap(); + let (_scope, mut personas) = shared_persona_scope(dir.path()); + personas[0].shared = true; + + // The real recovery-mode failure: `active_retention_scope` cannot + // resolve a scope without signing keys, which is exactly what + // `identity_lost` / `keyring_locked` withhold. + let state = crate::app_state::build_app_state(); + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + let error = state + .signing_keys() + .expect_err("recovery mode must withhold signing keys"); + + project_scoped_persona_sharing(Err(error), &mut personas); + + assert!( + !personas[0].shared, + "an unresolvable scope degrades to unshared so list/create/update keep working" + ); + } + + #[test] + fn test_unopenable_retention_db_projects_unshared_instead_of_failing() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let mut personas = vec![persona()]; + personas[0].shared = true; + + project_scoped_persona_sharing( + Ok(RetentionScope { + // A directory cannot be opened as the retention database. + db_path: dir.path().to_path_buf(), + relay_url: "wss://a.example".to_string(), + owner_keys: keys, + }), + &mut personas, + ); + + assert!(!personas[0].shared); + } + + #[test] + fn explicit_share_enqueue_failure_is_returned() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let error = prepare_persona_publication_at(dir.path(), &keys, &persona(), Some(true)) + .expect_err("a directory cannot be opened as the retention database"); + assert!(error.contains("failed to open retention db")); + } +} diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs new file mode 100644 index 00000000000..914c56252d0 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -0,0 +1,391 @@ +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + load_personas, + retention::{mark_synced, open_retention_db}, + AgentDefinition, + }, +}; + +use super::pending::{prepare_persona_publication, PreparedPersonaPublication}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PersonaSharePublicationStatus { + Published, + Queued, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetPersonaSharedResult { + pub persona: AgentDefinition, + pub publication_status: PersonaSharePublicationStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub relay_message: Option, +} + +#[tauri::command] +pub async fn set_persona_shared( + id: String, + shared: bool, + app: AppHandle, +) -> Result { + let prepared = tokio::task::spawn_blocking({ + let app = app.clone(); + move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let personas = load_personas(&app)?; + let persona = personas + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("agent {id} not found"))?; + + if persona.is_builtin { + return Err("Built-in agents cannot be shared to the catalog.".to_string()); + } + + // Strict path: unlike ordinary definition saves, an enqueue failure + // for this privacy-sensitive toggle must reach the command/UI. + prepare_persona_publication(&app, &state, persona, Some(shared)) + } + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + let state = app.state::(); + publish_prepared_persona(&state, prepared).await +} + +/// Save a persona edit AND publish its catalog head, returning the same +/// `published | queued` outcome as [`set_persona_shared`]. +/// +/// The "save and publish" affordance in the edit dialog promises the change +/// reaches the catalog on save. Plain `update_persona` only enqueues +/// best-effort, so the UI could not report whether the relay accepted it. This +/// takes the identical input and reuses the strict preparation path, then awaits +/// the relay exactly like the share toggle does — a rejection or an unreachable +/// relay stays durably queued for the flush loop and is reported as `queued`. +#[tauri::command] +pub async fn update_persona_and_publish( + input: crate::managed_agents::UpdatePersonaRequest, + app: AppHandle, +) -> Result { + let (_, prepared) = + super::update::update_persona_with(input, app.clone(), |app, state, persona| { + // Strict path: this command's contract is to report the publication + // outcome, so an enqueue failure must reach the UI rather than being + // logged and swallowed. + prepare_persona_publication(app, state, persona, None) + }) + .await?; + + let state = app.state::(); + publish_prepared_persona(&state, prepared).await +} + +async fn publish_prepared_persona( + state: &AppState, + prepared: PreparedPersonaPublication, +) -> Result { + let api_base_url = crate::relay::relay_http_base_url(&prepared.scope.relay_url); + let publish_result = crate::relay::submit_signed_event_at_with_keys( + &prepared.event, + state, + &api_base_url, + &prepared.scope.owner_keys, + ) + .await; + + match publish_result { + Ok(_) => { + let conn = open_retention_db(&prepared.scope.db_path)?; + mark_synced( + &conn, + prepared.retained.kind, + &prepared.retained.pubkey, + &prepared.retained.d_tag, + prepared.retained.created_at, + &prepared.retained.content, + )?; + Ok(SetPersonaSharedResult { + persona: prepared.persona, + publication_status: PersonaSharePublicationStatus::Published, + relay_message: None, + }) + } + Err(error) => Ok(SetPersonaSharedResult { + persona: prepared.persona, + publication_status: PersonaSharePublicationStatus::Queued, + relay_message: Some(error), + }), + } +} + +#[cfg(all(test, not(target_os = "windows")))] +mod tests { + use super::*; + use crate::{ + app_state::build_app_state, + commands::personas::pending::prepare_persona_publication_at, + managed_agents::{ + retention::{get_retained_event, open_retention_db, RetentionScope}, + AgentDefinition, + }, + }; + use std::collections::BTreeMap; + + fn persona() -> AgentDefinition { + AgentDefinition { + id: "catalog-reviewer".to_string(), + display_name: "Catalog Reviewer".to_string(), + avatar_url: None, + system_prompt: "Review the catalog.".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-27T00:00:00Z".to_string(), + updated_at: "2026-07-27T00:00:00Z".to_string(), + } + } + + async fn spawn_relay(accepted: bool) -> String { + use axum::{routing::post, Router}; + + let app = Router::new().route( + "/events", + post(move |body: String| async move { + let event: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + serde_json::json!({ + "event_id": event.get("id").and_then(serde_json::Value::as_str).unwrap_or(""), + "accepted": accepted, + "message": if accepted { "" } else { "policy rejection" } + }) + .to_string() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + format!("http://{addr}") + } + + fn prepared( + db_path: &std::path::Path, + relay_url: String, + keys: nostr::Keys, + shared_override: Option, + ) -> PreparedPersonaPublication { + let (event, retained, persona) = + prepare_persona_publication_at(db_path, &keys, &persona(), shared_override).unwrap(); + PreparedPersonaPublication { + scope: RetentionScope { + db_path: db_path.to_path_buf(), + relay_url, + owner_keys: keys, + }, + event, + retained, + persona, + } + } + + #[tokio::test] + async fn relay_rejection_stays_durably_queued() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, spawn_relay(false).await, keys, Some(true)); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Queued + ); + assert!(result + .relay_message + .as_deref() + .is_some_and(|message| message.contains("relay rejected event"))); + assert!( + get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + #[tokio::test] + async fn unavailable_relay_stays_durably_queued() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, relay_url, keys, Some(true)); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Queued + ); + assert!(result + .relay_message + .as_deref() + .is_some_and(|message| message.starts_with("relay unreachable:"))); + assert!( + get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + #[tokio::test] + async fn relay_acceptance_marks_the_scoped_head_synced() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, spawn_relay(true).await, keys, Some(true)); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Published + ); + assert!( + !get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + /// `update_persona_and_publish` differs from the share toggle in one way: + /// it passes no share override, so the edit must keep whatever the scoped + /// head already says, and it reports the relay outcome to the caller. + #[tokio::test] + async fn test_update_and_publish_acceptance_publishes_the_edit_at_the_current_share_state() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + // The persona is already shared in this scope. + prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap(); + let prepared = prepared(&db_path, spawn_relay(true).await, keys, None); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Published + ); + assert!( + result.persona.shared, + "an ordinary edit must not silently unshare the persona" + ); + assert!( + !get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + #[tokio::test] + async fn test_update_and_publish_relay_rejection_reports_queued_not_failure() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap(); + let prepared = prepared(&db_path, spawn_relay(false).await, keys, None); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Queued + ); + assert!(result + .relay_message + .as_deref() + .is_some_and(|message| message.contains("relay rejected event"))); + assert!( + get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync, + "the edit stays queued for the flush loop" + ); + } + + /// The save path swallows enqueue failures (`retain_persona_pending` logs + /// them). This command promises a publication outcome, so the strict + /// preparation it uses must surface the failure instead. + #[tokio::test] + async fn test_update_and_publish_enqueue_failure_is_returned() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + + let error = prepare_persona_publication_at(dir.path(), &keys, &persona(), None) + .expect_err("a directory cannot be opened as the retention database"); + + assert!(error.contains("failed to open retention db")); + } +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e4d8a5d1bc9..a3ba731875a 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 ac5c0eace6b..9d7d238918a 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. @@ -408,8 +424,10 @@ pub async fn confirm_agent_snapshot_import( name_pool: snapshot.definition.name_pool.clone(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: respond_to_wire.clone(), respond_to_allowlist: minted.respond_to_allowlist.clone(), @@ -476,8 +494,10 @@ pub async fn confirm_agent_snapshot_import( respond_to_allowlist: minted.respond_to_allowlist.clone(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, @@ -585,7 +605,6 @@ pub async fn confirm_agent_snapshot_import( fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { use crate::managed_agents::{ agent_events::{agent_event_content, build_agent_event}, - managed_agents_base_dir, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; @@ -593,11 +612,12 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize agent content: {e}"))?; let (owner_pubkey, event) = { - let keys = state.signing_keys()?; + let keys = &scope.owner_keys; let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -606,7 +626,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent } let event = build_agent_event(record)? .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(&keys) + .sign_with_keys(keys) .map_err(|e| format!("failed to sign agent event: {e}"))?; (owner_pubkey, event) }; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b6e..42893102807 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::{ @@ -64,8 +65,10 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { name_pool: vec![], is_builtin: false, is_active: false, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, @@ -94,6 +97,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 +555,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 +634,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/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs new file mode 100644 index 00000000000..ed2472d54ea --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -0,0 +1,252 @@ +//! The persona edit command surface: `update_persona` (best-effort enqueue) +//! and the `update_persona_with` seam that `update_persona_and_publish` reuses +//! to await relay acceptance for the same save. + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + apply_persona_behavior, effective_agent_command, load_managed_agents, load_personas, + managed_agent_avatar_url, save_managed_agents, save_personas, try_regenerate_nest, + AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest, + }, + util::now_iso, +}; + +use super::{pending, retain_persona_pending, trim_optional, trim_required}; + +#[cfg(test)] +mod name_propagation_tests; + +/// Return value of the `update_persona` command. Uses flatten so all +/// `AgentDefinition` fields appear at the top level of the JSON response — +/// backward-compatible with callers that already destructure a raw persona object. +#[derive(Debug, serde::Serialize)] +pub struct UpdatePersonaResult { + #[serde(flatten)] + persona: AgentDefinition, +} + +/// Propagate a persona definition's display_name rename to linked agent instances. +/// Only instances whose current `name` equals `old_display_name` are updated; +/// pool-named instances (e.g. "Birch", "Compass") keep their individualised name. +/// Updates both `record.name` (relay display name) and `record.display_name`. +/// Returns the pubkeys of the records that were renamed. +fn propagate_persona_name_rename( + records: &mut [ManagedAgentRecord], + persona_id: &str, + old_display_name: &str, + new_display_name: &str, +) -> Vec { + let mut renamed = Vec::new(); + for record in records.iter_mut() { + if record.persona_id.as_deref() != Some(persona_id) { + continue; + } + if record.name != old_display_name { + continue; // pool-named instance — keep its individualised name + } + record.name = new_display_name.to_string(); + record.display_name = Some(new_display_name.to_string()); + renamed.push(record.pubkey.clone()); + } + renamed +} + +/// Profile sync params collected under the store lock for async relay publish. +type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option, Option)>; + +#[tauri::command] +pub async fn update_persona( + input: UpdatePersonaRequest, + app: AppHandle, +) -> Result { + let (persona, ()) = update_persona_with(input, app, |app, state, persona| { + retain_persona_pending(app, state, persona); + Ok(()) + }) + .await?; + Ok(UpdatePersonaResult { persona }) +} + +/// Save an edited persona, hand the saved record to `retain` while the store +/// lock is still held, then sync the relay profiles of linked agent instances. +/// +/// `retain` is the only difference between the two update commands: +/// [`update_persona`] enqueues best-effort, while +/// [`sharing::update_persona_and_publish`] prepares a strict publication and +/// returns the event so the caller can await relay acceptance. +pub(super) async fn update_persona_with( + input: UpdatePersonaRequest, + app: AppHandle, + retain: impl FnOnce(&AppHandle, &AppState, &AgentDefinition) -> Result + Send + 'static, +) -> Result<(AgentDefinition, R), String> { + use tauri::Manager; + + // Phase 1: synchronous save (persona record + linked agent avatar updates) + let (result, retained, profile_sync_params) = tokio::task::spawn_blocking({ + let app = app.clone(); + move || -> Result<(AgentDefinition, R, ProfileSyncParams), String> { + let state = app.state::(); + let display_name = trim_required(&input.display_name, "Display name")?; + let system_prompt = input.system_prompt.clone(); + let avatar_url = trim_optional(input.avatar_url); + let runtime = trim_optional(input.runtime); + let model = trim_optional(input.model); + let provider = trim_optional(input.provider); + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut personas = load_personas(&app)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); + let persona = personas + .iter_mut() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("agent {} not found", input.id))?; + + // Track what changed so we can propagate to linked agent records. + let avatar_changed = persona.avatar_url != avatar_url; + let name_changed = persona.display_name != display_name; + let old_display_name = persona.display_name.clone(); + + persona.display_name = display_name; + persona.avatar_url = avatar_url; + persona.system_prompt = system_prompt; + persona.runtime = runtime; + persona.model = model; + persona.provider = provider; + persona.name_pool = input + .name_pool + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if let Some(env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(&env_vars)?; + persona.env_vars = env_vars; + } + apply_persona_behavior(persona, input.behavior)?; + persona.updated_at = now_iso(); + + let result = persona.clone(); + save_personas(&app, &personas)?; + + let retained = retain(&app, &state, &result)?; + try_regenerate_nest(&app); + + // If the avatar or display_name changed, propagate to linked agent + // records and collect relay profile sync params for the async phase. + let sync_params: ProfileSyncParams = if avatar_changed || name_changed { + let mut records = load_managed_agents(&app)?; + let mut params: ProfileSyncParams = Vec::new(); + let mut agents_modified = false; + let workspace_relay = crate::relay::relay_ws_url_with_override(&state); + + // Propagate the display_name rename to instances that still + // carry the old definition display_name (pool-named instances + // keep their individualised name) in one pass; the loop below + // only decides which records need a relay profile sync. + let renamed: Vec = if name_changed { + propagate_persona_name_rename( + &mut records, + &result.id, + &old_display_name, + &result.display_name, + ) + } else { + Vec::new() + }; + + for record in records.iter_mut() { + if record.persona_id.as_deref() != Some(&result.id) { + continue; + } + let mut record_changed = renamed.contains(&record.pubkey); + + if avatar_changed { + // Update the persisted avatar so reconciliation on next + // start agrees with what we're about to publish. + // When the persona avatar is cleared, fall back to the + // command-default icon so the record never stores `None` + // (which reconcile_agent_profile treats as "un-migrated"). + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(&result), + record.agent_command_override.as_deref(), + ); + record.avatar_url = result + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + record_changed = true; + } + + if record_changed { + agents_modified = true; + if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &workspace_relay, + ); + params.push(( + agent_keys, + relay_url, + record.name.clone(), + record.avatar_url.clone(), + record.auth_tag.clone(), + )); + } + } + } + + if agents_modified { + save_managed_agents(&app, &records)?; + // Keep retained kind:30177 identity records in lockstep with + // the rename (#2423): `record.name` is part of the published + // identity projection, so skipping this strands the relay on + // the stale name→pubkey binding until the next boot reconcile. + // Avatar-only edits are excluded — the avatar is not in the + // projection, so retaining would be a guaranteed no-op. + for record in records.iter().filter(|r| renamed.contains(&r.pubkey)) { + crate::commands::agents::retain_managed_agent_pending(&app, &state, record); + } + } + + params + } else { + Vec::new() + }; + + Ok((result, retained, sync_params)) + } + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + // Phase 2: await relay profile sync for linked agents whose avatar or + // display_name was just updated. We await (rather than fire-and-forget) + // so the frontend cache invalidation that follows the mutation settlement + // sees the fresh relay profile. Best-effort — failures are logged, not surfaced. + if !profile_sync_params.is_empty() { + let state = app.state::(); + for (agent_keys, relay_url, display_name, avatar_url, auth_tag) in profile_sync_params { + if let Err(e) = crate::relay::sync_managed_agent_profile( + &state, + &relay_url, + &agent_keys, + &display_name, + avatar_url.as_deref(), + auth_tag.as_deref(), + ) + .await + { + eprintln!("buzz-desktop: relay profile sync failed after persona update: {e}"); + } + } + } + + Ok((result, retained)) +} diff --git a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs similarity index 99% rename from desktop/src-tauri/src/commands/personas/name_propagation_tests.rs rename to desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index ba855ccbd64..c60215ae4dd 100644 --- a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -50,8 +50,10 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 0476be79a99..91a0126f582 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -129,8 +129,10 @@ fn definition_from_snapshot( name_pool: member.definition.name_pool.clone(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to, respond_to_allowlist: behavior.respond_to_allowlist, @@ -599,8 +601,10 @@ pub async fn confirm_team_snapshot_import( respond_to_allowlist: definition.respond_to_allowlist.clone(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, @@ -846,7 +850,6 @@ pub async fn confirm_team_snapshot_import( fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { use crate::managed_agents::{ agent_events::{agent_event_content, build_agent_event}, - managed_agents_base_dir, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; @@ -854,11 +857,12 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize agent content: {e}"))?; let (owner_pubkey, event) = { - let keys = state.signing_keys()?; + let keys = &scope.owner_keys; let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -867,7 +871,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent } let event = build_agent_event(record)? .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(&keys) + .sign_with_keys(keys) .map_err(|e| format!("failed to sign agent event: {e}"))?; (owner_pubkey, event) }; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc61830d..06164113079 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, @@ -64,8 +65,10 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -84,8 +87,10 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -145,8 +150,10 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -214,8 +221,10 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { respond_to_allowlist: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index ea9a6a49582..4377ddaa434 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -39,7 +39,6 @@ fn trim_optional(value: Option) -> Option { /// happens on an actual user edit. The guard is intentionally omitted. pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) { use crate::managed_agents::{ - managed_agents_base_dir, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, team_events::build_team_event, @@ -48,19 +47,16 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - // Monotonic created_at: bump past the retained head (NIP-AP step 3). - let prior = - get_retained_event(&conn, KIND_TEAM, &pubkey, &team.id)?.map(|row| row.created_at); - let event = build_team_event(team)? - .custom_created_at(monotonic_created_at(prior)) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign team event: {e}"))?; - (pubkey, event) - }; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + // Monotonic created_at: bump past the retained head (NIP-AP step 3). + let prior = + get_retained_event(&conn, KIND_TEAM, &pubkey, &team.id)?.map(|row| row.created_at); + let event = build_team_event(team)? + .custom_created_at(monotonic_created_at(prior)) + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign team event: {e}"))?; retain_event( &conn, &RetainedEvent { @@ -90,7 +86,6 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team /// disk-authoritative delete. fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { use crate::managed_agents::{ - managed_agents_base_dir, retention::{ delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, RetainedEvent, @@ -103,15 +98,12 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { const KIND_DELETE: u32 = 5; let result = (|| -> Result<(), String> { - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - let event = build_team_delete(d_tag, &pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign team tombstone: {e}"))?; - (pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_team_delete(d_tag, &pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign team tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; delete_retained_event(&conn, KIND_TEAM, &pubkey, d_tag)?; retain_event( &conn, diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 561e9019987..731a99d9d9b 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -10,6 +10,31 @@ use crate::managed_agents::{ }; use crate::relay; +/// Adopt the pre-scoping global retention database's pending rows into `scope`. +/// +/// Best-effort: a failure is logged and the boot proceeds. The migration's own +/// crash-safety guards make the next launch retry safely, and blocking the +/// workspace apply on it would be worse than a delayed publish. +fn migrate_legacy_retention_into( + app: &AppHandle, + scope: &crate::managed_agents::retention::RetentionScope, +) { + let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { + return; + }; + match crate::managed_agents::retention::migrate_legacy_retention_db( + &base_dir, + &scope.db_path, + &scope.owner_keys.public_key().to_hex(), + ) { + Ok(0) => {} + Ok(copied) => { + eprintln!("buzz-desktop: adopted {copied} legacy retained event(s) into this community") + } + Err(error) => eprintln!("buzz-desktop: legacy retention migration failed: {error}"), + } +} + #[derive(Deserialize)] struct RelayInfoIcon { #[serde(default)] @@ -187,6 +212,27 @@ pub async fn apply_workspace( .map_err(|e| format!("spawn_blocking failed: {e}"))??; let state = restore_app.state::(); + // Backfill this exact relay+owner scope only after the workspace has been + // applied. Running at process boot would target the fallback relay and + // collapse every community into one pending-event store. + match crate::managed_agents::retention::active_retention_scope(&restore_app, &state) { + Ok(scope) => { + // Adopt whatever the pre-scoping release left queued in the global + // retention database BEFORE the scoped reconcile and flush run, so + // stranded tombstones and archive requests publish on this boot + // instead of being abandoned by the storage cutover. + migrate_legacy_retention_into(&restore_app, &scope); + crate::event_sync::spawn_event_sync( + restore_app.clone(), + scope.owner_keys, + scope.db_path, + ) + } + Err(error) => { + eprintln!("buzz-desktop: scoped event-sync unavailable after workspace apply: {error}"); + } + } + let restore_pending = state .managed_agent_restore_pending .swap(false, Ordering::AcqRel); diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index 2ec5aa1c0ee..d9fe6acdb99 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -13,10 +13,10 @@ use std::path::Path; /// `sync_team_personas` wrote in [`crate::migration::run_boot_migrations`] /// (see its `# Ordering` guard). Event signing needs the resolved owner keys, /// so this runs after identity resolution, not in the boot migrations. -pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { - migrate_personas_to_events(app, owner_keys); - migrate_teams_to_events(app, owner_keys); - crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys); +pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: &Path) { + migrate_personas_to_events(app, owner_keys, db_path); + migrate_teams_to_events(app, owner_keys, db_path); + crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys, db_path); } /// Spawn the best-effort event reconcile off the synchronous Tauri setup path. @@ -25,10 +25,14 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { /// `AppState::keys` mutex. The reconcile itself is still synchronous JSON, /// SQLite, and signing work, so it runs on the blocking pool rather than an /// async worker. -pub fn spawn_event_sync(app: tauri::AppHandle, owner_keys: nostr::Keys) { +pub fn spawn_event_sync( + app: tauri::AppHandle, + owner_keys: nostr::Keys, + db_path: std::path::PathBuf, +) { tauri::async_runtime::spawn(async move { if let Err(e) = tauri::async_runtime::spawn_blocking(move || { - run_event_sync(&app, &owner_keys); + run_event_sync(&app, &owner_keys, &db_path); }) .await { @@ -57,14 +61,14 @@ pub fn spawn_event_sync(app: tauri::AppHandle, owner_keys: nostr::Keys) { /// `pending_sync = 1` for later relay publish. Migration succeeds on local /// write, not relay acknowledgment. Every retained row is a real signed /// event — there is no placeholder path. -pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { +pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) { use crate::managed_agents::managed_agents_base_dir; let Ok(base_dir) = managed_agents_base_dir(app) else { return; }; - match migrate_personas_in_dir(&base_dir, keys) { + match migrate_personas_in_dir_at(&base_dir, keys, db_path) { Ok(0) => {} Ok(migrated) => { eprintln!( @@ -82,7 +86,16 @@ pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { /// Returns the number of personas (re)written to the retention store. Returns /// `Ok(0)` when every non-builtin persona already has a matching retained row /// (or there are none to reconcile). +#[cfg(test)] fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + migrate_personas_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) +} + +fn migrate_personas_in_dir_at( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { use crate::managed_agents::{ persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, @@ -127,9 +140,8 @@ fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result Result Result {} Ok(migrated) => { eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention"); @@ -225,7 +242,16 @@ pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { /// Returns the number of teams (re)written to the retention store. The /// per-coordinate content compare matches [`migrate_personas_in_dir`]: an /// unchanged team is skipped so a launch does not churn `pending_sync`. +#[cfg(test)] fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + migrate_teams_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) +} + +fn migrate_teams_in_dir_at( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { use crate::managed_agents::{ persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, @@ -252,9 +278,8 @@ fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result k.clone(), - Err(e) => { - eprintln!("buzz-desktop: fatal: owner keys lock poisoned: {e}"); - std::process::exit(1); - } - }; - // Backfill the pinned persona snapshot for any pre-existing agent // that predates the record-authoritative-spawn cutover (persona_id // set but no source_version). Must run before @@ -547,15 +537,6 @@ pub fn run() { try_regenerate_nest(&app_handle); - // Sync team-dir edits and reconcile persona/team/agent events after - // setup can continue. It is best-effort retention backfill, unlike - // identity resolution above, so JSON/SQLite/signing work must not - // hold the boot path hostage. Skipped in recovery mode — the owner - // key is ephemeral. - if !recovery_mode { - event_sync::spawn_event_sync(app_handle.clone(), owner_keys); - } - if let Some(mgr) = huddle::models::global_model_manager() { mgr.start_stt_download(state.http_client.clone()); mgr.start_tts_download(state.http_client.clone()); @@ -638,17 +619,13 @@ pub fn run() { tauri::async_runtime::spawn(async move { use std::time::Duration; use tauri::Manager; - let Ok(db_path) = managed_agents::managed_agents_base_dir(&flush_handle) - .map(|d| d.join("retention.db")) - else { - eprintln!("buzz-desktop: event-flush: cannot resolve retention db path"); - return; - }; loop { let state = flush_handle.state::(); - if let Err(e) = - managed_agents::persona_events::flush_pending_events(&db_path, &state) - .await + if let Err(e) = managed_agents::persona_events::flush_active_pending_events( + &flush_handle, + &state, + ) + .await { eprintln!("buzz-desktop: event-flush: {e}"); } @@ -826,8 +803,10 @@ pub fn run() { list_personas, create_persona, update_persona, + update_persona_and_publish, delete_persona, set_persona_active, + set_persona_shared, reconcile_inbound_persona_event, list_channel_templates, create_channel_template, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index ba4407d164d..4a7b80079d8 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -208,8 +208,10 @@ mod tests { name_pool: vec!["poolname".to_string()], is_builtin: true, is_active: false, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index b0bf8f59913..16a0d35b23d 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(), @@ -526,9 +535,11 @@ mod tests { name_pool: vec!["Alice".to_string(), "Bob".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: Some("team-id-123".to_string()), // MUST NOT appear source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear definition_respond_to: Some("allowlist".to_string()), + catalog_source: None, definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, @@ -913,6 +924,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/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 4c11cd6c49e..4ee4ec79c32 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -106,8 +106,10 @@ fn test_record() -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 8761346b1f8..1b587dca0e5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -46,10 +46,8 @@ fn returns_none_for_unknown_commands() { #[test] fn default_agent_command_resolves_bundled_buzz_agent() { - // The create-path default must be the bundled buzz-agent, never the - // bare `goose` that isn't on PATH on a stock Windows install. + // The default must be bundled buzz-agent, never bare `goose` on a stock Windows install. assert_eq!(default_agent_command(), "buzz-agent"); - // And buzz-agent takes no `acp` arg — confirm no arg leakage from the default. assert_eq!( normalize_agent_args(&default_agent_command(), vec!["acp".into()]), Vec::::new() @@ -285,8 +283,10 @@ fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agent name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -359,8 +359,10 @@ fn record_with( name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -974,14 +976,14 @@ fn probe_codex_acp_version_returns_version_when_descendant_holds_pipe_open() { // (the parent closed its write end), read_to_end() returns immediately // without waiting for the descendant to close its inherited fd. // - // `(exec sleep 60 &)` forks a subshell that execs `sleep 60`; the subshell - // inherits the parent's stdout fd and keeps it open. + // `sleep 60 &` starts a descendant that inherits the parent's stdout fd + // without making the direct child wait for a nested subshell to exit. let dir = std::env::temp_dir().join(format!("buzz-probe-descendant-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("create temp dir"); let bin = dir.join("codex-acp"); std::fs::write( &bin, - "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\n(exec sleep 60 &)\nexit 0\n", + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.2'\nsleep 60 &\nexit 0\n", ) .expect("write script"); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 81c2611d5c5..c8e437809ce 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -18,8 +18,10 @@ fn definition( name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], @@ -81,8 +83,10 @@ fn record( name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 33b93d8a52e..553596e226c 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -343,8 +343,10 @@ fn bare_record() -> ManagedAgentRecord { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, @@ -365,8 +367,10 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], @@ -624,8 +628,10 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index a9593816036..d2c415e725c 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -422,8 +422,10 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -480,8 +482,10 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 5b62615a8c1..ea61a811dbc 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; -use buzz_core_pkg::kind::KIND_PERSONA; +use buzz_core_pkg::kind::{persona_event_is_shared, KIND_PERSONA}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; @@ -138,7 +138,11 @@ pub fn build_persona_event(record: &AgentDefinition) -> Result Result Result Result { + let relay_url = crate::relay::relay_ws_url_with_override(state); + let owner_keys = state.signing_keys()?; + flush_pending_events_at(db_path, state, &relay_url, &owner_keys).await +} + +/// Resolve and flush only the currently active `(relay, owner)` scope. +/// +/// The scope snapshots its relay, owner keys, and database path together +/// before network work starts. Switching communities during the flush cannot +/// redirect rows from the old scope into the new relay. +pub async fn flush_active_pending_events( + app: &tauri::AppHandle, + state: &AppState, +) -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await +} + +async fn flush_pending_events_at( + db_path: &std::path::Path, + state: &AppState, + relay_url: &str, + owner_keys: &nostr::Keys, ) -> Result { use crate::managed_agents::retention::{ deferred_behind_failed_tombstone, get_pending_sync, get_retained_event, mark_synced, @@ -228,6 +259,8 @@ pub async fn flush_pending_events( }; use nostr::JsonUtil; + let owner_pubkey = owner_keys.public_key().to_hex(); + let relay_api_base = crate::relay::relay_http_base_url(relay_url); let pending = { let conn = open_retention_db(db_path)?; get_pending_sync(&conn)? @@ -237,6 +270,9 @@ pub async fn flush_pending_events( let mut failed_tombstones: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); for row in pending { + if row.pubkey != owner_pubkey { + continue; + } if deferred_behind_failed_tombstone(row.kind, &row.pubkey, &row.d_tag, &failed_tombstones) { continue; // its tombstone failed this sweep; next sweep re-orders them } @@ -270,9 +306,14 @@ pub async fn flush_pending_events( event }; - if crate::relay::submit_signed_event(&event, state) - .await - .is_err() + if crate::relay::submit_signed_event_at_with_keys( + &event, + state, + &relay_api_base, + owner_keys, + ) + .await + .is_err() { if current.kind == 5 { failed_tombstones.insert((current.pubkey.clone(), current.d_tag.clone())); diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 27d3b0ce066..b9542f9a879 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -50,8 +50,10 @@ fn sample_record() -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -149,8 +151,10 @@ fn sample_persona() -> AgentDefinition { name_pool: vec!["Alpha".to_string(), "Beta".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: Some("test-slug".to_string()), + catalog_source: None, env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), respond_to: None, respond_to_allowlist: Vec::new(), @@ -250,6 +254,25 @@ fn build_persona_event_produces_correct_kind() { assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA); } +#[test] +fn shared_persona_event_has_exact_tag_and_round_trips() { + let mut record = sample_persona(); + record.shared = true; + let event = build_persona_event(&record) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + + let shared_tags: Vec> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().is_some_and(|part| part == "shared")) + .map(|tag| tag.as_slice().iter().map(String::as_str).collect()) + .collect(); + assert_eq!(shared_tags, vec![vec!["shared", "true"]]); + assert!(persona_from_event(&event).unwrap().shared); +} + #[test] fn round_trip_serialization() { let record = sample_persona(); @@ -355,8 +378,10 @@ fn content_matches_nip_ap_vector() { name_pool: vec!["Alpha".to_string(), "Beta".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -384,8 +409,10 @@ fn round_trip_minimal_persona() { name_pool: vec![], is_builtin: true, is_active: false, + shared: false, source_team: Some("team-1".to_string()), source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -479,8 +506,10 @@ fn quad_absent_definition_hash_stable_across_activation() { name_pool: vec!["nib".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -521,8 +550,10 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef name_pool: content.name_pool, is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: content.respond_to, respond_to_allowlist: content.respond_to_allowlist, @@ -880,6 +911,7 @@ mod flush_barrier { } let state = build_app_state(); + *state.keys.lock().unwrap() = keys; *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index b0d874dc782..9bf7ab74b01 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -121,8 +121,10 @@ fn built_in_persona_records(now: &str) -> Vec { name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(), is_builtin: true, is_active: persona.default_active, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -254,10 +256,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 e924345e8be..387b4d72c65 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -18,8 +18,10 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -171,10 +173,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] @@ -317,6 +316,7 @@ fn migrate_preserves_customized_personas() { system_prompt: "My custom research workflow with special instructions".to_string(), is_builtin: false, is_active: true, + shared: false, ..custom_persona("builtin:researcher", "My Researcher") }]; @@ -350,6 +350,7 @@ fn migrate_is_idempotent() { system_prompt: "My custom prompt".to_string(), is_builtin: false, is_active: false, + shared: false, ..custom_persona("builtin:researcher", "Researcher (retired)") }]; assert!( @@ -365,6 +366,7 @@ fn migrate_is_idempotent() { system_prompt: "Custom review prompt".to_string(), is_builtin: true, is_active: true, + shared: false, ..custom_persona("builtin:reviewer", "Reviewer") }]; assert!(migrate_retired_personas(&mut stored_pre_demotion, now)); diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c5480b24793..c053d933c53 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1510,8 +1510,10 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 315e558c549..90f05c5750d 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -32,12 +32,16 @@ use nostr::JsonUtil; /// Reconcile `managed-agents.json` into kind:30177 events in the retention /// store. Boot-time entry point, called from `event_sync::run_event_sync` /// after the persona and team legs. -pub(crate) fn reconcile_agents_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { +pub(crate) fn reconcile_agents_to_events( + app: &tauri::AppHandle, + keys: &nostr::Keys, + db_path: &Path, +) { let Ok(base_dir) = super::managed_agents_base_dir(app) else { return; }; - match reconcile_agents_in_dir(&base_dir, keys) { + match reconcile_agents_in_dir_at(&base_dir, keys, db_path) { Ok(0) => {} Ok(reconciled) => { eprintln!( @@ -61,7 +65,16 @@ pub(crate) fn reconcile_agents_to_events(app: &tauri::AppHandle, keys: &nostr::K /// never churns `pending_sync`. /// /// Returns the number of agents (re)written to the retention store. +#[cfg(test)] pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + reconcile_agents_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) +} + +fn reconcile_agents_in_dir_at( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { let store_path = base_dir.join("managed-agents.json"); if !store_path.exists() { return Ok(0); @@ -79,9 +92,8 @@ pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Re return Ok(0); } - let db_path = base_dir.join("retention.db"); let conn = - open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; + open_retention_db(db_path).map_err(|e| format!("failed to open retention db: {e}"))?; let mut reconciled = 0u32; diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 5df566dbbea..7e97fa1f566 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -5,10 +5,106 @@ //! keyed on `(kind, pubkey, d_tag)`, replacing only on a newer-or-equal //! `created_at` for NIP-33 latest-wins semantics. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use rusqlite::{params, Connection, OptionalExtension}; +use sha2::{Digest, Sha256}; +use tauri::AppHandle; + +use crate::app_state::AppState; + +mod legacy_migration; +pub use legacy_migration::migrate_legacy_retention_db; + +/// Durable event-retention scope for one community relay and owner identity. +/// +/// Persona, team, and managed-agent definitions are workspace-global, but +/// their relay heads and pending publications are not. Keeping a separate +/// database per `(relay_url, owner_pubkey)` prevents a pending write created in +/// community A from being drained into community B after a workspace switch. +pub struct RetentionScope { + pub db_path: PathBuf, + pub relay_url: String, + pub owner_keys: nostr::Keys, +} + +/// Decide whether `scope` — the workspace's active retention scope — is the one +/// that owns an event delivered by `arrival_relay_url`. +/// +/// Inbound reconcile resolves its retention database when it PROCESSES an event, +/// while the event belongs to the community that DELIVERED it. `None` means a +/// workspace switch happened in between and the caller must drop the event +/// rather than file community A's event into community B's store. +/// +/// The comparison goes through the same normalization +/// [`scoped_retention_db_path`] hashes, so "same relay" can never disagree with +/// "same database". +pub fn scope_for_arrival(scope: RetentionScope, arrival_relay_url: &str) -> Option { + let same_scope = + normalized_relay_scope(&scope.relay_url) == normalized_relay_scope(arrival_relay_url); + same_scope.then_some(scope) +} + +/// Relay-URL form that identifies a retention scope: equivalent workspace URLs +/// (surrounding space, trailing slash) must resolve to one scope. +fn normalized_relay_scope(relay_url: &str) -> &str { + relay_url.trim().trim_end_matches('/') +} + +/// Resolve the retention database path for a relay + owner pair. +/// +/// The normalized scope is hashed so relay URLs never become path components. +/// Trimming a trailing slash keeps equivalent workspace URLs on one scope. +pub fn scoped_retention_db_path(base_dir: &Path, relay_url: &str, owner_pubkey: &str) -> PathBuf { + let normalized_relay = normalized_relay_scope(relay_url); + let mut hasher = Sha256::new(); + hasher.update(owner_pubkey.trim().to_ascii_lowercase().as_bytes()); + hasher.update(b"\0"); + hasher.update(normalized_relay.as_bytes()); + let scope_id = hex::encode(hasher.finalize()); + base_dir.join("retention").join(format!("{scope_id}.db")) +} + +/// Snapshot the active relay + owner and resolve their durable event store. +/// +/// Callers keep the returned relay and keys alongside the path whenever work +/// crosses an `.await`; a later workspace switch cannot retarget that work. +pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result { + let relay_url = crate::relay::relay_ws_url_with_override(state); + let owner_keys = state.signing_keys()?; + let base_dir = super::managed_agents_base_dir(app)?; + let db_path = + scoped_retention_db_path(&base_dir, &relay_url, &owner_keys.public_key().to_hex()); + let parent = db_path + .parent() + .ok_or_else(|| "retention scope path has no parent".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create retention scope directory: {error}"))?; + Ok(RetentionScope { + db_path, + relay_url, + owner_keys, + }) +} + +/// Snapshot the active relay + owner, but only when it is the scope that owns +/// events delivered by `arrival_relay_url`. +/// +/// Resolving the scope and matching it in one step is what closes the gap: the +/// returned scope is both the one that will be written to and the one the event +/// arrived on. `Ok(None)` means the arrival community is no longer active and +/// the caller must drop the event — see [`scope_for_arrival`]. +pub fn arrival_retention_scope( + app: &AppHandle, + state: &AppState, + arrival_relay_url: &str, +) -> Result, String> { + Ok(scope_for_arrival( + active_retention_scope(app, state)?, + arrival_relay_url, + )) +} /// A retained persona event row. #[derive(Debug, Clone)] @@ -368,6 +464,64 @@ pub fn get_retained_event( mod tests { use super::*; + #[test] + fn retention_scope_is_stable_and_separates_relay_and_owner() { + let base = Path::new("/tmp/buzz-retention-test"); + let owner_a = "a".repeat(64); + let owner_b = "b".repeat(64); + let community_a = scoped_retention_db_path(base, "wss://a.example/", &owner_a); + assert_eq!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://b.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_b) + ); + } + + #[test] + fn test_arrival_relay_matching_agrees_with_database_identity() { + let base = Path::new("/tmp/buzz-retention-test"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let scope = |relay: &str| RetentionScope { + db_path: scoped_retention_db_path(base, relay, &owner), + relay_url: relay.to_string(), + owner_keys: keys.clone(), + }; + let community_a = scoped_retention_db_path(base, "wss://a.example", &owner); + + // "Same relay" and "same database" must never disagree: every URL the + // match accepts has to hash to the scope's own db path, and every URL it + // rejects has to hash somewhere else. + for equivalent in ["wss://a.example", "wss://a.example/", " wss://a.example "] { + assert_eq!( + scope_for_arrival(scope("wss://a.example"), equivalent).map(|scope| scope.db_path), + Some(community_a.clone()), + "{equivalent}" + ); + assert_eq!( + scoped_retention_db_path(base, equivalent, &owner), + community_a, + "{equivalent}" + ); + } + + assert!( + scope_for_arrival(scope("wss://b.example"), "wss://a.example").is_none(), + "an event from community A must not be filed while community B is active" + ); + assert_ne!( + scoped_retention_db_path(base, "wss://b.example", &owner), + community_a + ); + } + #[test] fn concurrent_open_waits_for_initialization_lock() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs b/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs new file mode 100644 index 00000000000..1975f5d6df9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs @@ -0,0 +1,212 @@ +//! One-time migration of the pre-scoping global retention database into the +//! active relay+owner scope. +//! +//! Before community scoping, every durable event lived in one +//! `/retention.db`. Scoped storage +//! ([`super::scoped_retention_db_path`]) reads a different file, so an upgrade +//! would otherwise abandon whatever the previous release left pending — +//! including signed kind:5 tombstones and NIP-IA archive requests queued while +//! offline, which no reconcile can reconstruct (boot reconcile rebuilds upserts +//! from records still on disk, and deletions have no reconcile at all). +//! +//! # Crash safety +//! +//! Two guards, each written transactionally, make the migration exactly-once +//! without a completion file: +//! +//! 1. A **claim** in the legacy database naming the scope that owns its rows. +//! Legacy rows were queued for whichever single relay the old build had +//! active, so exactly one scope may take them; every other scope skips. This +//! is what keeps the migration from fanning one community's pending events +//! out to all of them — the leak class scoping exists to close. +//! 2. A **marker** in the scoped database, committed in the same transaction as +//! the copied rows. A crash mid-copy therefore leaves neither rows nor +//! marker, and the next boot copies from scratch; once the marker is there +//! the copy never repeats. +//! +//! The relay dimension is not recoverable from the legacy file — only the owner +//! pubkey is — so the claiming scope is the first one this owner activates after +//! upgrading. That is the workspace the app restores at launch, i.e. the same +//! relay the stranded rows were queued for in all but a contrived +//! switch-before-first-flush case. + +use std::path::{Path, PathBuf}; + +use rusqlite::{params, Connection, OptionalExtension}; + +use super::{open_retention_db, RetainedEvent}; + +/// Marker/claim identifier for this migration. +const MIGRATION_NAME: &str = "legacy_global_retention_db"; + +/// The pre-scoping global retention database path. +pub fn legacy_retention_db_path(base_dir: &Path) -> PathBuf { + base_dir.join("retention.db") +} + +/// Copy the legacy global database's rows for `owner_pubkey` into the scoped +/// database at `scope_db_path`. +/// +/// Returns the number of rows copied — `0` both when there is nothing to do and +/// when another scope already claimed the legacy rows. Best-effort by design: +/// the caller logs a failure and proceeds, and the guards make a later retry +/// safe. +pub fn migrate_legacy_retention_db( + base_dir: &Path, + scope_db_path: &Path, + owner_pubkey: &str, +) -> Result { + let legacy_path = legacy_retention_db_path(base_dir); + if !legacy_path.exists() || legacy_path == scope_db_path { + return Ok(0); + } + + let scope_id = scope_identifier(scope_db_path); + let mut scope_conn = open_retention_db(scope_db_path)?; + if migration_marker_present(&scope_conn)? { + return Ok(0); + } + + let legacy_conn = open_retention_db(&legacy_path)?; + if !claim_legacy_rows(&legacy_conn, &scope_id)? { + return Ok(0); // another scope owns these rows + } + + let rows = legacy_rows_for_owner(&legacy_conn, owner_pubkey)?; + let copied = rows.len(); + + let transaction = scope_conn + .transaction() + .map_err(|e| format!("failed to open retention migration transaction: {e}"))?; + for row in &rows { + // The scoped database is authoritative for any coordinate it already + // holds: those rows were written after the upgrade, so they are newer + // than anything legacy by construction. Legacy rows only fill gaps. + transaction + .execute( + "INSERT INTO persona_events + (kind, pubkey, d_tag, content, created_at, raw_event, pending_sync) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT (kind, pubkey, d_tag) DO NOTHING", + params![ + row.kind, + row.pubkey, + row.d_tag, + row.content, + row.created_at, + row.raw_event, + row.pending_sync as i32, + ], + ) + .map_err(|e| format!("failed to copy legacy retained event: {e}"))?; + } + write_migration_marker(&transaction, &scope_id)?; + transaction + .commit() + .map_err(|e| format!("failed to commit retention migration: {e}"))?; + + Ok(copied) +} + +/// Read every retained row authored by `owner_pubkey` from the legacy database. +/// +/// Owner-filtered because the flush loop only publishes rows matching the +/// active owner anyway; a different identity's rows belong to that identity's +/// scope, not this one. +fn legacy_rows_for_owner( + conn: &Connection, + owner_pubkey: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + FROM persona_events + WHERE pubkey = ?1 + ORDER BY (kind != 5), created_at ASC", + ) + .map_err(|e| format!("failed to prepare legacy retention query: {e}"))?; + + let rows = stmt + .query_map(params![owner_pubkey], |row| { + Ok(RetainedEvent { + kind: row.get(0)?, + pubkey: row.get(1)?, + d_tag: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + raw_event: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + }) + }) + .map_err(|e| format!("failed to query legacy retained events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("failed to read legacy retained row: {e}")) +} + +/// Identify a scope by its database file stem — the relay+owner hash +/// [`super::scoped_retention_db_path`] already computes. +fn scope_identifier(scope_db_path: &Path) -> String { + scope_db_path + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_default() +} + +fn ensure_migration_table(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS retention_migrations ( + name TEXT PRIMARY KEY, + scope_id TEXT NOT NULL + );", + ) + .map_err(|e| format!("failed to create retention migration table: {e}")) +} + +fn migration_marker_present(conn: &Connection) -> Result { + ensure_migration_table(conn)?; + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM retention_migrations WHERE name = ?1)", + params![MIGRATION_NAME], + |row| row.get(0), + ) + .map_err(|e| format!("failed to read retention migration marker: {e}")) +} + +fn write_migration_marker(conn: &Connection, scope_id: &str) -> Result<(), String> { + ensure_migration_table(conn)?; + conn.execute( + "INSERT OR REPLACE INTO retention_migrations (name, scope_id) VALUES (?1, ?2)", + params![MIGRATION_NAME, scope_id], + ) + .map_err(|e| format!("failed to write retention migration marker: {e}"))?; + Ok(()) +} + +/// Record `scope_id` as the owner of the legacy rows, or confirm it already is. +/// +/// `INSERT OR IGNORE` then read-back is atomic enough for this purpose: the +/// loser of a race reads the winner's scope id and returns `false`. +fn claim_legacy_rows(legacy_conn: &Connection, scope_id: &str) -> Result { + ensure_migration_table(legacy_conn)?; + legacy_conn + .execute( + "INSERT OR IGNORE INTO retention_migrations (name, scope_id) VALUES (?1, ?2)", + params![MIGRATION_NAME, scope_id], + ) + .map_err(|e| format!("failed to claim legacy retention rows: {e}"))?; + + let claimed_by: Option = legacy_conn + .query_row( + "SELECT scope_id FROM retention_migrations WHERE name = ?1", + params![MIGRATION_NAME], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("failed to read legacy retention claim: {e}"))?; + + Ok(claimed_by.as_deref() == Some(scope_id)) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs b/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs new file mode 100644 index 00000000000..75da221320e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs @@ -0,0 +1,186 @@ +use super::*; +use crate::managed_agents::retention::{ + get_pending_sync, get_retained_event, retain_event, scoped_retention_db_path, + tombstone_retention_d_tag, +}; +use buzz_core_pkg::kind::KIND_PERSONA; + +const KIND_DELETE: u32 = 5; +const OWNER: &str = "a1b2c3"; + +fn pending_tombstone(d_tag: &str) -> RetainedEvent { + RetainedEvent { + kind: KIND_DELETE, + pubkey: OWNER.to_string(), + d_tag: tombstone_retention_d_tag(KIND_PERSONA, d_tag), + content: String::new(), + created_at: 1_700_000_000, + raw_event: format!(r#"{{"kind":5,"d":"{d_tag}"}}"#), + pending_sync: true, + } +} + +fn seed_legacy(base_dir: &Path, events: &[RetainedEvent]) { + let conn = open_retention_db(&legacy_retention_db_path(base_dir)).unwrap(); + for event in events { + retain_event(&conn, event).unwrap(); + } +} + +fn scope_path(base_dir: &Path, relay: &str) -> PathBuf { + let path = scoped_retention_db_path(base_dir, relay, OWNER); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + path +} + +#[test] +fn test_pending_legacy_tombstone_migrates_into_the_active_scope_and_stays_pending() { + let dir = tempfile::tempdir().unwrap(); + seed_legacy(dir.path(), &[pending_tombstone("retired-agent")]); + let scope = scope_path(dir.path(), "wss://a.example"); + + let copied = migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(); + + assert_eq!(copied, 1); + let conn = open_retention_db(&scope).unwrap(); + let migrated = get_retained_event( + &conn, + KIND_DELETE, + OWNER, + &tombstone_retention_d_tag(KIND_PERSONA, "retired-agent"), + ) + .unwrap() + .expect("legacy tombstone lands in the scoped db"); + assert!( + migrated.pending_sync, + "the tombstone must still be queued for the flush loop" + ); + assert_eq!( + migrated.raw_event, + pending_tombstone("retired-agent").raw_event + ); + assert_eq!(get_pending_sync(&conn).unwrap().len(), 1); +} + +#[test] +fn test_repeat_migration_of_the_same_scope_copies_nothing_further() { + let dir = tempfile::tempdir().unwrap(); + seed_legacy(dir.path(), &[pending_tombstone("retired-agent")]); + let scope = scope_path(dir.path(), "wss://a.example"); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(), + 1 + ); + + // Simulate the flush loop clearing the row, then boot again: the marker + // must stop the legacy row from being resurrected as pending. + let conn = open_retention_db(&scope).unwrap(); + conn.execute("UPDATE persona_events SET pending_sync = 0", []) + .unwrap(); + drop(conn); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(), + 0 + ); + let conn = open_retention_db(&scope).unwrap(); + assert!( + get_pending_sync(&conn).unwrap().is_empty(), + "a published row must not be re-queued by a second migration pass" + ); +} + +#[test] +fn test_second_community_does_not_receive_another_communitys_legacy_rows() { + let dir = tempfile::tempdir().unwrap(); + seed_legacy(dir.path(), &[pending_tombstone("retired-agent")]); + let first = scope_path(dir.path(), "wss://a.example"); + let second = scope_path(dir.path(), "wss://b.example"); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &first, OWNER).unwrap(), + 1 + ); + assert_eq!( + migrate_legacy_retention_db(dir.path(), &second, OWNER).unwrap(), + 0, + "legacy rows belong to exactly one relay scope" + ); + + let conn = open_retention_db(&second).unwrap(); + assert!(get_pending_sync(&conn).unwrap().is_empty()); +} + +#[test] +fn test_rows_authored_by_another_identity_are_left_behind() { + let dir = tempfile::tempdir().unwrap(); + let mut foreign = pending_tombstone("someone-elses"); + foreign.pubkey = "ffffff".to_string(); + seed_legacy(dir.path(), &[pending_tombstone("mine"), foreign]); + let scope = scope_path(dir.path(), "wss://a.example"); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(), + 1 + ); + + let conn = open_retention_db(&scope).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].pubkey, OWNER); +} + +#[test] +fn test_post_upgrade_scoped_row_is_not_overwritten_by_its_legacy_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let legacy_head = RetainedEvent { + kind: KIND_PERSONA, + pubkey: OWNER.to_string(), + d_tag: "reviewer".to_string(), + content: r#"{"display_name":"Old"}"#.to_string(), + created_at: 1_700_000_000, + raw_event: r#"{"content":"old"}"#.to_string(), + pending_sync: true, + }; + seed_legacy(dir.path(), &[legacy_head]); + let scope = scope_path(dir.path(), "wss://a.example"); + + // An edit made after the upgrade already occupies the coordinate. + let conn = open_retention_db(&scope).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: KIND_PERSONA, + pubkey: OWNER.to_string(), + d_tag: "reviewer".to_string(), + content: r#"{"display_name":"New"}"#.to_string(), + created_at: 1_700_000_500, + raw_event: r#"{"content":"new"}"#.to_string(), + pending_sync: true, + }, + ) + .unwrap(); + drop(conn); + + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(); + + let conn = open_retention_db(&scope).unwrap(); + let row = get_retained_event(&conn, KIND_PERSONA, OWNER, "reviewer") + .unwrap() + .unwrap(); + assert_eq!(row.created_at, 1_700_000_500); + assert_eq!(row.raw_event, r#"{"content":"new"}"#); +} + +#[test] +fn test_absent_legacy_database_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + let scope = scope_path(dir.path(), "wss://a.example"); + + assert_eq!( + migrate_legacy_retention_db(dir.path(), &scope, OWNER).unwrap(), + 0 + ); + assert!(!legacy_retention_db_path(dir.path()).exists()); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 8deb0c4da92..3f6ee996f6c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -53,8 +53,7 @@ fn identifier_exact_match_at_end_of_buffer() { #[test] fn longer_id_matches_when_short_prefix_also_present() { - // Searching for the longer ID finds it even when a shorter prefix token - // appears earlier — Thufir's "longer-of-prefix must match" case. + // The longer ID still matches when a shorter prefix token appears earlier. let mut buf = b"xyz.block.buzz.app".to_vec(); buf.push(0); buf.extend_from_slice(br#""identifier":"xyz.block.buzz.app.dev""#); @@ -72,8 +71,7 @@ fn identifier_empty_returns_false() { #[test] fn marker_entry_is_namespaced_by_instance_id() { - // The spawn stamp and the sweep matcher must produce identical bytes; - // both go through buzz_marker_entry, so this pins the on-the-wire + // The spawn stamp and sweep matcher both go through buzz_marker_entry, pinning the on-the-wire // format and guards against a dev build (`...app.dev`) matching a // release build's (`...app`) agents. assert_eq!( @@ -175,8 +173,10 @@ fn fixture( name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -294,8 +294,10 @@ fn persona_with_provider( name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 686ad52d4f0..f4ad4048143 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -49,8 +49,10 @@ fn record() -> ManagedAgentRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -70,8 +72,10 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index d88a362723b..96082acc76d 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -301,9 +301,11 @@ mod tests { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: Some("SENTINEL_SOURCE_TEAM".to_string()), // MUST NOT appear source_team_persona_slug: Some("SENTINEL_SLUG".to_string()), // MUST NOT appear definition_respond_to: None, + catalog_source: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 140ac3cab96..1ffa60eda97 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -208,8 +208,10 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { name_pool: vec![], is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, relay_mesh: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index dcb8095a7cf..3d8e0ed02ba 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -40,6 +40,13 @@ pub struct AgentDefinition { pub is_builtin: bool, #[serde(default = "default_record_active")] pub is_active: bool, + /// Whether this persona is discoverable in the currently active community. + /// + /// This is a command/view projection only. Durable share state lives in + /// the relay+owner-scoped retention head so one workspace's choice cannot + /// leak into another workspace's definition record. + #[serde(default)] + pub shared: bool, /// Team ID if this persona was imported from a team directory. /// Team personas are non-editable (system_prompt, model locked). #[serde( @@ -57,6 +64,13 @@ pub struct AgentDefinition { alias = "source_pack_persona_slug" )] pub source_team_persona_slug: Option, + /// Provenance of a persona copied from another owner's shared catalog. + /// + /// Set only on the copy, never on the original. It is what makes + /// "already added" answerable for a foreign catalog entry: the copy carries + /// a new local id, so the only link back to the publication is this pair. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_source: Option, /// Harness-level configuration passed to the agent subprocess as environment variables. /// Opaque to Buzz — keys and values are runtime-specific. /// @@ -130,8 +144,11 @@ impl AgentDefinition { name_pool: self.name_pool, is_builtin: self.is_builtin, is_active: self.is_active, + // Catalog visibility is relay+owner scoped, not definition-global. + shared: false, source_team: self.source_team, source_team_persona_slug: self.source_team_persona_slug, + catalog_source: self.catalog_source, definition_respond_to: self.respond_to, definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, @@ -161,8 +178,11 @@ impl ManagedAgentRecord { name_pool: self.name_pool.clone(), is_builtin: self.is_builtin, is_active: self.is_active, + // Projected by `list_personas` from the active retention scope. + shared: false, source_team: self.source_team.clone(), source_team_persona_slug: self.source_team_persona_slug.clone(), + catalog_source: self.catalog_source.clone(), env_vars: self.env_vars.clone(), respond_to: self.definition_respond_to.clone(), respond_to_allowlist: self.definition_respond_to_allowlist.clone(), @@ -368,6 +388,13 @@ pub struct ManagedAgentRecord { /// definition hidden from pickers. Defaults `true` for existing records. #[serde(default = "default_record_active")] pub is_active: bool, + /// Legacy process-global catalog visibility field. + /// + /// New writes omit it and definition views ignore it. It remains + /// deserializable for branch-era stores, but active visibility is projected + /// from the relay+owner-scoped retention database instead. + #[serde(default, skip_serializing)] + pub shared: bool, /// Absorbed from `AgentDefinition.source_team` — team ID when this /// definition was imported from a team directory (team definitions are /// non-editable). Distinct from `persona_team_dir`/`persona_name_in_team`, @@ -378,6 +405,10 @@ pub struct ManagedAgentRecord { /// definition's slug within its source team. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_team_persona_slug: Option, + /// Absorbed from `AgentDefinition.catalog_source` — the publication this + /// definition was copied from, when it came from another owner's catalog. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_source: Option, /// NIP-AP definition-level behavioral defaults, absorbed from /// `AgentDefinition` in WIRE shape (kebab-case string / optional u32), /// distinct from the instance-side `respond_to`/`respond_to_allowlist`/ @@ -954,6 +985,8 @@ pub fn resolve_mint_behavioral_defaults( }) } +mod catalog_source; +pub use catalog_source::CatalogSource; mod requests; pub use requests::*; diff --git a/desktop/src-tauri/src/managed_agents/types/catalog_source.rs b/desktop/src-tauri/src/managed_agents/types/catalog_source.rs new file mode 100644 index 00000000000..237ffbbfe9b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/catalog_source.rs @@ -0,0 +1,52 @@ +//! The catalog-provenance coordinate carried on a copied persona +//! definition, split from `types.rs` (file-size cap). + +use serde::{Deserialize, Serialize}; + +/// Where a persona copy came from in another owner's shared catalog. +/// +/// The pair is the publication's NIP-AP coordinate minus the kind: the owner +/// who published it and the `d`-tag identifying the persona within that +/// owner's catalog. A copy carries a fresh local `id`, so this pair is the +/// only thing that can answer "is this catalog entry already added". +/// +/// Field casing follows [`super::RelayMeshConfig`]: persisted records use snake_case +/// and the camelCase `alias`es accept the create payload the frontend sends +/// (`rename_all` on the request does not recurse into nested structs). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CatalogSource { + #[serde(alias = "ownerPubkey")] + pub owner_pubkey: String, + #[serde(alias = "personaId")] + pub persona_id: String, +} + +impl CatalogSource { + /// Normalize a coordinate arriving from the frontend. + /// + /// "Already added" is decided by comparing this pair against a + /// publication's author and `d`-tag, so an un-normalized value silently + /// fails to match and mints another copy — the exact duplicate the field + /// exists to prevent. Owner pubkey: 64 hex, any case in, lowercase out + /// (same contract as [`super::validate_respond_to_allowlist`]). Persona id: the + /// publication's `d`-tag, trimmed and required. + pub fn normalized(self) -> Result { + let owner_pubkey = self.owner_pubkey.trim().to_ascii_lowercase(); + if owner_pubkey.len() != 64 || !owner_pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "invalid catalog source owner pubkey: '{owner_pubkey}' (must be 64 hex chars)" + )); + } + let persona_id = self.persona_id.trim().to_string(); + if persona_id.is_empty() { + return Err("catalog source persona id is required".to_string()); + } + Ok(Self { + owner_pubkey, + persona_id, + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/types/catalog_source/tests.rs b/desktop/src-tauri/src/managed_agents/types/catalog_source/tests.rs new file mode 100644 index 00000000000..1cdb891c0ab --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/catalog_source/tests.rs @@ -0,0 +1,62 @@ +use super::CatalogSource; + +fn source(owner_pubkey: &str, persona_id: &str) -> CatalogSource { + CatalogSource { + owner_pubkey: owner_pubkey.to_string(), + persona_id: persona_id.to_string(), + } +} + +#[test] +fn normalized_lowercases_and_trims_the_owner_pubkey() { + // "Already added" compares this against a publication's author hex, which + // is always lowercase — a mixed-case value from the UI must not miss. + let normalized = source(&format!(" {} ", "A".repeat(64)), " helper ") + .normalized() + .expect("64 hex chars with surrounding space is valid"); + assert_eq!(normalized.owner_pubkey, "a".repeat(64)); + assert_eq!(normalized.persona_id, "helper"); +} + +#[test] +fn normalized_rejects_a_short_owner_pubkey() { + let err = source("abc123", "helper").normalized().unwrap_err(); + assert!(err.contains("64 hex"), "error must name the rule: {err}"); +} + +#[test] +fn normalized_rejects_a_non_hex_owner_pubkey() { + let err = source(&"z".repeat(64), "helper").normalized().unwrap_err(); + assert!(err.contains("64 hex"), "error must name the rule: {err}"); +} + +#[test] +fn normalized_rejects_a_blank_persona_id() { + let err = source(&"a".repeat(64), " ").normalized().unwrap_err(); + assert!( + err.contains("persona id"), + "error must name the field: {err}" + ); +} + +#[test] +fn deserializes_the_camel_case_payload_the_frontend_sends() { + // `rename_all` on CreatePersonaRequest does not recurse into this struct, + // so without the aliases the copy request fails at the Tauri boundary. + let parsed: CatalogSource = + serde_json::from_str(r#"{"ownerPubkey":"abc","personaId":"helper"}"#) + .expect("camelCase payload from TS should deserialize"); + assert_eq!(parsed, source("abc", "helper")); +} + +#[test] +fn round_trips_persisted_snake_case() { + let value = source(&"a".repeat(64), "helper"); + let json = serde_json::to_string(&value).unwrap(); + assert!(json.contains("owner_pubkey"), "persisted shape: {json}"); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + value, + "the camelCase alias must not break the stored-record round trip" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 58d60218a13..e28b0bd461a 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use super::{ default_start_on_app_launch, validate_respond_to_allowlist, AgentDefinition, BackendKind, - RelayMeshConfig, RespondTo, + CatalogSource, RelayMeshConfig, RespondTo, }; /// The NIP-AP behavioral group as one grouped request field. @@ -91,6 +91,10 @@ pub struct CreatePersonaRequest { /// NIP-AP behavioral group. Absent = behavior group stays unset. #[serde(default)] pub behavior: Option, + /// Set when this persona is a copy of another owner's shared catalog entry, + /// so the catalog can tell an already-added foreign persona from a new one. + #[serde(default)] + pub catalog_source: Option, } #[derive(Debug, Deserialize)] @@ -275,8 +279,10 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -428,4 +434,37 @@ mod tests { .unwrap(); assert_eq!(record.parallelism, Some(8)); } + + /// The catalog copy path is the only caller that sends this field, and it + /// sends camelCase from TS. Without it deserializing, the copy silently + /// lands with no provenance and duplicate-add returns. + #[test] + fn create_request_deserializes_camel_case_catalog_source() { + let request: CreatePersonaRequest = serde_json::from_str( + r#"{ + "displayName": "Copy", + "avatarUrl": null, + "systemPrompt": "Prompt", + "catalogSource": { "ownerPubkey": "abc", "personaId": "helper" } + }"#, + ) + .expect("camelCase catalogSource payload from TS should deserialize"); + assert_eq!( + request.catalog_source, + Some(CatalogSource { + owner_pubkey: "abc".to_string(), + persona_id: "helper".to_string(), + }) + ); + } + + /// Ordinary agent creation never sends the field. + #[test] + fn create_request_without_catalog_source_is_not_a_catalog_copy() { + let request: CreatePersonaRequest = serde_json::from_str( + r#"{ "displayName": "Fresh", "avatarUrl": null, "systemPrompt": "Prompt" }"#, + ) + .expect("a create payload without provenance should deserialize"); + assert_eq!(request.catalog_source, None); + } } diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 667a41a538a..96ed5560689 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -1,4 +1,4 @@ -use super::{AgentDefinition, ManagedAgentRecord}; +use super::{AgentDefinition, CatalogSource, ManagedAgentRecord}; use std::path::PathBuf; #[test] @@ -482,8 +482,10 @@ fn sample_persona() -> AgentDefinition { name_pool: vec!["Nimble".to_string()], is_builtin: false, is_active: true, + shared: false, source_team: Some("team-1".to_string()), source_team_persona_slug: Some("helper".to_string()), + catalog_source: None, env_vars: [("K".to_string(), "v".to_string())].into_iter().collect(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -493,6 +495,49 @@ fn sample_persona() -> AgentDefinition { } } +#[test] +fn persona_record_without_catalog_source_deserializes_and_omits_it() { + // Every persona already on disk predates the field — an old record must + // load as "not a catalog copy" and must not gain a null key on save. + let record: AgentDefinition = serde_json::from_str( + r#"{ + "id": "persona-1", + "display_name": "Test", + "avatar_url": null, + "system_prompt": "Prompt", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#, + ) + .expect("pre-catalog-source persona should deserialize"); + + assert_eq!(record.catalog_source, None); + let json = serde_json::to_string(&record).unwrap(); + assert!( + !json.contains("catalog_source"), + "absent provenance must stay absent on disk: {json}" + ); +} + +#[test] +fn persona_catalog_source_survives_the_agent_store_fold() { + // Provenance is only useful if it is still there on the next launch, and + // `save_personas` funnels every definition through `into_agent_record`. + let mut persona = sample_persona(); + persona.catalog_source = Some(CatalogSource { + owner_pubkey: "a".repeat(64), + persona_id: "helper".to_string(), + }); + + let view = persona + .clone() + .into_agent_record() + .to_definition_view() + .expect("slugged record must present a persona view"); + + assert_eq!(view.catalog_source, persona.catalog_source); +} + #[test] fn persona_into_agent_record_is_keyless_and_slugged() { let record = sample_persona().into_agent_record(); diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index ce6d495a472..809fab89933 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -412,6 +412,7 @@ mod tests { is_active: true, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: std::collections::BTreeMap::from([ ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), ( diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 2a93c00185b..39dfc988ddf 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -35,8 +35,10 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati name_pool: vec!["Fizzy".to_string()], is_builtin: true, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1c9ba0095af..f8966956241 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -532,49 +532,9 @@ pub struct AgentProfileInfo { // ── Signed-event submission ───────────────────────────────────────────────── mod submit; -pub use submit::{submit_event, submit_event_at_with_keys, SubmitEventResponse}; - -/// POST an already-signed event to `/events` with NIP-98 auth. -/// -/// The persona flush loop drains pre-signed events from the retention store, -/// so it must publish them verbatim — re-signing through `submit_event` would -/// mint a new `created_at`/signature and break the compare-and-clear that -/// `mark_synced` relies on. Only the NIP-98 request auth is signed here (with -/// the owner keys), and that lock is dropped before the `.await`. -pub async fn submit_signed_event( - event: &nostr::Event, - state: &AppState, -) -> Result { - crate::relay_admission::wait_for_rate_limit().await; - let url = format!("{}/events", relay_api_base_url_with_override(state)); - let body_bytes = event.as_json().into_bytes(); - let auth_header = { - let keys = state.signing_keys()?; - build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body_bytes)? - }; // keys dropped here - - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; - - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - let result: SubmitEventResponse = parse_json_response(response).await?; - - if !result.accepted { - return Err(format!("relay rejected event: {}", result.message)); - } - - Ok(result) -} +pub use submit::{ + submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse, +}; /// Sign an event with explicit keys and POST it to `/events` with NIP-98 auth. /// diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index 7fb3f94041d..2a42d86c2b1 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -8,22 +8,22 @@ pub struct SubmitEventResponse { pub message: String, } -/// Sign with an explicit identity and POST the event to an explicit relay. +/// POST an already-signed event to an explicit relay with an explicit owner. /// -/// The caller owns the signer lifetime. This is important for deferred work: -/// an in-process identity swap cannot retarget the event or its NIP-98 auth -/// after the caller has validated which identity the operation belongs to. -pub async fn submit_event_at_with_keys( - builder: nostr::EventBuilder, +/// Deferred/scoped publication uses this form so a workspace or identity +/// switch cannot retarget either the event or its NIP-98 authentication after +/// the operation captured its `(relay, owner)` scope. +pub async fn submit_signed_event_at_with_keys( + event: &nostr::Event, state: &AppState, api_base_url: &str, keys: &nostr::Keys, ) -> Result { + if event.pubkey != keys.public_key() { + return Err("signed event does not match the publishing identity".to_string()); + } crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/events", api_base_url.trim_end_matches('/')); - let event = builder - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event: {e}"))?; let body_bytes = event.as_json().into_bytes(); let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; @@ -49,6 +49,23 @@ pub async fn submit_event_at_with_keys( Ok(result) } +/// Sign with an explicit identity and POST the event to an explicit relay. +/// +/// The caller owns the signer lifetime. This is important for deferred work: +/// an in-process identity swap cannot retarget the event or its NIP-98 auth +/// after the caller has validated which identity the operation belongs to. +pub async fn submit_event_at_with_keys( + builder: nostr::EventBuilder, + state: &AppState, + api_base_url: &str, + keys: &nostr::Keys, +) -> Result { + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + submit_signed_event_at_with_keys(&event, state, api_base_url, keys).await +} + /// Build and submit an event to the currently active workspace relay. pub async fn submit_event( builder: nostr::EventBuilder, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 877cd948ad6..75f57257ccc 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -167,7 +167,10 @@ export function AppShell() { const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( identityQuery.data?.pubkey, ); - usePersonaSync(identityQuery.data?.pubkey); + usePersonaSync( + identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, + ); useAgentsDataRefresh(); // Chunk F: auto-restart drifted idle agents (per-agent opt-out, default ON). useAutoRestartPolicy(); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 06e6c02acbb..35ad4a63af5 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -106,6 +106,14 @@ with a TypeScript lookup table or an id comparison in a component. Edit. In Edit, selecting Custom command keeps its required command field beside the harness picker rather than hiding it in Advanced. +10. **Catalog visibility is community-scoped relay state, never a global + definition field.** `AgentDefinition.shared` is only the active + relay+owner projection returned to the UI. Durable heads and pending + publications live in the scoped retention database, and explicit share + toggles await relay acceptance before the UI claims that an agent was + published or removed. A queued update must stay visibly queued, and the + catalog itself must render only relay-confirmed publications — never an + optimistic local persona. ## The tests that enforce this @@ -124,6 +132,8 @@ with a TypeScript lookup table or an id comparison in a component. acceptance coverage for readiness, failure states, defaults, navigation, successful-empty vs failed optional-model discovery, and persistence races. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. +- Rust: persona sharing/retention tests pin relay+owner scoping, durable + enqueue errors, relay rejection/unavailability, and accepted publication. ## Keep this file true 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 00000000000..b89f4c61c93 --- /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 7fa72f4f3ee..62e809bdb5c 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 226aafca5e3..fabc0af87ec 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 9439d4a36e0..00000000000 --- 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 38b2d5d9743..00000000000 --- 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 00000000000..24f6959b1cf --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -0,0 +1,460 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { emojiAvatarDataUrl } from "@/features/profile/ui/ProfileAvatarEditor.utils.ts"; +import { + catalogPersonasFromPublications, + catalogPublicationsFromEvents, + fetchPersonaCatalogPublications, + personaEventIsShared, +} from "./personaCatalogRelay.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); + +function personaEvent({ + createdAt, + id, + owner = ALICE, + sourcePersonaId = "reviewer", + shared = true, + avatarUrl = null, + respondTo = null, + sharedTag, +}) { + return { + id, + pubkey: owner, + created_at: createdAt, + kind: 30175, + tags: [ + ["d", sourcePersonaId], + ...(shared + ? [sharedTag ?? ["shared", "true"]] + : sharedTag + ? [sharedTag] + : []), + ], + content: JSON.stringify({ + display_name: "Relay Reviewer", + system_prompt: "Review changes.", + avatar_url: avatarUrl, + runtime: "goose", + model: "claude", + provider: null, + name_pool: ["Reviewer"], + respond_to: respondTo, + respond_to_allowlist: respondTo === "allowlist" ? [BOB] : undefined, + parallelism: 4, + }), + sig: "sig", + }; +} + +test("a shared kind 30175 persona from Alice is discoverable by Bob", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ 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].shared, true); + assert.equal(personas[0].catalogSource.ownerPubkey, ALICE); + assert.equal(personas[0].catalogSource.isOwn, false); +}); + +test("a newer unshared head hides the older shared head", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "shared" }), + personaEvent({ createdAt: 2, id: "unshared", shared: false }), + ]); + + assert.deepEqual(publications, []); +}); + +test("persona coordinates remain independent across authors", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice", owner: ALICE }), + personaEvent({ createdAt: 1, id: "bob", owner: BOB }), + ]); + + assert.equal(publications.length, 2); + assert.equal( + catalogPersonasFromPublications(publications, [], BOB).length, + 2, + ); +}); + +test("equal-second persona heads use the relay lowest-id tie-break", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "b".repeat(64), + shared: true, + }), + personaEvent({ + createdAt: 1, + id: "a".repeat(64), + shared: false, + }), + ]); + + assert.deepEqual(publications, []); +}); + +test("an invalid canonical head does not resurrect an older shared persona", () => { + const invalidHead = { + ...personaEvent({ createdAt: 2, id: "a".repeat(64) }), + content: "{}", + }; + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "older-valid" }), + invalidHead, + ]); + + assert.deepEqual(publications, []); +}); + +test("only an exact shared true tag opts a persona into discovery", () => { + assert.equal( + personaEventIsShared(personaEvent({ createdAt: 1, id: "exact-shared" })), + true, + ); + for (const [index, sharedTag] of [ + ["shared"], + ["shared", "false"], + ["shared", "true", "extra"], + ].entries()) { + const event = personaEvent({ + createdAt: index + 2, + id: `malformed-${index}`, + shared: false, + sharedTag, + }); + assert.equal(personaEventIsShared(event), false); + assert.deepEqual(catalogPublicationsFromEvents([event]), []); + } + const duplicate = personaEvent({ + createdAt: 5, + id: "duplicate", + }); + duplicate.tags.push(["shared", "true"]); + assert.equal(personaEventIsShared(duplicate), false); +}); + +test("catalog avatars keep bounded http URLs and drop unsafe schemes", () => { + const safe = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "safe-avatar", + avatarUrl: "https://relay.example/avatar.png", + }), + ]), + [], + BOB, + ); + assert.equal(safe[0].avatarUrl, "https://relay.example/avatar.png"); + + const unsafe = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "unsafe-avatar", + avatarUrl: "javascript:alert(1)", + }), + ]), + [], + BOB, + ); + assert.equal(unsafe[0].avatarUrl, null); +}); + +/** The avatar a catalog entry projects for `avatarUrl`, or null if dropped. */ +function catalogAvatarUrl(avatarUrl) { + const personas = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "avatar-vector", avatarUrl }), + ]), + [], + BOB, + ); + return personas[0].avatarUrl; +} + +// An emoji avatar is self-contained, so it is the one `data:` avatar that can +// render on another member's machine. Dropping it left shared agents looking +// avatar-less in the catalog. +test("test_percent_encoded_emoji_svg_avatar_survives_the_catalog", () => { + const emojiAvatar = emojiAvatarDataUrl("🐝", "#FFCC00"); + + assert.equal(catalogAvatarUrl(emojiAvatar), emojiAvatar); +}); + +test("test_base64_svg_avatar_is_rejected", () => { + assert.equal( + catalogAvatarUrl(`data:image/svg+xml;base64,${btoa("")}`), + null, + ); +}); + +test("test_non_svg_data_avatar_is_rejected", () => { + assert.equal(catalogAvatarUrl("data:image/png,%89PNG"), null); +}); + +test("test_oversized_inline_svg_avatar_is_rejected", () => { + const withinCap = `data:image/svg+xml,${"a".repeat(8_192 - "data:image/svg+xml,".length)}`; + assert.equal(withinCap.length, 8_192); + assert.equal(catalogAvatarUrl(withinCap), withinCap); + assert.equal(catalogAvatarUrl(`${withinCap}a`), null); +}); + +// Catalog avatars render through `` (ProfileAvatar → AvatarImage), +// where an SVG document is never scripted, so a script-bearing avatar is +// accepted and inert rather than filtered — the projection must not silently +// start sanitizing markup it does not render. +test("test_script_bearing_inline_svg_avatar_is_accepted_and_rendered_inert", () => { + const scripted = `data:image/svg+xml,${encodeURIComponent( + '', + )}`; + + assert.equal(catalogAvatarUrl(scripted), scripted); +}); + +test("foreign allowlist behavior imports as owner-only", () => { + const personas = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "allowlist", + respondTo: "allowlist", + }), + ]), + [], + BOB, + ); + + assert.equal(personas[0].respondTo, "owner-only"); + assert.deepEqual(personas[0].respondToAllowlist, []); +}); + +test("a pending local share does not appear before relay confirmation", () => { + const localPersona = { + id: "local-reviewer", + displayName: "Local Reviewer", + avatarUrl: null, + systemPrompt: "Review local changes.", + runtime: null, + model: null, + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + shared: true, + sourceTeam: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: "2026-07-26T00:00:00.000Z", + updatedAt: "2026-07-26T00:00:00.000Z", + }; + + const personas = catalogPersonasFromPublications([], [localPersona], ALICE); + assert.deepEqual(personas, []); +}); + +function localPersona(overrides = {}) { + return { + id: "local-1", + displayName: "Relay Reviewer", + avatarUrl: null, + systemPrompt: "Review changes.", + runtime: null, + model: null, + provider: null, + namePool: [], + isBuiltIn: false, + isActive: true, + shared: false, + sourceTeam: null, + catalogSource: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: "2026-07-26T00:00:00.000Z", + updatedAt: "2026-07-26T00:00:00.000Z", + ...overrides, + }; +} + +// The duplicate-add bug: a copy of Alice's entry carries a fresh local UUID, so +// matching by id finds nothing and the catalog offers "Add" again. Only the +// stored catalogSource coordinate links the copy back to the publication. +test("test_added_foreign_catalog_entry_resolves_to_its_local_copy", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + const copy = localPersona({ + id: "a-fresh-uuid", + catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, + }); + + const personas = catalogPersonasFromPublications(publications, [copy], BOB); + + assert.equal(personas.length, 1); + assert.equal( + personas[0].id, + "a-fresh-uuid", + "the projection must resolve to the existing local copy, not a synthetic id", + ); + assert.equal( + personas[0].isActive, + true, + "an added foreign entry must read as already selected", + ); +}); + +test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + // A same-named local persona with no provenance is a different agent. + const unrelated = localPersona({ id: "unrelated" }); + + const personas = catalogPersonasFromPublications( + publications, + [unrelated], + BOB, + ); + + assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].isActive, false); +}); + +// Provenance is per-owner: the same d-tag under a different publisher is a +// different agent, so a copy of Alice's must not mask Bob's entry. +test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "bob-reviewer", owner: BOB }), + ]); + const copyOfAlices = localPersona({ + id: "copy-of-alices", + catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, + }); + + const personas = catalogPersonasFromPublications( + publications, + [copyOfAlices], + ALICE, + ); + + assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].isActive, false); +}); + +test("test_own_publication_still_resolves_by_local_id", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + const own = localPersona({ id: "reviewer", shared: true }); + + const personas = catalogPersonasFromPublications(publications, [own], ALICE); + + assert.equal(personas[0].id, "reviewer"); + assert.equal(personas[0].catalogSource.isOwn, true); +}); + +function pageOfEvents(count, startId, createdAt) { + return Array.from({ length: count }, (_, index) => + personaEvent({ + createdAt: typeof createdAt === "function" ? createdAt(index) : createdAt, + id: `event-${startId + index}`, + sourcePersonaId: `persona-${startId + index}`, + }), + ); +} + +function stubPagedRelay(pages) { + const filters = []; + mock.method(relayClient, "fetchEvents", (filter) => { + filters.push(filter); + return Promise.resolve(pages[filters.length - 1] ?? []); + }); + return filters; +} + +// A single limit-capped fetch drops every entry past the relay's clamp, making +// those agents undiscoverable. The walk must keep going while pages come back +// full, and must carry an `until` cursor derived from the oldest event seen. +test("test_full_page_is_followed_by_a_cursored_request_for_older_events", async (t) => { + t.after(() => mock.restoreAll()); + const filters = stubPagedRelay([ + pageOfEvents(500, 0, (index) => 10_000 - index), + pageOfEvents(3, 500, 9_000), + ]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal(filters.length, 2, "a full page must be followed by another"); + assert.equal(filters[0].until, undefined, "the first page has no cursor"); + assert.equal( + filters[1].until, + 10_000 - 499, + "the cursor must be the oldest created_at from the previous page", + ); + assert.equal( + publications.length, + 503, + "entries past the first page must still be discoverable", + ); +}); + +test("test_short_first_page_does_not_issue_a_second_request", async (t) => { + t.after(() => mock.restoreAll()); + const filters = stubPagedRelay([pageOfEvents(2, 0, 10_000)]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal(filters.length, 1); + assert.equal(publications.length, 2); +}); + +// `until` is inclusive on the relay, so consecutive pages overlap on the +// boundary timestamp. Without id dedupe the repeats would be counted twice. +test("test_overlapping_pages_are_deduped_by_event_id", async (t) => { + t.after(() => mock.restoreAll()); + const firstPage = pageOfEvents(500, 0, (index) => 10_000 - index); + const secondPage = [ + // The boundary event repeats because `until` includes its timestamp. + firstPage[firstPage.length - 1], + ...pageOfEvents(2, 500, 9_000), + ]; + stubPagedRelay([firstPage, secondPage]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal(publications.length, 502, "the repeated event must count once"); +}); + +// The stop-on-no-progress guard: a full page whose events all share one +// created_at cannot advance the cursor, so paging must terminate instead of +// re-requesting the same page forever. +test("test_full_page_of_tied_timestamps_terminates_the_walk", async (t) => { + t.after(() => mock.restoreAll()); + const tiedPage = pageOfEvents(500, 0, 10_000); + const filters = stubPagedRelay([tiedPage, tiedPage, tiedPage, tiedPage]); + + const publications = await fetchPersonaCatalogPublications(); + + assert.equal( + filters.length, + 2, + "the walk must stop once a page contributes nothing new", + ); + assert.equal(publications.length, 500); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts new file mode 100644 index 00000000000..c85a976ba69 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -0,0 +1,359 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { + AgentPersona, + CatalogSourceCoordinate, + RelayEvent, + RespondToMode, +} from "@/shared/api/types"; +import { KIND_PERSONA } from "@/shared/constants/kinds"; + +export type CatalogPersonaShareLevel = "not-shared" | "none"; + +type CatalogAgentProjection = { + displayName: string; + avatarUrl: string | null; + systemPrompt: string; + runtime: string | null; + model: string | null; + provider: string | null; + namePool: string[]; + respondTo: RespondToMode | null; + parallelism: number | null; +}; + +export type PersonaCatalogPublication = { + eventId: string; + ownerPubkey: string; + sourcePersonaId: string; + createdAt: number; + agent: CatalogAgentProjection; +}; + +export type CatalogPersona = AgentPersona & { + catalogSource: CatalogSourceCoordinate & { + /** The publication event this projection was built from. */ + eventId: string; + /** Whether the current identity published it. */ + isOwn: boolean; + }; +}; + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function extractTag(event: RelayEvent, name: string): string | null { + const matches = event.tags.filter( + (tag) => tag.length >= 2 && tag[0] === name && typeof tag[1] === "string", + ); + return matches.length === 1 ? (matches[0]?.[1] ?? null) : null; +} + +export function personaEventIsShared(event: RelayEvent): boolean { + const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); + return ( + sharedTags.length === 1 && + sharedTags[0]?.length === 2 && + sharedTags[0]?.[1] === "true" + ); +} + +function isSafeHttpUrl(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 2_048 || + /[\s()]/u.test(value) + ) { + return false; + } + try { + const parsed = new URL(value); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +} + +/** + * Emoji avatars are the one `data:` avatar a catalog entry keeps. + * + * They persist as inline, percent-encoded SVG (`emojiAvatarDataUrl` in + * `ProfileAvatarEditor.utils.ts`), so they are self-contained and render on + * any member's machine — unlike a bundled runtime-default avatar, whose local + * asset path means nothing to another install. The accepted shape is exactly + * that prefix: the trailing comma is what rejects `;base64` payloads, and + * every other `data:` MIME stays rejected. Catalog avatars render through + * `` (`ProfileAvatar` → `AvatarImage`), where SVG script never + * executes, so bounding the length is the remaining concern — 8 KiB is an + * order of magnitude above the ~700 characters an emoji avatar encodes to. + */ +const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,"; +const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192; + +function isInlineSvgAvatar(value: unknown): value is string { + return ( + typeof value === "string" && + value.startsWith(INLINE_SVG_AVATAR_PREFIX) && + value.length <= MAX_INLINE_SVG_AVATAR_LENGTH + ); +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { + let parsed: unknown; + try { + parsed = JSON.parse(event.content); + } catch { + return null; + } + if ( + !isObject(parsed) || + typeof parsed.display_name !== "string" || + parsed.display_name.trim().length === 0 + ) { + return null; + } + + const avatarUrl = + isSafeHttpUrl(parsed.avatar_url) || isInlineSvgAvatar(parsed.avatar_url) + ? parsed.avatar_url + : null; + const namePool = Array.isArray(parsed.name_pool) + ? parsed.name_pool.filter( + (candidate): candidate is string => typeof candidate === "string", + ) + : []; + const respondTo = + parsed.respond_to === "allowlist" + ? "owner-only" + : parsed.respond_to === "owner-only" || parsed.respond_to === "anyone" + ? parsed.respond_to + : null; + const parallelism = + typeof parsed.parallelism === "number" && + Number.isInteger(parsed.parallelism) && + parsed.parallelism >= 1 && + parsed.parallelism <= 32 + ? parsed.parallelism + : null; + + return { + displayName: parsed.display_name, + avatarUrl, + systemPrompt: + typeof parsed.system_prompt === "string" ? parsed.system_prompt : "", + runtime: optionalString(parsed.runtime), + model: optionalString(parsed.model), + provider: optionalString(parsed.provider), + namePool, + respondTo, + parallelism, + }; +} + +/** + * Collapse relay results to the canonical NIP-33 head for each persona + * coordinate, then keep only exact `["shared", "true"]` heads. + * + * The relay normally returns one replaceable head. The client-side collapse is + * defense in depth for older relays and fixtures, and deliberately claims the + * coordinate before parsing so an invalid or unshared newest head cannot + * resurrect an older shared definition. + */ +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) { + if (event.kind !== KIND_PERSONA) continue; + const sourcePersonaId = extractTag(event, "d"); + if (!sourcePersonaId) continue; + const ownerPubkey = event.pubkey.toLowerCase(); + const coordinate = `${ownerPubkey}:${sourcePersonaId}`; + if (seenCoordinates.has(coordinate)) continue; + seenCoordinates.add(coordinate); + + if (!personaEventIsShared(event)) continue; + const agent = parsePersonaContent(event); + if (!agent) continue; + publications.push({ + eventId: event.id, + ownerPubkey, + sourcePersonaId, + createdAt: event.created_at, + agent, + }); + } + + return publications; +} + +/** + * Events per catalog page. + * + * Kept well under the relay's 1,000-row `query_events` clamp so a page that + * comes back full is a reliable "there may be more" signal rather than a + * silently truncated result. + */ +const CATALOG_PAGE_SIZE = 500; + +/** + * Hard bound on pages walked, so a relay that keeps returning full pages can + * never spin this forever. + */ +const MAX_CATALOG_PAGES = 40; + +/** + * Read every shared persona event, page by page. + * + * A single `limit`-capped fetch silently truncates once a community publishes + * more agents than the relay's clamp, and the entries that fall off are simply + * undiscoverable. Paging walks backwards through `created_at` using the only + * cursor a WS `REQ` filter carries — `until` — which the relay treats as + * *inclusive*, so consecutive pages overlap on tied timestamps. Two things + * follow, and both are load-bearing: + * + * - dedupe by event id, because the boundary events repeat; and + * - stop when a page contributes nothing new, because a page whose events all + * share one `created_at` would otherwise be requested forever. + */ +export async function fetchPersonaCatalogPublications(): Promise< + PersonaCatalogPublication[] +> { + const byId = new Map(); + let until: number | undefined; + + for (let page = 0; page < MAX_CATALOG_PAGES; page += 1) { + const events = await relayClient.fetchEvents({ + kinds: [KIND_PERSONA], + limit: CATALOG_PAGE_SIZE, + ...(until === undefined ? {} : { until }), + }); + + const sizeBefore = byId.size; + let oldestCreatedAt = Number.POSITIVE_INFINITY; + for (const event of events) { + byId.set(event.id, event); + oldestCreatedAt = Math.min(oldestCreatedAt, event.created_at); + } + + // A short page is the end of the catalog; a page of only-repeats means the + // cursor cannot advance past a run of tied timestamps. + if (events.length < CATALOG_PAGE_SIZE || byId.size === sizeBefore) { + break; + } + until = oldestCreatedAt; + } + + return catalogPublicationsFromEvents([...byId.values()]); +} + +function publicationToPersona( + publication: PersonaCatalogPublication, + localPersona: AgentPersona | undefined, + isOwn: boolean, +): CatalogPersona { + const timestamp = new Date(publication.createdAt * 1_000).toISOString(); + const basePersona: AgentPersona = localPersona ?? { + id: `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: publication.agent.namePool, + isBuiltIn: false, + isActive: false, + shared: true, + sourceTeam: null, + envVars: {}, + respondTo: publication.agent.respondTo, + respondToAllowlist: [], + parallelism: publication.agent.parallelism, + createdAt: timestamp, + updatedAt: timestamp, + }; + + return { + ...basePersona, + // Catalog membership is relay-confirmed by the shared event itself. Do not + // let a local pending toggle override this projection. + shared: true, + catalogSource: { + eventId: publication.eventId, + ownerPubkey: publication.ownerPubkey, + isOwn, + personaId: publication.sourcePersonaId, + }, + }; +} + +export function catalogPersonasFromPublications( + publications: readonly PersonaCatalogPublication[], + localPersonas: readonly AgentPersona[], + currentPubkey: string | null | undefined, +): CatalogPersona[] { + const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; + const personas: CatalogPersona[] = []; + + for (const publication of publications) { + const isOwn = publication.ownerPubkey === normalizedCurrentPubkey; + personas.push( + publicationToPersona( + publication, + findLocalPersonaForCatalogEntry(localPersonas, { + ownerPubkey: publication.ownerPubkey, + personaId: publication.sourcePersonaId, + isOwn, + }), + isOwn, + ), + ); + } + + return personas.sort((left, right) => + left.displayName.localeCompare(right.displayName), + ); +} + +/** + * The local persona backing a catalog entry, if the user already has it. + * + * An own publication is found by id — its `d`-tag *is* the local persona id. A + * copy of another owner's entry carries a fresh local id instead, so the only + * link back is the `catalogSource` coordinate stored on the copy. Matching on + * that coordinate is what stops the catalog from offering "Add" for an entry + * the user already added, which would mint a second copy. + */ +export function findLocalPersonaForCatalogEntry( + localPersonas: readonly AgentPersona[], + source: CatalogSourceCoordinate & { isOwn: boolean }, +): AgentPersona | undefined { + if (source.isOwn) { + return localPersonas.find((persona) => persona.id === source.personaId); + } + return localPersonas.find( + (persona) => + persona.catalogSource?.ownerPubkey === source.ownerPubkey && + persona.catalogSource?.personaId === source.personaId, + ); +} + +export function isCatalogPersona( + persona: AgentPersona, +): persona is CatalogPersona { + return "catalogSource" in persona && isObject(persona.catalogSource); +} diff --git a/desktop/src/features/agents/lib/personaEditCaches.ts b/desktop/src/features/agents/lib/personaEditCaches.ts new file mode 100644 index 00000000000..c5071d1246b --- /dev/null +++ b/desktop/src/features/agents/lib/personaEditCaches.ts @@ -0,0 +1,42 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { evictUsersBatchEntries } from "@/features/profile/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Refresh every cache a saved persona edit can invalidate. + * + * Shared by the plain edit mutation and the publish-on-save edit mutation so + * the two cannot drift on what a saved edit refreshes. + */ +export async function invalidatePersonaEditCaches( + queryClient: QueryClient, + personaId: string, +): Promise { + // Evict per-pubkey users-batch-entry caches for agents linked to this + // persona so the batch invalidation below refetches fresh profiles instead + // of re-reading stale entries (mirrors useUpdateManagedAgentMutation). + const agents = queryClient.getQueryData(["managed-agents"]); + if (agents) { + evictUsersBatchEntries( + queryClient, + agents + .filter((agent) => agent.personaId === personaId) + .map((agent) => agent.pubkey.toLowerCase()), + ); + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["personas"] }), + queryClient.invalidateQueries({ queryKey: ["managed-agents"] }), + // Persona avatar changes re-sync linked agents' relay profiles; + // invalidate cached user-profile and users-batch queries so the UI picks + // up the updated kind:0 picture without waiting for staleTime expiry — + // covers agent cards, message timelines, and member lists. + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === "user-profile" || + query.queryKey[0] === "users-batch", + }), + ]); +} diff --git a/desktop/src/features/agents/lib/personaSaveNotice.test.mjs b/desktop/src/features/agents/lib/personaSaveNotice.test.mjs new file mode 100644 index 00000000000..36f3fcdc8a0 --- /dev/null +++ b/desktop/src/features/agents/lib/personaSaveNotice.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { personaSaveNotice } from "./personaSaveNotice.ts"; + +test("test_plain_save_notice_says_nothing_about_the_catalog", () => { + const notice = personaSaveNotice("Helper", null); + assert.equal(notice, "Updated Helper."); + assert.ok(!/catalog/i.test(notice)); +}); + +test("test_accepted_publish_notice_claims_the_catalog_has_the_edit", () => { + assert.match( + personaSaveNotice("Helper", "published"), + /published it to the community catalog/, + ); +}); + +// The whole point of routing "Save and publish" through the strict command is +// that a queued edit must NOT be reported as published — the relay hasn't taken +// it yet, so the catalog still shows the old definition. +test("test_queued_publish_notice_does_not_claim_the_edit_is_published", () => { + const notice = personaSaveNotice("Helper", "queued"); + assert.match(notice, /queued/); + assert.ok( + !/\bpublished\b/.test(notice), + "a queued edit must not be described as published", + ); +}); diff --git a/desktop/src/features/agents/lib/personaSaveNotice.ts b/desktop/src/features/agents/lib/personaSaveNotice.ts new file mode 100644 index 00000000000..f75f0e1c68a --- /dev/null +++ b/desktop/src/features/agents/lib/personaSaveNotice.ts @@ -0,0 +1,24 @@ +import type { PersonaSharePublicationResult } from "@/shared/api/tauriPersonas"; + +/** + * The confirmation shown after a persona edit is saved. + * + * `publicationStatus` is null when the edit did not promise publication, so + * the copy stays silent about the catalog. When it did, the copy must + * distinguish a relay-accepted publish from a queued one — a "published" + * message for an edit still sitting in the outbox is the promise the + * "Save and publish" button was making falsely. + */ +export function personaSaveNotice( + displayName: string, + publicationStatus: PersonaSharePublicationResult["publicationStatus"] | null, +): string { + switch (publicationStatus) { + case "published": + return `Updated ${displayName} and published it to the community catalog.`; + case "queued": + return `Updated ${displayName}. Publishing to the community catalog is queued and will appear after the relay accepts the update.`; + default: + return `Updated ${displayName}.`; + } +} diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts new file mode 100644 index 00000000000..c7835f93739 --- /dev/null +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -0,0 +1,115 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchPersonaCatalogPublications, + type PersonaCatalogPublication, +} from "@/features/agents/lib/personaCatalogRelay"; +import { invalidatePersonaEditCaches } from "@/features/agents/lib/personaEditCaches"; +import { relayClient } from "@/shared/api/relayClient"; +import { + setPersonaShared, + updatePersonaAndPublish, +} from "@/shared/api/tauriPersonas"; +import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types"; +import { KIND_PERSONA } 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], limit: 0 }, () => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }) + .then((unsubscribe) => { + if (disposed) { + void unsubscribe(); + } else { + dispose = unsubscribe; + } + }) + .catch((error) => { + console.error( + "Couldn’t 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 useSetPersonaCatalogSharedMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, shared }: { id: string; shared: boolean }) => + setPersonaShared(id, shared), + onSuccess: (result) => { + queryClient.setQueryData( + ["personas"], + (current) => + current?.map((persona) => + persona.id === result.persona.id ? result.persona : persona, + ) ?? [result.persona], + ); + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }, + }); +} + +/** + * Save a persona edit and publish its catalog head, reporting the relay's + * verdict. + * + * The plain edit mutation only enqueues the head best-effort, so it cannot back + * the "Save and publish" promise. This awaits the relay and additionally + * refreshes the catalog query, since the published edit changes what the + * catalog shows. + */ +export function useUpdatePersonaAndPublishMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: UpdatePersonaInput) => updatePersonaAndPublish(input), + onSettled: async (_data, _error, variables) => { + await Promise.all([ + invalidatePersonaEditCaches(queryClient, variables.id), + queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }), + ]); + }, + }); +} diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index a1cbbf93fe4..0dc12ddfd1e 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -35,7 +35,7 @@ test("startPersonaSync backfills history including the deletion kind", () => { return Promise.resolve(() => Promise.resolve()); }); - startPersonaSync("owner-pubkey", () => false); + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); assert.equal(fetchCalls.length, 1, "must do exactly one backfill fetch"); assert.deepEqual( @@ -58,3 +58,53 @@ test("startPersonaSync backfills history including the deletion kind", () => { mock.reset(); }); + +// Regression guard for the arrival-scope fix (F6): the reconcile must carry the +// relay this subscription was opened on, NOT whichever community happens to be +// active when the reconcile runs. Without the forwarded URL the backend falls +// back to the active workspace and an in-flight event lands in the wrong +// community's scoped retention store on a mid-flight switch. +test("startPersonaSync forwards its own relay as the event arrival relay", async () => { + const invokes = []; + // @tauri-apps/api/core reads `window.__TAURI_INTERNALS__.invoke`. + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (cmd, args) => { + invokes.push({ cmd, args }); + return Promise.resolve(); + }, + }, + }; + + const ownEvent = { id: "e1", pubkey: "owner-pubkey", kind: KIND_PERSONA }; + const foreignEvent = { id: "e2", pubkey: "someone-else", kind: KIND_PERSONA }; + + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ownEvent, foreignEvent]), + ); + mock.method(relayClient, "subscribeLive", () => + Promise.resolve(() => Promise.resolve()), + ); + + startPersonaSync("owner-pubkey", "wss://community-a.example", () => false); + // Let the backfill promise chain and the reconcile invoke settle. + await new Promise((resolve) => setImmediate(resolve)); + + const reconciles = invokes.filter( + (call) => call.cmd === "reconcile_inbound_persona_event", + ); + assert.equal( + reconciles.length, + 1, + "only the subscribed author's event reconciles", + ); + assert.equal( + reconciles[0].args.arrivalRelayUrl, + "wss://community-a.example", + "reconcile must carry the subscription's relay as the arrival relay", + ); + assert.equal(JSON.parse(reconciles[0].args.eventJson).id, "e1"); + + mock.reset(); + delete globalThis.window; +}); diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index e713ed71d15..f18194c5c6e 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -20,19 +20,28 @@ const PERSONA_SYNC_KINDS = [ KIND_DELETION, ]; -// Start the persona/team/agent/deletion sync for `pubkey`: one-shot backfill -// of existing heads + tombstones, then a live subscription. Returns a disposer -// that closes the live subscription. Extracted from the hook so the wiring is -// unit-testable without a React renderer (see `usePersonaSync.test.mjs`). +// Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: +// one-shot backfill of existing heads + tombstones, then a live subscription. +// Returns a disposer that closes the live subscription. Extracted from the hook +// so the wiring is unit-testable without a React renderer (see +// `usePersonaSync.test.mjs`). +// +// `relayUrl` is the community this subscription is bound to, and every reconcile +// carries it as the event's arrival relay. Capturing it here — rather than +// letting the backend read whichever workspace is active when the reconcile runs +// — is what keeps an in-flight event out of the next community's scoped store. export function startPersonaSync( pubkey: string, + relayUrl: string, onCancelled: () => boolean, ): () => Promise { const reconcile = (event: RelayEvent) => { if (event.pubkey !== pubkey) return; - void reconcileInboundPersonaEvent(JSON.stringify(event)).catch((error) => { - console.warn("[usePersonaSync] reconcile failed:", error); - }); + void reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl).catch( + (error) => { + console.warn("[usePersonaSync] reconcile failed:", error); + }, + ); }; // One-shot backfill of existing heads + tombstones (closes the fresh-start @@ -68,23 +77,27 @@ export function startPersonaSync( // Subscribes to this device's own persona/team/agent projection + deletion // events and patches each into the local store. The subscription is keyed on -// the active pubkey: an identity switch re-runs the effect, whose cleanup -// closes the old subscription before a new one opens on the new pubkey's -// filter — so no stale-coordinate subscription survives. +// the active pubkey and relay: an identity or community switch re-runs the +// effect, whose cleanup closes the old subscription before a new one opens on +// the new filter — so no stale-coordinate subscription survives, and every +// reconcile is attributed to the community it was subscribed to. // // A fresh device that comes online AFTER another already published gets no // history from a live-only subscription: relayClient's replayLiveSubscriptions // only replays from a since-cursor that is undefined until the first live // event arrives. So `startPersonaSync` does an explicit one-shot history fetch // up front and feeds each event through the same reconcile path. -export function usePersonaSync(pubkey: string | undefined): void { +export function usePersonaSync( + pubkey: string | undefined, + relayUrl: string | undefined, +): void { React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - const dispose = startPersonaSync(pubkey, () => cancelled); + const dispose = startPersonaSync(pubkey, relayUrl, () => cancelled); return () => { cancelled = true; void dispose(); }; - }, [pubkey]); + }, [pubkey, relayUrl]); } diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 29cb48c6439..45031e0371c 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"; @@ -36,6 +35,7 @@ import { AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, BLOCK_BUILD_HIDDEN_PROVIDER_IDS, + buildPersonaRuntimeDropdownOptions, CUSTOM_PROVIDER_DROPDOWN_VALUE, computeLocalModeGate, formatRuntimeOptionLabel, @@ -50,7 +50,6 @@ import { PERSONA_FIELD_SHELL_CLASS, PERSONA_LABEL_OPTIONAL_CLASS, shouldClearKnownModelForSelectionScope, - sortPersonaRuntimes, } from "./agentConfigOptions"; import { RequiredFieldLabel } from "./agentConfigControls"; import { @@ -83,6 +82,7 @@ import { } from "./agentAiConfigurationPolicy"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; +import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter"; type AgentDefinitionDialogProps = { open: boolean; @@ -97,13 +97,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], @@ -121,6 +128,7 @@ export function AgentDefinitionDialog({ runtimesLoading = false, onOpenChange, onSubmit, + publishCatalogUpdatesOnSave = false, createRunSection, createSubmitBlocked = false, }: AgentDefinitionDialogProps) { @@ -158,6 +166,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: { @@ -212,6 +221,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]); @@ -297,6 +307,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. } @@ -348,14 +359,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) { @@ -382,6 +398,7 @@ export function AgentDefinitionDialog({ enabled: open, }); function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) { + setHasUserChanges(true); setAiConfigurationMode(nextMode); setIsCustomProviderEditing(false); setIsCustomModelEditing(false); @@ -553,44 +570,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"; @@ -675,6 +662,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. @@ -693,6 +681,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") { @@ -710,6 +699,7 @@ export function AgentDefinitionDialog({ } function handleModelDropdownChange(nextValue: string) { + setHasUserChanges(true); applySelection( selectionOnModelDropdownChange(selection, { nextValue, @@ -736,42 +726,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); + }} />
@@ -1008,7 +994,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 00000000000..92428ad95cb --- /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 00000000000..50109143cdc --- /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 02a6d0e64aa..f5be3cc7e87 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 ad1219310d7..4a9584dfb9a 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 8e1c47c6157..6e55f92dfe9 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, @@ -20,7 +20,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"; @@ -70,11 +73,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 @@ -106,18 +112,23 @@ export function AgentsView() { return ( <>

-
+
{runningAgentCount > 0 ? ( @@ -135,11 +146,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 @@ -186,10 +195,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} @@ -289,9 +296,11 @@ export function AgentsView() { error={ personas.updatePersonaMutation.error instanceof Error ? personas.updatePersonaMutation.error - : personas.createPersonaMutation.error instanceof Error - ? personas.createPersonaMutation.error - : null + : personas.updatePersonaAndPublishMutation.error instanceof Error + ? personas.updatePersonaAndPublishMutation.error + : personas.createPersonaMutation.error instanceof Error + ? personas.createPersonaMutation.error + : null } initialValues={personas.personaDialogState.initialValues} isPending={personas.isPending} @@ -303,8 +312,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} /> @@ -330,8 +353,19 @@ export function AgentsView() { ) : null} {personas.personaToShare ? ( { + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + void personas.setPersonaCatalogShareLevel( + shareTarget.persona, + shareLevel, + ); + }} onExport={() => { const shareTarget = personas.personaToShare; if (!shareTarget) return; @@ -390,8 +424,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 70d063098b7..4fdd6db26f3 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 66e5ee31f9e..3cdec291046 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/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index 0d6b5583ff8..ba76d6e4edb 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 b6d3fafd3ce..af45e8f071d 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, @@ -20,7 +22,6 @@ import { uploadMediaBytes, type BlobDescriptor } from "@/shared/api/tauri"; import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia"; import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas"; import type { AgentPersona, UserSearchResult } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; import { AlertDialog, AlertDialogAction, @@ -39,7 +40,6 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; import { @@ -51,8 +51,10 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; import { useSnapshotSendController } from "./useSnapshotSendController"; type PersonaShareDialogProps = { + catalogShareLevel: CatalogPersonaShareLevel; isPending: boolean; linkedAgentPubkey: string | null; + onCatalogShareLevelChange: (shareLevel: CatalogPersonaShareLevel) => void; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; @@ -60,6 +62,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { + afterLink?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -109,6 +112,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"; @@ -179,39 +196,32 @@ function MemoryShareConfirmation({ function ShareLevelControl({ ariaLabel, - className, disabled, hasMemoryOptions, - onOpenChange, - staticClassName, - staticLabel, testId, value, options, onChange, }: { ariaLabel: string; - className?: string; disabled: boolean; hasMemoryOptions: boolean; - onOpenChange?: (open: boolean) => void; - staticClassName?: string; - staticLabel: string; testId: string; value: SnapshotMemoryLevel; options: { value: SnapshotMemoryLevel; label: string }[]; onChange: (level: SnapshotMemoryLevel) => void; }) { if (!hasMemoryOptions) { + // Nothing to choose from, so there is no dropdown to open. State the + // outcome rather than naming the sole option: the memory-level labels + // ("Agent only", "+ core memory", …) are comparative and only make sense + // when the alternatives are actually offered. return ( - {staticLabel} + No memories included ); } @@ -219,9 +229,7 @@ function ShareLevelControl({ return ( onChange(nextValue as SnapshotMemoryLevel)} options={options} testId={testId} @@ -231,6 +239,7 @@ function ShareLevelControl({ } export function SnapshotShareDialog({ + afterLink, displayName, encodeSnapshot, hasMemoryOptions, @@ -252,9 +261,7 @@ export function SnapshotShareDialog({ const [copyStatus, setCopyStatus] = React.useState("idle"); const [pendingMemoryShare, setPendingMemoryShare] = React.useState(null); - const [linkShareLevel, setLinkShareLevel] = - React.useState("none"); - const [recipientShareLevel, setRecipientShareLevel] = + const [shareLevel, setShareLevel] = React.useState("none"); const encodedSnapshotCacheRef = React.useRef( new Map>(), @@ -273,9 +280,7 @@ export function SnapshotShareDialog({ const isActionPending = isPending || isCopying || isSending; const isInterfacePending = isPending || isSending; const hasSelectedRecipients = selectedRecipients.length > 0; - const showMemoryWarning = - linkShareLevel !== "none" || - (hasSelectedRecipients && recipientShareLevel !== "none"); + const showMemoryWarning = shareLevel !== "none"; const recipientActionTransition = shouldReduceMotion ? { duration: 0 } : RECIPIENT_ACTION_TRANSITION; @@ -298,17 +303,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( @@ -337,8 +332,7 @@ export function SnapshotShareDialog({ setSelectedRecipients([]); setCopyStatus("idle"); setPendingMemoryShare(null); - setLinkShareLevel("none"); - setRecipientShareLevel("none"); + setShareLevel("none"); onReset?.(); snapshotSendController.reset(); } @@ -495,21 +489,6 @@ export function SnapshotShareDialog({ excludedPubkeys={excludedRecipientPubkeys} onSelectionChange={setSelectedRecipients} open={open} - renderEndControl={(handleAccessOpenChange) => ( - - )} selectedUsers={selectedRecipients} testIdPrefix={testIdPrefix} /> @@ -532,9 +511,7 @@ export function SnapshotShareDialog({ isActionPending || !snapshotSendController.isDmSafetyReady } - onClick={() => - requestMemoryShare("send", recipientShareLevel) - } + onClick={() => requestMemoryShare("send", shareLevel)} type="button" > {isSending ? "Sending…" : "Send"} @@ -552,6 +529,116 @@ export function SnapshotShareDialog({

+
+ + + +
+

Share with a link

+

+ Anyone with the link can add and use a copy. +

+
+ +
+ +
+

+ What’s included +

+ +
+ {showMemoryWarning ? ( -
-
- - - -
-

Share with a link

-

- Anyone with the link can add and use a copy. -

-
- -
- -
- -
-
+ {afterLink}
- {selectedUsers.length > 0 && renderEndControl - ? renderEndControl((controlOpen) => { - if (controlOpen) setIsPickerOpen(false); - }) - : null}
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 f24d6a41ffc..34f9f9819f3 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -45,7 +45,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; @@ -53,7 +52,7 @@ type UnifiedAgentsSectionProps = { isPersonasLoading: boolean; isPersonasPending: boolean; onCreatePersona: () => void; - onChooseCatalog: () => void; + onDiscoverPersonas: () => void; onDuplicatePersona: (persona: AgentPersona) => void; onEditPersona: (persona: AgentPersona) => void; onSharePersona: ( @@ -66,7 +65,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 { @@ -83,7 +84,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onOpenPersonaProfile, onStartAgent, onStartPersona, - canChooseCatalog, personas, personasError, personaFeedbackErrorMessage, @@ -91,7 +91,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { isPersonasLoading, isPersonasPending, onCreatePersona, - onChooseCatalog, + onDiscoverPersonas, onDuplicatePersona, onEditPersona, onSharePersona, @@ -184,11 +184,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ); })}
@@ -430,50 +429,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 6ae81ff6cba..1313d2cec41 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 53c5e7a16f4..79ddad1c3ce 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 54535d121c9..0c56eeac100 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -17,9 +17,26 @@ 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, + findLocalPersonaForCatalogEntry, + isCatalogPersona, +} from "@/features/agents/lib/personaCatalogRelay"; +import { + usePersonaCatalogLiveUpdates, + usePersonaCatalogQuery, + useSetPersonaCatalogSharedMutation, + useUpdatePersonaAndPublishMutation, +} from "@/features/agents/lib/usePersonaCatalogRelay"; +import { personaSaveNotice } from "@/features/agents/lib/personaSaveNotice"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; import type { SnapshotFormat, SnapshotMemoryLevel, @@ -51,7 +68,14 @@ 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 setCatalogSharedMutation = + useSetPersonaCatalogSharedMutation(communityId); const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] = React.useState(false); const acpRuntimesQuery = useAcpRuntimesQuery({ @@ -60,6 +84,8 @@ export function usePersonaActions() { const createAgentMutation = useCreateManagedAgentMutation(); const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); + const updatePersonaAndPublishMutation = + useUpdatePersonaAndPublishMutation(communityId); const deletePersonaMutation = useDeletePersonaMutation(); const setPersonaActiveMutation = useSetPersonaActiveMutation(); const exportAgentSnapshotMutation = useExportAgentSnapshotMutation(); @@ -101,9 +127,15 @@ export function usePersonaActions() { React.useState(false); const personas = personasQuery.data ?? []; - React.useEffect(() => { - clearLegacyPersonaCatalogVisibility(); - }, []); + const publications = catalogQuery.data ?? []; + const sharedCatalogPersonaIdSet = React.useMemo(() => { + const currentPubkey = identityQuery.data?.pubkey.toLowerCase(); + return new Set( + publications + .filter((publication) => publication.ownerPubkey === currentPubkey) + .map((publication) => publication.sourcePersonaId), + ); + }, [identityQuery.data?.pubkey, publications]); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -112,8 +144,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 +175,7 @@ export function usePersonaActions() { intent?: AgentCreateIntent, backendIntent?: BackendIntent | null, targetChannel?: Pick | null, + options?: { publishCatalogUpdates?: boolean }, ): Promise { if (isPersonaSubmitPending) { return false; @@ -139,8 +185,24 @@ export function usePersonaActions() { setIsPersonaSubmitPending(true); try { if ("id" in input) { - await updatePersonaMutation.mutateAsync(input); - setPersonaNoticeMessage(`Updated ${input.displayName}.`); + // "Save and publish" promises the community catalog sees this edit, so + // it must use the command that awaits the relay. A plain save only + // enqueues the head and cannot report the outcome. + if (options?.publishCatalogUpdates) { + const result = + await updatePersonaAndPublishMutation.mutateAsync(input); + if (result.publicationStatus === "queued" && result.relayMessage) { + console.warn( + `[updatePersonaAndPublish] relay publication queued: ${result.relayMessage}`, + ); + } + setPersonaNoticeMessage( + personaSaveNotice(input.displayName, result.publicationStatus), + ); + } else { + await updatePersonaMutation.mutateAsync(input); + setPersonaNoticeMessage(personaSaveNotice(input.displayName, null)); + } } else { const runtime = availableRuntimes.find( (candidate) => candidate.id === input.runtime, @@ -240,7 +302,46 @@ export function usePersonaActions() { ) { clearFeedback(surface); try { - await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + if (active && isCatalogPersona(persona)) { + const localPersona = findLocalPersonaForCatalogEntry( + personas, + persona.catalogSource, + ); + + if (localPersona) { + if (!localPersona.isActive) { + await setPersonaActiveMutation.mutateAsync({ + id: localPersona.id, + active: true, + }); + } + } else { + await createPersonaMutation.mutateAsync({ + displayName: persona.displayName, + avatarUrl: persona.avatarUrl ?? undefined, + systemPrompt: persona.systemPrompt, + runtime: persona.runtime ?? undefined, + model: persona.model ?? undefined, + provider: persona.provider ?? undefined, + namePool: persona.namePool, + behavior: { + respondTo: + persona.respondTo === "anyone" ? "anyone" : "owner-only", + parallelism: persona.parallelism ?? undefined, + }, + // Provenance on the copy: without it the copy's fresh local id is + // the only identifier, and the catalog offers "Add" again. + catalogSource: persona.catalogSource.isOwn + ? undefined + : { + ownerPubkey: persona.catalogSource.ownerPubkey, + personaId: persona.catalogSource.personaId, + }, + }); + } + } else { + await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + } setPersonaNoticeMessage( active ? `Selected ${persona.displayName} for My Agents.` @@ -334,6 +435,7 @@ export function usePersonaActions() { function openCatalog() { clearFeedback("catalog"); + void catalogQuery.refetch(); setIsCatalogDialogOpen(true); } @@ -386,22 +488,83 @@ export function usePersonaActions() { ); } + function getPersonaCatalogShareLevel( + persona: AgentPersona, + ): CatalogPersonaShareLevel { + return persona.shared ? "none" : "not-shared"; + } + + async function setPersonaCatalogShareLevel( + persona: AgentPersona, + shareLevel: CatalogPersonaShareLevel, + ): Promise { + if (persona.isBuiltIn) return; + + clearFeedback("library"); + try { + const shared = shareLevel !== "not-shared"; + const result = await setCatalogSharedMutation.mutateAsync({ + id: persona.id, + shared, + }); + setPersonaToShare((current) => + current?.persona.id === result.persona.id + ? { ...current, persona: result.persona } + : current, + ); + if (result.publicationStatus === "queued") { + if (shared) { + setPersonaNoticeMessage( + `Sharing ${persona.displayName} is queued. It will appear after the relay accepts the update.`, + ); + } else { + setPersonaNoticeMessage( + `Removing ${persona.displayName} is queued. It may remain discoverable until the relay accepts the update.`, + ); + } + if (result.relayMessage) { + console.warn( + `[setPersonaShared] relay publication queued: ${result.relayMessage}`, + ); + } + } else if (!shared) { + setPersonaNoticeMessage( + `${persona.displayName} is no longer discoverable in the community catalog.`, + ); + } else { + setPersonaNoticeMessage( + `Published ${persona.displayName} to the community catalog.`, + ); + } + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? error.message + : "Failed to update catalog sharing.", + ); + } + } + const isPending = isPersonaSubmitPending || createPersonaMutation.isPending || createAgentMutation.isPending || updatePersonaMutation.isPending || + updatePersonaAndPublishMutation.isPending || deletePersonaMutation.isPending || setPersonaActiveMutation.isPending || exportAgentSnapshotMutation.isPending || previewSnapshotImportMutation.isPending || - confirmSnapshotImportMutation.isPending; + confirmSnapshotImportMutation.isPending || + setCatalogSharedMutation.isPending; return { personasQuery, + catalogQuery, acpRuntimesQuery, createPersonaMutation, updatePersonaMutation, + updatePersonaAndPublishMutation, setPersonaActiveMutation, catalogPersonas, libraryPersonas, @@ -431,6 +594,9 @@ export function usePersonaActions() { personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, + getPersonaCatalogShareLevel, + setPersonaCatalogShareLevel, + sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, snapshotImportResult, diff --git a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx deleted file mode 100644 index 268bd863f28..00000000000 --- a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import * as React from "react"; - -import { useManagedAgentsQuery } from "@/features/agents/hooks"; -import { - useManagedAgentRuntimeAction, - useManagedAgentRuntimesQuery, -} from "@/features/agents/managedAgentRuntimeHooks"; -import { - agentCommunityAvailability, - agentCommunityStatusDetail, - managedAgentRuntimeKey, -} from "@/features/agents/managedAgentRuntimeStatus"; -import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; -import { Badge } from "@/shared/ui/badge"; -import { truncatePubkey } from "@/shared/lib/pubkey"; -import { SettingsSectionHeader } from "./SettingsSectionHeader"; - -export function ActiveAgentCommunitiesSettingsCard() { - const agentsQuery = useManagedAgentsQuery(); - const runtimesQuery = useManagedAgentRuntimesQuery(); - const action = useManagedAgentRuntimeAction(); - const [pendingRuntimeKey, setPendingRuntimeKey] = React.useState< - string | null - >(null); - - const agentNames = React.useMemo( - () => - new Map( - (agentsQuery.data ?? []).map((agent) => [ - agent.pubkey.toLowerCase(), - agent.name, - ]), - ), - [agentsQuery.data], - ); - const runtimes = runtimesQuery.data ?? []; - - async function runAction(runtime: ManagedAgentRuntimeStatus) { - setPendingRuntimeKey(managedAgentRuntimeKey(runtime)); - try { - await action.mutateAsync({ - action: - runtime.lifecycle === "starting" || - runtime.lifecycle === "listening" || - runtime.lifecycle === "waking" || - runtime.lifecycle === "ready" - ? "stop" - : runtime.lifecycle === "stopped" - ? "start" - : "restart", - pubkey: runtime.pubkey, - relayUrl: runtime.relayUrl, - }); - } finally { - setPendingRuntimeKey(null); - } - } - - return ( -
- -
- {runtimesQuery.isPending ? ( -

Loading…

- ) : runtimes.length === 0 ? ( -

- No agent community runtimes found. -

- ) : ( - runtimes.map((runtime) => { - const status = agentCommunityAvailability(runtime); - const detail = agentCommunityStatusDetail(runtime); - const runtimeKey = managedAgentRuntimeKey(runtime); - const pending = pendingRuntimeKey === runtimeKey; - return ( -
-
-
-

- {agentNames.get(runtime.pubkey.toLowerCase()) ?? - truncatePubkey(runtime.pubkey)} -

- - {status} - -
-

- {runtime.relayUrl} -

- {detail ? ( -

{detail}

- ) : null} -
- {runtime.localSetup ? ( - - ) : null} -
- ); - }) - )} -
- {action.error instanceof Error ? ( -

{action.error.message}

- ) : null} -
- ); -} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 156be00b72f..e74d1f38371 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -77,7 +77,6 @@ import { MobilePairingCard } from "./MobilePairingCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; -import { ActiveAgentCommunitiesSettingsCard } from "./ActiveAgentCommunitiesSettingsCard"; import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; @@ -815,7 +814,6 @@ export function renderSettingsSection(
-
); diff --git a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs index 8503a793497..d4e0b1d5f3e 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 bd3be924077..66e07f5e880 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -17,7 +17,14 @@ export type RawPersona = { name_pool?: string[]; is_builtin: boolean; is_active?: boolean; + shared?: boolean; source_team?: string | null; + /** + * Provenance of a local copy of another owner's catalog entry. Serialized by + * the backend `CatalogSource` in snake_case; the create payload sends the + * camelCase aliases it accepts. + */ + catalog_source?: { owner_pubkey: string; persona_id: string } | null; env_vars?: Record; respond_to?: string | null; respond_to_allowlist?: string[]; @@ -40,7 +47,14 @@ export function fromRawPersona(persona: RawPersona): AgentPersona { namePool: persona.name_pool ?? [], isBuiltIn: persona.is_builtin, isActive: persona.is_active ?? true, + shared: persona.shared ?? false, sourceTeam: persona.source_team ?? null, + catalogSource: persona.catalog_source + ? { + ownerPubkey: persona.catalog_source.owner_pubkey, + personaId: persona.catalog_source.persona_id, + } + : null, envVars: persona.env_vars ?? {}, respondTo: (persona.respond_to as RespondToMode | undefined) ?? null, respondToAllowlist: persona.respond_to_allowlist ?? [], @@ -69,31 +83,37 @@ export async function createPersona( namePool: input.namePool ?? [], envVars: input.envVars ?? {}, behavior: input.behavior, + catalogSource: input.catalogSource, }, }), ); } +/** The `UpdatePersonaRequest` payload shared by both edit commands. */ +function updatePersonaPayload(input: UpdatePersonaInput) { + return { + id: input.id, + displayName: input.displayName, + avatarUrl: input.avatarUrl, + systemPrompt: input.systemPrompt, + runtime: input.runtime, + model: input.model, + provider: input.provider, + namePool: input.namePool ?? [], + // Send envVars only when caller explicitly provided it; omitting + // tells the backend "don't touch the stored env vars" so editing + // unrelated fields can't silently wipe saved credentials. + envVars: input.envVars, + // Same absent-vs-present contract as envVars for the behavioral quad. + behavior: input.behavior, + }; +} + export async function updatePersona( input: UpdatePersonaInput, ): Promise { const raw = await invokeTauri("update_persona", { - input: { - id: input.id, - displayName: input.displayName, - avatarUrl: input.avatarUrl, - systemPrompt: input.systemPrompt, - runtime: input.runtime, - model: input.model, - provider: input.provider, - namePool: input.namePool ?? [], - // Send envVars only when caller explicitly provided it; omitting - // tells the backend "don't touch the stored env vars" so editing - // unrelated fields can't silently wipe saved credentials. - envVars: input.envVars, - // Same absent-vs-present contract as envVars for the behavioral quad. - behavior: input.behavior, - }, + input: updatePersonaPayload(input), }); if (raw.writeback_warning) { console.warn( @@ -103,6 +123,41 @@ export async function updatePersona( return fromRawPersona(raw); } +/** + * Save an edit AND publish the persona's catalog head, reporting whether the + * relay accepted it. + * + * `updatePersona` only enqueues the head best-effort, so it cannot tell the UI + * whether the community catalog actually received the change. Use this for the + * "Save and publish" affordance, which promises exactly that. + */ +export async function updatePersonaAndPublish( + input: UpdatePersonaInput, +): Promise { + return fromRawPublicationResult( + await invokeTauri( + "update_persona_and_publish", + { input: updatePersonaPayload(input) }, + ), + ); +} + +type RawPersonaSharePublicationResult = { + persona: RawPersona; + publicationStatus: "published" | "queued"; + relayMessage?: string; +}; + +function fromRawPublicationResult( + raw: RawPersonaSharePublicationResult, +): PersonaSharePublicationResult { + return { + persona: fromRawPersona(raw.persona), + publicationStatus: raw.publicationStatus, + relayMessage: raw.relayMessage ?? null, + }; +} + export async function deletePersona(id: string): Promise { await invokeTauri("delete_persona", { id }); } @@ -116,6 +171,24 @@ export async function setPersonaActive( ); } +export async function setPersonaShared( + id: string, + shared: boolean, +): Promise { + return fromRawPublicationResult( + await invokeTauri("set_persona_shared", { + id, + shared, + }), + ); +} + +export type PersonaSharePublicationResult = { + persona: AgentPersona; + publicationStatus: "published" | "queued"; + relayMessage: string | null; +}; + export type SnapshotMemoryLevel = "none" | "core" | "everything"; export type SnapshotFormat = "json" | "png"; @@ -172,6 +245,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; @@ -235,9 +312,15 @@ export async function confirmAgentSnapshotImport( // Patches a single inbound persona/team/agent projection event into the local // store (personas.json). The backend resolves the match key and the -// pending-edit race; the frontend only forwards the raw Nostr event JSON. +// pending-edit race; the frontend forwards the raw Nostr event JSON plus the +// relay it arrived on, so a workspace switch mid-flight cannot retain the event +// into the newly active community's scoped store. export async function reconcileInboundPersonaEvent( eventJson: string, + arrivalRelayUrl: string, ): Promise { - await invokeTauri("reconcile_inbound_persona_event", { eventJson }); + await invokeTauri("reconcile_inbound_persona_event", { + eventJson, + arrivalRelayUrl, + }); } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d14b66eebbd..689c400b03c 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -753,10 +753,17 @@ export type AgentPersona = { namePool: string[]; isBuiltIn: boolean; isActive: boolean; + /** Whether this persona is discoverable in the active community catalog. */ + shared: boolean; /** Team ID if this persona was imported from a team directory. Team personas are non-editable. */ sourceTeam?: string | null; - /** Environment variables injected for agents created from this persona. - * Layered as: desktop parent env < persona envVars < agent envVars. */ + /** + * Set only on a local copy of another owner's shared catalog entry. A copy + * carries a fresh local `id`, so this coordinate is the only thing that can + * answer "is this catalog entry already added" without minting a duplicate. + */ + catalogSource?: CatalogSourceCoordinate | null; + /** Agent environment variables, layered after desktop parent and persona values. */ envVars: Record; /** NIP-AP behavioral defaults (wire shape). Null/empty = unset. */ respondTo: RespondToMode | null; @@ -767,9 +774,18 @@ export type AgentPersona = { }; /** - * NIP-AP behavioral group for a definition, sent as one group: absent = don't - * touch the stored behavior group (legacy callers), present = replace the fields as a - * unit. Mirrors `PersonaBehaviorRequest`. + * A catalog publication's coordinate: the owner who published it and the + * `d`-tag identifying the persona within that owner's catalog. Mirrors the + * backend `CatalogSource`. + */ +export type CatalogSourceCoordinate = { + ownerPubkey: string; + personaId: string; +}; + +/** + * NIP-AP behavioral group for a definition: absent preserves the stored group + * for legacy callers; present replaces it as a unit. Mirrors `PersonaBehaviorRequest`. */ export type PersonaBehaviorInput = { respondTo?: RespondToMode; @@ -787,6 +803,11 @@ export type CreatePersonaInput = { namePool?: string[]; envVars?: Record; behavior?: PersonaBehaviorInput; + /** + * Set when this persona is a copy of another owner's shared catalog entry, + * so the catalog can tell an already-added foreign entry from a new one. + */ + catalogSource?: CatalogSourceCoordinate; }; export type UpdatePersonaInput = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 03c05fe8778..7b13273c602 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -40,6 +40,7 @@ import { KIND_HUDDLE_STARTED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_PERSONA, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, @@ -112,13 +113,17 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; + shared?: boolean; sourceTeam?: string | null; envVars?: Record; runtime?: string | null; model?: string | null; provider?: string | null; namePool?: string[]; + respondTo?: "owner-only" | "allowlist" | "anyone"; + respondToAllowlist?: string[]; }; type MockTeamSeed = { @@ -224,6 +229,10 @@ type E2eConfig = { * (`list/start/stop/restart_managed_agent_runtime`). */ managedAgentRuntimes?: MockManagedAgentRuntimeSeed[]; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit persona share publications. */ + personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number; @@ -809,7 +818,9 @@ type RawPersona = { name_pool?: string[]; is_builtin: boolean; is_active: boolean; + shared: boolean; source_team?: string | null; + catalog_source?: { owner_pubkey: string; persona_id: string } | null; env_vars?: Record; respond_to?: string | null; respond_to_allowlist?: string[]; @@ -2171,6 +2182,7 @@ function resetMockPersonas(config?: E2eConfig) { name_pool: [], is_builtin: true, is_active: activePersonaIds.has(persona.id), + shared: false, source_team: null, created_at: now, updated_at: now, @@ -2186,12 +2198,18 @@ 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, + shared: persona.shared ?? false, source_team: persona.sourceTeam ?? null, env_vars: { ...(persona.envVars ?? {}) }, created_at: now, - updated_at: now, + updated_at: persona.updatedAt ?? now, }); } } @@ -2786,6 +2804,7 @@ const mockChannels: MockChannel[] = [ const mockMessages = new Map(); const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; +const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; @@ -2816,6 +2835,16 @@ function resetMockSaveSubscriptions(config: E2eConfig | undefined) { })); } +function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) { + mockPersonaEvents.length = 0; + for (const event of config?.mock?.personaCatalogEvents ?? []) { + mockPersonaEvents.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__` @@ -3846,6 +3875,13 @@ function emitMockLiveEvent(channelId: string, event: RelayEvent) { } function emitMockGlobalEvent(event: RelayEvent) { + if ( + event.kind === KIND_PERSONA && + event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && + !personaHasExactSharedTag(event) + ) { + return; + } for (const socket of mockSockets.values()) { for (const [subId, subscription] of socket.subscriptions) { if (subscription.kinds && !subscription.kinds.includes(event.kind)) { @@ -7158,6 +7194,9 @@ let mockGlobalAgentConfig: { // Per-page get_nsec call counter for sequenced error testing. let nsecCallCount = 0; +// Per-page explicit catalog publication outcomes. +let personaSharePublicationCallCount = 0; + // Per-page confirm_team_snapshot_import call counter for sequenced error testing. let teamSnapshotConfirmCallCount = 0; @@ -7351,6 +7390,7 @@ async function handleCreatePersona(args: { provider?: string; envVars?: Record; behavior?: PersonaBehaviorInput; + catalogSource?: { ownerPubkey: string; personaId: string }; }; }): Promise { const now = new Date().toISOString(); @@ -7364,49 +7404,78 @@ async function handleCreatePersona(args: { provider: args.input.provider?.trim() || null, is_builtin: false, is_active: true, + shared: false, source_team: null, + // Mirrors `CatalogSource::normalized`: the coordinate a catalog copy keeps + // so the catalog can tell an already-added foreign entry from a new one. + catalog_source: args.input.catalogSource + ? { + owner_pubkey: args.input.catalogSource.ownerPubkey + .trim() + .toLowerCase(), + persona_id: args.input.catalogSource.personaId.trim(), + } + : null, env_vars: { ...(args.input.envVars ?? {}) }, created_at: now, updated_at: now, }; applyMockPersonaBehavior(persona, args.input.behavior); mockPersonas.push(persona); + upsertMockPersonaEvent(persona); return { ...persona }; } +type MockUpdatePersonaInput = { + id: string; + displayName: string; + avatarUrl?: string; + systemPrompt: string; + runtime?: string; + model?: string; + provider?: string; + envVars?: Record; + behavior?: PersonaBehaviorInput; +}; + async function handleUpdatePersona(args: { - input: { - id: string; - displayName: string; - avatarUrl?: string; - systemPrompt: string; - runtime?: string; - model?: string; - provider?: string; - envVars?: Record; - behavior?: PersonaBehaviorInput; - }; + input: MockUpdatePersonaInput; }): Promise { - const persona = mockPersonas.find( - (candidate) => candidate.id === args.input.id, - ); + return { ...applyMockPersonaUpdate(args.input) }; +} + +/** + * Save an edit to the mock persona store, exactly like `update_persona_with`, + * and return the live record so a caller can publish it. + * + * Deliberately does NOT publish a catalog event: the real `update_persona` + * only enqueues a pending head for the out-of-band flush loop, so nothing has + * reached the relay by the time the command returns. Publishing here would + * make a UI that never calls `update_persona_and_publish` look like it kept + * the "Save and publish" promise. + */ +function applyMockPersonaUpdate(input: MockUpdatePersonaInput): RawPersona { + const persona = mockPersonas.find((candidate) => candidate.id === input.id); if (!persona) { - throw new Error(`agent ${args.input.id} not found`); - } - persona.display_name = args.input.displayName.trim(); - persona.avatar_url = args.input.avatarUrl?.trim() || null; - persona.system_prompt = args.input.systemPrompt.trim(); - persona.runtime = args.input.runtime?.trim() || null; - persona.model = args.input.model?.trim() || null; - persona.provider = args.input.provider?.trim() || null; - if (args.input.envVars !== undefined) { + throw new Error(`agent ${input.id} not found`); + } + persona.display_name = input.displayName.trim(); + persona.avatar_url = input.avatarUrl?.trim() || null; + persona.system_prompt = input.systemPrompt.trim(); + persona.runtime = input.runtime?.trim() || null; + persona.model = input.model?.trim() || null; + persona.provider = input.provider?.trim() || null; + if (input.envVars !== undefined) { // Absent = preserve; present = replace entirely (matches Rust handler). - persona.env_vars = { ...args.input.envVars }; + persona.env_vars = { ...input.envVars }; } - applyMockPersonaBehavior(persona, args.input.behavior); + applyMockPersonaBehavior(persona, input.behavior); persona.updated_at = new Date().toISOString(); - return { ...persona }; + for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) { + callback(); + } + return persona; } async function handleDeletePersona(args: { id: string }): Promise { @@ -7468,15 +7537,115 @@ async function handleSetPersonaActive(args: { return { ...persona }; } +function personaHasExactSharedTag(event: RelayEvent): boolean { + const tags = event.tags.filter((tag) => tag[0] === "shared"); + return tags.length === 1 && tags[0]?.length === 2 && tags[0]?.[1] === "true"; +} + +function upsertMockPersonaRelayEvent(event: RelayEvent): void { + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!sourceId) return; + const existingIndex = mockPersonaEvents.findIndex( + (candidate) => + candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && + candidate.tags.some((tag) => tag[0] === "d" && tag[1] === sourceId), + ); + if (existingIndex >= 0) { + mockPersonaEvents.splice(existingIndex, 1); + } + mockPersonaEvents.push(event); +} + +function upsertMockPersonaEvent(persona: RawPersona): void { + const event: RelayEvent = { + id: mockEventId(), + pubkey: MOCK_IDENTITY_PUBKEY, + created_at: Math.floor(Date.now() / 1_000), + kind: KIND_PERSONA, + tags: [["d", persona.id], ...(persona.shared ? [["shared", "true"]] : [])], + content: JSON.stringify({ + display_name: persona.display_name, + system_prompt: persona.system_prompt, + avatar_url: persona.avatar_url, + runtime: persona.runtime ?? null, + model: persona.model ?? null, + provider: persona.provider ?? null, + name_pool: persona.name_pool ?? [], + respond_to: persona.respond_to ?? null, + respond_to_allowlist: persona.respond_to_allowlist ?? [], + parallelism: persona.parallelism ?? null, + }), + sig: "0".repeat(128), + }; + upsertMockPersonaRelayEvent(event); + emitMockGlobalEvent(event); +} + +type MockPersonaPublicationResult = { + persona: RawPersona; + publicationStatus: "published" | "queued"; + relayMessage?: string; +}; + +/** + * Publish a persona's catalog head and report the relay outcome, like + * `publish_prepared_persona`. A `queued` outcome must NOT make the event + * visible to catalog readers — that is the whole distinction the UI reports. + */ +function publishMockPersonaHead( + persona: RawPersona, + config: E2eConfig | undefined, +): MockPersonaPublicationResult { + const publicationStatus = + config?.mock?.personaSharePublicationStatuses?.[ + personaSharePublicationCallCount++ + ] ?? "published"; + if (publicationStatus === "published") { + upsertMockPersonaEvent(persona); + } + return { + persona: { ...persona }, + publicationStatus, + ...(publicationStatus === "queued" + ? { relayMessage: "relay unreachable: could not connect to relay" } + : {}), + }; +} + +async function handleSetPersonaShared( + args: { + id: string; + shared: boolean; + }, + config?: E2eConfig, +): Promise { + const persona = mockPersonas.find((candidate) => candidate.id === args.id); + if (!persona) { + throw new Error(`agent ${args.id} not found`); + } + if (persona.is_builtin) { + throw new Error("Built-in agents cannot be shared to the catalog."); + } + persona.shared = args.shared; + persona.updated_at = new Date().toISOString(); + return publishMockPersonaHead(persona, config); +} + +/** Mirrors `update_persona_and_publish`: save the edit, then await the relay. */ +async function handleUpdatePersonaAndPublish( + args: { input: MockUpdatePersonaInput }, + config?: E2eConfig, +): Promise { + return publishMockPersonaHead(applyMockPersonaUpdate(args.input), config); +} + function ensureMockPersonaIsActive(personaId: string) { const persona = mockPersonas.find((candidate) => candidate.id === personaId); if (!persona) { 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.`); } } @@ -8235,6 +8404,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; @@ -8930,6 +9128,25 @@ function sendToMockSocket(args: { return; } + if (filter.kinds?.includes(KIND_PERSONA)) { + const authors = filter.authors?.map((author) => author.toLowerCase()); + const sourceIds = filter["#d"]; + for (const event of mockPersonaEvents) { + if (authors && !authors.includes(event.pubkey.toLowerCase())) continue; + if ( + event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && + !personaHasExactSharedTag(event) + ) { + 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 ( @@ -9031,6 +9248,36 @@ function sendToMockSocket(args: { return; } + if (event.kind === KIND_PERSONA) { + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!sourceId) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + "invalid: persona event missing d tag.", + ]); + return; + } + const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); + if ( + sharedTags.length > 1 || + (sharedTags.length === 1 && !personaHasExactSharedTag(event)) + ) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + 'invalid: shared tag must be exactly ["shared","true"].', + ]); + return; + } + upsertMockPersonaRelayEvent(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") { @@ -9149,6 +9396,7 @@ export function maybeInstallE2eTauriMocks() { resetMockWorkflows(); resetMockMesh(); resetMockUserStatuses(); + resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); mockWebsocketSendMutexWedged = false; @@ -10222,6 +10470,11 @@ export function maybeInstallE2eTauriMocks() { return handleUpdatePersona( payload as Parameters[0], ); + case "update_persona_and_publish": + return handleUpdatePersonaAndPublish( + payload as Parameters[0], + activeConfig, + ); case "delete_persona": return handleDeletePersona( payload as Parameters[0], @@ -10245,11 +10498,16 @@ export function maybeInstallE2eTauriMocks() { }; const now = new Date().toISOString(); const existing = mockPersonas.find((p) => p.id === dTag); + const shared = nostrEvent.tags.some( + (tag) => + tag.length === 2 && tag[0] === "shared" && tag[1] === "true", + ); if (existing) { existing.display_name = content.display_name ?? existing.display_name; existing.system_prompt = content.system_prompt ?? existing.system_prompt; + existing.shared = shared; existing.updated_at = now; } else { mockPersonas.push({ @@ -10259,6 +10517,7 @@ export function maybeInstallE2eTauriMocks() { system_prompt: content.system_prompt ?? "", is_builtin: false, is_active: true, + shared, env_vars: {}, created_at: now, updated_at: now, @@ -10285,6 +10544,11 @@ export function maybeInstallE2eTauriMocks() { return handleSetPersonaActive( payload as Parameters[0], ); + case "set_persona_shared": + return handleSetPersonaShared( + payload as Parameters[0], + activeConfig, + ); case "list_teams": return handleListTeams(); case "list_channel_templates": @@ -10331,8 +10595,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; @@ -10341,6 +10605,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", @@ -10350,6 +10654,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", @@ -10861,7 +11168,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 8f4c7d14786..a92efcd40a3 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 a20c649df4f..9ce80593f10 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 0579e98dad2..2e7cdc9e838 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1,8 +1,43 @@ import { expect, test } from "@playwright/test"; +import type { RelayEvent } from "@/shared/api/types"; + +import { emojiAvatarDataUrl } from "@/features/profile/ui/ProfileAvatarEditor.utils"; + import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +function createCatalogEvent(input: { + ownerPubkey: string; + sourcePersonaId: string; + displayName: string; + systemPrompt: string; + createdAt?: number; + shared?: boolean; + avatarUrl?: string; +}): RelayEvent { + return { + id: "1".repeat(64), + pubkey: input.ownerPubkey, + created_at: input.createdAt ?? 1_721_750_400, + kind: 30175, + tags: [ + ["d", input.sourcePersonaId], + ...(input.shared === false ? [] : [["shared", "true"]]), + ], + content: JSON.stringify({ + display_name: input.displayName, + system_prompt: input.systemPrompt, + avatar_url: input.avatarUrl ?? null, + runtime: null, + model: null, + provider: null, + name_pool: [], + }), + sig: "2".repeat(128), + }; +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); @@ -32,7 +67,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 +87,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: "Shared", exact: true }) + .click(); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) .click(); } @@ -154,78 +198,86 @@ async function invokeTauriExpectError( ); } -test("built-in personas are used from the catalog dialog", async ({ page }) => { +async function countCommandInvocations( + page: import("@playwright/test").Page, + command: string, +): Promise { + return page.evaluate( + (targetCommand) => + ( + window as Window & { + __BUZZ_E2E_COMMANDS__?: string[]; + } + ).__BUZZ_E2E_COMMANDS__?.filter((invoked) => invoked === targetCommand) + .length ?? 0, + command, + ); +} + +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 }) => { @@ -267,7 +319,9 @@ test("searches agent avatar emoji with focus on open", async ({ page }) => { 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(); @@ -292,7 +346,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(); @@ -329,70 +385,315 @@ 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 agentCards = page.locator( + '[data-testid^="persona-agent-row-"], [data-testid="new-agent-card"]', + ); + await expect(agentCards.first()).toBeVisible(); + const headerBox = await page + .getByRole("heading", { level: 1, name: "Agents" }) + .locator("../..") + .boundingBox(); + const cardBoxes = await agentCards.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"), - ).not.toContainText("Added by You"); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "You are Fizz.", + "Added by You", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Built-in agent", + "Research the question and cite the evidence.", + ); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Custom agent", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( "Preferred model", @@ -405,14 +706,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 }; @@ -586,80 +883,99 @@ test("custom personas share with people and keep export separate", async ({ ).toHaveCount(0); await expect(shareDialog.getByText("Owner", { exact: true })).toHaveCount(0); await expect(shareDialog.getByText("(You)", { exact: true })).toHaveCount(0); - const copyLinkFooter = page.getByTestId("persona-share-copy-link-footer"); + const linkRow = page.getByTestId("persona-share-link-row"); await expect( - copyLinkFooter.getByRole("heading", { name: "Share with a link" }), + linkRow.getByRole("heading", { name: "Share with a link" }), ).toBeVisible(); await expect( - copyLinkFooter.getByText("Anyone with the link can add and use a copy."), + linkRow.getByText("Anyone with the link can add and use a copy."), ).toHaveClass(/text-xs.*text-secondary-foreground\/75/); await expect(page.getByTestId("persona-share-send")).toHaveCount(0); const copyLinkButton = page.getByTestId("persona-share-copy-link"); - const linkRow = page.getByTestId("persona-share-link-row"); 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 staticLinkAccess = page.getByTestId("persona-share-link-access"); + const catalogSection = page.getByTestId("persona-share-catalog"); + const staticShareLevel = page.getByTestId("persona-share-share-level"); + const shareLevelRow = page.getByTestId("persona-share-share-level-row"); await waitForAnimations(page); const [ linkRowBox, initialCopyLinkButtonBox, linkIconBox, linkCopyBox, - linkDividerBox, - staticLinkAccessBox, + catalogSectionBox, + staticShareLevelBox, + shareLevelRowBox, ] = await Promise.all([ linkRow.boundingBox(), copyLinkButton.boundingBox(), linkIcon.boundingBox(), linkCopy.boundingBox(), - linkDivider.boundingBox(), - staticLinkAccess.boundingBox(), + catalogSection.boundingBox(), + staticShareLevel.boundingBox(), + shareLevelRow.boundingBox(), ]); const sendDescriptionBox = await sendDescription.boundingBox(); - expect((linkRowBox?.y ?? 0) - (sendDescriptionBox?.y ?? 0)).toBeGreaterThan( - (sendDescriptionBox?.height ?? 0) + 30, + const recipientFieldBox = await page + .getByTestId("persona-share-recipient-field") + .boundingBox(); + // Reading order: who → how it goes out → what's included → catalog. + expect(sendDescriptionBox?.y ?? 0).toBeGreaterThanOrEqual( + (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), + ); + expect(linkRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (sendDescriptionBox?.y ?? 0) + (sendDescriptionBox?.height ?? 0), + ); + expect(shareLevelRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), + ); + expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareLevelRowBox?.y ?? 0) + (shareLevelRowBox?.height ?? 0), ); + // Copy link is the link row's own action, not a stranded footer button, so + // it rides on that row, vertically centred with the link icon and flush to + // the row's right edge. expect(initialCopyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 23, + linkRowBox?.y ?? 0, ); + expect( + (initialCopyLinkButtonBox?.y ?? 0) + + (initialCopyLinkButtonBox?.height ?? 0), + ).toBeLessThanOrEqual((linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 1); expect( Math.abs( - (linkCopyBox?.y ?? 0) + - (linkCopyBox?.height ?? 0) / 2 - + (initialCopyLinkButtonBox?.y ?? 0) + + (initialCopyLinkButtonBox?.height ?? 0) / 2 - ((linkIconBox?.y ?? 0) + (linkIconBox?.height ?? 0) / 2), ), ).toBeLessThanOrEqual(1); - expect(linkDividerBox?.y ?? 0).toBeGreaterThan( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), - ); - expect(linkDividerBox?.y ?? 0).toBeLessThan(initialCopyLinkButtonBox?.y ?? 0); expect( - Math.abs((linkDividerBox?.width ?? 0) - (linkRowBox?.width ?? 0)), + Math.abs( + (linkRowBox?.x ?? 0) + + (linkRowBox?.width ?? 0) - + ((initialCopyLinkButtonBox?.x ?? 0) + + (initialCopyLinkButtonBox?.width ?? 0)), + ), ).toBeLessThanOrEqual(1); - await expect(linkDivider).toHaveClass(/my-4.*bg-input\/40/); expect( Math.abs( (linkCopyBox?.y ?? 0) + (linkCopyBox?.height ?? 0) / 2 - - ((staticLinkAccessBox?.y ?? 0) + - (staticLinkAccessBox?.height ?? 0) / 2), + ((linkIconBox?.y ?? 0) + (linkIconBox?.height ?? 0) / 2), + ), + ).toBeLessThanOrEqual(1); + await expect(page.getByTestId("persona-share-link-divider")).toHaveCount(0); + await expect(page.getByTestId("persona-share-copy-link-footer")).toHaveCount( + 0, + ); + expect( + Math.abs( + (shareLevelRowBox?.y ?? 0) + + (shareLevelRowBox?.height ?? 0) / 2 - + ((staticShareLevelBox?.y ?? 0) + + (staticShareLevelBox?.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/, ); @@ -676,21 +992,28 @@ test("custom personas share with people and keep export separate", async ({ await expect.poll(copyLinkHasVisibleShadow).toBe(false); await copyLinkButton.hover(); await expect.poll(copyLinkHasVisibleShadow).toBe(false); - await expect(page.getByTestId("persona-share-link-access")).toHaveText( - "Agent only", + await expect(page.getByTestId("persona-share-share-level")).toHaveText( + "No memories included", ); + await expect( + shareDialog.getByText("No memories included", { exact: true }), + ).toHaveCount(1); await expect(page.getByTestId("persona-share-recipient-access")).toHaveCount( 0, ); + await expect(page.getByTestId("persona-share-link-access")).toHaveCount(0); await expect( shareDialog.getByLabel("What to include in the link"), ).toHaveCount(0); await expect( shareDialog.getByLabel("What to include", { exact: true }), ).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); + await expect(shareDialog.getByText("Memories", { exact: true })).toHaveCount( + 0, + ); + await expect( + shareDialog.getByText("File format", { exact: true }), + ).toHaveCount(0); const shareMainCard = page.getByTestId("persona-share-main-card"); const exportAgentRow = page.getByTestId("persona-share-export"); await expect(exportAgentRow).toHaveText("Export agent"); @@ -723,6 +1046,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(); @@ -934,29 +1260,7 @@ test("custom personas share with people and keep export separate", async ({ page .getByTestId("persona-share-recipient-field") .getByTestId("persona-share-recipient-access"), - ).toHaveText("Agent only"); - const staticRecipientAccess = page.getByTestId( - "persona-share-recipient-access", - ); - const [ - staticRecipientAccessBox, - recipientAccessPaddingRight, - recipientFieldBox, - ] = await Promise.all([ - staticRecipientAccess.boundingBox(), - staticRecipientAccess.evaluate((element) => - Number.parseFloat(getComputedStyle(element).paddingRight), - ), - recipientField.boundingBox(), - ]); - const staticRecipientTextInset = - (recipientFieldBox?.x ?? 0) + - (recipientFieldBox?.width ?? 0) - - ((staticRecipientAccessBox?.x ?? 0) + - (staticRecipientAccessBox?.width ?? 0) - - recipientAccessPaddingRight); - expect(staticRecipientTextInset).toBeGreaterThanOrEqual(8); - expect(staticRecipientTextInset).toBeLessThanOrEqual(10); + ).toHaveCount(0); await expect(page.getByTestId("persona-share-send")).toBeVisible(); await recipientSearch.fill("bob"); @@ -1057,7 +1361,374 @@ test("custom personas share with people and keep export separate", async ({ await expect(shareDialog).toHaveCount(0); }); -test("share access controls include the selected memories", async ({ +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"); + 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( + "Your agent instruction is shared as plaintext. Memories and secrets aren’t included.", + ); + const [copyLinkButtonBox, catalogSectionBox, shareMainCardBox] = + await Promise.all([ + copyLinkButton.boundingBox(), + catalogSection.boundingBox(), + shareMainCard.boundingBox(), + ]); + // Copy link belongs to the link row above, so the catalog is the section + // that closes the card rather than trailing an orphaned button. + expect( + (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), + ).toBeLessThanOrEqual(catalogSectionBox?.y ?? 0); + expect( + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), + ).toBeLessThanOrEqual( + (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), + ); + await expect(catalogAccess).toHaveText("Not shared"); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Shared", + ]); + await page + .getByRole("menuitemradio", { name: "Shared", exact: true }) + .click(); + await expect(catalogAccess).toHaveText("Shared"); + const storedPersonas = await invokeTauri< + Array<{ id: string; shared: boolean }> + >(page, "list_personas"); + expect( + storedPersonas.find((persona) => persona.id === personaId)?.shared, + ).toBe(true); + 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); + // The promise in the button label is only kept by the command that awaits the + // relay; a plain `update_persona` merely enqueues a head best-effort. + await expect + .poll(() => countCommandInvocations(page, "update_persona_and_publish")) + .toBe(1); + expect(await countCommandInvocations(page, "update_persona")).toBe(0); + await expect( + page.getByText( + "Updated Catalog Analyst and published it to the community catalog.", + ), + ).toBeVisible(); + + await openPersonaCatalog(page); + await selectCatalogPersona(page, personaId); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Review the latest catalog changes.", + ); + await page.keyboard.press("Escape"); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(catalogAccess).toHaveText("Shared"); + 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("a queued catalog share is not presented as relay-published", async ({ + page, +}) => { + const personaId = "custom:queued-catalog-agent"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Queued Catalog Agent", + systemPrompt: "Wait for relay acceptance.", + }, + ], + personaSharePublicationStatuses: ["queued"], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await page.getByLabel("Open actions for Queued Catalog Agent").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await page.getByTestId("persona-share-catalog-access").click(); + await page + .getByRole("menuitemradio", { name: "Shared", exact: true }) + .click(); + + await expect( + page.getByText( + "Sharing Queued Catalog Agent is queued. It will appear after the relay accepts the update.", + ), + ).toBeVisible(); + 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("a foreign reader does not receive an unshared kind 30175 persona", async ({ + page, +}) => { + const personaId = "private-reviewer"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + displayName: "Alice’s Private Reviewer", + systemPrompt: "This instruction must remain private.", + shared: false, + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + await expect( + page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`), + ).toHaveCount(0); + await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible(); +}); + +test("a catalog entry keeps the owner's emoji avatar", async ({ page }) => { + const personaId = "emoji-reviewer"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + // Emoji avatars persist as inline percent-encoded SVG rather than a hosted + // URL, so build the value with the same producer the editor uses — a + // hand-rolled data URL would pass even if the real shape stopped matching. + const avatarUrl = emojiAvatarDataUrl("🐝", "#FFCC00"); + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + avatarUrl, + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + displayName: "Alice’s Reviewer", + systemPrompt: "Review changes for the whole community.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + // An `` carrying the avatar — not the initials fallback — in both the + // list row and the detail header is what proves the projection kept it. + const remoteEntry = page.getByTestId( + `persona-catalog-list-item-${remoteCatalogId}`, + ); + await expect(remoteEntry.locator("img")).toHaveAttribute("src", avatarUrl); + await remoteEntry.click(); + await expect( + page.getByTestId("persona-catalog-detail-pane").locator("img").first(), + ).toHaveAttribute("src", avatarUrl); +}); + +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, + 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(() => countCommandInvocations(page, "create_persona")) + .toBe(1); + const imported = await invokeTauri< + Array<{ + display_name: string; + system_prompt: string; + shared: boolean; + catalog_source: { owner_pubkey: string; persona_id: string } | null; + }> + >(page, "list_personas"); + expect( + imported.find((persona) => persona.display_name === "Alice’s Reviewer"), + ).toMatchObject({ + system_prompt: "Review changes for the whole community.", + shared: false, + // Provenance is what lets the catalog recognise the copy on the next open. + catalog_source: { + owner_pubkey: TEST_IDENTITIES.alice.pubkey, + persona_id: personaId, + }, + }); + + // Reopening must offer the entry as already added rather than minting a + // second copy — the copy has a fresh local id, so only the stored + // coordinate can link it back to Alice's publication. + await page.keyboard.press("Escape"); + await openPersonaCatalog(page); + // The entry now projects onto the local copy, so its list-item testid is the + // local persona id rather than the catalog coordinate. + await expect( + page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`), + ).toHaveCount(0); + await page + .locator('[data-testid^="persona-catalog-list-item-"]') + .filter({ hasText: "Alice’s Reviewer" }) + .click(); + const addedTarget = page.getByRole("button", { + name: "Alice’s Reviewer is already in My Agents", + }); + await expect(addedTarget).toBeDisabled(); + await expect(addedTarget).toHaveText("Added to My Agents"); + expect(await countCommandInvocations(page, "create_persona")).toBe(1); +}); + +test("one share level selector drives both the link and send paths", async ({ page, }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); @@ -1107,34 +1778,60 @@ test("share access controls include the selected memories", async ({ const initialShareCardHeight = await shareMainCard.evaluate( (element) => element.getBoundingClientRect().height, ); - const linkAccess = shareDialog.getByLabel("What to include in the link"); + const shareLevel = shareDialog.getByLabel("What to include", { + exact: true, + }); + 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); - await expect(linkAccess).toHaveText("Agent only"); - expect((await linkAccess.boundingBox())?.width).toBeLessThan(120); - expect(await linkAccess.evaluate((element) => element.tagName)).toBe( + await expect(shareLevel).toHaveText("Agent only"); + expect((await shareLevel.boundingBox())?.width).toBeLessThan(140); + expect(await shareLevel.evaluate((element) => element.tagName)).toBe( "BUTTON", ); - await expect(linkAccess).toHaveCSS("text-decoration-line", "none"); - await expect(linkAccess).toHaveCSS("padding-left", "8px"); - await expect(linkAccess).toHaveCSS("padding-right", "8px"); - const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); - const [linkAccessBox, copyLinkButtonBox] = await Promise.all([ - linkAccess.boundingBox(), - copyLinkButton.boundingBox(), + await expect(shareLevel).toHaveCSS("text-decoration-line", "none"); + await expect(shareLevel).toHaveCSS("padding-left", "8px"); + await expect(shareLevel).toHaveCSS("padding-right", "8px"); + await expect(catalogAccess).toHaveText("Not shared"); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Shared", ]); + await page.keyboard.press("Escape"); + const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); + const recipientFieldBox = await recipientField.boundingBox(); + const [shareLevelBox, copyLinkButtonBox, catalogAccessBox] = + await Promise.all([ + shareLevel.boundingBox(), + copyLinkButton.boundingBox(), + catalogAccess.boundingBox(), + ]); + // Reading order: who → how it goes out → what's included → catalog. expect(copyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkAccessBox?.y ?? 0) + (linkAccessBox?.height ?? 0) + 8, + (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), + ); + expect(shareLevelBox?.y ?? 0).toBeGreaterThanOrEqual( + (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), + ); + expect(catalogAccessBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareLevelBox?.y ?? 0) + (shareLevelBox?.height ?? 0), ); + // The memory choice is stated once, governing both delivery actions — + // neither the recipients row nor the link row carries its own copy. await expect( - shareDialog.getByLabel("What to include", { exact: true }), + shareDialog.getByTestId("persona-share-recipient-access"), + ).toHaveCount(0); + await expect( + shareDialog.getByTestId("persona-share-link-access"), ).toHaveCount(0); + await expect(shareLevel).toHaveCount(1); await expect( shareDialog.getByTestId("persona-share-memory-warning"), ).toHaveCount(0); - await linkAccess.click(); + await shareLevel.click(); await expect(page.getByRole("menuitemradio")).toHaveText([ "Agent only", "Agent + core memory", @@ -1143,7 +1840,7 @@ test("share access controls include the selected memories", async ({ await page .getByRole("menuitemradio", { name: "Agent + core memory" }) .click(); - await expect(linkAccess).toHaveText("Agent + core memory"); + await expect(shareLevel).toHaveText("Agent + core memory"); await waitForAnimations(page); const expandedShareCardHeight = await shareMainCard.evaluate( (element) => element.getBoundingClientRect().height, @@ -1151,6 +1848,9 @@ test("share access controls include the selected memories", async ({ const inlineMemoryWarning = shareDialog.getByTestId( "persona-share-memory-warning", ); + // No recipient is selected yet: the warning tracks the chosen contents, not + // whichever delivery button might be pressed. + await expect(shareDialog.getByTestId("persona-share-send")).toHaveCount(0); await expect(inlineMemoryWarning).toBeVisible(); await expect(inlineMemoryWarning).toContainText( "Memory is stored as plaintext in the snapshot.", @@ -1193,11 +1893,11 @@ test("share access controls include the selected memories", async ({ await expect(page.getByTestId("persona-share-copy-link")).toContainText( "Copied", ); - await linkAccess.click(); + await shareLevel.click(); await page .getByRole("menuitemradio", { name: "Agent only", exact: true }) .click(); - await expect(linkAccess).toHaveText("Agent only"); + await expect(shareLevel).toHaveText("Agent only"); await expect(inlineMemoryWarning).toHaveCount(0); const recipientSearch = page.getByTestId("persona-share-recipient-search"); @@ -1210,11 +1910,6 @@ test("share access controls include the selected memories", async ({ const recipientInputRegion = recipientField.getByTestId( "persona-share-recipient-input-region", ); - const recipientAccess = recipientField.getByLabel("What to include", { - exact: true, - }); - await expect(recipientAccess).toHaveText("Agent only"); - expect((await recipientAccess.boundingBox())?.width).toBeLessThan(140); await expect(recipientField).toHaveCSS("column-gap", "12px"); await expect(recipientInputRegion).toHaveCSS("flex-wrap", "wrap"); const sendButton = shareDialog.getByTestId("persona-share-send"); @@ -1238,42 +1933,17 @@ test("share access controls include the selected memories", async ({ ); }) .toBeLessThanOrEqual(1); - const recipientInputRegionBox = await recipientInputRegion.boundingBox(); - const recipientAccessBox = await recipientAccess.boundingBox(); - expect( - (recipientAccessBox?.x ?? 0) - - ((recipientInputRegionBox?.x ?? 0) + - (recipientInputRegionBox?.width ?? 0)), - ).toBeGreaterThanOrEqual(12); - const recipientAccessRightEdge = - (recipientAccessBox?.x ?? 0) + (recipientAccessBox?.width ?? 0); - expect( - Math.abs( - (resizedRecipientFieldBox?.x ?? 0) + - (resizedRecipientFieldBox?.width ?? 0) - - 8 - - recipientAccessRightEdge, - ), - ).toBeLessThanOrEqual(8); - await recipientAccess.click(); + // Same single selector now drives the send path; picking a level here is + // what the send confirmation must report. + await shareLevel.click(); await page .getByRole("menuitemradio", { name: "Agent + all memories" }) .click(); - await expect(recipientAccess).toHaveText("Agent + all memories"); + await expect(shareLevel).toHaveText("Agent + all memories"); await expect(inlineMemoryWarning).toBeVisible(); await waitForAnimations(page); - await expect - .poll(async () => { - const expandedRecipientAccessBox = await recipientAccess.boundingBox(); - return Math.abs( - (expandedRecipientAccessBox?.x ?? 0) + - (expandedRecipientAccessBox?.width ?? 0) - - recipientAccessRightEdge, - ); - }) - .toBeLessThanOrEqual(1); expect( - await recipientAccess + await shareLevel .locator("span") .evaluate((element) => element.scrollWidth <= element.clientWidth), ).toBe(true); @@ -1594,19 +2264,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", @@ -1614,7 +2281,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 099c2cb752a..7ec1daa2366 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 53efa09a0e1..1e9b077a82a 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 38d1df09141..508b123d7f1 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/persona-sync.spec.ts b/desktop/tests/e2e/persona-sync.spec.ts index 5dfa7e1a16c..84b24f7eb79 100644 --- a/desktop/tests/e2e/persona-sync.spec.ts +++ b/desktop/tests/e2e/persona-sync.spec.ts @@ -14,6 +14,10 @@ const TYLER_PUBKEY = const D_TAG = "sync-test-persona"; const KIND_PERSONA = 30175; const KIND_DELETION = 5; +// The command scopes an inbound event to the community it arrived on. Under the +// mock bridge the app subscribes on e2eBridge's DEFAULT_RELAY_WS_URL, so that is +// the arrival relay these direct invocations stand in for. +const ARRIVAL_RELAY_URL = "ws://localhost:3000"; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -139,6 +143,7 @@ test("upsert round-trip: reconcile_inbound_persona_event writes record and emits // Drive the inbound reconcile path. await invokeTauri(page, "reconcile_inbound_persona_event", { eventJson: JSON.stringify(personaEvent), + arrivalRelayUrl: ARRIVAL_RELAY_URL, }); // Assert the record landed on disk. @@ -176,6 +181,7 @@ test("tombstone round-trip: reconcile_inbound_persona_event removes record and e await invokeTauri(page, "reconcile_inbound_persona_event", { eventJson: JSON.stringify(personaEvent), + arrivalRelayUrl: ARRIVAL_RELAY_URL, }); // Step 2: confirm it landed. @@ -202,6 +208,7 @@ test("tombstone round-trip: reconcile_inbound_persona_event removes record and e await invokeTauri(page, "reconcile_inbound_persona_event", { eventJson: JSON.stringify(tombstoneEvent), + arrivalRelayUrl: ARRIVAL_RELAY_URL, }); // Step 4: assert the record is gone. diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index bc32a310649..0f61de0f7c4 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -138,7 +138,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 @@ -167,7 +167,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); @@ -211,7 +211,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/e2e/team-snapshot.spec.ts b/desktop/tests/e2e/team-snapshot.spec.ts index c04a0400474..6246c7ac8c0 100644 --- a/desktop/tests/e2e/team-snapshot.spec.ts +++ b/desktop/tests/e2e/team-snapshot.spec.ts @@ -271,7 +271,7 @@ test("team sharing uses the people picker and gates memory before sending", asyn `team-share-recipient-option-${TEST_IDENTITIES.charlie.pubkey}`, ) .click(); - await shareDialog.getByTestId("team-share-recipient-access").click(); + await shareDialog.getByTestId("team-share-share-level").click(); await page.getByRole("menuitemradio", { name: "Team + core memory" }).click(); await shareDialog.getByTestId("team-share-send").click(); @@ -311,6 +311,73 @@ test("team sharing uses the people picker and gates memory before sending", asyn expect(sendPayload?.content).not.toContain("![image]("); }); +test("team share level carries memories onto the link path too", async ({ + page, +}) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await installMockBridge(page, { + personas: [ + { + id: ANALYST_PERSONA_ID, + displayName: "Analyst", + systemPrompt: "You are an analyst.", + }, + ], + managedAgents: [ + { + pubkey: ANALYST_PUBKEY, + name: "Analyst", + personaId: ANALYST_PERSONA_ID, + status: "running", + }, + ], + agentMemory: createMockAgentMemoryListing(), + uploadDescriptors: [MOCK_UPLOAD_DESCRIPTOR], + }); + await gotoAgentsPage(page); + + await page.getByLabel("Engineering team actions").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + const shareDialog = page.getByTestId("team-share-dialog"); + await expect(shareDialog).toBeVisible(); + + // No recipient selected — the copy-link path alone must still honour the + // shared selector and gate plaintext memories behind the confirmation. + await shareDialog.getByTestId("team-share-share-level").click(); + await page.getByRole("menuitemradio", { name: "Team + core memory" }).click(); + await expect( + shareDialog.getByTestId("team-share-memory-warning"), + ).toBeVisible(); + await shareDialog.getByTestId("team-share-copy-link").click(); + + const memoryConfirmation = page.getByTestId("team-share-memory-confirmation"); + await expect(memoryConfirmation).toBeVisible(); + await expect(memoryConfirmation).toContainText("plaintext core memory"); + await expect(memoryConfirmation).toContainText( + "Anyone with the link can view it.", + ); + const encodeLevelsBeforeConfirmation = (await readCommandLog(page)) + .filter((entry) => entry.command === "encode_team_snapshot_for_send") + .map( + (entry) => + (entry.payload as { memoryLevel?: string } | undefined)?.memoryLevel, + ); + expect(encodeLevelsBeforeConfirmation).toEqual([]); + + await memoryConfirmation.getByTestId("team-share-memory-confirm").click(); + await expect(shareDialog.getByTestId("team-share-copy-link")).toContainText( + "Copied", + ); + expect( + (await readCommandLog(page)).filter( + (entry) => + entry.command === "encode_team_snapshot_for_send" && + (entry.payload as { memoryLevel?: string } | undefined)?.memoryLevel === + "core", + ), + ).toHaveLength(1); +}); + test("team sharing keeps link copy and export in the shared surface", async ({ page, }) => { @@ -343,7 +410,7 @@ test("team sharing keeps link copy and export in the shared surface", async ({ await menu.getByRole("menuitem", { name: "Share" }).click(); const shareDialog = page.getByTestId("team-share-dialog"); - await expect(shareDialog.getByTestId("team-share-link-access")).toHaveText( + await expect(shareDialog.getByTestId("team-share-share-level")).toHaveText( "Team only", ); const exportTeamRow = shareDialog.getByTestId("team-share-export"); @@ -351,7 +418,7 @@ test("team sharing keeps link copy and export in the shared surface", async ({ const recipientSearch = shareDialog.getByTestId( "team-share-recipient-search", ); - const linkAccess = shareDialog.getByTestId("team-share-link-access"); + const shareLevel = shareDialog.getByTestId("team-share-share-level"); const closeButton = shareDialog.getByRole("button", { name: "Close" }); await waitForAnimations(page); await expect( @@ -363,7 +430,7 @@ test("team sharing keeps link copy and export in the shared surface", async ({ await expect(copyLinkButton).toContainText("Copying…"); await expect(copyLinkButton).toHaveCSS("opacity", "1"); await expect(recipientSearch).toBeEnabled(); - await expect(linkAccess).toBeEnabled(); + await expect(shareLevel).toBeEnabled(); await expect(closeButton).toBeDisabled(); await expect(exportTeamRow).toBeDisabled(); await expect(exportTeamRow).toHaveCSS("opacity", "1"); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d65a260f30a..ca4d62ddd62 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,7 +88,9 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; + shared?: boolean; sourceTeam?: string | null; envVars?: Record; /** @@ -103,6 +105,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 = { @@ -220,6 +224,10 @@ type MockBridgeOptions = { | "stopped"; }>; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit persona share publications. */ + personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number;