From d6fe9de6787dd396a9599ab3fe69f7cc2c25acc2 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 22 Jul 2026 16:59:00 -0700 Subject: [PATCH 01/40] Refine agent catalog sharing --- .../src-tauri/src/commands/media_download.rs | 3 + .../src/commands/personas/snapshot.rs | 1 + .../src/commands/personas/snapshot/import.rs | 60 ++- .../src/commands/personas/snapshot/tests.rs | 22 +- .../src/commands/team_snapshot/tests.rs | 1 + .../src/managed_agents/agent_snapshot.rs | 12 +- .../src-tauri/src/managed_agents/personas.rs | 5 +- .../src/managed_agents/personas/tests.rs | 5 +- .../features/agents/assets/agent-outline.svg | 15 + .../src/features/agents/lib/catalog.test.mjs | 43 +- desktop/src/features/agents/lib/catalog.ts | 2 +- .../legacyPersonaCatalogVisibility.test.mjs | 27 -- .../lib/legacyPersonaCatalogVisibility.ts | 28 -- .../lib/personaCatalogVisibility.test.mjs | 56 +++ .../agents/lib/personaCatalogVisibility.ts | 51 ++ .../agents/ui/AgentDefinitionDialog.tsx | 45 +- .../agents/ui/AgentDefinitionDialogFooter.tsx | 57 +++ .../agents/ui/AgentDefinitionMetadata.tsx | 55 +++ .../agents/ui/AgentSnapshotImportDialog.tsx | 8 + desktop/src/features/agents/ui/AgentsView.tsx | 53 +- .../features/agents/ui/CreateIdentityCard.tsx | 6 +- .../agents/ui/PersonaCatalogDetailsSheet.tsx | 30 +- .../agents/ui/PersonaCatalogDialog.tsx | 99 ++-- .../features/agents/ui/PersonaShareDialog.tsx | 35 ++ .../src/features/agents/ui/TeamsSection.tsx | 10 +- .../agents/ui/UnifiedAgentsSection.tsx | 66 +-- .../features/agents/ui/personaLibraryCopy.ts | 9 +- .../features/agents/ui/usePersonaActions.ts | 42 +- .../api/tauriPersonas.snapshotImport.test.mjs | 3 + desktop/src/shared/api/tauriPersonas.ts | 4 + desktop/src/testing/e2eBridge.ts | 7 +- .../e2e/agent-snapshot-recipient.spec.ts | 7 + desktop/tests/e2e/agents.spec.ts | 456 ++++++++++++++---- 33 files changed, 909 insertions(+), 414 deletions(-) create mode 100644 desktop/src/features/agents/assets/agent-outline.svg delete mode 100644 desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs delete mode 100644 desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts create mode 100644 desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs create mode 100644 desktop/src/features/agents/lib/personaCatalogVisibility.ts create mode 100644 desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx create mode 100644 desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index c182986cba..91429846f9 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -610,6 +610,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, @@ -659,6 +660,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, @@ -704,6 +706,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e4d8a5d1bc..a3ba731875 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -432,6 +432,7 @@ mod png_body_tests { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition { name: "Agent".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index ac5c0eace6..a444794dd4 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -13,7 +13,7 @@ use tauri::{AppHandle, Emitter, State}; use crate::{ app_state::AppState, managed_agents::{ - agent_snapshot::{decode_snapshot_json, decode_snapshot_png, MemoryLevel}, + agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel}, load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, @@ -50,6 +50,13 @@ pub(super) fn reject_legacy_persona_filename(file_name: &str) -> Result<(), Stri pub struct AgentSnapshotImportPreview { /// Agent display name from the snapshot. pub display_name: String, + /// Whether the exported source definition was built in. This is display + /// metadata only; confirmed imports are always independent custom agents. + pub is_builtin: bool, + /// Preferred model from the exported definition. + pub model: Option, + /// Preferred runtime from the exported definition. + pub runtime: Option, /// System prompt, if any. pub system_prompt: Option, /// Effective avatar: data URL if present, otherwise the source URL fallback. @@ -262,32 +269,41 @@ pub async fn preview_agent_snapshot_import( reject_legacy_persona_filename(&file_name)?; let snapshot = decode_snapshot_from_bytes(&file_bytes)?; - let memory_level = match snapshot.memory.level { - MemoryLevel::None => "none", - MemoryLevel::Core => "core", - MemoryLevel::Everything => "everything", - } - .to_string(); - - Ok(AgentSnapshotImportPreview { - display_name: snapshot.profile.display_name.clone(), - system_prompt: snapshot.definition.system_prompt.clone(), - // Effective avatar: data URL wins; URL fallback if no data URL. - avatar_url: snapshot - .profile - .avatar_data_url - .clone() - .or_else(|| snapshot.profile.avatar_url.clone()), - memory_level, - memory_entry_count: snapshot.memory.entries.len(), - source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), - has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), - }) + Ok(build_agent_snapshot_import_preview(&snapshot)) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } +pub(crate) fn build_agent_snapshot_import_preview( + snapshot: &AgentSnapshot, +) -> AgentSnapshotImportPreview { + let memory_level = match snapshot.memory.level { + MemoryLevel::None => "none", + MemoryLevel::Core => "core", + MemoryLevel::Everything => "everything", + } + .to_string(); + + AgentSnapshotImportPreview { + display_name: snapshot.profile.display_name.clone(), + is_builtin: snapshot.definition.source_is_builtin, + model: snapshot.definition.model.clone(), + runtime: snapshot.definition.runtime.clone(), + system_prompt: snapshot.definition.system_prompt.clone(), + // Effective avatar: data URL wins; URL fallback if no data URL. + avatar_url: snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()), + memory_level, + memory_entry_count: snapshot.memory.entries.len(), + source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), + has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), + } +} + // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── /// Import a `buzz-agent-snapshot v1` file as a brand-new agent. diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b6..c7ddbc37a9 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -1,6 +1,7 @@ use super::import::{ - decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior, - AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, + build_agent_snapshot_import_preview, decode_snapshot_from_bytes, + reject_legacy_persona_filename, resolve_snapshot_import_behavior, AgentSnapshotImportResult, + MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, }; use super::*; use crate::managed_agents::{ @@ -94,6 +95,7 @@ fn make_snapshot( version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "Test Agent".to_string(), + source_is_builtin: false, system_prompt: Some("You are helpful.".to_string()), runtime: None, model: None, @@ -551,6 +553,22 @@ fn import_preview_flags_non_empty_source_allowlist() { ); } +#[test] +fn import_preview_includes_exported_definition_metadata() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.definition.source_is_builtin = true; + snapshot.definition.model = Some("claude-opus-4-5".to_string()); + snapshot.definition.runtime = Some("goose".to_string()); + let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap(); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + + let preview = build_agent_snapshot_import_preview(&decoded); + + assert!(preview.is_builtin); + assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5")); + assert_eq!(preview.runtime.as_deref(), Some("goose")); +} + // ── Import: resolve_snapshot_import_behavior — the production selection path // // All tests below call `resolve_snapshot_import_behavior` directly. This is diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc61830..c91d63459e 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -13,6 +13,7 @@ fn member(name: &str) -> AgentSnapshot { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: AgentSnapshotDefinition { name: name.to_string(), + source_is_builtin: false, system_prompt: Some(format!("{name} prompt")), runtime: Some("goose".to_string()), model: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index b0bf8f5991..fff7494e92 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -31,7 +31,11 @@ //! - lineage ids: `persona_id`, `team_id`, `source_team`, `source_team_persona_slug`, //! `persona_source_version` //! - internal bookkeeping: `start_on_app_launch`, -//! `auto_restart_on_config_change`, `is_builtin` +//! `auto_restart_on_config_change` +//! +//! The portable `sourceIsBuiltIn` hint preserves how the exported definition +//! should be described in an import preview. It never grants built-in status +//! to the newly imported definition. //! //! These exclusions are enforced by construction (only explicit fields are //! placed into `AgentSnapshotDefinition`) and asserted by unit tests. @@ -87,6 +91,10 @@ pub enum MemoryLevel { #[serde(rename_all = "camelCase")] pub struct AgentSnapshotDefinition { pub name: String, + /// Portable source classification for import-preview metadata. Imported + /// definitions are still created as custom agents with fresh identities. + #[serde(default)] + pub source_is_builtin: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub system_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -191,6 +199,7 @@ pub fn build_snapshot( .display_name .clone() .unwrap_or_else(|| record.name.clone()), + source_is_builtin: record.is_builtin, system_prompt: record.system_prompt.clone(), runtime: record.runtime.clone(), model: record.model.clone(), @@ -913,6 +922,7 @@ mod tests { let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); assert_eq!(snapshot.definition.name, "Test Agent Display"); + assert!(!snapshot.definition.source_is_builtin); assert_eq!( snapshot.definition.system_prompt.as_deref(), Some("You are a test agent.") diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index b0d874dc78..fc7e9449ed 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -254,10 +254,7 @@ pub fn ensure_persona_is_active( .ok_or_else(|| format!("agent {persona_id} not found"))?; if !persona.is_active { - return Err(format!( - "{} is not in My Agents. Choose it from Agent Catalog first.", - persona.display_name - )); + return Err(format!("{} is not in My Agents.", persona.display_name)); } Ok(()) diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index e924345e8b..1598f26067 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -171,10 +171,7 @@ fn ensure_persona_is_active_rejects_inactive_personas() { let err = ensure_persona_is_active(&[persona], "builtin:fizz").unwrap_err(); - assert_eq!( - err, - "Fizz is not in My Agents. Choose it from Agent Catalog first." - ); + assert_eq!(err, "Fizz is not in My Agents."); } #[test] diff --git a/desktop/src/features/agents/assets/agent-outline.svg b/desktop/src/features/agents/assets/agent-outline.svg new file mode 100644 index 0000000000..b89f4c61c9 --- /dev/null +++ b/desktop/src/features/agents/assets/agent-outline.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs index 7fa72f4f3e..59ad24c1dd 100644 --- a/desktop/src/features/agents/lib/catalog.test.mjs +++ b/desktop/src/features/agents/lib/catalog.test.mjs @@ -25,33 +25,38 @@ function createPersona(id, displayName, overrides = {}) { }; } -test("getCatalogPersonas keeps built-ins visible whether selected or not", () => { +test("getCatalogPersonas hides built-ins and includes shared custom agents", () => { const personas = [ createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), createPersona("custom:builder", "Builder"), ]; assert.deepEqual( - getCatalogPersonas(personas).map((persona) => persona.id), - ["builtin:fizz"], + getCatalogPersonas(personas, new Set(["custom:builder"])).map( + (persona) => persona.id, + ), + ["custom:builder"], ); }); -test("getCatalogSelectionState keeps built-in selection rules in one place", () => { +test("getCatalogSelectionState only selects shared custom agents", () => { const personas = [ createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), createPersona("custom:builder", "Builder"), ]; - const state = getCatalogSelectionState(personas); + const state = getCatalogSelectionState( + personas, + new Set(["builtin:fizz", "custom:builder"]), + ); assert.deepEqual( state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], + ["custom:builder"], ); assert.deepEqual( state.selectedCatalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], + ["custom:builder"], ); assert.deepEqual( state.unselectedCatalogPersonas.map((persona) => persona.id), @@ -61,23 +66,22 @@ test("getCatalogSelectionState keeps built-in selection rules in one place", () 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, + createPersona("custom:fizz", "Fizz", { isActive: false }), + createPersona("custom:reviewer", "Reviewer", { isActive: true, }), ]; const active = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("builtin:reviewer", "Reviewer", { - isBuiltIn: true, + createPersona("custom:fizz", "Fizz", { isActive: true }), + createPersona("custom:reviewer", "Reviewer", { isActive: false, }), ]; + const shared = new Set(["custom:fizz", "custom:reviewer"]); assert.deepEqual( - getCatalogPersonas(inactive).map((persona) => persona.id), - getCatalogPersonas(active).map((persona) => persona.id), + getCatalogPersonas(inactive, shared).map((persona) => persona.id), + getCatalogPersonas(active, shared).map((persona) => persona.id), ); }); @@ -118,13 +122,16 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => { }); }); -test("getPersonaLibraryState keeps the working library and full catalog in one place", () => { +test("getPersonaLibraryState keeps built-ins in the library but not the catalog", () => { const personas = [ createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), createPersona("custom:builder", "Builder"), ]; - const state = getPersonaLibraryState(personas); + const state = getPersonaLibraryState( + personas, + new Set(["builtin:fizz", "custom:builder"]), + ); assert.deepEqual( state.libraryPersonas.map((persona) => persona.id), @@ -132,7 +139,7 @@ test("getPersonaLibraryState keeps the working library and full catalog in one p ); assert.deepEqual( state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], + ["custom:builder"], ); assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz"); }); diff --git a/desktop/src/features/agents/lib/catalog.ts b/desktop/src/features/agents/lib/catalog.ts index 226aafca5e..95cf12c2c3 100644 --- a/desktop/src/features/agents/lib/catalog.ts +++ b/desktop/src/features/agents/lib/catalog.ts @@ -28,7 +28,7 @@ export function isPersonaVisibleInCatalog( persona: AgentPersona, sharedCatalogPersonaIds: ReadonlySet = new Set(), ) { - return persona.isBuiltIn || sharedCatalogPersonaIds.has(persona.id); + return !persona.isBuiltIn && sharedCatalogPersonaIds.has(persona.id); } export function getCatalogPersonas( diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs deleted file mode 100644 index 9439d4a36e..0000000000 --- a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { clearLegacyPersonaCatalogVisibility } from "./legacyPersonaCatalogVisibility.ts"; - -test("clearLegacyPersonaCatalogVisibility removes the retired preference", () => { - const removedKeys = []; - - clearLegacyPersonaCatalogVisibility({ - removeItem(key) { - removedKeys.push(key); - }, - }); - - assert.deepEqual(removedKeys, ["buzz-persona-catalog-visibility-v1"]); -}); - -test("clearLegacyPersonaCatalogVisibility ignores unavailable storage", () => { - assert.doesNotThrow(() => clearLegacyPersonaCatalogVisibility(null)); - assert.doesNotThrow(() => - clearLegacyPersonaCatalogVisibility({ - removeItem() { - throw new Error("storage unavailable"); - }, - }), - ); -}); diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts deleted file mode 100644 index 38b2d5d974..0000000000 --- a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts +++ /dev/null @@ -1,28 +0,0 @@ -const LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = - "buzz-persona-catalog-visibility-v1"; - -/** - * Removes the retired custom-persona catalog preference so it cannot resurface - * agents after the visibility control has been removed. - */ -export function clearLegacyPersonaCatalogVisibility( - storage?: Pick | null, -) { - let targetStorage = storage; - if (targetStorage === undefined) { - if (typeof window === "undefined") return; - - try { - targetStorage = window.localStorage; - } catch { - return; - } - } - if (!targetStorage) return; - - try { - targetStorage.removeItem(LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY); - } catch { - // Catalog cleanup is best-effort and should not block the agents view. - } -} diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs new file mode 100644 index 0000000000..5259271e00 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + readSharedCatalogPersonaIds, + writeSharedCatalogPersonaIds, +} from "./personaCatalogVisibility.ts"; + +test("catalog visibility reads stored persona ids", () => { + const storage = { + getItem: () => JSON.stringify(["custom:analyst", 42, "custom:writer"]), + }; + + assert.deepEqual(readSharedCatalogPersonaIds(storage), [ + "custom:analyst", + "custom:writer", + ]); +}); + +test("catalog visibility tolerates unavailable and invalid storage", () => { + assert.deepEqual(readSharedCatalogPersonaIds(null), []); + assert.deepEqual( + readSharedCatalogPersonaIds({ getItem: () => "not-json" }), + [], + ); + assert.deepEqual(readSharedCatalogPersonaIds({ getItem: () => "{}" }), []); + assert.deepEqual( + readSharedCatalogPersonaIds({ + getItem: () => { + throw new Error("unavailable"); + }, + }), + [], + ); +}); + +test("catalog visibility persists persona ids without blocking on storage errors", () => { + let storedKey = ""; + let storedValue = ""; + writeSharedCatalogPersonaIds(["custom:analyst"], { + setItem: (key, value) => { + storedKey = key; + storedValue = value; + }, + }); + + assert.equal(storedKey, "buzz-persona-catalog-visibility-v1"); + assert.equal(storedValue, '["custom:analyst"]'); + assert.doesNotThrow(() => + writeSharedCatalogPersonaIds(["custom:analyst"], { + setItem: () => { + throw new Error("unavailable"); + }, + }), + ); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.ts b/desktop/src/features/agents/lib/personaCatalogVisibility.ts new file mode 100644 index 0000000000..43b30036e6 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogVisibility.ts @@ -0,0 +1,51 @@ +const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = + "buzz-persona-catalog-visibility-v1"; + +function resolveStorage( + storage: Pick | null | undefined, +): Pick | null { + if (storage !== undefined) return storage; + if (typeof window === "undefined") return null; + + try { + return window.localStorage; + } catch { + return null; + } +} + +export function readSharedCatalogPersonaIds( + storage?: Pick | null, +): string[] { + const targetStorage = resolveStorage(storage); + if (!targetStorage) return []; + + try { + const raw = targetStorage.getItem(PERSONA_CATALOG_VISIBILITY_STORAGE_KEY); + if (!raw) return []; + + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + + return parsed.filter((id): id is string => typeof id === "string"); + } catch { + return []; + } +} + +export function writeSharedCatalogPersonaIds( + ids: readonly string[], + storage?: Pick | null, +): void { + const targetStorage = resolveStorage(storage); + if (!targetStorage) return; + + try { + targetStorage.setItem( + PERSONA_CATALOG_VISIBILITY_STORAGE_KEY, + JSON.stringify(ids), + ); + } catch { + // Catalog visibility is a convenience setting and should not block sharing. + } +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index d69028cfd1..049fa82307 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"; @@ -83,6 +82,7 @@ import { } from "./agentAiConfigurationPolicy"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; +import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter"; type AgentDefinitionDialogProps = { open: boolean; @@ -734,41 +734,14 @@ export function AgentDefinitionDialog({ headerClassName="pb-2" title={title} footer={ -
-
- {submitBlockReason ? ( -

- {submitBlockReason} -

- ) : null} -
- -
- - -
-
+ handleOpenChange(false)} + submitBlockReason={displayName.trim() ? submitBlockReason : null} + submitLabel={submitLabel} + /> } >
void; + submitBlockReason: string | null; + submitLabel: string; +}; + +export function AgentDefinitionDialogFooter({ + canSubmit, + isAvatarUploadPending, + isPending, + onCancel, + submitBlockReason, + submitLabel, +}: AgentDefinitionDialogFooterProps) { + return ( +
+
+ {submitBlockReason ? ( +

+ {submitBlockReason} +

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

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx index ad1219310d..4a9584dfb9 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -15,6 +15,8 @@ import { } from "@/shared/ui/dialog"; import { Separator } from "@/shared/ui/separator"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; + // ── Types ───────────────────────────────────────────────────────────────────── type ImportPhase = "preview" | "confirming" | "result"; @@ -164,6 +166,12 @@ function PreviewBody({ ) : null} + +

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

-
+
{runningAgentCount > 0 ? ( @@ -139,7 +150,7 @@ export function AgentsView() { description="Set up and manage your agents." title="Agents" /> -
+
0} personas={personas.libraryPersonas} personasError={ personas.personasQuery.error instanceof Error @@ -186,10 +196,8 @@ export function AgentsView() { } isPersonasLoading={personas.personasQuery.isLoading} isPersonasPending={personas.isPending} - onCreatePersona={() => { - openUnifiedCreate(); - }} - onChooseCatalog={personas.openCatalog} + onCreatePersona={openUnifiedCreate} + onDiscoverPersonas={personas.openCatalog} onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} onSharePersona={personas.openShare} @@ -341,8 +349,19 @@ export function AgentsView() { ) : null} {personas.personaToShare ? ( { + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + personas.setPersonaCatalogVisibility(shareTarget.persona, visible); + }} onExport={() => { const shareTarget = personas.personaToShare; if (!shareTarget) return; diff --git a/desktop/src/features/agents/ui/CreateIdentityCard.tsx b/desktop/src/features/agents/ui/CreateIdentityCard.tsx index 70d063098b..4fdd6db26f 100644 --- a/desktop/src/features/agents/ui/CreateIdentityCard.tsx +++ b/desktop/src/features/agents/ui/CreateIdentityCard.tsx @@ -6,7 +6,7 @@ import { cn } from "@/shared/lib/cn"; type CreateIdentityCardProps = React.ButtonHTMLAttributes & { ariaLabel: string; dataTestId: string; - label: string; + label?: string; }; export const CreateIdentityCard = React.forwardRef< @@ -30,7 +30,9 @@ export const CreateIdentityCard = React.forwardRef< > - {label} + {label ? ( + {label} + ) : null} ); diff --git a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx index 6aab3096cb..34d8fd8b16 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx @@ -11,6 +11,7 @@ import { SheetTitle, } from "@/shared/ui/sheet"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge"; import { getPersonaCatalogDetailSelectionCopy, @@ -121,30 +122,11 @@ export function PersonaCatalogDetailsSheet({

) : null} -
-
-

- Type -

-

Built-in agent

-
-
-

- Preferred model -

-

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

-
-
-

- Preferred runtime -

-

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

-
-
+

diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index 0d6b5583ff..7d6afc564b 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -11,6 +11,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 +30,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", @@ -154,6 +156,31 @@ function PersonaCatalogChooser({ selectedPersona, selectedPersonaId, }: PersonaCatalogChooserProps) { + if (!isLoading && personas.length === 0 && !error) { + return ( +

+
+ +

+ {personaCatalogCopy.emptyCatalogTitle} +

+

+ {personaCatalogCopy.emptyCatalogDescription} +

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

- {personaCatalogCopy.emptyCatalogTitle} -

-

- {personaCatalogCopy.emptyCatalogDescription} -

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

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

+
- -
+

Agent instruction

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

- {item.label} -

-

- {item.value} -

-
- ))} -
-
- ); -} - function PersonaCatalogListSkeleton() { return (
diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index b6d3fafd3c..8f34ef1960 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, @@ -41,6 +42,7 @@ import { } from "@/shared/ui/dialog"; import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; import { formatShareRecipientName, @@ -51,8 +53,10 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; import { useSnapshotSendController } from "./useSnapshotSendController"; type PersonaShareDialogProps = { + isCatalogVisible: boolean; isPending: boolean; linkedAgentPubkey: string | null; + onCatalogVisibilityChange: (visible: boolean) => void; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; @@ -60,6 +64,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { + beforeExport?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -231,6 +236,7 @@ function ShareLevelControl({ } export function SnapshotShareDialog({ + beforeExport, displayName, encodeSnapshot, hasMemoryOptions, @@ -691,6 +697,7 @@ export function SnapshotShareDialog({
+ {beforeExport}
@@ -419,50 +418,37 @@ function firstAvatarUrl( } function NewAgentCard({ - canChooseCatalog, - isPersonasPending, - openFilePicker, - onChooseCatalog, - onCreatePersona, + isPending, + onCreate, + onDiscover, + onImport, }: { - canChooseCatalog: boolean; - isPersonasPending: boolean; - openFilePicker: () => void; - onChooseCatalog: () => void; - onCreatePersona: () => void; + isPending: boolean; + onCreate: () => void; + onDiscover: () => void; + onImport: () => void; }) { return ( - + event.preventDefault()} > - - Create from scratch + + Create agent + + + Discover agents - {canChooseCatalog ? ( - - Choose from catalog - - ) : null} - Import agent snapshot + Import diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts index 53c5e7a16f..79ddad1c3c 100644 --- a/desktop/src/features/agents/ui/personaLibraryCopy.ts +++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts @@ -14,14 +14,13 @@ export const personaLibraryCopy = { export const personaCatalogCopy = { title: "Agent Catalog", - description: "Browse built-in agents and add them to My Agents.", + description: "Browse agents shared to this relay.", dialogTitle: "Agent Catalog", - dialogDescription: "Browse built-in agents and add them to My Agents.", + dialogDescription: "Browse agents shared to this relay.", emptyTitle: "You're all set", emptyDescription: "Everything in Agent Catalog is already in My Agents.", - emptyCatalogDescription: - "New agents will show up here when the app ships more options.", - emptyCatalogTitle: "No agents in the catalog yet", + emptyCatalogDescription: "Shared agents will appear here.", + emptyCatalogTitle: "No agents are being shared", detailsAction: "View details", selectAction: "Choose", deselectAction: "Deselect", diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 54535d121c..494b4ae1cf 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -18,7 +18,10 @@ import { type AgentSnapshotImportResult, } from "@/features/agents/hooks"; import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; -import { clearLegacyPersonaCatalogVisibility } from "@/features/agents/lib/legacyPersonaCatalogVisibility"; +import { + readSharedCatalogPersonaIds, + writeSharedCatalogPersonaIds, +} from "@/features/agents/lib/personaCatalogVisibility"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; import type { SnapshotFormat, @@ -88,6 +91,9 @@ export function usePersonaActions() { const [snapshotImportConfirmError, setSnapshotImportConfirmError] = React.useState(null); const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false); + const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< + string[] + >(readSharedCatalogPersonaIds); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< string | null >(null); @@ -101,9 +107,10 @@ export function usePersonaActions() { React.useState(false); const personas = personasQuery.data ?? []; - React.useEffect(() => { - clearLegacyPersonaCatalogVisibility(); - }, []); + const sharedCatalogPersonaIdSet = React.useMemo( + () => new Set(sharedCatalogPersonaIds), + [sharedCatalogPersonaIds], + ); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -113,8 +120,8 @@ export function usePersonaActions() { [acpRuntimesQuery.data], ); const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo( - () => getPersonaLibraryState(personas), - [personas], + () => getPersonaLibraryState(personas, sharedCatalogPersonaIdSet), + [personas, sharedCatalogPersonaIdSet], ); function clearFeedback( @@ -386,6 +393,27 @@ export function usePersonaActions() { ); } + function setPersonaCatalogVisibility( + persona: AgentPersona, + visible: boolean, + ) { + if (persona.isBuiltIn) return; + + clearFeedback("library"); + setSharedCatalogPersonaIds((current) => { + const next = new Set(current); + if (visible) { + next.add(persona.id); + } else { + next.delete(persona.id); + } + + const ids = Array.from(next); + writeSharedCatalogPersonaIds(ids); + return ids; + }); + } + const isPending = isPersonaSubmitPending || createPersonaMutation.isPending || @@ -431,6 +459,8 @@ export function usePersonaActions() { personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, + setPersonaCatalogVisibility, + sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, snapshotImportResult, diff --git a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs index 8503a79349..d4e0b1d5f3 100644 --- a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs +++ b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs @@ -12,6 +12,9 @@ import test from "node:test"; function makePreview(overrides = {}) { return { displayName: "Test Agent", + isBuiltIn: false, + model: null, + runtime: null, systemPrompt: "You are helpful.", avatarUrl: null, memoryLevel: "none", diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index bd3be92407..aa5bc8ed9e 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -172,6 +172,10 @@ export async function encodeAgentSnapshotForSend( /** Preview returned by `preview_agent_snapshot_import` before any write. */ export type AgentSnapshotImportPreview = { displayName: string; + /** Source classification shown in the preview; imports remain custom. */ + isBuiltIn: boolean; + model: string | null; + runtime: string | null; systemPrompt: string | null; /** Effective avatar: data URL if present, source URL fallback otherwise. */ avatarUrl: string | null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 33e9e02ba1..3c5f8f8a47 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7282,9 +7282,7 @@ function ensureMockPersonaIsActive(personaId: string) { throw new Error(`agent ${personaId} not found`); } if (!persona.is_active) { - throw new Error( - `${persona.display_name} is not in My Agents. Choose it from Agent Catalog first.`, - ); + throw new Error(`${persona.display_name} is not in My Agents.`); } } @@ -9957,6 +9955,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", diff --git a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts index a20c649df4..9ce80593f1 100644 --- a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts +++ b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts @@ -271,6 +271,13 @@ test("recipient_import_navigates_to_agents_and_opens_preview", async ({ // Decoded display name must appear. await expect(dialog).toContainText("Imported Agent"); + const metadata = dialog.getByTestId("agent-definition-metadata"); + await expect(metadata).toContainText("Type"); + await expect(metadata).toContainText("Built-in agent"); + await expect(metadata).toContainText("Preferred model"); + await expect(metadata).toContainText("claude-opus-4-5"); + await expect(metadata).toContainText("Preferred runtime"); + await expect(metadata).toContainText("goose"); }); // ── Confirm imports the agent ───────────────────────────────────────────────── diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 3c0c60d935..25d29adeab 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -32,7 +32,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 +52,16 @@ 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-show-in-catalog").click(); await page - .getByTestId(`persona-catalog-use-agent-target-${personaId}`) + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) .click(); } @@ -154,78 +160,72 @@ async function invokeTauriExpectError( ); } -test("built-in personas are used from the catalog dialog", async ({ page }) => { +test("catalog hides built-ins and shows the shared-agent empty state", async ({ + page, +}) => { await page.setViewportSize({ width: 1280, height: 420 }); + await installMockBridge(page, { + activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("agents-library-personas")).toBeVisible(); - await openPersonaCatalog(page); - await expect(page.getByTestId("persona-catalog-dialog")).toContainText( - "Fizz", - ); for (const personaName of ["Fizz", "Honey", "Bumble"]) { - await expect(page.getByTestId("persona-catalog-dialog")).toContainText( + await expect(page.getByTestId("agents-library-personas")).toContainText( personaName, ); } - for (const retiredPersonaName of [ - "Product Strategist", - "Implementation Partner", - "QA Reviewer", - "Work Coordinator", - "Support Guide", - "Experiment Designer", - ]) { + + await openPersonaCatalog(page); + for (const personaName of ["Fizz", "Honey", "Bumble"]) { await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - retiredPersonaName, + personaName, ); } await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible(); + await expect(page.getByTestId("persona-catalog-dialog-body")).toBeVisible(); + const emptyState = page.getByTestId("persona-catalog-empty-state"); + await expect(emptyState).toContainText("No agents are being shared"); await expect( - page.getByTestId("persona-catalog-dialog-scroll-area"), + emptyState.getByTestId("persona-catalog-empty-agent-artwork"), ).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"); + page.locator('[data-testid^="persona-catalog-list-item-"]'), + ).toHaveCount(0); await expect( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Selected Fizz for My Agents." }), - ).toBeVisible(); + page.getByTestId("persona-catalog-use-agent-target"), + ).toHaveCount(0); - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", + 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-show-in-catalog")).toHaveCount( + 0, ); - await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toHaveText("Added to My Agents"); - await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toBeDisabled(); - await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - "Delete", +}); + +test("catalog empty state remains available after reopening", async ({ + page, +}) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible(); + + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await expect(page.getByTestId("persona-catalog-dialog")).not.toBeVisible(); + await openPersonaCatalog(page); + await expect(page.getByTestId("persona-catalog-empty-state")).toContainText( + "No agents are being shared", ); - await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder); }); test("built-in persona edits persist", async ({ page }) => { @@ -269,7 +269,9 @@ test("agent avatar emoji picker scrolls inside its popover", async ({ await gotoApp(page); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page + .getByRole("menuitem", { exact: true, name: "Create agent" }) + .click(); await expect(page.getByTestId("persona-dialog")).toBeVisible(); await page.getByLabel("Add avatar").click(); @@ -306,70 +308,239 @@ test("agent avatar emoji picker scrolls inside its popover", async ({ .toBeGreaterThan(before); }); -test("agent catalog can reopen from the populated library header", async ({ +test("the new agent card offers create, discover, and import", async ({ page, }) => { + await installMockBridge(page, { + activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"], + personas: [ + { + id: "custom:code-reviewer", + displayName: "Code Reviewer", + systemPrompt: "Review code changes.", + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", - ); + const newAgentCard = page.getByTestId("new-agent-card"); + await expect(newAgentCard).toHaveText(""); + await expect(newAgentCard.locator(".lucide-plus")).toBeVisible(); - await page.keyboard.press("Escape"); - await openPersonaCatalog(page); + const personaCards = page.locator('[data-testid^="persona-agent-row-"]'); + await expect(personaCards.first()).toBeVisible(); + const headerBox = await page + .getByRole("heading", { level: 1, name: "Agents" }) + .locator("../..") + .boundingBox(); + const cardBoxes = await personaCards.evaluateAll((cards) => + cards.map((card) => { + const box = card.getBoundingClientRect(); + return { right: box.right, top: box.top }; + }), + ); + const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top)); + const rightmostFirstRowCard = Math.max( + ...cardBoxes + .filter(({ top }) => Math.abs(top - firstRowTop) < 1) + .map(({ right }) => right), + ); + expect(headerBox).not.toBeNull(); + expect( + Math.abs( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard, + ), + ).toBeLessThan(1); + await newAgentCard.click(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Create agent" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Discover agents" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Import" }), + ).toBeVisible(); + await page + .getByRole("menuitem", { exact: true, name: "Discover agents" }) + .click(); await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible(); - await selectCatalogPersona(page, "builtin:fizz"); + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await newAgentCard.click(); + await page + .getByRole("menuitem", { exact: true, name: "Create agent" }) + .click(); + + const dialog = page.getByTestId("persona-dialog"); + await expect(dialog).toBeVisible(); await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toBeDisabled(); + dialog.getByTestId("import-agent-snapshot-dialog-action"), + ).toHaveCount(0); + await expect(dialog).not.toContainText("Enter a name for this agent."); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await newAgentCard.click(); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("menuitem", { exact: true, name: "Import" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + buffer: Buffer.from("{}"), + mimeType: "application/json", + name: "imported.agent.json", + }); + await expect(page.getByTestId("agent-snapshot-import-dialog")).toBeVisible(); }); -test("agent catalog chooser order stays stable when selection changes", async ({ +test("the new team card offers create and import", async ({ page }) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const newTeamCard = page.getByTestId("new-team-card"); + await expect(newTeamCard).toHaveText(""); + await expect(newTeamCard.locator(".lucide-plus")).toBeVisible(); + + await newTeamCard.click(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Create team" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Import" }), + ).toBeVisible(); +}); + +test("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 openPersonaCatalog(page); - const before = await getCatalogOrder(page); + await expect(page.getByTestId("agent-header-actions-button")).toHaveCount(0); + await expect( + page.getByRole("menuitem", { name: "Import agent" }), + ).toHaveCount(0); - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); + 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( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Selected Fizz for My Agents." }), - ).toBeVisible(); + defaultsDialog.getByTestId("global-agent-default-harness"), + ).toHaveAttribute("data-value", "codex"); + await expect( + defaultsDialog.getByTestId("global-agent-default-harness"), + ).toContainText("Codex"); + await expect( + defaultsDialog.getByTestId("global-agent-model"), + ).toHaveAttribute("data-value", "gpt-5.5[high]"); + await expect(defaultsDialog.getByTestId("global-agent-model")).toContainText( + "gpt-5.5[high]", + ); + await page.keyboard.press("Escape"); + await expect(defaultsDialog).toHaveCount(0); +}); +test("unconfigured agent defaults use the setup label", async ({ page }) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await expect(page.getByTestId("agent-defaults-button")).toHaveText( + "Set agent defaults", + ); +}); + +test("agent catalog chooser order stays stable when selection changes", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:builder", + displayName: "Builder", + systemPrompt: "Build the requested change.", + }, + { + id: "custom:reviewer", + displayName: "Reviewer", + systemPrompt: "Review the requested change.", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await sharePersonaToCatalog(page, "Builder"); + await sharePersonaToCatalog(page, "Reviewer"); + await openPersonaCatalog(page); + + const before = await getCatalogOrder(page); + await selectCatalogPersona(page, "custom:reviewer"); expect(await getCatalogOrder(page)).toEqual(before); }); test("catalog detail pane shows the full persona details", async ({ page }) => { + const personaId = "custom:researcher"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Researcher", + systemPrompt: "Research the question and cite the evidence.", + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); + await sharePersonaToCatalog(page, "Researcher"); await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:fizz"); + await selectCatalogPersona(page, personaId); const useAgentTarget = page.getByTestId( - "persona-catalog-use-agent-target-builtin:fizz", + `persona-catalog-use-agent-target-${personaId}`, ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Fizz", + "Researcher", + ); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Added by You", ); - await expect( - page.getByTestId("persona-catalog-detail-pane"), - ).not.toContainText("Added by You"); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "You are Fizz.", + "Research the question and cite the evidence.", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Built-in agent", + "Custom agent", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( "Preferred model", @@ -382,14 +553,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 }; @@ -667,18 +834,25 @@ test("custom personas share with people and keep export separate", async ({ ).toHaveCount(0); await expect(shareDialog.getByText("Memories")).toHaveCount(0); await expect(shareDialog.getByText("File format")).toHaveCount(0); - await expect(page.getByText("Show in my catalog")).toHaveCount(0); const shareMainCard = page.getByTestId("persona-share-main-card"); const exportAgentRow = page.getByTestId("persona-share-export"); + const catalogSection = page.getByTestId("persona-share-catalog"); + const catalogToggle = page.getByTestId("persona-share-show-in-catalog"); await expect(exportAgentRow).toHaveText("Export agent"); + await expect(catalogSection).toContainText("Share to catalog"); + await expect(catalogSection).toContainText( + "Let anyone on this relay find and use this agent.", + ); + await expect(catalogToggle).toHaveAttribute("aria-checked", "false"); await expect(shareMainCard.getByTestId("persona-share-export")).toHaveCount( 0, ); await waitForAnimations(page); const shareMainCardBox = await shareMainCard.boundingBox(); const exportAgentRowBox = await exportAgentRow.boundingBox(); + const catalogSectionBox = await catalogSection.boundingBox(); const shareCardGap = - (exportAgentRowBox?.y ?? 0) - + (catalogSectionBox?.y ?? 0) - ((shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0)); expect(shareCardGap).toBeGreaterThanOrEqual(12); expect(shareCardGap).toBeLessThan(16); @@ -700,6 +874,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( + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0) + 12, + ); await expect(page.getByTestId("agent-snapshot-export-dialog")).toHaveCount(0); await exportAgentRow.click(); @@ -1034,6 +1211,83 @@ test("custom personas share with people and keep export separate", async ({ await expect(shareDialog).toHaveCount(0); }); +test("custom personas can be shared to the relay catalog", async ({ page }) => { + const personaId = "custom:catalog-analyst"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Catalog Analyst", + 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. +\`\`\``, + }, + ], + }); + 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 catalogToggle = page.getByTestId("persona-share-show-in-catalog"); + await expect(catalogToggle).toHaveAttribute("aria-checked", "false"); + await catalogToggle.click(); + await expect(catalogToggle).toHaveAttribute("aria-checked", "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 catalogDetailPane = page.getByTestId("persona-catalog-detail-pane"); + await expect(catalogDetailPane).toContainText("Design System And Styling"); + 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: "Share" }).click(); + await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await catalogToggle.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("share access controls include the selected memories", async ({ page, }) => { @@ -1571,19 +1825,16 @@ test("inactive built-ins cannot be used to create teams", async ({ page }) => { }, }); - expect(error).toBe( - "Honey is not in My Agents. Choose it from Agent Catalog first.", - ); + expect(error).toBe("Honey is not in My Agents."); }); test("built-in removal failures show up from My Agents", async ({ page }) => { + await installMockBridge(page, { + activePersonaIds: ["builtin:honey"], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:honey"); - await useCatalogPersona(page, "builtin:honey"); - await invokeTauri(page, "create_team", { input: { name: "Honeys", @@ -1591,7 +1842,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(); From ea89b930938c3393ac8830233d85cf5f332a1e0c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 22 Jul 2026 17:07:09 -0700 Subject: [PATCH 02/40] Stack team card avatars --- .../features/agents/ui/TeamIdentityCard.tsx | 36 ++++++++++--- desktop/tests/e2e/agents.spec.ts | 50 +++++++++++++++++++ 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/agents/ui/TeamIdentityCard.tsx b/desktop/src/features/agents/ui/TeamIdentityCard.tsx index 824b206604..b7d71c6a4f 100644 --- a/desktop/src/features/agents/ui/TeamIdentityCard.tsx +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -115,6 +115,7 @@ function TeamAvatarRow({ }) { const visiblePersonas = personas.slice(0, MAX_VISIBLE_MEMBER_AVATARS); const overflowCount = Math.max(0, memberCount - visiblePersonas.length); + const stackItemCount = visiblePersonas.length + (overflowCount > 0 ? 1 : 0); if (visiblePersonas.length === 0 && overflowCount === 0) { return ( @@ -130,16 +131,26 @@ function TeamAvatarRow({
{visiblePersonas.map((persona, index) => ( - + ))} {overflowCount > 0 ? ( - - +{overflowCount} - +
0 ? "-ml-5" : ""} + style={{ zIndex: stackItemCount }} + > + + +{overflowCount} + +
) : null}
@@ -148,15 +159,28 @@ 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 ? ( { ).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: [ + { + 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(); + + 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"); +}); + test("agent defaults stays in the header without an actions menu", async ({ page, }) => { From d41e6cc8538f1007c7f0dc4a2d8d410584649c61 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 22 Jul 2026 17:10:14 -0700 Subject: [PATCH 03/40] Simplify team avatar stack styling --- .../features/agents/ui/TeamIdentityCard.tsx | 5 ++-- desktop/tests/e2e/agents.spec.ts | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/TeamIdentityCard.tsx b/desktop/src/features/agents/ui/TeamIdentityCard.tsx index b7d71c6a4f..19596b76d6 100644 --- a/desktop/src/features/agents/ui/TeamIdentityCard.tsx +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -147,7 +147,7 @@ function TeamAvatarRow({ className={visiblePersonas.length > 0 ? "-ml-5" : ""} style={{ zIndex: stackItemCount }} > - + +{overflowCount}
@@ -184,13 +184,14 @@ function TeamAvatarItem({ {avatarUrl ? ( ) : ( *") + .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 }, + ]); }); test("agent defaults stays in the header without an actions menu", async ({ From 1320d6426b3c174fb7441d9b1a61044bd16a834a Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 08:36:57 -0700 Subject: [PATCH 04/40] Add catalog update publishing --- .../lib/personaCatalogVisibility.test.mjs | 48 +++++++++ .../agents/lib/personaCatalogVisibility.ts | 48 +++++++++ .../agents/ui/AgentDefinitionDialog.tsx | 98 ++++++++++--------- .../agents/ui/AgentDefinitionDialogFooter.tsx | 24 +++++ .../src/features/agents/ui/AgentDialog.tsx | 7 +- desktop/src/features/agents/ui/AgentsView.tsx | 24 ++++- .../features/agents/ui/PersonaShareDialog.tsx | 31 ++++-- .../features/agents/ui/agentConfigOptions.tsx | 51 ++++++++++ .../features/agents/ui/usePersonaActions.ts | 51 +++++++++- desktop/tests/e2e/agents.spec.ts | 66 +++++++++++++ 10 files changed, 394 insertions(+), 54 deletions(-) diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs index 5259271e00..cf61c0c4cb 100644 --- a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + readPublishedCatalogPersonaVersions, readSharedCatalogPersonaIds, + writePublishedCatalogPersonaVersions, writeSharedCatalogPersonaIds, } from "./personaCatalogVisibility.ts"; @@ -54,3 +56,49 @@ test("catalog visibility persists persona ids without blocking on storage errors }), ); }); + +test("catalog publication versions read only string revisions", () => { + const storage = { + getItem: () => + JSON.stringify({ + "custom:analyst": "2026-07-22T00:00:00.000Z", + "custom:invalid": 42, + }), + }; + + assert.deepEqual(readPublishedCatalogPersonaVersions(storage), { + "custom:analyst": "2026-07-22T00:00:00.000Z", + }); + assert.deepEqual(readPublishedCatalogPersonaVersions(null), {}); + assert.deepEqual( + readPublishedCatalogPersonaVersions({ getItem: () => "[]" }), + {}, + ); +}); + +test("catalog publication versions persist without blocking on storage errors", () => { + let storedKey = ""; + let storedValue = ""; + writePublishedCatalogPersonaVersions( + { "custom:analyst": "2026-07-22T00:00:00.000Z" }, + { + setItem: (key, value) => { + storedKey = key; + storedValue = value; + }, + }, + ); + + assert.equal(storedKey, "buzz-persona-catalog-published-versions-v1"); + assert.equal(storedValue, '{"custom:analyst":"2026-07-22T00:00:00.000Z"}'); + assert.doesNotThrow(() => + writePublishedCatalogPersonaVersions( + { "custom:analyst": "2026-07-22T00:00:00.000Z" }, + { + setItem: () => { + throw new Error("unavailable"); + }, + }, + ), + ); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.ts b/desktop/src/features/agents/lib/personaCatalogVisibility.ts index 43b30036e6..433df35c3c 100644 --- a/desktop/src/features/agents/lib/personaCatalogVisibility.ts +++ b/desktop/src/features/agents/lib/personaCatalogVisibility.ts @@ -1,5 +1,9 @@ const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = "buzz-persona-catalog-visibility-v1"; +const PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY = + "buzz-persona-catalog-published-versions-v1"; + +export type PublishedCatalogPersonaVersions = Record; function resolveStorage( storage: Pick | null | undefined, @@ -49,3 +53,47 @@ export function writeSharedCatalogPersonaIds( // Catalog visibility is a convenience setting and should not block sharing. } } + +export function readPublishedCatalogPersonaVersions( + storage?: Pick | null, +): PublishedCatalogPersonaVersions { + const targetStorage = resolveStorage(storage); + if (!targetStorage) return {}; + + try { + const raw = targetStorage.getItem( + PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY, + ); + if (!raw) return {}; + + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + } catch { + return {}; + } +} + +export function writePublishedCatalogPersonaVersions( + versions: Readonly, + storage?: Pick | null, +): void { + const targetStorage = resolveStorage(storage); + if (!targetStorage) return; + + try { + targetStorage.setItem( + PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY, + JSON.stringify(versions), + ); + } catch { + // Catalog publication state should not block sharing. + } +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 049fa82307..81bc3948af 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -35,9 +35,9 @@ import { AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, BLOCK_BUILD_HIDDEN_PROVIDER_IDS, + buildPersonaRuntimeDropdownOptions, CUSTOM_PROVIDER_DROPDOWN_VALUE, computeLocalModeGate, - formatRuntimeOptionLabel, getDefaultPersonaRuntime, getPersonaModelOptions, getPersonaProviderOptions, @@ -49,7 +49,6 @@ import { PERSONA_FIELD_SHELL_CLASS, PERSONA_LABEL_OPTIONAL_CLASS, shouldClearKnownModelForSelectionScope, - sortPersonaRuntimes, } from "./agentConfigOptions"; import { RequiredFieldLabel } from "./agentConfigControls"; import { @@ -97,13 +96,20 @@ type AgentDefinitionDialogProps = { onOpenChange: (open: boolean) => void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, + options: AgentDefinitionSubmitOptions, ) => Promise; + /** Offers publishing alongside Save after a shared agent has been edited. */ + showPublishUpdatesOption?: 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 +127,7 @@ export function AgentDefinitionDialog({ runtimesLoading = false, onOpenChange, onSubmit, + showPublishUpdatesOption = false, createRunSection, createSubmitBlocked = false, }: AgentDefinitionDialogProps) { @@ -158,6 +165,9 @@ export function AgentDefinitionDialog({ const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); + const [hasUserChanges, setHasUserChanges] = React.useState(false); + const [publishUpdatesChecked, setPublishUpdatesChecked] = + React.useState(false); const { globalConfig, inheritedDefaults: { @@ -213,6 +223,8 @@ export function AgentDefinitionDialog({ // sufficient reason to auto-open. setShowAdvancedFields(false); setIsAvatarUploadPending(false); + setHasUserChanges(false); + setPublishUpdatesChecked(false); isRuntimeAutoSeededRef.current = false; hasSeededForOpenRef.current = false; }, [initialValues, open]); @@ -258,6 +270,8 @@ export function AgentDefinitionDialog({ behaviorSeedRef.current = emptyPersonaBehaviorDraft; setShowAdvancedFields(false); setIsAvatarUploadPending(false); + setHasUserChanges(false); + setPublishUpdatesChecked(false); // isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the // [initialValues, open] effect resets both when the dialog re-opens. } @@ -309,14 +323,20 @@ export function AgentDefinitionDialog({ }; if ("id" in initialValues) { - await onSubmit({ - id: initialValues.id, - ...baseInput, - }); + await onSubmit( + { + id: initialValues.id, + ...baseInput, + }, + { + publishCatalogUpdates: + showPublishUpdatesOption && hasUserChanges && publishUpdatesChecked, + }, + ); return; } - await onSubmit(baseInput); + await onSubmit(baseInput, { publishCatalogUpdates: false }); } function handleSubmitForm(event: React.FormEvent) { @@ -342,6 +362,7 @@ export function AgentDefinitionDialog({ const { data: runtimeFileConfig, isLoading: fileConfigLoading } = useRuntimeFileConfigQuery(runtime, { enabled: open }); function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) { + setHasUserChanges(true); setAiConfigurationMode(nextMode); setIsCustomProviderEditing(false); setIsCustomModelEditing(false); @@ -552,41 +573,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 && 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 providerDropdownOptions: PersonaDropdownOption[] = [ ...providerOptions .filter((option) => option.id.trim().length > 0) @@ -673,6 +667,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. @@ -691,6 +686,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") { @@ -708,6 +704,7 @@ export function AgentDefinitionDialog({ } function handleModelDropdownChange(nextValue: string) { + setHasUserChanges(true); applySelection( selectionOnModelDropdownChange(selection, { nextValue, @@ -739,6 +736,9 @@ export function AgentDefinitionDialog({ isAvatarUploadPending={isAvatarUploadPending} isPending={isPending} onCancel={() => handleOpenChange(false)} + onPublishUpdatesCheckedChange={setPublishUpdatesChecked} + publishUpdatesChecked={publishUpdatesChecked} + showPublishUpdates={showPublishUpdatesOption && hasUserChanges} submitBlockReason={displayName.trim() ? submitBlockReason : null} submitLabel={submitLabel} /> @@ -747,15 +747,22 @@ export function AgentDefinitionDialog({ setHasUserChanges(true)} onSubmit={handleSubmitForm} > setAvatarUrl("")} + onClearAvatar={() => { + setHasUserChanges(true); + setAvatarUrl(""); + }} onUploadPendingChange={setIsAvatarUploadPending} - onSelectAvatar={setAvatarUrl} + onSelectAvatar={(nextAvatarUrl) => { + setHasUserChanges(true); + setAvatarUrl(nextAvatarUrl); + }} />
@@ -985,7 +992,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 index b7d6add9e0..7d847bdc02 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx @@ -1,10 +1,14 @@ import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; type AgentDefinitionDialogFooterProps = { canSubmit: boolean; isAvatarUploadPending: boolean; isPending: boolean; onCancel: () => void; + onPublishUpdatesCheckedChange: (checked: boolean) => void; + publishUpdatesChecked: boolean; + showPublishUpdates: boolean; submitBlockReason: string | null; submitLabel: string; }; @@ -14,6 +18,9 @@ export function AgentDefinitionDialogFooter({ isAvatarUploadPending, isPending, onCancel, + onPublishUpdatesCheckedChange, + publishUpdatesChecked, + showPublishUpdates, submitBlockReason, submitLabel, }: AgentDefinitionDialogFooterProps) { @@ -51,6 +58,23 @@ export function AgentDefinitionDialogFooter({ ? "Uploading..." : submitLabel} + {showPublishUpdates ? ( + + ) : null}
); diff --git a/desktop/src/features/agents/ui/AgentDialog.tsx b/desktop/src/features/agents/ui/AgentDialog.tsx index 02a6d0e64a..cf5deaed1d 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; + showPublishUpdatesOption?: boolean; }; type AgentDialogProps = diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 352566f1a7..60f8a7f129 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -322,8 +322,22 @@ export function AgentsView() { personas.setPersonaDialogState(null); } }} - onSubmit={personas.handleSubmit} + onSubmit={(input, options) => + personas.handleSubmit( + input, + undefined, + undefined, + undefined, + options, + ) + } open={personas.personaDialogState !== null} + showPublishUpdatesOption={ + "id" in personas.personaDialogState.initialValues && + personas.sharedCatalogPersonaIdSet.has( + personas.personaDialogState.initialValues.id, + ) + } submitLabel={personas.personaDialogState.submitLabel} title={personas.personaDialogState.title} /> @@ -349,6 +363,9 @@ export function AgentsView() { ) : null} {personas.personaToShare ? ( { + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + personas.publishPersonaCatalogUpdates(shareTarget.persona); + }} open={personas.personaToShare !== null} persona={personas.personaToShare.persona} /> diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 8f34ef1960..4b5bb714d4 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -53,6 +53,7 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; import { useSnapshotSendController } from "./useSnapshotSendController"; type PersonaShareDialogProps = { + hasCatalogUpdates: boolean; isCatalogVisible: boolean; isPending: boolean; linkedAgentPubkey: string | null; @@ -60,6 +61,7 @@ type PersonaShareDialogProps = { onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; + onPublishCatalogUpdates: () => void; persona: AgentPersona; }; @@ -722,12 +724,14 @@ export function SnapshotShareDialog({ } export function PersonaShareDialog({ + hasCatalogUpdates, isCatalogVisible, isPending, linkedAgentPubkey, onCatalogVisibilityChange, onExport, onOpenChange, + onPublishCatalogUpdates, open, persona, }: PersonaShareDialogProps) { @@ -767,13 +771,26 @@ export function PersonaShareDialog({ Let anyone on this relay find and use this agent.

- +
+ + {isCatalogVisible && hasCatalogUpdates ? ( + + ) : null} +
) } diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cb..dd11208060 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -426,6 +426,57 @@ 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 && 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/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 494b4ae1cf..217a96a400 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -19,7 +19,9 @@ import { } from "@/features/agents/hooks"; import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; import { + readPublishedCatalogPersonaVersions, readSharedCatalogPersonaIds, + writePublishedCatalogPersonaVersions, writeSharedCatalogPersonaIds, } from "@/features/agents/lib/personaCatalogVisibility"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; @@ -94,6 +96,8 @@ export function usePersonaActions() { const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< string[] >(readSharedCatalogPersonaIds); + const [publishedCatalogPersonaVersions, setPublishedCatalogPersonaVersions] = + React.useState>(readPublishedCatalogPersonaVersions); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< string | null >(null); @@ -137,6 +141,7 @@ export function usePersonaActions() { intent?: AgentCreateIntent, backendIntent?: BackendIntent | null, targetChannel?: Pick | null, + options?: { publishCatalogUpdates?: boolean }, ): Promise { if (isPersonaSubmitPending) { return false; @@ -146,7 +151,10 @@ export function usePersonaActions() { setIsPersonaSubmitPending(true); try { if ("id" in input) { - await updatePersonaMutation.mutateAsync(input); + const updatedPersona = await updatePersonaMutation.mutateAsync(input); + if (options?.publishCatalogUpdates) { + publishPersonaCatalogUpdates(updatedPersona); + } setPersonaNoticeMessage(`Updated ${input.displayName}.`); } else { const runtime = availableRuntimes.find( @@ -354,6 +362,16 @@ export function usePersonaActions() { linkedAgent: ManagedAgent | undefined, ) { clearFeedback("library"); + if ( + sharedCatalogPersonaIdSet.has(persona.id) && + publishedCatalogPersonaVersions[persona.id] === undefined + ) { + setPublishedCatalogPersonaVersions((current) => { + const next = { ...current, [persona.id]: persona.updatedAt }; + writePublishedCatalogPersonaVersions(next); + return next; + }); + } setPersonaToShare({ persona, linkedAgentPubkey: linkedAgent?.pubkey ?? null, @@ -412,6 +430,35 @@ export function usePersonaActions() { writeSharedCatalogPersonaIds(ids); return ids; }); + setPublishedCatalogPersonaVersions((current) => { + const next = { ...current }; + if (visible) { + next[persona.id] = persona.updatedAt; + } else { + delete next[persona.id]; + } + writePublishedCatalogPersonaVersions(next); + return next; + }); + } + + function hasPersonaCatalogUpdates(persona: AgentPersona) { + const publishedVersion = publishedCatalogPersonaVersions[persona.id]; + return ( + sharedCatalogPersonaIdSet.has(persona.id) && + publishedVersion !== undefined && + publishedVersion !== persona.updatedAt + ); + } + + function publishPersonaCatalogUpdates(persona: AgentPersona) { + if (persona.isBuiltIn || !sharedCatalogPersonaIdSet.has(persona.id)) return; + + setPublishedCatalogPersonaVersions((current) => { + const next = { ...current, [persona.id]: persona.updatedAt }; + writePublishedCatalogPersonaVersions(next); + return next; + }); } const isPending = @@ -460,6 +507,8 @@ export function usePersonaActions() { setPersonaToExportSnapshot, handleExportSnapshot, setPersonaCatalogVisibility, + hasPersonaCatalogUpdates, + publishPersonaCatalogUpdates, sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index df7713266f..b822013db1 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1288,6 +1288,11 @@ test("custom personas share with people and keep export separate", 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, @@ -1319,9 +1324,14 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); const catalogToggle = page.getByTestId("persona-share-show-in-catalog"); + const publishCatalogUpdatesButton = page.getByTestId( + "persona-share-publish-catalog-updates", + ); await expect(catalogToggle).toHaveAttribute("aria-checked", "false"); + await expect(publishCatalogUpdatesButton).toHaveCount(0); await catalogToggle.click(); await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await expect(publishCatalogUpdatesButton).toHaveCount(0); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -1347,9 +1357,65 @@ This deliberately long fenced-code example must not establish the minimum width ).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 publishUpdatesCheckbox = editDialog.getByTestId( + "persona-dialog-publish-updates", + ); + await expect(publishUpdatesCheckbox).toHaveCount(0); + await editDialog + .getByLabel("Agent instructions") + .fill("Review the latest catalog changes."); + await expect(publishUpdatesCheckbox).toBeVisible(); + await expect(publishUpdatesCheckbox).toHaveAttribute( + "data-state", + "unchecked", + ); + const [saveButtonBox, publishUpdatesCheckboxBox] = await Promise.all([ + editDialog.getByRole("button", { name: "Save changes" }).boundingBox(), + publishUpdatesCheckbox.boundingBox(), + ]); + expect(publishUpdatesCheckboxBox?.x ?? 0).toBeGreaterThan( + (saveButtonBox?.x ?? 0) + (saveButtonBox?.width ?? 0), + ); + await editDialog.getByRole("button", { name: "Save changes" }).click(); + await expect(editDialog).toHaveCount(0); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await expect(publishCatalogUpdatesButton).toBeVisible(); + const [catalogToggleBox, publishCatalogUpdatesButtonBox] = await Promise.all([ + catalogToggle.boundingBox(), + publishCatalogUpdatesButton.boundingBox(), + ]); + expect(publishCatalogUpdatesButtonBox?.x ?? 0).toBeGreaterThan( + (catalogToggleBox?.x ?? 0) + (catalogToggleBox?.width ?? 0), + ); + await publishCatalogUpdatesButton.click(); + await expect(publishCatalogUpdatesButton).toHaveCount(0); + await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + await editDialog + .getByLabel("Agent instructions") + .fill("Review and publish the latest catalog changes."); + await expect(publishUpdatesCheckbox).toBeVisible(); + await publishUpdatesCheckbox.click(); + await expect(publishUpdatesCheckbox).toHaveAttribute("data-state", "checked"); + await editDialog.getByRole("button", { name: "Save changes" }).click(); + await expect(editDialog).toHaveCount(0); + await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await expect(publishCatalogUpdatesButton).toHaveCount(0); await catalogToggle.click(); await page .getByTestId("persona-share-dialog") From eddd919554243825a8d870088ad03dd17205e58f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 09:38:04 -0700 Subject: [PATCH 05/40] Clarify catalog copy behavior --- desktop/src/features/agents/ui/PersonaShareDialog.tsx | 2 +- desktop/tests/e2e/agents.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 4b5bb714d4..5e9806b721 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -768,7 +768,7 @@ export function PersonaShareDialog({ Share to catalog

- Let anyone on this relay find and use this agent. + Let anyone on this relay find and use a copy of this agent.

diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index b822013db1..226cd8c760 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -915,7 +915,7 @@ test("custom personas share with people and keep export separate", async ({ await expect(exportAgentRow).toHaveText("Export agent"); await expect(catalogSection).toContainText("Share to catalog"); await expect(catalogSection).toContainText( - "Let anyone on this relay find and use this agent.", + "Let anyone on this relay find and use a copy of this agent.", ); await expect(catalogToggle).toHaveAttribute("aria-checked", "false"); await expect(shareMainCard.getByTestId("persona-share-export")).toHaveCount( From b561105972b562f30ec70a15cd1e826509a27532 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 09:41:19 -0700 Subject: [PATCH 06/40] Use community in catalog sharing copy --- desktop/src/features/agents/ui/PersonaShareDialog.tsx | 2 +- desktop/tests/e2e/agents.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 5e9806b721..972b81c07d 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -768,7 +768,7 @@ export function PersonaShareDialog({ Share to catalog

- Let anyone on this relay find and use a copy of this agent. + Let anyone in this community find and use a copy of this agent.

diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 226cd8c760..e68856a1b4 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -915,7 +915,7 @@ test("custom personas share with people and keep export separate", async ({ await expect(exportAgentRow).toHaveText("Export agent"); await expect(catalogSection).toContainText("Share to catalog"); await expect(catalogSection).toContainText( - "Let anyone on this relay find and use a copy of this agent.", + "Let anyone in this community find and use a copy of this agent.", ); await expect(catalogToggle).toHaveAttribute("aria-checked", "false"); await expect(shareMainCard.getByTestId("persona-share-export")).toHaveCount( From 691b305be4ff7f19ad3c0503a42ba91941f7f6fb Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 09:56:43 -0700 Subject: [PATCH 07/40] Update agent creation test selectors --- desktop/tests/e2e/agent-readiness-screenshots.spec.ts | 2 +- desktop/tests/e2e/global-agent-config-screenshots.spec.ts | 2 +- desktop/tests/e2e/persona-env-vars.spec.ts | 6 +++--- .../tests/e2e/persona-model-combobox-screenshots.spec.ts | 2 +- desktop/tests/e2e/smoke.spec.ts | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts index 856cc67422..3bfce9bf1f 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/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 59d6759e3d..a4b4d824cc 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"); } diff --git a/desktop/tests/e2e/persona-env-vars.spec.ts b/desktop/tests/e2e/persona-env-vars.spec.ts index 53efa09a0e..1e9b077a82 100644 --- a/desktop/tests/e2e/persona-env-vars.spec.ts +++ b/desktop/tests/e2e/persona-env-vars.spec.ts @@ -267,10 +267,10 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({ }) => { await gotoApp(page); - // Open the Agents view, click New > New agent to open the persona dialog. + // Open the Agents view, then choose Create agent from the new-agent menu. await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); // Scope all env-vars queries to the dialog: AgentDefaultsSettingsCard // also renders an EnvVarsEditor in the background settings pane (introduced @@ -315,7 +315,7 @@ test("persona model options follow the selected LLM provider", async ({ await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); const provider = page.locator("#persona-runtime"); await page.getByRole("tab", { name: "Customize for this agent" }).click(); diff --git a/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts index 38d1df0914..508b123d7f 100644 --- a/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts +++ b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts @@ -36,7 +36,7 @@ async function openNewPersonaDialog(page: import("@playwright/test").Page) { }); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); const dialog = page.getByTestId("persona-dialog"); await expect(dialog).toBeVisible({ timeout: 8_000 }); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index d5ae2046db..4a930e4651 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -141,7 +141,7 @@ test("Buzz shared compute explains automatic model selection", async ({ }); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await chooseSharedComputeProvider(page); await expect @@ -170,7 +170,7 @@ test("create agent persists Buzz shared compute with auto model", async ({ await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill(agentName); await chooseSharedComputeProvider(page); @@ -214,7 +214,7 @@ test("create agent supports parallelism and system prompt overrides", async ({ await page.goto("/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); await page.locator("#persona-display-name").fill(agentName); await page From 40301c3f564828ec55872af4651f2b981e548c74 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 10:48:41 -0700 Subject: [PATCH 08/40] Constrain shared agent catalog details --- .../agents/ui/PersonaCatalogDialog.tsx | 2 +- desktop/tests/e2e/agents.spec.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index 7d6afc564b..b70e20f4d9 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -102,7 +102,7 @@ export function PersonaCatalogDialog({ { \`\`\`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. |`, }, ], }); @@ -1342,8 +1347,19 @@ This deliberately long fenced-code example must not establish the minimum width 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, From f08ab695865ae88f16bdccfa36f7830c95344ddb Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 11:04:25 -0700 Subject: [PATCH 09/40] Align catalog publish option with dialog footer --- .../agents/ui/AgentDefinitionDialogFooter.tsx | 34 +++++++++---------- desktop/tests/e2e/agents.spec.ts | 11 +++--- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx index 7d847bdc02..2c7adc741f 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx @@ -35,6 +35,23 @@ export function AgentDefinitionDialogFooter({ {submitBlockReason}

) : null} + {showPublishUpdates ? ( + + ) : null}
@@ -58,23 +75,6 @@ export function AgentDefinitionDialogFooter({ ? "Uploading..." : submitLabel} - {showPublishUpdates ? ( - - ) : null}
); diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index ead8925fde..d84410d0a7 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1388,13 +1388,14 @@ This deliberately long fenced-code example must not establish the minimum width "data-state", "unchecked", ); - const [saveButtonBox, publishUpdatesCheckboxBox] = await Promise.all([ - editDialog.getByRole("button", { name: "Save changes" }).boundingBox(), + const [cancelButtonBox, publishUpdatesCheckboxBox] = await Promise.all([ + editDialog.getByRole("button", { name: "Cancel" }).boundingBox(), publishUpdatesCheckbox.boundingBox(), ]); - expect(publishUpdatesCheckboxBox?.x ?? 0).toBeGreaterThan( - (saveButtonBox?.x ?? 0) + (saveButtonBox?.width ?? 0), - ); + expect( + (publishUpdatesCheckboxBox?.x ?? 0) + + (publishUpdatesCheckboxBox?.width ?? 0), + ).toBeLessThan(cancelButtonBox?.x ?? 0); await editDialog.getByRole("button", { name: "Save changes" }).click(); await expect(editDialog).toHaveCount(0); From 66991027d38e7fd891e431f73c32ca08996cea07 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 11:14:31 -0700 Subject: [PATCH 10/40] Place catalog publish action before toggle --- .../src/features/agents/ui/PersonaShareDialog.tsx | 14 +++++++------- desktop/tests/e2e/agents.spec.ts | 7 ++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 972b81c07d..690658cd9d 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -772,13 +772,6 @@ export function PersonaShareDialog({

- {isCatalogVisible && hasCatalogUpdates ? (
) diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index d84410d0a7..8a756ca991 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1407,9 +1407,10 @@ This deliberately long fenced-code example must not establish the minimum width catalogToggle.boundingBox(), publishCatalogUpdatesButton.boundingBox(), ]); - expect(publishCatalogUpdatesButtonBox?.x ?? 0).toBeGreaterThan( - (catalogToggleBox?.x ?? 0) + (catalogToggleBox?.width ?? 0), - ); + expect( + (publishCatalogUpdatesButtonBox?.x ?? 0) + + (publishCatalogUpdatesButtonBox?.width ?? 0), + ).toBeLessThan(catalogToggleBox?.x ?? 0); await publishCatalogUpdatesButton.click(); await expect(publishCatalogUpdatesButton).toHaveCount(0); await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); From a1570f18170717e3c98882d32ce0755bef374f52 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 11:59:21 -0700 Subject: [PATCH 11/40] Group catalog sharing with link controls --- .../features/agents/ui/PersonaShareDialog.tsx | 22 ++++++--- desktop/tests/e2e/agents.spec.ts | 48 ++++++++++++------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 690658cd9d..ece93ccf3a 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -66,7 +66,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { - beforeExport?: React.ReactNode; + afterLink?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -238,7 +238,7 @@ function ShareLevelControl({ } export function SnapshotShareDialog({ - beforeExport, + afterLink, displayName, encodeSnapshot, hasMemoryOptions, @@ -696,10 +696,18 @@ export function SnapshotShareDialog({
+ {afterLink ? ( + <> + + {afterLink} + + ) : null}
- {beforeExport}
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 217a96a400..8b26d8bb2e 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -19,8 +19,11 @@ import { } from "@/features/agents/hooks"; import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; import { + type CatalogPersonaShareLevel, + readCatalogPersonaMemoryLevels, readPublishedCatalogPersonaVersions, readSharedCatalogPersonaIds, + writeCatalogPersonaMemoryLevels, writePublishedCatalogPersonaVersions, writeSharedCatalogPersonaIds, } from "@/features/agents/lib/personaCatalogVisibility"; @@ -96,6 +99,8 @@ export function usePersonaActions() { const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< string[] >(readSharedCatalogPersonaIds); + const [catalogPersonaMemoryLevels, setCatalogPersonaMemoryLevels] = + React.useState(readCatalogPersonaMemoryLevels); const [publishedCatalogPersonaVersions, setPublishedCatalogPersonaVersions] = React.useState>(readPublishedCatalogPersonaVersions); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< @@ -411,12 +416,20 @@ export function usePersonaActions() { ); } - function setPersonaCatalogVisibility( + function getPersonaCatalogShareLevel( persona: AgentPersona, - visible: boolean, + ): CatalogPersonaShareLevel { + if (!sharedCatalogPersonaIdSet.has(persona.id)) return "not-shared"; + return catalogPersonaMemoryLevels[persona.id] ?? "none"; + } + + function setPersonaCatalogShareLevel( + persona: AgentPersona, + shareLevel: CatalogPersonaShareLevel, ) { if (persona.isBuiltIn) return; + const visible = shareLevel !== "not-shared"; clearFeedback("library"); setSharedCatalogPersonaIds((current) => { const next = new Set(current); @@ -430,6 +443,16 @@ export function usePersonaActions() { writeSharedCatalogPersonaIds(ids); return ids; }); + setCatalogPersonaMemoryLevels((current) => { + const next = { ...current }; + if (shareLevel !== "not-shared") { + next[persona.id] = shareLevel; + } else { + delete next[persona.id]; + } + writeCatalogPersonaMemoryLevels(next); + return next; + }); setPublishedCatalogPersonaVersions((current) => { const next = { ...current }; if (visible) { @@ -506,7 +529,8 @@ export function usePersonaActions() { personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, - setPersonaCatalogVisibility, + getPersonaCatalogShareLevel, + setPersonaCatalogShareLevel, hasPersonaCatalogUpdates, publishPersonaCatalogUpdates, sharedCatalogPersonaIdSet, diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 9d5400d213..73315be521 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -58,7 +58,10 @@ async function sharePersonaToCatalog( ) { await page.getByLabel(`Open actions for ${displayName}`).click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await page.getByTestId("persona-share-show-in-catalog").click(); + await page.getByTestId("persona-share-catalog-access").click(); + await page + .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .click(); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -204,9 +207,7 @@ test("catalog hides built-ins and shows the shared-agent empty state", async ({ 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-show-in-catalog")).toHaveCount( - 0, - ); + await expect(page.getByTestId("persona-share-catalog-access")).toHaveCount(0); }); test("catalog empty state remains available after reopening", async ({ @@ -1317,7 +1318,7 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); - const catalogToggle = page.getByTestId("persona-share-show-in-catalog"); + 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"); @@ -1346,10 +1347,17 @@ This deliberately long fenced-code example must not establish the minimum width ).toBeLessThanOrEqual( (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), ); - await expect(catalogToggle).toHaveAttribute("aria-checked", "false"); + await expect(catalogAccess).toHaveText("Not shared"); await expect(publishCatalogUpdatesButton).toHaveCount(0); - await catalogToggle.click(); - await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Agent only", + ]); + await page + .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .click(); + await expect(catalogAccess).toHaveText("Agent only"); await expect(publishCatalogUpdatesButton).toHaveCount(0); await page .getByTestId("persona-share-dialog") @@ -1415,19 +1423,19 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await expect(catalogAccess).toHaveText("Agent only"); await expect(publishCatalogUpdatesButton).toBeVisible(); - const [catalogToggleBox, publishCatalogUpdatesButtonBox] = await Promise.all([ - catalogToggle.boundingBox(), + const [catalogAccessBox, publishCatalogUpdatesButtonBox] = await Promise.all([ + catalogAccess.boundingBox(), publishCatalogUpdatesButton.boundingBox(), ]); expect( (publishCatalogUpdatesButtonBox?.x ?? 0) + (publishCatalogUpdatesButtonBox?.width ?? 0), - ).toBeLessThan(catalogToggleBox?.x ?? 0); + ).toBeLessThan(catalogAccessBox?.x ?? 0); await publishCatalogUpdatesButton.click(); await expect(publishCatalogUpdatesButton).toHaveCount(0); - await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await expect(catalogAccess).toHaveText("Agent only"); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -1446,9 +1454,12 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await expect(catalogAccess).toHaveText("Agent only"); await expect(publishCatalogUpdatesButton).toHaveCount(0); - await catalogToggle.click(); + await catalogAccess.click(); + await page + .getByRole("menuitemradio", { name: "Not shared", exact: true }) + .click(); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -1511,6 +1522,7 @@ test("share access controls include the selected memories", async ({ (element) => element.getBoundingClientRect().height, ); const linkAccess = shareDialog.getByLabel("What to include in the link"); + const catalogAccess = shareDialog.getByLabel("What to share in the catalog"); const recipientField = page.getByTestId("persona-share-recipient-field"); const emptyRecipientFieldBox = await recipientField.boundingBox(); await expect(shareDialog.getByTestId("persona-share-send")).toHaveCount(0); @@ -1522,6 +1534,15 @@ test("share access controls include the selected memories", async ({ await expect(linkAccess).toHaveCSS("text-decoration-line", "none"); await expect(linkAccess).toHaveCSS("padding-left", "8px"); await expect(linkAccess).toHaveCSS("padding-right", "8px"); + await expect(catalogAccess).toHaveText("Not shared"); + await catalogAccess.click(); + await expect(page.getByRole("menuitemradio")).toHaveText([ + "Not shared", + "Agent only", + "Agent + core memory", + "Agent + all memories", + ]); + await page.keyboard.press("Escape"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); const [linkAccessBox, copyLinkButtonBox] = await Promise.all([ linkAccess.boundingBox(), From 5dfc7930503972265dd7980499e4635f848dd4f1 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 12:22:24 -0700 Subject: [PATCH 13/40] Keep copy link action last --- .../features/agents/ui/PersonaShareDialog.tsx | 10 +------- desktop/tests/e2e/agents.spec.ts | 25 ++++++++----------- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 4f6ae52d36..65a561c968 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -623,6 +623,7 @@ export function SnapshotShareDialog({ value={linkShareLevel} />
+ {afterLink}
- {afterLink ? ( - <> - - {afterLink} - - ) : null}
diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 73315be521..eeb647b18c 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -818,7 +818,7 @@ test("custom personas share with people and keep export separate", async ({ const linkIcon = page.getByTestId("persona-share-link-icon"); const linkCopy = page.getByTestId("persona-share-link-copy"); const linkDivider = page.getByTestId("persona-share-link-divider"); - const catalogDivider = page.getByTestId("persona-share-catalog-divider"); + const catalogSection = page.getByTestId("persona-share-catalog"); const staticLinkAccess = page.getByTestId("persona-share-link-access"); await waitForAnimations(page); const [ @@ -827,7 +827,7 @@ test("custom personas share with people and keep export separate", async ({ linkIconBox, linkCopyBox, linkDividerBox, - catalogDividerBox, + catalogSectionBox, staticLinkAccessBox, ] = await Promise.all([ linkRow.boundingBox(), @@ -835,7 +835,7 @@ test("custom personas share with people and keep export separate", async ({ linkIcon.boundingBox(), linkCopy.boundingBox(), linkDivider.boundingBox(), - catalogDivider.boundingBox(), + catalogSection.boundingBox(), staticLinkAccess.boundingBox(), ]); const sendDescriptionBox = await sendDescription.boundingBox(); @@ -843,7 +843,10 @@ test("custom personas share with people and keep export separate", async ({ (sendDescriptionBox?.height ?? 0) + 30, ); expect(initialCopyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 23, + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0) + 23, + ); + expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( + (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), ); expect( Math.abs( @@ -853,7 +856,7 @@ test("custom personas share with people and keep export separate", async ({ ), ).toBeLessThanOrEqual(1); expect(linkDividerBox?.y ?? 0).toBeGreaterThan( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), ); expect(linkDividerBox?.y ?? 0).toBeLessThan(initialCopyLinkButtonBox?.y ?? 0); expect( @@ -868,14 +871,6 @@ test("custom personas share with people and keep export separate", async ({ (staticLinkAccessBox?.height ?? 0) / 2), ), ).toBeLessThanOrEqual(1); - const gapAboveCopyLink = - (initialCopyLinkButtonBox?.y ?? 0) - - ((linkDividerBox?.y ?? 0) + (linkDividerBox?.height ?? 0)); - const gapBelowCopyLink = - (catalogDividerBox?.y ?? 0) - - ((initialCopyLinkButtonBox?.y ?? 0) + - (initialCopyLinkButtonBox?.height ?? 0)); - expect(Math.abs(gapAboveCopyLink - gapBelowCopyLink)).toBeLessThanOrEqual(1); await expect(copyLinkButton).toHaveClass( /border.*bg-background.*border-border/, ); @@ -1339,8 +1334,8 @@ This deliberately long fenced-code example must not establish the minimum width catalogSection.boundingBox(), shareMainCard.boundingBox(), ]); - expect(catalogSectionBox?.y ?? 0).toBeGreaterThan( - (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), + expect(copyLinkButtonBox?.y ?? 0).toBeGreaterThan( + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), ); expect( (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), From 597bdf029120b0944c7153fb9af6e98528ad6105 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 12:51:36 -0700 Subject: [PATCH 14/40] Default catalog sharing to off --- desktop/src/features/agents/ui/PersonaShareDialog.tsx | 2 +- desktop/src/features/agents/ui/usePersonaActions.ts | 9 +++++++-- desktop/tests/e2e/agents.spec.ts | 8 +++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 65a561c968..1780f514a8 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -623,7 +623,7 @@ export function SnapshotShareDialog({ value={linkShareLevel} /> - {afterLink} + {afterLink ?
{afterLink}
: null} new Set(sharedCatalogPersonaIds), - [sharedCatalogPersonaIds], + () => + new Set( + sharedCatalogPersonaIds.filter( + (personaId) => catalogPersonaMemoryLevels[personaId] !== undefined, + ), + ), + [catalogPersonaMemoryLevels, sharedCatalogPersonaIds], ); const availableRuntimes = React.useMemo( () => diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index eeb647b18c..9ff6bd296c 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -846,7 +846,7 @@ test("custom personas share with people and keep export separate", async ({ (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0) + 23, ); expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), + (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 7, ); expect( Math.abs( @@ -1272,6 +1272,12 @@ test("custom personas share with people and keep export separate", async ({ test("custom personas can be shared to the relay catalog", async ({ page }) => { const personaId = "custom:catalog-analyst"; + await page.addInitScript((legacyPersonaId) => { + localStorage.setItem( + "buzz-persona-catalog-visibility-v1", + JSON.stringify([legacyPersonaId]), + ); + }, personaId); await installMockBridge(page, { globalAgentConfig: { env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" }, From 0520fe0e862aaedca5f603252a8f75e4c4a44136 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 13:33:53 -0700 Subject: [PATCH 15/40] Publish catalog updates on save --- .../agents/ui/AgentDefinitionDialog.tsx | 19 ++---- .../agents/ui/AgentDefinitionDialogFooter.tsx | 35 ++++------ .../src/features/agents/ui/AgentDialog.tsx | 2 +- desktop/src/features/agents/ui/AgentsView.tsx | 2 +- desktop/tests/e2e/agents.spec.ts | 64 +++++++++++-------- 5 files changed, 57 insertions(+), 65 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 81bc3948af..09b2a98a4d 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -98,8 +98,8 @@ type AgentDefinitionDialogProps = { input: CreatePersonaInput | UpdatePersonaInput, options: AgentDefinitionSubmitOptions, ) => Promise; - /** Offers publishing alongside Save after a shared agent has been edited. */ - showPublishUpdatesOption?: boolean; + /** 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). */ @@ -127,7 +127,7 @@ export function AgentDefinitionDialog({ runtimesLoading = false, onOpenChange, onSubmit, - showPublishUpdatesOption = false, + publishCatalogUpdatesOnSave = false, createRunSection, createSubmitBlocked = false, }: AgentDefinitionDialogProps) { @@ -166,8 +166,6 @@ export function AgentDefinitionDialog({ const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); const [hasUserChanges, setHasUserChanges] = React.useState(false); - const [publishUpdatesChecked, setPublishUpdatesChecked] = - React.useState(false); const { globalConfig, inheritedDefaults: { @@ -224,7 +222,6 @@ export function AgentDefinitionDialog({ setShowAdvancedFields(false); setIsAvatarUploadPending(false); setHasUserChanges(false); - setPublishUpdatesChecked(false); isRuntimeAutoSeededRef.current = false; hasSeededForOpenRef.current = false; }, [initialValues, open]); @@ -271,7 +268,6 @@ export function AgentDefinitionDialog({ setShowAdvancedFields(false); setIsAvatarUploadPending(false); setHasUserChanges(false); - setPublishUpdatesChecked(false); // isRuntimeAutoSeededRef and hasSeededForOpenRef are NOT reset here — the // [initialValues, open] effect resets both when the dialog re-opens. } @@ -329,8 +325,7 @@ export function AgentDefinitionDialog({ ...baseInput, }, { - publishCatalogUpdates: - showPublishUpdatesOption && hasUserChanges && publishUpdatesChecked, + publishCatalogUpdates: publishCatalogUpdatesOnSave && hasUserChanges, }, ); return; @@ -736,9 +731,9 @@ export function AgentDefinitionDialog({ isAvatarUploadPending={isAvatarUploadPending} isPending={isPending} onCancel={() => handleOpenChange(false)} - onPublishUpdatesCheckedChange={setPublishUpdatesChecked} - publishUpdatesChecked={publishUpdatesChecked} - showPublishUpdates={showPublishUpdatesOption && hasUserChanges} + publishesCatalogUpdates={ + publishCatalogUpdatesOnSave && hasUserChanges + } submitBlockReason={displayName.trim() ? submitBlockReason : null} submitLabel={submitLabel} /> diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx index 2c7adc741f..92428ad95c 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx @@ -1,14 +1,11 @@ import { Button } from "@/shared/ui/button"; -import { Checkbox } from "@/shared/ui/checkbox"; type AgentDefinitionDialogFooterProps = { canSubmit: boolean; isAvatarUploadPending: boolean; isPending: boolean; onCancel: () => void; - onPublishUpdatesCheckedChange: (checked: boolean) => void; - publishUpdatesChecked: boolean; - showPublishUpdates: boolean; + publishesCatalogUpdates: boolean; submitBlockReason: string | null; submitLabel: string; }; @@ -18,9 +15,7 @@ export function AgentDefinitionDialogFooter({ isAvatarUploadPending, isPending, onCancel, - onPublishUpdatesCheckedChange, - publishUpdatesChecked, - showPublishUpdates, + publishesCatalogUpdates, submitBlockReason, submitLabel, }: AgentDefinitionDialogFooterProps) { @@ -35,22 +30,14 @@ export function AgentDefinitionDialogFooter({ {submitBlockReason}

) : null} - {showPublishUpdates ? ( - + This agent is in the community catalog. Your changes will be + published when you save. +

) : null} @@ -73,7 +60,9 @@ export function AgentDefinitionDialogFooter({ ? "Saving..." : isAvatarUploadPending ? "Uploading..." - : submitLabel} + : publishesCatalogUpdates + ? "Save and publish" + : submitLabel} diff --git a/desktop/src/features/agents/ui/AgentDialog.tsx b/desktop/src/features/agents/ui/AgentDialog.tsx index cf5deaed1d..f5be3cc7e8 100644 --- a/desktop/src/features/agents/ui/AgentDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDialog.tsx @@ -69,7 +69,7 @@ type AgentDialogDefinitionEditProps = { input: CreatePersonaInput | UpdatePersonaInput, options: AgentDefinitionSubmitOptions, ) => Promise; - showPublishUpdatesOption?: boolean; + publishCatalogUpdatesOnSave?: boolean; }; type AgentDialogProps = diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 30bb095f32..ce3f1ad07a 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -332,7 +332,7 @@ export function AgentsView() { ) } open={personas.personaDialogState !== null} - showPublishUpdatesOption={ + publishCatalogUpdatesOnSave={ "id" in personas.personaDialogState.initialValues && personas.sharedCatalogPersonaIdSet.has( personas.personaDialogState.initialValues.id, diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 9ff6bd296c..e7a0e073b8 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1399,29 +1399,48 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Edit" }).click(); const editDialog = page.getByTestId("persona-dialog"); - const publishUpdatesCheckbox = editDialog.getByTestId( - "persona-dialog-publish-updates", + const catalogPublishNotice = editDialog.getByTestId( + "persona-dialog-catalog-publish-notice", ); - await expect(publishUpdatesCheckbox).toHaveCount(0); + 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(publishUpdatesCheckbox).toBeVisible(); - await expect(publishUpdatesCheckbox).toHaveAttribute( - "data-state", - "unchecked", - ); - const [cancelButtonBox, publishUpdatesCheckboxBox] = await Promise.all([ - editDialog.getByRole("button", { name: "Cancel" }).boundingBox(), - publishUpdatesCheckbox.boundingBox(), - ]); - expect( - (publishUpdatesCheckboxBox?.x ?? 0) + - (publishUpdatesCheckboxBox?.width ?? 0), - ).toBeLessThan(cancelButtonBox?.x ?? 0); - await editDialog.getByRole("button", { name: "Save changes" }).click(); + await expect(catalogPublishNotice).toHaveText( + "This agent is in the community catalog. Your changes will be published when you save.", + ); + await expect( + editDialog.getByRole("button", { name: "Save changes" }), + ).toHaveCount(0); + await editDialog.getByRole("button", { name: "Save and publish" }).click(); await expect(editDialog).toHaveCount(0); + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(catalogAccess).toHaveText("Agent only"); + await expect(publishCatalogUpdatesButton).toHaveCount(0); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await page.evaluate((id) => { + const storageKey = "buzz-persona-catalog-published-versions-v1"; + const publishedVersions = JSON.parse( + localStorage.getItem(storageKey) ?? "{}", + ) as Record; + publishedVersions[id] = "stale"; + localStorage.setItem(storageKey, JSON.stringify(publishedVersions)); + }, personaId); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); await expect(catalogAccess).toHaveText("Agent only"); @@ -1442,17 +1461,6 @@ This deliberately long fenced-code example must not establish the minimum width .getByRole("button", { name: "Close" }) .click(); - await page.getByLabel("Open actions for Catalog Analyst").click(); - await page.getByRole("menuitem", { name: "Edit" }).click(); - await editDialog - .getByLabel("Agent instructions") - .fill("Review and publish the latest catalog changes."); - await expect(publishUpdatesCheckbox).toBeVisible(); - await publishUpdatesCheckbox.click(); - await expect(publishUpdatesCheckbox).toHaveAttribute("data-state", "checked"); - await editDialog.getByRole("button", { name: "Save changes" }).click(); - await expect(editDialog).toHaveCount(0); - await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); await expect(catalogAccess).toHaveText("Agent only"); From 2b8f1d790f6b13a0d83c21d205e76736a7312965 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 15:27:03 -0700 Subject: [PATCH 16/40] Publish agent catalog entries to relay --- crates/buzz-core/src/kind.rs | 17 + crates/buzz-relay/src/handlers/ingest.rs | 359 +++++++++- .../src/features/agents/lib/catalog.test.mjs | 85 --- desktop/src/features/agents/lib/catalog.ts | 62 -- .../agents/lib/personaCatalogRelay.test.mjs | 199 ++++++ .../agents/lib/personaCatalogRelay.ts | 618 ++++++++++++++++++ .../lib/personaCatalogVisibility.test.mjs | 153 ----- .../agents/lib/personaCatalogVisibility.ts | 157 ----- .../agents/lib/usePersonaCatalogRelay.ts | 107 +++ desktop/src/features/agents/ui/AgentsView.tsx | 16 +- .../src/features/agents/ui/PersonaAddedBy.tsx | 8 +- .../agents/ui/PersonaCatalogDialog.tsx | 12 +- .../features/agents/ui/PersonaShareDialog.tsx | 159 +++-- .../features/agents/ui/usePersonaActions.ts | 290 +++++--- desktop/src/shared/constants/kinds.ts | 3 + desktop/src/testing/e2eBridge.ts | 136 +++- desktop/tests/e2e/agents.spec.ts | 190 ++++-- desktop/tests/helpers/bridge.ts | 4 + 18 files changed, 1921 insertions(+), 654 deletions(-) create mode 100644 desktop/src/features/agents/lib/personaCatalogRelay.test.mjs create mode 100644 desktop/src/features/agents/lib/personaCatalogRelay.ts delete mode 100644 desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs delete mode 100644 desktop/src/features/agents/lib/personaCatalogVisibility.ts create mode 100644 desktop/src/features/agents/lib/usePersonaCatalogRelay.ts diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b912169801..636a5bc2bb 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -182,6 +182,21 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +/// Buzz community agent catalog entry (parameterized replaceable, owner-authored). +/// +/// Addressed by `(pubkey, kind, d_tag)` where `d_tag` is the source persona's +/// stable local id. Unlike [`KIND_PERSONA`], readers intentionally query this +/// kind across every community member: a `published` head contains an explicit +/// public projection plus a verified same-relay agent-snapshot reference, while +/// an `unpublished` head removes that coordinate from discovery. +/// +/// Privacy contract: catalog entries and their referenced snapshots are +/// community-readable plaintext. Publishers MUST use an explicit allowlist +/// projection that excludes private keys, auth tags, environment variables, +/// machine-local runtime state, and response allowlists. Optional memory is +/// included only at the user-selected `none`, `core`, or `everything` level. +pub const KIND_PERSONA_CATALOG: u32 = 30178; + // NIP-56 reporting /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). /// @@ -510,6 +525,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PERSONA_CATALOG, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -708,6 +724,7 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000– const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA_CATALOG)); // 30178 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ca529d1db6..9c6b70593b 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -27,9 +27,9 @@ use buzz_core::kind::{ KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PERSONA_CATALOG, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, + KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -200,7 +200,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM - | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT + | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_PERSONA_CATALOG | KIND_TEAM | KIND_MANAGED_AGENT | super::push_lease::KIND_PUSH_LEASE => { Ok(Scope::UsersWrite) } @@ -406,6 +406,9 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_AGENT_PROFILE // NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag). | KIND_PERSONA + // Community catalog publications (30178): owner-authored, but read + // across all members and keyed by the source persona's stable id. + | KIND_PERSONA_CATALOG // NIP-AP: team (30176) + managed-agent (30177) definitions: owner-authored, // keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. | KIND_TEAM @@ -1066,6 +1069,254 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> { Ok(()) } +/// Validate the public envelope and plaintext projection of a community catalog +/// entry before it can replace the current kind:30178 head. +/// +/// The relay deliberately rejects unknown fields here. This is stricter than +/// the private owner projection (`kind:30175`) because every catalog entry and +/// referenced snapshot is readable by the community. The explicit allowlist +/// prevents accidental additions such as env vars, auth tags, or response +/// allowlists from silently becoming public metadata. +fn validate_persona_catalog_envelope(event: &Event) -> Result<(), String> { + use serde_json::{Map, Value}; + + const MAX_SOURCE_ID_LEN: usize = 128; + const MAX_SOURCE_UPDATED_AT_LEN: usize = 64; + const MAX_AGENT_SNAPSHOT_JSON_BYTES: u64 = 5 * 1024 * 1024; + + fn one_tag<'a>(event: &'a Event, name: &str) -> Result<&'a str, String> { + let values: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].as_str()) + }) + .collect(); + if values.len() != 1 { + return Err(format!( + "persona catalog event must have exactly one `{name}` tag (got {})", + values.len() + )); + } + Ok(values[0]) + } + + fn optional_one_tag<'a>(event: &'a Event, name: &str) -> Result, String> { + let values: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].as_str()) + }) + .collect(); + if values.len() > 1 { + return Err(format!( + "persona catalog event must have at most one `{name}` tag (got {})", + values.len() + )); + } + Ok(values.first().copied()) + } + + fn object<'a>(value: &'a Value, label: &str) -> Result<&'a Map, String> { + value + .as_object() + .ok_or_else(|| format!("persona catalog `{label}` must be an object")) + } + + fn reject_unknown( + object: &Map, + allowed: &[&str], + label: &str, + ) -> Result<(), String> { + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!( + "persona catalog `{label}` contains unsupported field `{key}`" + )); + } + Ok(()) + } + + fn required_string<'a>( + object: &'a Map, + key: &str, + label: &str, + ) -> Result<&'a str, String> { + object + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("persona catalog `{label}.{key}` must be a non-empty string")) + } + + fn optional_string_or_null( + object: &Map, + key: &str, + label: &str, + ) -> Result<(), String> { + if object + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_string()) + { + return Err(format!( + "persona catalog `{label}.{key}` must be a string or null" + )); + } + Ok(()) + } + + let source_id = one_tag(event, "d")?; + if source_id.is_empty() + || source_id.len() > MAX_SOURCE_ID_LEN + || source_id.chars().any(char::is_control) + { + return Err(format!( + "persona catalog `d` tag must be 1-{MAX_SOURCE_ID_LEN} non-control characters" + )); + } + + let status = one_tag(event, "status")?; + if status != "published" && status != "unpublished" { + return Err("persona catalog `status` must be `published` or `unpublished`".to_string()); + } + let source_updated_at = one_tag(event, "source_updated_at")?; + if source_updated_at.is_empty() || source_updated_at.len() > MAX_SOURCE_UPDATED_AT_LEN { + return Err(format!( + "persona catalog `source_updated_at` must be 1-{MAX_SOURCE_UPDATED_AT_LEN} characters" + )); + } + let memory_tag = optional_one_tag(event, "memory")?; + + let parsed: Value = serde_json::from_str(&event.content) + .map_err(|_| "persona catalog content must be valid JSON".to_string())?; + let root = object(&parsed, "content")?; + + if root.get("format").and_then(Value::as_str) != Some("buzz-persona-catalog") + || root.get("version").and_then(Value::as_u64) != Some(1) + || root.get("status").and_then(Value::as_str) != Some(status) + || root.get("sourcePersonaId").and_then(Value::as_str) != Some(source_id) + || root.get("sourceUpdatedAt").and_then(Value::as_str) != Some(source_updated_at) + { + return Err("persona catalog content does not match its signed envelope".to_string()); + } + + if status == "unpublished" { + reject_unknown( + root, + &[ + "format", + "version", + "status", + "sourcePersonaId", + "sourceUpdatedAt", + ], + "content", + )?; + if memory_tag.is_some() { + return Err( + "unpublished persona catalog events must not carry a `memory` tag".to_string(), + ); + } + return Ok(()); + } + + reject_unknown( + root, + &[ + "format", + "version", + "status", + "sourcePersonaId", + "sourceUpdatedAt", + "memoryLevel", + "agent", + "snapshot", + ], + "content", + )?; + + let memory = memory_tag.ok_or_else(|| { + "published persona catalog events must carry exactly one `memory` tag".to_string() + })?; + if !matches!(memory, "none" | "core" | "everything") + || root.get("memoryLevel").and_then(Value::as_str) != Some(memory) + { + return Err( + "persona catalog memory level must be `none`, `core`, or `everything` and match content" + .to_string(), + ); + } + + let agent = object( + root.get("agent") + .ok_or_else(|| "persona catalog content is missing `agent`".to_string())?, + "agent", + )?; + reject_unknown( + agent, + &[ + "displayName", + "avatarUrl", + "systemPrompt", + "runtime", + "model", + "provider", + ], + "agent", + )?; + required_string(agent, "displayName", "agent")?; + if !agent.get("systemPrompt").is_some_and(Value::is_string) { + return Err("persona catalog `agent.systemPrompt` must be a string".to_string()); + } + for key in ["avatarUrl", "runtime", "model", "provider"] { + optional_string_or_null(agent, key, "agent")?; + } + + let snapshot = object( + root.get("snapshot") + .ok_or_else(|| "persona catalog content is missing `snapshot`".to_string())?, + "snapshot", + )?; + reject_unknown( + snapshot, + &["url", "sha256", "size", "type", "fileName"], + "snapshot", + )?; + required_string(snapshot, "url", "snapshot")?; + let sha256 = required_string(snapshot, "sha256", "snapshot")?; + if sha256.len() != 64 + || !sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err("persona catalog `snapshot.sha256` must be 64 lowercase hex chars".to_string()); + } + snapshot + .get("size") + .and_then(Value::as_u64) + .filter(|size| (1..=MAX_AGENT_SNAPSHOT_JSON_BYTES).contains(size)) + .ok_or_else(|| { + "persona catalog `snapshot.size` must be within the 5 MiB agent JSON limit".to_string() + })?; + if snapshot.get("type").and_then(Value::as_str) != Some("application/json") { + return Err("persona catalog snapshot must use `application/json`".to_string()); + } + let filename = required_string(snapshot, "fileName", "snapshot")?; + if !filename.to_ascii_lowercase().ends_with(".agent.json") + || filename.contains('/') + || filename.contains('\\') + { + return Err( + "persona catalog `snapshot.fileName` must be a plain `.agent.json` filename" + .to_string(), + ); + } + + Ok(()) +} + /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// /// Checks: @@ -2025,6 +2276,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_PERSONA_CATALOG { + validate_persona_catalog_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -2808,6 +3064,7 @@ mod tests { KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_PERSONA, + KIND_PERSONA_CATALOG, KIND_TEAM, KIND_MANAGED_AGENT, KIND_AGENT_TURN_METRIC, @@ -2893,6 +3150,17 @@ mod tests { assert!(!requires_h_channel_scope(KIND_PERSONA)); } + #[test] + fn persona_catalog_is_in_scope_allowlist_and_global_only() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PERSONA_CATALOG, &dummy).unwrap(), + Scope::UsersWrite, + ); + assert!(is_global_only_kind(KIND_PERSONA_CATALOG)); + assert!(!requires_h_channel_scope(KIND_PERSONA_CATALOG)); + } + #[test] fn team_and_managed_agent_are_in_scope_allowlist() { let dummy = make_dummy_event(); @@ -3534,6 +3802,89 @@ mod tests { assert!(err.contains("`d` tag"), "got: {err}"); } + fn make_persona_catalog(status: &str, memory: Option<&str>, content: &str) -> Event { + let mut tags = vec![ + vec!["d", "persona-1"], + vec!["status", status], + vec!["source_updated_at", "2026-07-23T00:00:00Z"], + ]; + if let Some(memory) = memory { + tags.push(vec!["memory", memory]); + } + let tag_refs: Vec> = tags; + let borrowed: Vec<&[&str]> = tag_refs.iter().map(Vec::as_slice).collect(); + make_event_with_tags(KIND_PERSONA_CATALOG, content, &borrowed) + } + + fn valid_published_catalog_content() -> String { + serde_json::json!({ + "format": "buzz-persona-catalog", + "version": 1, + "status": "published", + "sourcePersonaId": "persona-1", + "sourceUpdatedAt": "2026-07-23T00:00:00Z", + "memoryLevel": "none", + "agent": { + "displayName": "Reviewer", + "avatarUrl": null, + "systemPrompt": "Review changes.", + "runtime": "goose", + "model": "claude", + "provider": null + }, + "snapshot": { + "url": "https://relay.example/media/snapshot", + "sha256": "a".repeat(64), + "size": 512, + "type": "application/json", + "fileName": "reviewer.agent.json" + } + }) + .to_string() + } + + #[test] + fn persona_catalog_accepts_public_allowlist_projection() { + let content = valid_published_catalog_content(); + let event = make_persona_catalog("published", Some("none"), &content); + assert!(validate_persona_catalog_envelope(&event).is_ok()); + } + + #[test] + fn persona_catalog_rejects_secret_or_allowlist_fields() { + let mut content: serde_json::Value = + serde_json::from_str(&valid_published_catalog_content()).unwrap(); + content["agent"]["envVars"] = serde_json::json!({"ANTHROPIC_API_KEY": "secret"}); + let event = make_persona_catalog("published", Some("none"), &content.to_string()); + let error = validate_persona_catalog_envelope(&event).unwrap_err(); + assert!( + error.contains("unsupported field `envVars`"), + "got: {error}" + ); + } + + #[test] + fn persona_catalog_accepts_unpublished_replacement() { + let content = serde_json::json!({ + "format": "buzz-persona-catalog", + "version": 1, + "status": "unpublished", + "sourcePersonaId": "persona-1", + "sourceUpdatedAt": "2026-07-23T00:00:00Z" + }) + .to_string(); + let event = make_persona_catalog("unpublished", None, &content); + assert!(validate_persona_catalog_envelope(&event).is_ok()); + } + + #[test] + fn persona_catalog_rejects_memory_mismatch() { + let content = valid_published_catalog_content(); + let event = make_persona_catalog("published", Some("everything"), &content); + let error = validate_persona_catalog_envelope(&event).unwrap_err(); + assert!(error.contains("memory level"), "got: {error}"); + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs index 59ad24c1dd..62e809bdb5 100644 --- a/desktop/src/features/agents/lib/catalog.test.mjs +++ b/desktop/src/features/agents/lib/catalog.test.mjs @@ -2,11 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - getCatalogPersonas, - getCatalogSelectionState, getLibraryPersonas, getPersonaLabelsById, - getPersonaLibraryState, isCatalogPersonaSelected, } from "./catalog.ts"; @@ -25,66 +22,6 @@ function createPersona(id, displayName, overrides = {}) { }; } -test("getCatalogPersonas hides built-ins and includes shared custom agents", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("custom:builder", "Builder"), - ]; - - assert.deepEqual( - getCatalogPersonas(personas, new Set(["custom:builder"])).map( - (persona) => persona.id, - ), - ["custom:builder"], - ); -}); - -test("getCatalogSelectionState only selects shared custom agents", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getCatalogSelectionState( - personas, - new Set(["builtin:fizz", "custom:builder"]), - ); - - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["custom:builder"], - ); - assert.deepEqual( - state.selectedCatalogPersonas.map((persona) => persona.id), - ["custom:builder"], - ); - assert.deepEqual( - state.unselectedCatalogPersonas.map((persona) => persona.id), - [], - ); -}); - -test("getCatalogPersonas keeps chooser order stable when selection changes", () => { - const inactive = [ - createPersona("custom:fizz", "Fizz", { isActive: false }), - createPersona("custom:reviewer", "Reviewer", { - isActive: true, - }), - ]; - const active = [ - createPersona("custom:fizz", "Fizz", { isActive: true }), - createPersona("custom:reviewer", "Reviewer", { - isActive: false, - }), - ]; - const shared = new Set(["custom:fizz", "custom:reviewer"]); - - assert.deepEqual( - getCatalogPersonas(inactive, shared).map((persona) => persona.id), - getCatalogPersonas(active, shared).map((persona) => persona.id), - ); -}); - test("isCatalogPersonaSelected treats active catalog personas as selected", () => { assert.equal( isCatalogPersonaSelected( @@ -122,28 +59,6 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => { }); }); -test("getPersonaLibraryState keeps built-ins in the library but not the catalog", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getPersonaLibraryState( - personas, - new Set(["builtin:fizz", "custom:builder"]), - ); - - assert.deepEqual( - state.libraryPersonas.map((persona) => persona.id), - ["builtin:fizz", "custom:builder"], - ); - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["custom:builder"], - ); - 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 95cf12c2c3..fabc0af87e 100644 --- a/desktop/src/features/agents/lib/catalog.ts +++ b/desktop/src/features/agents/lib/catalog.ts @@ -1,17 +1,5 @@ import type { AgentPersona } from "@/shared/api/types"; -export type CatalogSelectionState = { - catalogPersonas: AgentPersona[]; - selectedCatalogPersonas: AgentPersona[]; - unselectedCatalogPersonas: AgentPersona[]; -}; - -export type PersonaLibraryState = { - catalogPersonas: AgentPersona[]; - libraryPersonas: AgentPersona[]; - personaLabelsById: Record; -}; - export function isPersonaActive(persona: AgentPersona) { return persona.isActive; } @@ -24,62 +12,12 @@ export function getLibraryPersonas(personas: readonly AgentPersona[]) { return getActivePersonas(personas); } -export function isPersonaVisibleInCatalog( - persona: AgentPersona, - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return !persona.isBuiltIn && sharedCatalogPersonaIds.has(persona.id); -} - -export function getCatalogPersonas( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return personas - .filter((persona) => - isPersonaVisibleInCatalog(persona, sharedCatalogPersonaIds), - ) - .sort((left, right) => left.displayName.localeCompare(right.displayName)); -} - export function isCatalogPersonaSelected(persona: AgentPersona) { return persona.isActive; } -export function getCatalogSelectionState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): CatalogSelectionState { - const catalogPersonas = getCatalogPersonas(personas, sharedCatalogPersonaIds); - - return { - catalogPersonas, - selectedCatalogPersonas: catalogPersonas.filter(isCatalogPersonaSelected), - unselectedCatalogPersonas: catalogPersonas.filter( - (persona) => !isCatalogPersonaSelected(persona), - ), - }; -} - export function getPersonaLabelsById(personas: readonly AgentPersona[]) { return Object.fromEntries( personas.map((persona) => [persona.id, persona.displayName]), ); } - -export function getPersonaLibraryState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): PersonaLibraryState { - const libraryPersonas = getLibraryPersonas(personas); - const { catalogPersonas } = getCatalogSelectionState( - personas, - sharedCatalogPersonaIds, - ); - - return { - catalogPersonas, - libraryPersonas, - personaLabelsById: getPersonaLabelsById(personas), - }; -} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs new file mode 100644 index 0000000000..dd04627e1f --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + catalogPersonasFromPublications, + catalogPublicationsFromEvents, + sanitizeCatalogSnapshotBytes, +} from "./personaCatalogRelay.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); +const SNAPSHOT = { + url: `https://relay.example/media/${"c".repeat(64)}`, + sha256: "c".repeat(64), + size: 512, + type: "application/json", + fileName: "reviewer.agent.json", +}; + +function catalogEvent({ + createdAt, + id, + owner = ALICE, + sourcePersonaId = "reviewer", + status = "published", + memoryLevel = "none", +}) { + const sourceUpdatedAt = `2026-07-23T00:00:0${createdAt}.000Z`; + const content = + status === "published" + ? { + format: "buzz-persona-catalog", + version: 1, + status, + sourcePersonaId, + sourceUpdatedAt, + memoryLevel, + agent: { + displayName: "Relay Reviewer", + avatarUrl: null, + systemPrompt: "Review changes.", + runtime: "goose", + model: "claude", + provider: null, + }, + snapshot: SNAPSHOT, + } + : { + format: "buzz-persona-catalog", + version: 1, + status, + sourcePersonaId, + sourceUpdatedAt, + }; + return { + id, + pubkey: owner, + created_at: createdAt, + kind: 30178, + tags: [ + ["d", sourcePersonaId], + ["status", status], + ["source_updated_at", sourceUpdatedAt], + ...(status === "published" ? [["memory", memoryLevel]] : []), + ], + content: JSON.stringify(content), + sig: "sig", + }; +} + +test("a catalog publication from Alice is discoverable by Bob", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + const personas = catalogPersonasFromPublications(publications, [], BOB); + + assert.equal(personas.length, 1); + assert.equal(personas[0].displayName, "Relay Reviewer"); + assert.equal(personas[0].isActive, false); + assert.equal(personas[0].catalogSource.ownerPubkey, ALICE); + assert.equal(personas[0].catalogSource.isOwn, false); + assert.deepEqual(personas[0].catalogSource.snapshot, SNAPSHOT); +}); + +test("the newest unpublished head removes an older publication from discovery", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "published" }), + catalogEvent({ createdAt: 2, id: "unpublished", status: "unpublished" }), + ]); + + assert.equal(publications.length, 1); + assert.equal(publications[0].status, "unpublished"); + assert.deepEqual(catalogPersonasFromPublications(publications, [], BOB), []); +}); + +test("catalog coordinates remain independent across authors", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "alice", owner: ALICE }), + catalogEvent({ createdAt: 1, id: "bob", owner: BOB }), + ]); + + assert.equal(publications.length, 2); + assert.equal( + catalogPersonasFromPublications(publications, [], BOB).length, + 2, + ); +}); + +test("catalog snapshot sanitization strips secrets and response allowlists", () => { + const source = { + format: "buzz-agent-snapshot", + version: 1, + definition: { + name: "Reviewer", + systemPrompt: "Review changes.", + runtime: "goose", + model: "claude", + provider: "anthropic", + respondTo: "allowlist", + respondToAllowlist: [BOB], + namePool: ["Reviewer"], + envVars: { ANTHROPIC_API_KEY: "secret" }, + privateKeyNsec: "nsec-secret", + authTag: "auth-secret", + }, + profile: { displayName: "Reviewer" }, + memory: { level: "none", entries: [] }, + relayUrl: "wss://private.example", + }; + + const sanitized = JSON.parse( + new TextDecoder().decode( + Uint8Array.from( + sanitizeCatalogSnapshotBytes( + Array.from(new TextEncoder().encode(JSON.stringify(source))), + "none", + ), + ), + ), + ); + + assert.equal(sanitized.definition.systemPrompt, "Review changes."); + assert.equal(sanitized.definition.respondTo, "allowlist"); + assert.equal("respondToAllowlist" in sanitized.definition, false); + assert.equal("envVars" in sanitized.definition, false); + assert.equal("privateKeyNsec" in sanitized.definition, false); + assert.equal("authTag" in sanitized.definition, false); + assert.equal("relayUrl" in sanitized, false); +}); + +test("selected core memory survives the public allowlist projection", () => { + const source = { + format: "buzz-agent-snapshot", + version: 1, + definition: { name: "Reviewer", systemPrompt: "Review changes." }, + profile: { displayName: "Reviewer" }, + memory: { + level: "core", + entries: [{ slug: "core", body: "Prefers concise findings." }], + }, + }; + const sanitized = JSON.parse( + new TextDecoder().decode( + Uint8Array.from( + sanitizeCatalogSnapshotBytes( + Array.from(new TextEncoder().encode(JSON.stringify(source))), + "core", + ), + ), + ), + ); + + assert.deepEqual(sanitized.memory, source.memory); +}); + +test("catalog snapshot sanitization rejects memory beyond the selected level", () => { + const source = { + format: "buzz-agent-snapshot", + version: 1, + definition: { name: "Reviewer", systemPrompt: "Review changes." }, + profile: { displayName: "Reviewer" }, + memory: { + level: "core", + entries: [ + { slug: "core", body: "Public core instructions." }, + { slug: "mem/private", body: "Must not escape a core-only share." }, + ], + }, + }; + + assert.throws( + () => + sanitizeCatalogSnapshotBytes( + Array.from(new TextEncoder().encode(JSON.stringify(source))), + "core", + ), + /outside the selected sharing level/u, + ); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts new file mode 100644 index 0000000000..4f3d36dbf2 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -0,0 +1,618 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent, uploadMediaBytes } from "@/shared/api/tauri"; +import { + encodeAgentSnapshotForSend, + type SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; +import type { + AgentPersona, + ManagedAgent, + RelayEvent, +} from "@/shared/api/types"; +import { KIND_PERSONA_CATALOG } from "@/shared/constants/kinds"; + +const CATALOG_FORMAT = "buzz-persona-catalog"; +const CATALOG_VERSION = 1; +const MAX_CATALOG_EVENTS = 1_000; +const MAX_SNAPSHOT_JSON_BYTES = 5 * 1024 * 1024; + +type CatalogStatus = "published" | "unpublished"; + +export type CatalogSnapshotReference = { + url: string; + sha256: string; + size: number; + type: "application/json"; + fileName: string; +}; + +export type CatalogPersonaShareLevel = "not-shared" | SnapshotMemoryLevel; + +type CatalogAgentProjection = { + displayName: string; + avatarUrl: string | null; + systemPrompt: string; + runtime: string | null; + model: string | null; + provider: string | null; +}; + +type PublishedCatalogContent = { + format: typeof CATALOG_FORMAT; + version: typeof CATALOG_VERSION; + status: "published"; + sourcePersonaId: string; + sourceUpdatedAt: string; + memoryLevel: SnapshotMemoryLevel; + agent: CatalogAgentProjection; + snapshot: CatalogSnapshotReference; +}; + +type UnpublishedCatalogContent = { + format: typeof CATALOG_FORMAT; + version: typeof CATALOG_VERSION; + status: "unpublished"; + sourcePersonaId: string; + sourceUpdatedAt: string; +}; + +type CatalogContent = PublishedCatalogContent | UnpublishedCatalogContent; + +export type PersonaCatalogPublication = { + eventId: string; + ownerPubkey: string; + sourcePersonaId: string; + sourceUpdatedAt: string; + createdAt: number; + status: CatalogStatus; + memoryLevel: SnapshotMemoryLevel | null; + agent: CatalogAgentProjection | null; + snapshot: CatalogSnapshotReference | null; +}; + +export type CatalogPersona = AgentPersona & { + catalogSource: { + eventId: string; + ownerPubkey: string; + isOwn: boolean; + sourcePersonaId: string; + sourceUpdatedAt: string; + memoryLevel: SnapshotMemoryLevel; + snapshot: CatalogSnapshotReference; + }; +}; + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringOrNull(value: unknown): string | null | undefined { + return typeof value === "string" || value === null ? value : undefined; +} + +function extractTag(event: RelayEvent, name: string): string | null { + const matches = event.tags.filter( + (tag) => tag[0] === name && typeof tag[1] === "string", + ); + return matches.length === 1 ? (matches[0]?.[1] ?? null) : null; +} + +function isMemoryLevel(value: unknown): value is SnapshotMemoryLevel { + return value === "none" || value === "core" || value === "everything"; +} + +function isSafeSnapshotReference( + value: unknown, +): value is CatalogSnapshotReference { + if (!isObject(value)) return false; + const fileName = value.fileName; + const sha256 = value.sha256; + const size = value.size; + const url = value.url; + return ( + typeof url === "string" && + url.length > 0 && + !/[\s()]/u.test(url) && + (() => { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } + })() && + typeof sha256 === "string" && + /^[0-9a-f]{64}$/u.test(sha256) && + typeof size === "number" && + Number.isSafeInteger(size) && + size > 0 && + size <= MAX_SNAPSHOT_JSON_BYTES && + value.type === "application/json" && + typeof fileName === "string" && + fileName.toLowerCase().endsWith(".agent.json") && + !fileName.includes("/") && + !fileName.includes("\\") + ); +} + +function parseCatalogContent(event: RelayEvent): CatalogContent | null { + let parsed: unknown; + try { + parsed = JSON.parse(event.content); + } catch { + return null; + } + if (!isObject(parsed)) return null; + + const sourcePersonaId = extractTag(event, "d"); + const status = extractTag(event, "status"); + const sourceUpdatedAt = extractTag(event, "source_updated_at"); + if ( + event.kind !== KIND_PERSONA_CATALOG || + parsed.format !== CATALOG_FORMAT || + parsed.version !== CATALOG_VERSION || + parsed.status !== status || + parsed.sourcePersonaId !== sourcePersonaId || + parsed.sourceUpdatedAt !== sourceUpdatedAt || + !sourcePersonaId || + !sourceUpdatedAt || + (status !== "published" && status !== "unpublished") + ) { + return null; + } + + if (status === "unpublished") { + return { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status, + sourcePersonaId, + sourceUpdatedAt, + }; + } + + const memoryLevel = extractTag(event, "memory"); + if ( + !isMemoryLevel(memoryLevel) || + parsed.memoryLevel !== memoryLevel || + !isObject(parsed.agent) || + typeof parsed.agent.displayName !== "string" || + parsed.agent.displayName.trim().length === 0 || + typeof parsed.agent.systemPrompt !== "string" || + stringOrNull(parsed.agent.avatarUrl) === undefined || + stringOrNull(parsed.agent.runtime) === undefined || + stringOrNull(parsed.agent.model) === undefined || + stringOrNull(parsed.agent.provider) === undefined || + !isSafeSnapshotReference(parsed.snapshot) + ) { + return null; + } + + return { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status, + sourcePersonaId, + sourceUpdatedAt, + memoryLevel, + agent: { + displayName: parsed.agent.displayName, + avatarUrl: parsed.agent.avatarUrl as string | null, + systemPrompt: parsed.agent.systemPrompt, + runtime: parsed.agent.runtime as string | null, + model: parsed.agent.model as string | null, + provider: parsed.agent.provider as string | null, + }, + snapshot: parsed.snapshot, + }; +} + +/** + * Collapse relay results to one latest head per `(author, sourcePersonaId)`. + * The relay already applies NIP-33 replacement, but doing this client-side + * makes discovery deterministic against older relays and test fixtures. + */ +export function catalogPublicationsFromEvents( + events: readonly RelayEvent[], +): PersonaCatalogPublication[] { + const sorted = [...events].sort( + (left, right) => + right.created_at - left.created_at || right.id.localeCompare(left.id), + ); + const seenCoordinates = new Set(); + const publications: PersonaCatalogPublication[] = []; + + for (const event of sorted) { + const sourcePersonaId = extractTag(event, "d"); + if (!sourcePersonaId) continue; + const coordinate = `${event.pubkey.toLowerCase()}:${sourcePersonaId}`; + if (seenCoordinates.has(coordinate)) continue; + seenCoordinates.add(coordinate); + + const content = parseCatalogContent(event); + if (!content) continue; + publications.push({ + eventId: event.id, + ownerPubkey: event.pubkey.toLowerCase(), + sourcePersonaId: content.sourcePersonaId, + sourceUpdatedAt: content.sourceUpdatedAt, + createdAt: event.created_at, + status: content.status, + memoryLevel: content.status === "published" ? content.memoryLevel : null, + agent: content.status === "published" ? content.agent : null, + snapshot: content.status === "published" ? content.snapshot : null, + }); + } + + return publications; +} + +export async function fetchPersonaCatalogPublications(): Promise< + PersonaCatalogPublication[] +> { + const events = await relayClient.fetchEvents({ + kinds: [KIND_PERSONA_CATALOG], + limit: MAX_CATALOG_EVENTS, + }); + return catalogPublicationsFromEvents(events); +} + +export function catalogPersonasFromPublications( + publications: readonly PersonaCatalogPublication[], + localPersonas: readonly AgentPersona[], + currentPubkey: string | null | undefined, +): CatalogPersona[] { + const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; + return publications + .filter( + ( + publication, + ): publication is PersonaCatalogPublication & { + status: "published"; + memoryLevel: SnapshotMemoryLevel; + agent: CatalogAgentProjection; + snapshot: CatalogSnapshotReference; + } => + publication.status === "published" && + publication.memoryLevel !== null && + publication.agent !== null && + publication.snapshot !== null, + ) + .map((publication) => { + const ownLocalPersona = + publication.ownerPubkey === normalizedCurrentPubkey + ? localPersonas.find( + (persona) => persona.id === publication.sourcePersonaId, + ) + : undefined; + const persona: CatalogPersona = { + id: + publication.ownerPubkey === normalizedCurrentPubkey + ? publication.sourcePersonaId + : `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, + displayName: publication.agent.displayName, + avatarUrl: publication.agent.avatarUrl, + systemPrompt: publication.agent.systemPrompt, + runtime: publication.agent.runtime, + model: publication.agent.model, + provider: publication.agent.provider, + namePool: [], + isBuiltIn: false, + isActive: ownLocalPersona?.isActive ?? false, + sourceTeam: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: publication.sourceUpdatedAt, + updatedAt: publication.sourceUpdatedAt, + catalogSource: { + eventId: publication.eventId, + ownerPubkey: publication.ownerPubkey, + isOwn: publication.ownerPubkey === normalizedCurrentPubkey, + sourcePersonaId: publication.sourcePersonaId, + sourceUpdatedAt: publication.sourceUpdatedAt, + memoryLevel: publication.memoryLevel, + snapshot: publication.snapshot, + }, + }; + return persona; + }) + .sort((left, right) => left.displayName.localeCompare(right.displayName)); +} + +export function isCatalogPersona( + persona: AgentPersona, +): persona is CatalogPersona { + return "catalogSource" in persona && isObject(persona.catalogSource); +} + +export function ownCatalogPublication( + publications: readonly PersonaCatalogPublication[], + ownerPubkey: string | null | undefined, + sourcePersonaId: string, +): PersonaCatalogPublication | null { + if (!ownerPubkey) return null; + const normalizedOwner = ownerPubkey.toLowerCase(); + return ( + publications.find( + (publication) => + publication.ownerPubkey === normalizedOwner && + publication.sourcePersonaId === sourcePersonaId, + ) ?? null + ); +} + +function copyOptionalString( + source: JsonObject, + target: JsonObject, + key: string, +): void { + if (typeof source[key] === "string") target[key] = source[key]; +} + +function copyOptionalNumber( + source: JsonObject, + target: JsonObject, + key: string, +): void { + if ( + typeof source[key] === "number" && + Number.isFinite(source[key]) && + source[key] >= 0 + ) { + target[key] = source[key]; + } +} + +/** + * Rebuild a catalog snapshot from a strict public allowlist. + * + * The normal portable snapshot already excludes identity keys, auth tags, + * environment variables, relay URLs, commands, and runtime state. Catalog + * publication applies a second boundary: response allowlists are also removed + * because they disclose source-community pubkeys and are not portable. + */ +export function sanitizeCatalogSnapshotBytes( + fileBytes: readonly number[], + expectedMemoryLevel: SnapshotMemoryLevel, +): number[] { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(Uint8Array.from(fileBytes))); + } catch { + throw new Error("Couldn’t prepare this agent for the community catalog."); + } + if ( + !isObject(parsed) || + parsed.format !== "buzz-agent-snapshot" || + parsed.version !== 1 || + !isObject(parsed.definition) || + !isObject(parsed.profile) || + !isObject(parsed.memory) || + parsed.memory.level !== expectedMemoryLevel || + !Array.isArray(parsed.memory.entries) + ) { + throw new Error("The generated agent snapshot is invalid."); + } + + const definition: JsonObject = {}; + copyOptionalString(parsed.definition, definition, "name"); + if (typeof parsed.definition.sourceIsBuiltIn === "boolean") { + definition.sourceIsBuiltIn = parsed.definition.sourceIsBuiltIn; + } + for (const key of [ + "systemPrompt", + "runtime", + "model", + "provider", + "respondTo", + ]) { + copyOptionalString(parsed.definition, definition, key); + } + for (const key of [ + "parallelism", + "idleTimeoutSeconds", + "maxTurnDurationSeconds", + ]) { + copyOptionalNumber(parsed.definition, definition, key); + } + if ( + Array.isArray(parsed.definition.namePool) && + parsed.definition.namePool.every((value) => typeof value === "string") + ) { + definition.namePool = [...parsed.definition.namePool]; + } + + const profile: JsonObject = {}; + for (const key of ["displayName", "about", "avatarDataUrl", "avatarUrl"]) { + copyOptionalString(parsed.profile, profile, key); + } + if ( + typeof profile.displayName !== "string" || + profile.displayName.length === 0 + ) { + throw new Error("The generated agent snapshot has no display name."); + } + + const entries = parsed.memory.entries.map((entry) => { + if ( + !isObject(entry) || + typeof entry.slug !== "string" || + entry.slug.length === 0 || + typeof entry.body !== "string" + ) { + throw new Error("The generated agent snapshot has invalid memory."); + } + return { slug: entry.slug, body: entry.body }; + }); + const duplicateSlugs = + new Set(entries.map((entry) => entry.slug)).size !== entries.length; + const invalidMemorySelection = + duplicateSlugs || + (expectedMemoryLevel === "none" && entries.length > 0) || + (expectedMemoryLevel === "core" && + (entries.length > 1 || entries.some((entry) => entry.slug !== "core"))) || + (expectedMemoryLevel === "everything" && + entries.some( + (entry) => entry.slug !== "core" && !entry.slug.startsWith("mem/"), + )); + if (invalidMemorySelection) { + throw new Error( + "The generated agent snapshot includes memory outside the selected sharing level.", + ); + } + + const sanitized = { + format: "buzz-agent-snapshot", + version: 1, + definition, + profile, + memory: { + level: expectedMemoryLevel, + entries, + }, + }; + const bytes = Array.from(new TextEncoder().encode(JSON.stringify(sanitized))); + if (bytes.length > MAX_SNAPSHOT_JSON_BYTES) { + throw new Error( + "This agent is too large for the community catalog. Share less memory and try again.", + ); + } + return bytes; +} + +function publicAvatarUrl(avatarUrl: string | null): string | null { + if (!avatarUrl || avatarUrl.startsWith("data:") || avatarUrl.length > 2_048) { + return null; + } + return avatarUrl; +} + +function monotonicCreatedAt(previousCreatedAt?: number | null): number { + return Math.max(Math.floor(Date.now() / 1_000), (previousCreatedAt ?? 0) + 1); +} + +function requirePublishedCatalogEvent( + event: RelayEvent, +): PersonaCatalogPublication { + const publication = catalogPublicationsFromEvents([event])[0]; + if (!publication) { + throw new Error("The relay returned an invalid catalog publication."); + } + return publication; +} + +export async function publishPersonaToCatalog(input: { + persona: AgentPersona; + memoryLevel: SnapshotMemoryLevel; + linkedAgentPubkey: string | null; + previousCreatedAt?: number | null; +}): Promise { + if (input.persona.isBuiltIn) { + throw new Error("Built-in agents can’t be published to the catalog."); + } + if (input.memoryLevel !== "none" && !input.linkedAgentPubkey) { + throw new Error("Start this agent before sharing its memory."); + } + + const encoded = await encodeAgentSnapshotForSend( + input.persona.id, + input.memoryLevel, + "json", + input.linkedAgentPubkey, + ); + const snapshotBytes = sanitizeCatalogSnapshotBytes( + encoded.fileBytes, + input.memoryLevel, + ); + const descriptor = await uploadMediaBytes(snapshotBytes, encoded.fileName); + if ( + !/^[0-9a-f]{64}$/u.test(descriptor.sha256) || + descriptor.size !== snapshotBytes.length || + !descriptor.url + ) { + throw new Error("The relay returned an invalid catalog snapshot receipt."); + } + + const content: PublishedCatalogContent = { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status: "published", + sourcePersonaId: input.persona.id, + sourceUpdatedAt: input.persona.updatedAt, + memoryLevel: input.memoryLevel, + agent: { + displayName: input.persona.displayName, + avatarUrl: publicAvatarUrl(input.persona.avatarUrl), + systemPrompt: input.persona.systemPrompt, + runtime: input.persona.runtime, + model: input.persona.model, + provider: input.persona.provider, + }, + snapshot: { + url: descriptor.url, + sha256: descriptor.sha256, + size: descriptor.size, + type: "application/json", + fileName: encoded.fileName, + }, + }; + const event = await signRelayEvent({ + kind: KIND_PERSONA_CATALOG, + content: JSON.stringify(content), + createdAt: monotonicCreatedAt(input.previousCreatedAt), + tags: [ + ["d", input.persona.id], + ["status", "published"], + ["source_updated_at", input.persona.updatedAt], + ["memory", input.memoryLevel], + ], + }); + const published = await relayClient.publishEvent( + event, + "Timed out publishing this agent to the catalog.", + "Failed to publish this agent to the catalog.", + ); + return requirePublishedCatalogEvent(published); +} + +export async function unpublishPersonaFromCatalog(input: { + persona: AgentPersona; + previousCreatedAt?: number | null; +}): Promise { + const content: UnpublishedCatalogContent = { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status: "unpublished", + sourcePersonaId: input.persona.id, + sourceUpdatedAt: input.persona.updatedAt, + }; + const event = await signRelayEvent({ + kind: KIND_PERSONA_CATALOG, + content: JSON.stringify(content), + createdAt: monotonicCreatedAt(input.previousCreatedAt), + tags: [ + ["d", input.persona.id], + ["status", "unpublished"], + ["source_updated_at", input.persona.updatedAt], + ], + }); + const published = await relayClient.publishEvent( + event, + "Timed out removing this agent from the catalog.", + "Failed to remove this agent from the catalog.", + ); + return requirePublishedCatalogEvent(published); +} + +export function linkedAgentPubkeyForPersona( + personaId: string, + managedAgents: readonly ManagedAgent[], +): string | null { + return ( + managedAgents.find((agent) => agent.personaId === personaId)?.pubkey ?? null + ); +} diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs deleted file mode 100644 index c8a7fcd3eb..0000000000 --- a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs +++ /dev/null @@ -1,153 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - readCatalogPersonaMemoryLevels, - readPublishedCatalogPersonaVersions, - readSharedCatalogPersonaIds, - writeCatalogPersonaMemoryLevels, - writePublishedCatalogPersonaVersions, - writeSharedCatalogPersonaIds, -} from "./personaCatalogVisibility.ts"; - -test("catalog visibility reads stored persona ids", () => { - const storage = { - getItem: () => JSON.stringify(["custom:analyst", 42, "custom:writer"]), - }; - - assert.deepEqual(readSharedCatalogPersonaIds(storage), [ - "custom:analyst", - "custom:writer", - ]); -}); - -test("catalog visibility tolerates unavailable and invalid storage", () => { - assert.deepEqual(readSharedCatalogPersonaIds(null), []); - assert.deepEqual( - readSharedCatalogPersonaIds({ getItem: () => "not-json" }), - [], - ); - assert.deepEqual(readSharedCatalogPersonaIds({ getItem: () => "{}" }), []); - assert.deepEqual( - readSharedCatalogPersonaIds({ - getItem: () => { - throw new Error("unavailable"); - }, - }), - [], - ); -}); - -test("catalog visibility persists persona ids without blocking on storage errors", () => { - let storedKey = ""; - let storedValue = ""; - writeSharedCatalogPersonaIds(["custom:analyst"], { - setItem: (key, value) => { - storedKey = key; - storedValue = value; - }, - }); - - assert.equal(storedKey, "buzz-persona-catalog-visibility-v1"); - assert.equal(storedValue, '["custom:analyst"]'); - assert.doesNotThrow(() => - writeSharedCatalogPersonaIds(["custom:analyst"], { - setItem: () => { - throw new Error("unavailable"); - }, - }), - ); -}); - -test("catalog memory levels read only supported snapshot levels", () => { - const storage = { - getItem: () => - JSON.stringify({ - "custom:analyst": "none", - "custom:writer": "core", - "custom:reviewer": "everything", - "custom:invalid": "secret", - }), - }; - - assert.deepEqual(readCatalogPersonaMemoryLevels(storage), { - "custom:analyst": "none", - "custom:writer": "core", - "custom:reviewer": "everything", - }); - assert.deepEqual(readCatalogPersonaMemoryLevels(null), {}); - assert.deepEqual(readCatalogPersonaMemoryLevels({ getItem: () => "[]" }), {}); -}); - -test("catalog memory levels persist without blocking on storage errors", () => { - let storedKey = ""; - let storedValue = ""; - writeCatalogPersonaMemoryLevels( - { "custom:analyst": "core" }, - { - setItem: (key, value) => { - storedKey = key; - storedValue = value; - }, - }, - ); - - assert.equal(storedKey, "buzz-persona-catalog-memory-levels-v1"); - assert.equal(storedValue, '{"custom:analyst":"core"}'); - assert.doesNotThrow(() => - writeCatalogPersonaMemoryLevels( - { "custom:analyst": "core" }, - { - setItem: () => { - throw new Error("unavailable"); - }, - }, - ), - ); -}); - -test("catalog publication versions read only string revisions", () => { - const storage = { - getItem: () => - JSON.stringify({ - "custom:analyst": "2026-07-22T00:00:00.000Z", - "custom:invalid": 42, - }), - }; - - assert.deepEqual(readPublishedCatalogPersonaVersions(storage), { - "custom:analyst": "2026-07-22T00:00:00.000Z", - }); - assert.deepEqual(readPublishedCatalogPersonaVersions(null), {}); - assert.deepEqual( - readPublishedCatalogPersonaVersions({ getItem: () => "[]" }), - {}, - ); -}); - -test("catalog publication versions persist without blocking on storage errors", () => { - let storedKey = ""; - let storedValue = ""; - writePublishedCatalogPersonaVersions( - { "custom:analyst": "2026-07-22T00:00:00.000Z" }, - { - setItem: (key, value) => { - storedKey = key; - storedValue = value; - }, - }, - ); - - assert.equal(storedKey, "buzz-persona-catalog-published-versions-v1"); - assert.equal(storedValue, '{"custom:analyst":"2026-07-22T00:00:00.000Z"}'); - assert.doesNotThrow(() => - writePublishedCatalogPersonaVersions( - { "custom:analyst": "2026-07-22T00:00:00.000Z" }, - { - setItem: () => { - throw new Error("unavailable"); - }, - }, - ), - ); -}); diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.ts b/desktop/src/features/agents/lib/personaCatalogVisibility.ts deleted file mode 100644 index 374dd06c64..0000000000 --- a/desktop/src/features/agents/lib/personaCatalogVisibility.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas"; - -const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = - "buzz-persona-catalog-visibility-v1"; -const PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY = - "buzz-persona-catalog-published-versions-v1"; -const PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY = - "buzz-persona-catalog-memory-levels-v1"; - -export type PublishedCatalogPersonaVersions = Record; -export type CatalogPersonaMemoryLevels = Record; -export type CatalogPersonaShareLevel = "not-shared" | SnapshotMemoryLevel; - -const SNAPSHOT_MEMORY_LEVELS = new Set([ - "none", - "core", - "everything", -]); - -function resolveStorage( - storage: Pick | null | undefined, -): Pick | null { - if (storage !== undefined) return storage; - if (typeof window === "undefined") return null; - - try { - return window.localStorage; - } catch { - return null; - } -} - -export function readSharedCatalogPersonaIds( - storage?: Pick | null, -): string[] { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return []; - - try { - const raw = targetStorage.getItem(PERSONA_CATALOG_VISIBILITY_STORAGE_KEY); - if (!raw) return []; - - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - - return parsed.filter((id): id is string => typeof id === "string"); - } catch { - return []; - } -} - -export function writeSharedCatalogPersonaIds( - ids: readonly string[], - storage?: Pick | null, -): void { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return; - - try { - targetStorage.setItem( - PERSONA_CATALOG_VISIBILITY_STORAGE_KEY, - JSON.stringify(ids), - ); - } catch { - // Catalog visibility is a convenience setting and should not block sharing. - } -} - -export function readCatalogPersonaMemoryLevels( - storage?: Pick | null, -): CatalogPersonaMemoryLevels { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return {}; - - try { - const raw = targetStorage.getItem( - PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY, - ); - if (!raw) return {}; - - const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, SnapshotMemoryLevel] => - typeof entry[1] === "string" && - SNAPSHOT_MEMORY_LEVELS.has(entry[1] as SnapshotMemoryLevel), - ), - ); - } catch { - return {}; - } -} - -export function writeCatalogPersonaMemoryLevels( - levels: Readonly, - storage?: Pick | null, -): void { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return; - - try { - targetStorage.setItem( - PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY, - JSON.stringify(levels), - ); - } catch { - // Catalog publication state should not block sharing. - } -} - -export function readPublishedCatalogPersonaVersions( - storage?: Pick | null, -): PublishedCatalogPersonaVersions { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return {}; - - try { - const raw = targetStorage.getItem( - PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY, - ); - if (!raw) return {}; - - const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); - } catch { - return {}; - } -} - -export function writePublishedCatalogPersonaVersions( - versions: Readonly, - storage?: Pick | null, -): void { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return; - - try { - targetStorage.setItem( - PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY, - JSON.stringify(versions), - ); - } catch { - // Catalog publication state should not block sharing. - } -} diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts new file mode 100644 index 0000000000..890cd2d198 --- /dev/null +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -0,0 +1,107 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchPersonaCatalogPublications, + publishPersonaToCatalog, + unpublishPersonaFromCatalog, + type PersonaCatalogPublication, +} from "@/features/agents/lib/personaCatalogRelay"; +import { relayClient } from "@/shared/api/relayClient"; +import { KIND_PERSONA_CATALOG } from "@/shared/constants/kinds"; + +export function personaCatalogQueryKey(communityId: string | null) { + return ["persona-catalog", communityId] as const; +} + +export function usePersonaCatalogQuery(communityId: string | null) { + return useQuery({ + enabled: communityId !== null, + queryKey: personaCatalogQueryKey(communityId), + queryFn: fetchPersonaCatalogPublications, + staleTime: 30_000, + refetchInterval: 120_000, + }); +} + +export function usePersonaCatalogLiveUpdates(communityId: string | null): void { + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!communityId) return; + let disposed = false; + let dispose: (() => Promise) | null = null; + + void relayClient + .subscribeLive({ kinds: [KIND_PERSONA_CATALOG], limit: 0 }, () => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }) + .then((unsubscribe) => { + if (disposed) { + void unsubscribe(); + } else { + dispose = unsubscribe; + } + }) + .catch((error) => { + console.error( + "Failed to subscribe to the community agent catalog", + error, + ); + }); + + const unsubscribeReconnect = relayClient.subscribeToReconnects(() => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }); + + return () => { + disposed = true; + unsubscribeReconnect(); + if (dispose) void dispose(); + }; + }, [communityId, queryClient]); +} + +export function usePublishPersonaCatalogMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: publishPersonaToCatalog, + onSuccess: (publication) => { + queryClient.setQueryData( + personaCatalogQueryKey(communityId), + (current) => [ + publication, + ...(current ?? []).filter( + (candidate) => + candidate.ownerPubkey !== publication.ownerPubkey || + candidate.sourcePersonaId !== publication.sourcePersonaId, + ), + ], + ); + }, + }); +} + +export function useUnpublishPersonaCatalogMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: unpublishPersonaFromCatalog, + onSuccess: (publication) => { + queryClient.setQueryData( + personaCatalogQueryKey(communityId), + (current) => [ + publication, + ...(current ?? []).filter( + (candidate) => + candidate.ownerPubkey !== publication.ownerPubkey || + candidate.sourcePersonaId !== publication.sourcePersonaId, + ), + ], + ); + }, + }); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index ce3f1ad07a..def2bedc06 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -374,9 +374,10 @@ export function AgentsView() { onCatalogShareLevelChange={(shareLevel) => { const shareTarget = personas.personaToShare; if (!shareTarget) return; - personas.setPersonaCatalogShareLevel( + void personas.setPersonaCatalogShareLevel( shareTarget.persona, shareLevel, + shareTarget.linkedAgentPubkey, ); }} onExport={() => { @@ -393,7 +394,10 @@ export function AgentsView() { onPublishCatalogUpdates={() => { const shareTarget = personas.personaToShare; if (!shareTarget) return; - personas.publishPersonaCatalogUpdates(shareTarget.persona); + void personas.publishPersonaCatalogUpdates( + shareTarget.persona, + shareTarget.linkedAgentPubkey, + ); }} open={personas.personaToShare !== null} persona={personas.personaToShare.persona} @@ -442,8 +446,8 @@ export function AgentsView() { {personas.isCatalogDialogOpen ? ( { personas.clearFeedback("catalog"); }} diff --git a/desktop/src/features/agents/ui/PersonaAddedBy.tsx b/desktop/src/features/agents/ui/PersonaAddedBy.tsx index 66e5ee31f9..3cdec29104 100644 --- a/desktop/src/features/agents/ui/PersonaAddedBy.tsx +++ b/desktop/src/features/agents/ui/PersonaAddedBy.tsx @@ -2,13 +2,17 @@ import { cn } from "@/shared/lib/cn"; type PersonaAddedByProps = { className?: string; + label?: string; }; -export function PersonaAddedBy({ className }: PersonaAddedByProps) { +export function PersonaAddedBy({ + className, + label = "You", +}: PersonaAddedByProps) { return (

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

); } diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index b70e20f4d9..ba76d6e4ed 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; +import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; @@ -288,7 +289,16 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {

{persona.displayName}

- {persona.isBuiltIn ? null : } + {persona.isBuiltIn ? null : ( + + )} diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 1780f514a8..c8e7d471b9 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -12,7 +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/personaCatalogVisibility"; +import type { CatalogPersonaShareLevel } from "@/features/agents/lib/personaCatalogRelay"; import { useOpenDmMutation, useUpsertCachedChannel, @@ -740,6 +740,8 @@ export function PersonaShareDialog({ persona, }: PersonaShareDialogProps) { const encodeSnapshotMutation = useEncodeAgentSnapshotForSendMutation(); + const [pendingCatalogMemoryLevel, setPendingCatalogMemoryLevel] = + React.useState | null>(null); const catalogShareLevels = React.useMemo( () => [ { value: "not-shared", label: "Not shared" }, @@ -767,60 +769,107 @@ export function PersonaShareDialog({ ); return ( - - - - -
-

Share to catalog

-

- Let anyone in this community find and use a copy of this agent. -

-
-
- {catalogShareLevel !== "not-shared" && hasCatalogUpdates ? ( - + ) : null} + - Publish updates - - ) : null} - - onCatalogShareLevelChange( - nextValue as CatalogPersonaShareLevel, - ) - } - options={catalogShareLevels} - testId="persona-share-catalog-access" - value={catalogShareLevel} - /> -
- - ) - } - displayName={persona.displayName} - encodeSnapshot={encodeSnapshot} - hasMemoryOptions={linkedAgentPubkey !== null} - isPending={isPending} - onExport={onExport} - onOpenChange={onOpenChange} - onReset={encodeSnapshotMutation.reset} - open={open} - snapshotKind="agent" - testIdPrefix="persona-share" - /> + onValueChange={(nextValue) => { + const shareLevel = nextValue as CatalogPersonaShareLevel; + if (shareLevel === "core" || shareLevel === "everything") { + setPendingCatalogMemoryLevel(shareLevel); + } else { + onCatalogShareLevelChange(shareLevel); + } + }} + options={catalogShareLevels} + testId="persona-share-catalog-access" + value={catalogShareLevel} + /> + + + ) + } + displayName={persona.displayName} + encodeSnapshot={encodeSnapshot} + hasMemoryOptions={linkedAgentPubkey !== null} + isPending={isPending} + onExport={onExport} + onOpenChange={onOpenChange} + onReset={encodeSnapshotMutation.reset} + open={open} + snapshotKind="agent" + testIdPrefix="persona-share" + /> + { + if (!nextOpen) setPendingCatalogMemoryLevel(null); + }} + open={pendingCatalogMemoryLevel !== null} + > + + + + Publish memories to the catalog? + + + The selected memory will be stored as plaintext community data. + Anyone in this community can read it and keep a copy, even if you + stop sharing the agent later. + + + + + + + + + + + + + ); } diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 1d20e9f489..b620998480 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -17,17 +17,27 @@ import { type AgentSnapshotImportPreview, type AgentSnapshotImportResult, } from "@/features/agents/hooks"; -import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; +import { + getLibraryPersonas, + getPersonaLabelsById, +} from "@/features/agents/lib/catalog"; import { type CatalogPersonaShareLevel, - readCatalogPersonaMemoryLevels, - readPublishedCatalogPersonaVersions, - readSharedCatalogPersonaIds, - writeCatalogPersonaMemoryLevels, - writePublishedCatalogPersonaVersions, - writeSharedCatalogPersonaIds, -} from "@/features/agents/lib/personaCatalogVisibility"; + catalogPersonasFromPublications, + isCatalogPersona, + linkedAgentPubkeyForPersona, + ownCatalogPublication, +} from "@/features/agents/lib/personaCatalogRelay"; +import { + usePersonaCatalogLiveUpdates, + usePersonaCatalogQuery, + usePublishPersonaCatalogMutation, + useUnpublishPersonaCatalogMutation, +} from "@/features/agents/lib/usePersonaCatalogRelay"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { fetchSnapshotBytes } from "@/shared/api/tauriMedia"; import type { SnapshotFormat, SnapshotMemoryLevel, @@ -59,7 +69,15 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const communityId = activeCommunity?.id ?? null; const personasQuery = usePersonasQuery(); + const catalogQuery = usePersonaCatalogQuery(communityId); + usePersonaCatalogLiveUpdates(communityId); + const publishCatalogMutation = usePublishPersonaCatalogMutation(communityId); + const unpublishCatalogMutation = + useUnpublishPersonaCatalogMutation(communityId); const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] = React.useState(false); const acpRuntimesQuery = useAcpRuntimesQuery({ @@ -96,13 +114,6 @@ export function usePersonaActions() { const [snapshotImportConfirmError, setSnapshotImportConfirmError] = React.useState(null); const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false); - const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< - string[] - >(readSharedCatalogPersonaIds); - const [catalogPersonaMemoryLevels, setCatalogPersonaMemoryLevels] = - React.useState(readCatalogPersonaMemoryLevels); - const [publishedCatalogPersonaVersions, setPublishedCatalogPersonaVersions] = - React.useState>(readPublishedCatalogPersonaVersions); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< string | null >(null); @@ -116,15 +127,19 @@ export function usePersonaActions() { React.useState(false); const personas = personasQuery.data ?? []; - const sharedCatalogPersonaIdSet = React.useMemo( - () => - new Set( - sharedCatalogPersonaIds.filter( - (personaId) => catalogPersonaMemoryLevels[personaId] !== undefined, - ), - ), - [catalogPersonaMemoryLevels, sharedCatalogPersonaIds], - ); + const publications = catalogQuery.data ?? []; + const sharedCatalogPersonaIdSet = React.useMemo(() => { + const ownerPubkey = identityQuery.data?.pubkey?.toLowerCase(); + return new Set( + publications + .filter( + (publication) => + publication.ownerPubkey === ownerPubkey && + publication.status === "published", + ) + .map((publication) => publication.sourcePersonaId), + ); + }, [identityQuery.data?.pubkey, publications]); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -133,9 +148,22 @@ export function usePersonaActions() { ), [acpRuntimesQuery.data], ); - const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo( - () => getPersonaLibraryState(personas, sharedCatalogPersonaIdSet), - [personas, sharedCatalogPersonaIdSet], + 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], ); function clearFeedback( @@ -163,7 +191,7 @@ export function usePersonaActions() { if ("id" in input) { const updatedPersona = await updatePersonaMutation.mutateAsync(input); if (options?.publishCatalogUpdates) { - publishPersonaCatalogUpdates(updatedPersona); + await publishPersonaCatalogUpdates(updatedPersona); } setPersonaNoticeMessage(`Updated ${input.displayName}.`); } else { @@ -248,6 +276,17 @@ export function usePersonaActions() { async function handleDelete(persona: AgentPersona) { clearFeedback("library"); try { + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + if (publication?.status === "published") { + await unpublishCatalogMutation.mutateAsync({ + persona, + previousCreatedAt: publication.createdAt, + }); + } await deletePersonaMutation.mutateAsync(persona.id); setPersonaNoticeMessage(`Deleted ${persona.displayName}.`); setPersonaToDelete(null); @@ -265,7 +304,47 @@ export function usePersonaActions() { ) { clearFeedback(surface); try { - await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + if (active && isCatalogPersona(persona)) { + const isOwnPublication = + persona.catalogSource.ownerPubkey === + identityQuery.data?.pubkey?.toLowerCase(); + const ownLocalPersona = isOwnPublication + ? personas.find( + (candidate) => + candidate.id === persona.catalogSource.sourcePersonaId, + ) + : undefined; + + if (ownLocalPersona) { + await setPersonaActiveMutation.mutateAsync({ + id: ownLocalPersona.id, + active: true, + }); + } else { + const snapshot = persona.catalogSource.snapshot; + const fileBytes = await fetchSnapshotBytes({ + url: snapshot.url, + filename: snapshot.fileName, + expectedSha256: snapshot.sha256, + expectedSize: snapshot.size, + }); + const result = await confirmSnapshotImportMutation.mutateAsync({ + fileBytes, + // Catalog publication strips the source response allowlist, but + // fail closed at import too if a malicious entry bypassed it. + keepAllowlist: false, + }); + void queryClient.invalidateQueries({ queryKey: personasQueryKey }); + void queryClient.invalidateQueries({ + queryKey: managedAgentsQueryKey, + }); + void queryClient.invalidateQueries({ + queryKey: ["user-profile", result.newPubkey.toLowerCase()], + }); + } + } else { + await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + } setPersonaNoticeMessage( active ? `Selected ${persona.displayName} for My Agents.` @@ -372,16 +451,6 @@ export function usePersonaActions() { linkedAgent: ManagedAgent | undefined, ) { clearFeedback("library"); - if ( - sharedCatalogPersonaIdSet.has(persona.id) && - publishedCatalogPersonaVersions[persona.id] === undefined - ) { - setPublishedCatalogPersonaVersions((current) => { - const next = { ...current, [persona.id]: persona.updatedAt }; - writePublishedCatalogPersonaVersions(next); - return next; - }); - } setPersonaToShare({ persona, linkedAgentPubkey: linkedAgent?.pubkey ?? null, @@ -424,69 +493,113 @@ export function usePersonaActions() { function getPersonaCatalogShareLevel( persona: AgentPersona, ): CatalogPersonaShareLevel { - if (!sharedCatalogPersonaIdSet.has(persona.id)) return "not-shared"; - return catalogPersonaMemoryLevels[persona.id] ?? "none"; + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + if ( + publication?.status !== "published" || + publication.memoryLevel === null + ) { + return "not-shared"; + } + return publication.memoryLevel; } - function setPersonaCatalogShareLevel( + async function setPersonaCatalogShareLevel( persona: AgentPersona, shareLevel: CatalogPersonaShareLevel, - ) { + linkedAgentPubkey: string | null, + ): Promise { if (persona.isBuiltIn) return; - const visible = shareLevel !== "not-shared"; clearFeedback("library"); - setSharedCatalogPersonaIds((current) => { - const next = new Set(current); - if (visible) { - next.add(persona.id); - } else { - next.delete(persona.id); - } - - const ids = Array.from(next); - writeSharedCatalogPersonaIds(ids); - return ids; - }); - setCatalogPersonaMemoryLevels((current) => { - const next = { ...current }; - if (shareLevel !== "not-shared") { - next[persona.id] = shareLevel; - } else { - delete next[persona.id]; - } - writeCatalogPersonaMemoryLevels(next); - return next; - }); - setPublishedCatalogPersonaVersions((current) => { - const next = { ...current }; - if (visible) { - next[persona.id] = persona.updatedAt; + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + try { + if (shareLevel === "not-shared") { + await unpublishCatalogMutation.mutateAsync({ + persona, + previousCreatedAt: publication?.createdAt, + }); + setPersonaNoticeMessage( + `${persona.displayName} is no longer discoverable in the community catalog.`, + ); } else { - delete next[persona.id]; + await publishCatalogMutation.mutateAsync({ + persona, + memoryLevel: shareLevel, + linkedAgentPubkey, + previousCreatedAt: publication?.createdAt, + }); + setPersonaNoticeMessage( + `Published ${persona.displayName} to the community catalog.`, + ); } - writePublishedCatalogPersonaVersions(next); - return next; - }); + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? error.message + : "Failed to update catalog sharing.", + ); + } } function hasPersonaCatalogUpdates(persona: AgentPersona) { - const publishedVersion = publishedCatalogPersonaVersions[persona.id]; + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); return ( - sharedCatalogPersonaIdSet.has(persona.id) && - publishedVersion !== undefined && - publishedVersion !== persona.updatedAt + publication?.status === "published" && + publication.sourceUpdatedAt !== persona.updatedAt ); } - function publishPersonaCatalogUpdates(persona: AgentPersona) { - if (persona.isBuiltIn || !sharedCatalogPersonaIdSet.has(persona.id)) return; - - setPublishedCatalogPersonaVersions((current) => { - const next = { ...current, [persona.id]: persona.updatedAt }; - writePublishedCatalogPersonaVersions(next); - return next; - }); + async function publishPersonaCatalogUpdates( + persona: AgentPersona, + linkedAgentPubkey?: string | null, + ): Promise { + if (persona.isBuiltIn) return false; + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + if ( + publication?.status !== "published" || + publication.memoryLevel === null + ) { + return false; + } + const managedAgents = + queryClient.getQueryData(managedAgentsQueryKey) ?? []; + try { + await publishCatalogMutation.mutateAsync({ + persona, + memoryLevel: publication.memoryLevel, + linkedAgentPubkey: + linkedAgentPubkey ?? + linkedAgentPubkeyForPersona(persona.id, managedAgents), + previousCreatedAt: publication.createdAt, + }); + setPersonaNoticeMessage( + `Published updates to ${persona.displayName} in the community catalog.`, + ); + return true; + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? `${persona.displayName} was saved, but its catalog update failed: ${error.message}` + : `${persona.displayName} was saved, but its catalog update failed.`, + ); + return false; + } } const isPending = @@ -498,10 +611,13 @@ export function usePersonaActions() { setPersonaActiveMutation.isPending || exportAgentSnapshotMutation.isPending || previewSnapshotImportMutation.isPending || - confirmSnapshotImportMutation.isPending; + confirmSnapshotImportMutation.isPending || + publishCatalogMutation.isPending || + unpublishCatalogMutation.isPending; return { personasQuery, + catalogQuery, acpRuntimesQuery, createPersonaMutation, updatePersonaMutation, diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ef3234f4c5..ab8923b1b8 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -52,6 +52,9 @@ export const KIND_CHANNEL_SORT = 30078; export const KIND_PERSONA = 30175; export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; +// Community-visible catalog publications. Unlike KIND_PERSONA, clients query +// this kind across every author in the active community. +export const KIND_PERSONA_CATALOG = 30178; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_AGENT_TURN_METRIC = 44200; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ffcefee8a0..a1cc8dae87 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -35,6 +35,7 @@ import { KIND_HUDDLE_STARTED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_PERSONA_CATALOG, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, @@ -104,6 +105,7 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; sourceTeam?: string | null; envVars?: Record; @@ -193,6 +195,8 @@ type E2eConfig = { * (`list/start/stop/restart_managed_agent_runtime`). */ managedAgentRuntimes?: MockManagedAgentRuntimeSeed[]; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number; @@ -2121,7 +2125,7 @@ function resetMockPersonas(config?: E2eConfig) { source_team: persona.sourceTeam ?? null, env_vars: { ...(persona.envVars ?? {}) }, created_at: now, - updated_at: now, + updated_at: persona.updatedAt ?? now, }); } } @@ -2716,6 +2720,7 @@ const mockChannels: MockChannel[] = [ const mockMessages = new Map(); const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; +const mockPersonaCatalogEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; @@ -2746,6 +2751,16 @@ function resetMockSaveSubscriptions(config: E2eConfig | undefined) { })); } +function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) { + mockPersonaCatalogEvents.length = 0; + for (const event of config?.mock?.personaCatalogEvents ?? []) { + mockPersonaCatalogEvents.push({ + ...event, + tags: event.tags.map((tag) => [...tag]), + }); + } +} + // Mesh-compute mock state — TEST-ONLY. // // This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__` @@ -7235,6 +7250,9 @@ async function handleUpdatePersona(args: { applyMockPersonaBehavior(persona, args.input.behavior); persona.updated_at = new Date().toISOString(); + for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) { + callback(); + } return { ...persona }; } @@ -7992,6 +8010,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; @@ -8679,6 +8726,19 @@ function sendToMockSocket(args: { return; } + if (filter.kinds?.includes(KIND_PERSONA_CATALOG)) { + const authors = filter.authors?.map((author) => author.toLowerCase()); + const sourceIds = filter["#d"]; + for (const event of mockPersonaCatalogEvents) { + if (authors && !authors.includes(event.pubkey.toLowerCase())) continue; + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (sourceIds && (!sourceId || !sourceIds.includes(sourceId))) continue; + sendWsText(socket.handler, ["EVENT", subId, event]); + } + sendWsText(socket.handler, ["EOSE", subId]); + return; + } + // Project queries: NIP-34 kinds, or kind:1 comments scoped by repo `a` // tag (PR/issue discussions, approvals, review requests). if ( @@ -8775,6 +8835,31 @@ function sendToMockSocket(args: { return; } + if (event.kind === KIND_PERSONA_CATALOG) { + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!sourceId) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + "invalid: persona catalog event missing d tag.", + ]); + return; + } + const existingIndex = mockPersonaCatalogEvents.findIndex( + (candidate) => + candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && + candidate.tags.some((tag) => tag[0] === "d" && tag[1] === sourceId), + ); + if (existingIndex >= 0) { + mockPersonaCatalogEvents.splice(existingIndex, 1); + } + mockPersonaCatalogEvents.push(event); + emitMockGlobalEvent(event); + sendWsText(socket.handler, ["OK", event.id, true, ""]); + return; + } + if (event.kind === 20001) { const status = event.content; if (status === "online" || status === "away" || status === "offline") { @@ -8893,6 +8978,7 @@ export function maybeInstallE2eTauriMocks() { resetMockWorkflows(); resetMockMesh(); resetMockUserStatuses(); + resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); mockWebsocketSendMutexWedged = false; @@ -9969,8 +10055,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; @@ -9979,6 +10065,45 @@ 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, + 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", @@ -10501,7 +10626,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/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index e7a0e073b8..0dbe74a2dc 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1,8 +1,59 @@ import { expect, test } from "@playwright/test"; +import type { RelayEvent } from "@/shared/api/types"; + import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +const DEFAULT_MOCK_OWNER_PUBKEY = "deadbeef".repeat(8); + +function createCatalogEvent(input: { + ownerPubkey: string; + sourcePersonaId: string; + sourceUpdatedAt: string; + displayName: string; + systemPrompt: string; + createdAt?: number; +}): RelayEvent { + const snapshot = { + url: `https://relay.example/media/${"c".repeat(64)}`, + sha256: "c".repeat(64), + size: 512, + type: "application/json", + fileName: `${input.sourcePersonaId}.agent.json`, + } as const; + return { + id: "1".repeat(64), + pubkey: input.ownerPubkey, + created_at: input.createdAt ?? 1_721_750_400, + kind: 30178, + tags: [ + ["d", input.sourcePersonaId], + ["status", "published"], + ["source_updated_at", input.sourceUpdatedAt], + ["memory", "none"], + ], + content: JSON.stringify({ + format: "buzz-persona-catalog", + version: 1, + status: "published", + sourcePersonaId: input.sourcePersonaId, + sourceUpdatedAt: input.sourceUpdatedAt, + memoryLevel: "none", + agent: { + displayName: input.displayName, + avatarUrl: null, + systemPrompt: input.systemPrompt, + runtime: null, + model: null, + provider: null, + }, + snapshot, + }), + sig: "2".repeat(128), + }; +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); @@ -1272,12 +1323,6 @@ test("custom personas share with people and keep export separate", async ({ test("custom personas can be shared to the relay catalog", async ({ page }) => { const personaId = "custom:catalog-analyst"; - await page.addInitScript((legacyPersonaId) => { - localStorage.setItem( - "buzz-persona-catalog-visibility-v1", - JSON.stringify([legacyPersonaId]), - ); - }, personaId); await installMockBridge(page, { globalAgentConfig: { env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" }, @@ -1332,7 +1377,10 @@ This deliberately long fenced-code example must not establish the minimum width ).toBeVisible(); await expect(catalogSection).toContainText("Share to catalog"); await expect(catalogSection).toContainText( - "Let anyone in this community find and use a copy of this agent.", + "Anyone in this community can find and use a copy.", + ); + await expect(catalogSection).toContainText( + "Catalog data is plaintext; secrets and response allowlists are never included.", ); const [copyLinkButtonBox, catalogSectionBox, shareMainCardBox] = await Promise.all([ @@ -1430,37 +1478,6 @@ This deliberately long fenced-code example must not establish the minimum width .getByRole("button", { name: "Close" }) .click(); - await page.evaluate((id) => { - const storageKey = "buzz-persona-catalog-published-versions-v1"; - const publishedVersions = JSON.parse( - localStorage.getItem(storageKey) ?? "{}", - ) as Record; - publishedVersions[id] = "stale"; - localStorage.setItem(storageKey, JSON.stringify(publishedVersions)); - }, personaId); - await gotoApp(page); - await page.getByTestId("open-agents-view").click(); - - await page.getByLabel("Open actions for Catalog Analyst").click(); - await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogAccess).toHaveText("Agent only"); - await expect(publishCatalogUpdatesButton).toBeVisible(); - const [catalogAccessBox, publishCatalogUpdatesButtonBox] = await Promise.all([ - catalogAccess.boundingBox(), - publishCatalogUpdatesButton.boundingBox(), - ]); - expect( - (publishCatalogUpdatesButtonBox?.x ?? 0) + - (publishCatalogUpdatesButtonBox?.width ?? 0), - ).toBeLessThan(catalogAccessBox?.x ?? 0); - await publishCatalogUpdatesButton.click(); - await expect(publishCatalogUpdatesButton).toHaveCount(0); - await expect(catalogAccess).toHaveText("Agent only"); - await page - .getByTestId("persona-share-dialog") - .getByRole("button", { name: "Close" }) - .click(); - await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); await expect(catalogAccess).toHaveText("Agent only"); @@ -1480,6 +1497,103 @@ This deliberately long fenced-code example must not establish the minimum width ).toHaveCount(0); }); +test("catalog owners can publish local updates to an existing relay entry", async ({ + page, +}) => { + const personaId = "custom:catalog-updates"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Catalog Updates", + systemPrompt: "The locally updated instructions.", + updatedAt: "2026-07-23T17:00:00.000Z", + }, + ], + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: DEFAULT_MOCK_OWNER_PUBKEY, + sourcePersonaId: personaId, + sourceUpdatedAt: "2026-07-23T16:00:00.000Z", + displayName: "Catalog Updates", + systemPrompt: "The previously published instructions.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await page.getByLabel("Open actions for Catalog Updates").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + const catalogAccess = page.getByTestId("persona-share-catalog-access"); + const publishButton = page.getByTestId( + "persona-share-publish-catalog-updates", + ); + await expect(catalogAccess).toHaveText("Agent only"); + await expect(publishButton).toBeVisible(); + const [catalogAccessBox, publishButtonBox] = await Promise.all([ + catalogAccess.boundingBox(), + publishButton.boundingBox(), + ]); + expect( + (publishButtonBox?.x ?? 0) + (publishButtonBox?.width ?? 0), + ).toBeLessThan(catalogAccessBox?.x ?? 0); + + await publishButton.click(); + await expect(publishButton).toHaveCount(0); + await expect(catalogAccess).toHaveText("Agent only"); +}); + +test("a community member can discover and add another member's catalog agent", async ({ + page, +}) => { + const personaId = "shared-reviewer"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + sourceUpdatedAt: "2026-07-23T16:00:00.000Z", + displayName: "Alice’s Reviewer", + systemPrompt: "Review changes for the whole community.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + const remoteEntry = page.getByTestId( + `persona-catalog-list-item-${remoteCatalogId}`, + ); + await expect(remoteEntry).toContainText("Alice’s Reviewer"); + await remoteEntry.click(); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Added by Community member", + ); + + await page + .getByRole("button", { + name: "Add Alice’s Reviewer from Agent Catalog", + }) + .click(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMANDS__?: string[]; + } + ).__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "confirm_agent_snapshot_import", + ).length ?? 0, + ), + ) + .toBe(1); +}); + test("share access controls include the selected memories", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 21f69776d8..6459c322b3 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import type { RelayEvent } from "@/shared/api/types"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; export const TEST_IDENTITIES = { @@ -87,6 +88,7 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; sourceTeam?: string | null; envVars?: Record; @@ -210,6 +212,8 @@ type MockBridgeOptions = { | "stopped"; }>; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number; From e17d283827974ecbc1163b99d444f78167e003b3 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 15:39:51 -0700 Subject: [PATCH 17/40] Fix catalog snapshot test after main merge --- desktop/src-tauri/src/commands/media_snapshot_png.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index f2593ff9e0..734d8f5dc8 100644 --- a/desktop/src-tauri/src/commands/media_snapshot_png.rs +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -158,6 +158,7 @@ mod tests { version: 1, definition: AgentSnapshotDefinition { name: "Tree Trunks".to_string(), + source_is_builtin: false, system_prompt: Some("You are a helpful agent.".to_string()), runtime: Some("goose".to_string()), model: None, From e2abf33325f8195d355a8f1e45c133cf16ee7a55 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 09:09:27 -0700 Subject: [PATCH 18/40] fix(desktop): update agent creation e2e selector --- desktop/tests/e2e/global-agent-config-screenshots.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 34be60ab05..379aa17c65 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -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, From b72e024b2a11500238c7d6c7d8d219ea5e48810b Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 09:49:09 -0700 Subject: [PATCH 19/40] fix(desktop): align agent header with card grid --- desktop/src/features/agents/ui/AgentsView.tsx | 13 +++++++++---- .../src/features/agents/ui/UnifiedAgentsSection.tsx | 4 +++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 41aaad8924..def2bedc06 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -21,7 +21,10 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; -import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; +import { + AGENT_CARD_GRID_COLUMNS_CLASS, + UnifiedAgentsSection, +} from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -110,8 +113,11 @@ export function AgentsView() { return ( <>
-
+
- ) : null} - { - const shareLevel = nextValue as CatalogPersonaShareLevel; - if (shareLevel === "core" || shareLevel === "everything") { - setPendingCatalogMemoryLevel(shareLevel); - } else { - onCatalogShareLevelChange(shareLevel); - } - }} - options={catalogShareLevels} - testId="persona-share-catalog-access" - value={catalogShareLevel} - /> -
- - ) - } - displayName={persona.displayName} - encodeSnapshot={encodeSnapshot} - hasMemoryOptions={linkedAgentPubkey !== null} - isPending={isPending} - onExport={onExport} - onOpenChange={onOpenChange} - onReset={encodeSnapshotMutation.reset} - open={open} - snapshotKind="agent" - testIdPrefix="persona-share" - /> - { - if (!nextOpen) setPendingCatalogMemoryLevel(null); - }} - open={pendingCatalogMemoryLevel !== null} - > - - - - Publish memories to the catalog? - - - The selected memory will be stored as plaintext community data. - Anyone in this community can read it and keep a copy, even if you - stop sharing the agent later. - - - - - - - - - - - - - + + + + +
+

Share to catalog

+

+ Anyone in this community can find and use a copy. Your agent + instruction is shared as plaintext. Memories and secrets aren’t + included. +

+
+
+ + onCatalogShareLevelChange( + nextValue as CatalogPersonaShareLevel, + ) + } + options={catalogShareLevels} + testId="persona-share-catalog-access" + value={catalogShareLevel} + /> +
+ + ) + } + displayName={persona.displayName} + encodeSnapshot={encodeSnapshot} + hasMemoryOptions={linkedAgentPubkey !== null} + isPending={isPending} + onExport={onExport} + onOpenChange={onOpenChange} + onReset={encodeSnapshotMutation.reset} + open={open} + snapshotKind="agent" + testIdPrefix="persona-share" + /> ); } diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index b620998480..ff65277df3 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -25,19 +25,15 @@ import { type CatalogPersonaShareLevel, catalogPersonasFromPublications, isCatalogPersona, - linkedAgentPubkeyForPersona, - ownCatalogPublication, } from "@/features/agents/lib/personaCatalogRelay"; import { usePersonaCatalogLiveUpdates, usePersonaCatalogQuery, - usePublishPersonaCatalogMutation, - useUnpublishPersonaCatalogMutation, + useSetPersonaCatalogSharedMutation, } from "@/features/agents/lib/usePersonaCatalogRelay"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; import { useCommunities } from "@/features/communities/useCommunities"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { fetchSnapshotBytes } from "@/shared/api/tauriMedia"; import type { SnapshotFormat, SnapshotMemoryLevel, @@ -75,9 +71,8 @@ export function usePersonaActions() { const personasQuery = usePersonasQuery(); const catalogQuery = usePersonaCatalogQuery(communityId); usePersonaCatalogLiveUpdates(communityId); - const publishCatalogMutation = usePublishPersonaCatalogMutation(communityId); - const unpublishCatalogMutation = - useUnpublishPersonaCatalogMutation(communityId); + const setCatalogSharedMutation = + useSetPersonaCatalogSharedMutation(communityId); const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] = React.useState(false); const acpRuntimesQuery = useAcpRuntimesQuery({ @@ -129,17 +124,12 @@ export function usePersonaActions() { const personas = personasQuery.data ?? []; const publications = catalogQuery.data ?? []; const sharedCatalogPersonaIdSet = React.useMemo(() => { - const ownerPubkey = identityQuery.data?.pubkey?.toLowerCase(); return new Set( - publications - .filter( - (publication) => - publication.ownerPubkey === ownerPubkey && - publication.status === "published", - ) - .map((publication) => publication.sourcePersonaId), + personas + .filter((persona) => !persona.isBuiltIn && persona.shared) + .map((persona) => persona.id), ); - }, [identityQuery.data?.pubkey, publications]); + }, [personas]); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -179,7 +169,7 @@ export function usePersonaActions() { intent?: AgentCreateIntent, backendIntent?: BackendIntent | null, targetChannel?: Pick | null, - options?: { publishCatalogUpdates?: boolean }, + _options?: { publishCatalogUpdates?: boolean }, ): Promise { if (isPersonaSubmitPending) { return false; @@ -189,10 +179,7 @@ export function usePersonaActions() { setIsPersonaSubmitPending(true); try { if ("id" in input) { - const updatedPersona = await updatePersonaMutation.mutateAsync(input); - if (options?.publishCatalogUpdates) { - await publishPersonaCatalogUpdates(updatedPersona); - } + await updatePersonaMutation.mutateAsync(input); setPersonaNoticeMessage(`Updated ${input.displayName}.`); } else { const runtime = availableRuntimes.find( @@ -276,17 +263,6 @@ export function usePersonaActions() { async function handleDelete(persona: AgentPersona) { clearFeedback("library"); try { - const publication = ownCatalogPublication( - publications, - identityQuery.data?.pubkey, - persona.id, - ); - if (publication?.status === "published") { - await unpublishCatalogMutation.mutateAsync({ - persona, - previousCreatedAt: publication.createdAt, - }); - } await deletePersonaMutation.mutateAsync(persona.id); setPersonaNoticeMessage(`Deleted ${persona.displayName}.`); setPersonaToDelete(null); @@ -305,10 +281,7 @@ export function usePersonaActions() { clearFeedback(surface); try { if (active && isCatalogPersona(persona)) { - const isOwnPublication = - persona.catalogSource.ownerPubkey === - identityQuery.data?.pubkey?.toLowerCase(); - const ownLocalPersona = isOwnPublication + const ownLocalPersona = persona.catalogSource.isOwn ? personas.find( (candidate) => candidate.id === persona.catalogSource.sourcePersonaId, @@ -316,30 +289,26 @@ export function usePersonaActions() { : undefined; if (ownLocalPersona) { - await setPersonaActiveMutation.mutateAsync({ - id: ownLocalPersona.id, - active: true, - }); + if (!ownLocalPersona.isActive) { + await setPersonaActiveMutation.mutateAsync({ + id: ownLocalPersona.id, + active: true, + }); + } } else { - const snapshot = persona.catalogSource.snapshot; - const fileBytes = await fetchSnapshotBytes({ - url: snapshot.url, - filename: snapshot.fileName, - expectedSha256: snapshot.sha256, - expectedSize: snapshot.size, - }); - const result = await confirmSnapshotImportMutation.mutateAsync({ - fileBytes, - // Catalog publication strips the source response allowlist, but - // fail closed at import too if a malicious entry bypassed it. - keepAllowlist: false, - }); - void queryClient.invalidateQueries({ queryKey: personasQueryKey }); - void queryClient.invalidateQueries({ - queryKey: managedAgentsQueryKey, - }); - void queryClient.invalidateQueries({ - queryKey: ["user-profile", result.newPubkey.toLowerCase()], + 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, + }, }); } } else { @@ -438,6 +407,7 @@ export function usePersonaActions() { function openCatalog() { clearFeedback("catalog"); + void catalogQuery.refetch(); setIsCatalogDialogOpen(true); } @@ -493,49 +463,32 @@ export function usePersonaActions() { function getPersonaCatalogShareLevel( persona: AgentPersona, ): CatalogPersonaShareLevel { - const publication = ownCatalogPublication( - publications, - identityQuery.data?.pubkey, - persona.id, - ); - if ( - publication?.status !== "published" || - publication.memoryLevel === null - ) { - return "not-shared"; - } - return publication.memoryLevel; + return persona.shared ? "none" : "not-shared"; } async function setPersonaCatalogShareLevel( persona: AgentPersona, shareLevel: CatalogPersonaShareLevel, - linkedAgentPubkey: string | null, ): Promise { if (persona.isBuiltIn) return; clearFeedback("library"); - const publication = ownCatalogPublication( - publications, - identityQuery.data?.pubkey, - persona.id, - ); try { - if (shareLevel === "not-shared") { - await unpublishCatalogMutation.mutateAsync({ - persona, - previousCreatedAt: publication?.createdAt, - }); + const shared = shareLevel !== "not-shared"; + const updated = await setCatalogSharedMutation.mutateAsync({ + id: persona.id, + shared, + }); + setPersonaToShare((current) => + current?.persona.id === updated.id + ? { ...current, persona: updated } + : current, + ); + if (!shared) { setPersonaNoticeMessage( `${persona.displayName} is no longer discoverable in the community catalog.`, ); } else { - await publishCatalogMutation.mutateAsync({ - persona, - memoryLevel: shareLevel, - linkedAgentPubkey, - previousCreatedAt: publication?.createdAt, - }); setPersonaNoticeMessage( `Published ${persona.displayName} to the community catalog.`, ); @@ -549,59 +502,6 @@ export function usePersonaActions() { } } - function hasPersonaCatalogUpdates(persona: AgentPersona) { - const publication = ownCatalogPublication( - publications, - identityQuery.data?.pubkey, - persona.id, - ); - return ( - publication?.status === "published" && - publication.sourceUpdatedAt !== persona.updatedAt - ); - } - - async function publishPersonaCatalogUpdates( - persona: AgentPersona, - linkedAgentPubkey?: string | null, - ): Promise { - if (persona.isBuiltIn) return false; - const publication = ownCatalogPublication( - publications, - identityQuery.data?.pubkey, - persona.id, - ); - if ( - publication?.status !== "published" || - publication.memoryLevel === null - ) { - return false; - } - const managedAgents = - queryClient.getQueryData(managedAgentsQueryKey) ?? []; - try { - await publishCatalogMutation.mutateAsync({ - persona, - memoryLevel: publication.memoryLevel, - linkedAgentPubkey: - linkedAgentPubkey ?? - linkedAgentPubkeyForPersona(persona.id, managedAgents), - previousCreatedAt: publication.createdAt, - }); - setPersonaNoticeMessage( - `Published updates to ${persona.displayName} in the community catalog.`, - ); - return true; - } catch (error) { - setPersonaErrorMessage( - error instanceof Error - ? `${persona.displayName} was saved, but its catalog update failed: ${error.message}` - : `${persona.displayName} was saved, but its catalog update failed.`, - ); - return false; - } - } - const isPending = isPersonaSubmitPending || createPersonaMutation.isPending || @@ -612,8 +512,7 @@ export function usePersonaActions() { exportAgentSnapshotMutation.isPending || previewSnapshotImportMutation.isPending || confirmSnapshotImportMutation.isPending || - publishCatalogMutation.isPending || - unpublishCatalogMutation.isPending; + setCatalogSharedMutation.isPending; return { personasQuery, @@ -652,8 +551,6 @@ export function usePersonaActions() { handleExportSnapshot, getPersonaCatalogShareLevel, setPersonaCatalogShareLevel, - hasPersonaCatalogUpdates, - publishPersonaCatalogUpdates, sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index aa5bc8ed9e..b49efdbc75 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -17,6 +17,7 @@ export type RawPersona = { name_pool?: string[]; is_builtin: boolean; is_active?: boolean; + shared?: boolean; source_team?: string | null; env_vars?: Record; respond_to?: string | null; @@ -40,6 +41,7 @@ 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, envVars: persona.env_vars ?? {}, respondTo: (persona.respond_to as RespondToMode | undefined) ?? null, @@ -116,6 +118,15 @@ export async function setPersonaActive( ); } +export async function setPersonaShared( + id: string, + shared: boolean, +): Promise { + return fromRawPersona( + await invokeTauri("set_persona_shared", { id, shared }), + ); +} + export type SnapshotMemoryLevel = "none" | "core" | "everything"; export type SnapshotFormat = "json" | "png"; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index a82f766b63..2c1a8b1b37 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -748,6 +748,8 @@ 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. diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ab8923b1b8..ef3234f4c5 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -52,9 +52,6 @@ export const KIND_CHANNEL_SORT = 30078; export const KIND_PERSONA = 30175; export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; -// Community-visible catalog publications. Unlike KIND_PERSONA, clients query -// this kind across every author in the active community. -export const KIND_PERSONA_CATALOG = 30178; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_AGENT_TURN_METRIC = 44200; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index dfc4def14d..05ab87694c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -35,7 +35,7 @@ import { KIND_HUDDLE_STARTED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, - KIND_PERSONA_CATALOG, + KIND_PERSONA, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, @@ -108,6 +108,7 @@ type MockPersonaSeed = { systemPrompt: string; updatedAt?: string; isActive?: boolean; + shared?: boolean; sourceTeam?: string | null; envVars?: Record; runtime?: string | null; @@ -791,6 +792,7 @@ type RawPersona = { name_pool?: string[]; is_builtin: boolean; is_active: boolean; + shared: boolean; source_team?: string | null; env_vars?: Record; respond_to?: string | null; @@ -2147,6 +2149,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, @@ -2169,6 +2172,7 @@ function resetMockPersonas(config?: E2eConfig) { : [], is_builtin: false, is_active: persona.isActive ?? true, + shared: persona.shared ?? false, source_team: persona.sourceTeam ?? null, env_vars: { ...(persona.envVars ?? {}) }, created_at: now, @@ -2767,7 +2771,7 @@ const mockChannels: MockChannel[] = [ const mockMessages = new Map(); const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; -const mockPersonaCatalogEvents: RelayEvent[] = []; +const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; @@ -2799,9 +2803,9 @@ function resetMockSaveSubscriptions(config: E2eConfig | undefined) { } function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) { - mockPersonaCatalogEvents.length = 0; + mockPersonaEvents.length = 0; for (const event of config?.mock?.personaCatalogEvents ?? []) { - mockPersonaCatalogEvents.push({ + mockPersonaEvents.push({ ...event, tags: event.tags.map((tag) => [...tag]), }); @@ -3838,6 +3842,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)) { @@ -7317,6 +7328,7 @@ async function handleCreatePersona(args: { provider: args.input.provider?.trim() || null, is_builtin: false, is_active: true, + shared: false, source_team: null, env_vars: { ...(args.input.envVars ?? {}) }, created_at: now, @@ -7324,6 +7336,7 @@ async function handleCreatePersona(args: { }; applyMockPersonaBehavior(persona, args.input.behavior); mockPersonas.push(persona); + upsertMockPersonaEvent(persona); return { ...persona }; } @@ -7358,6 +7371,7 @@ async function handleUpdatePersona(args: { } applyMockPersonaBehavior(persona, args.input.behavior); persona.updated_at = new Date().toISOString(); + upsertMockPersonaEvent(persona); for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) { callback(); @@ -7424,6 +7438,67 @@ 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); +} + +async function handleSetPersonaShared(args: { + id: string; + shared: boolean; +}): 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(); + upsertMockPersonaEvent(persona); + return { ...persona }; +} + function ensureMockPersonaIsActive(personaId: string) { const persona = mockPersonas.find((candidate) => candidate.id === personaId); if (!persona) { @@ -8835,11 +8910,17 @@ function sendToMockSocket(args: { return; } - if (filter.kinds?.includes(KIND_PERSONA_CATALOG)) { + if (filter.kinds?.includes(KIND_PERSONA)) { const authors = filter.authors?.map((author) => author.toLowerCase()); const sourceIds = filter["#d"]; - for (const event of mockPersonaCatalogEvents) { + 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]); @@ -8949,26 +9030,31 @@ function sendToMockSocket(args: { return; } - if (event.kind === KIND_PERSONA_CATALOG) { + 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 catalog event missing d tag.", + "invalid: persona event missing d tag.", ]); return; } - const existingIndex = mockPersonaCatalogEvents.findIndex( - (candidate) => - candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && - candidate.tags.some((tag) => tag[0] === "d" && tag[1] === sourceId), - ); - if (existingIndex >= 0) { - mockPersonaCatalogEvents.splice(existingIndex, 1); + 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; } - mockPersonaCatalogEvents.push(event); + upsertMockPersonaRelayEvent(event); emitMockGlobalEvent(event); sendWsText(socket.handler, ["OK", event.id, true, ""]); return; @@ -10161,11 +10247,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({ @@ -10175,6 +10266,7 @@ export function maybeInstallE2eTauriMocks() { system_prompt: content.system_prompt ?? "", is_builtin: false, is_active: true, + shared, env_vars: {}, created_at: now, updated_at: now, @@ -10201,6 +10293,10 @@ export function maybeInstallE2eTauriMocks() { return handleSetPersonaActive( payload as Parameters[0], ); + case "set_persona_shared": + return handleSetPersonaShared( + payload as Parameters[0], + ); case "list_teams": return handleListTeams(); case "list_channel_templates": diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index bb4b1cbf12..be16814609 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -5,50 +5,31 @@ import type { RelayEvent } from "@/shared/api/types"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; -const DEFAULT_MOCK_OWNER_PUBKEY = "deadbeef".repeat(8); - function createCatalogEvent(input: { ownerPubkey: string; sourcePersonaId: string; - sourceUpdatedAt: string; displayName: string; systemPrompt: string; createdAt?: number; + shared?: boolean; }): RelayEvent { - const snapshot = { - url: `https://relay.example/media/${"c".repeat(64)}`, - sha256: "c".repeat(64), - size: 512, - type: "application/json", - fileName: `${input.sourcePersonaId}.agent.json`, - } as const; return { id: "1".repeat(64), pubkey: input.ownerPubkey, created_at: input.createdAt ?? 1_721_750_400, - kind: 30178, + kind: 30175, tags: [ ["d", input.sourcePersonaId], - ["status", "published"], - ["source_updated_at", input.sourceUpdatedAt], - ["memory", "none"], + ...(input.shared === false ? [] : [["shared", "true"]]), ], content: JSON.stringify({ - format: "buzz-persona-catalog", - version: 1, - status: "published", - sourcePersonaId: input.sourcePersonaId, - sourceUpdatedAt: input.sourceUpdatedAt, - memoryLevel: "none", - agent: { - displayName: input.displayName, - avatarUrl: null, - systemPrompt: input.systemPrompt, - runtime: null, - model: null, - provider: null, - }, - snapshot, + display_name: input.displayName, + system_prompt: input.systemPrompt, + avatar_url: null, + runtime: null, + model: null, + provider: null, + name_pool: [], }), sig: "2".repeat(128), }; @@ -1371,9 +1352,6 @@ This deliberately long fenced-code example must not establish the minimum width const shareMainCard = shareDialog.getByTestId("persona-share-main-card"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); const catalogSection = shareDialog.getByTestId("persona-share-catalog"); - const publishCatalogUpdatesButton = page.getByTestId( - "persona-share-publish-catalog-updates", - ); await expect( shareMainCard.getByTestId("persona-share-catalog"), ).toBeVisible(); @@ -1382,7 +1360,7 @@ This deliberately long fenced-code example must not establish the minimum width "Anyone in this community can find and use a copy.", ); await expect(catalogSection).toContainText( - "Catalog data is plaintext; secrets and response allowlists are never included.", + "Your agent instruction is shared as plaintext. Memories and secrets aren’t included.", ); const [copyLinkButtonBox, catalogSectionBox, shareMainCardBox] = await Promise.all([ @@ -1399,7 +1377,6 @@ This deliberately long fenced-code example must not establish the minimum width (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), ); await expect(catalogAccess).toHaveText("Not shared"); - await expect(publishCatalogUpdatesButton).toHaveCount(0); await catalogAccess.click(); await expect(page.getByRole("menuitemradio")).toHaveText([ "Not shared", @@ -1409,19 +1386,12 @@ This deliberately long fenced-code example must not establish the minimum width .getByRole("menuitemradio", { name: "Agent only", exact: true }) .click(); await expect(catalogAccess).toHaveText("Agent only"); - await expect(publishCatalogUpdatesButton).toHaveCount(0); - const uploadCommand = (await readAgentShareCommands(page)).find( - (entry) => entry.command === "upload_media_bytes", - ); - const uploadedSnapshot = JSON.parse( - new TextDecoder().decode( - Uint8Array.from( - (uploadCommand?.payload as { data?: number[] } | undefined)?.data ?? [], - ), - ), - ); - expect(uploadedSnapshot.definition.respondTo).toBe("owner-only"); - expect(uploadedSnapshot.definition).not.toHaveProperty("respondToAllowlist"); + 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" }) @@ -1483,19 +1453,16 @@ This deliberately long fenced-code example must not establish the minimum width await editDialog.getByRole("button", { name: "Save and publish" }).click(); await expect(editDialog).toHaveCount(0); - await page.getByLabel("Open actions for Catalog Analyst").click(); - await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogAccess).toHaveText("Agent only"); - await expect(publishCatalogUpdatesButton).toHaveCount(0); - await page - .getByTestId("persona-share-dialog") - .getByRole("button", { name: "Close" }) - .click(); + await 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("Agent only"); - await expect(publishCatalogUpdatesButton).toHaveCount(0); await catalogAccess.click(); await page .getByRole("menuitemradio", { name: "Not shared", exact: true }) @@ -1511,51 +1478,30 @@ This deliberately long fenced-code example must not establish the minimum width ).toHaveCount(0); }); -test("catalog owners can publish local updates to an existing relay entry", async ({ +test("a foreign reader does not receive an unshared kind 30175 persona", async ({ page, }) => { - const personaId = "custom:catalog-updates"; + const personaId = "private-reviewer"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; await installMockBridge(page, { - personas: [ - { - id: personaId, - displayName: "Catalog Updates", - systemPrompt: "The locally updated instructions.", - updatedAt: "2026-07-23T17:00:00.000Z", - }, - ], personaCatalogEvents: [ createCatalogEvent({ - ownerPubkey: DEFAULT_MOCK_OWNER_PUBKEY, + ownerPubkey: TEST_IDENTITIES.alice.pubkey, sourcePersonaId: personaId, - sourceUpdatedAt: "2026-07-23T16:00:00.000Z", - displayName: "Catalog Updates", - systemPrompt: "The previously published instructions.", + 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 page.getByLabel("Open actions for Catalog Updates").click(); - await page.getByRole("menuitem", { name: "Share" }).click(); - const catalogAccess = page.getByTestId("persona-share-catalog-access"); - const publishButton = page.getByTestId( - "persona-share-publish-catalog-updates", - ); - await expect(catalogAccess).toHaveText("Agent only"); - await expect(publishButton).toBeVisible(); - const [catalogAccessBox, publishButtonBox] = await Promise.all([ - catalogAccess.boundingBox(), - publishButton.boundingBox(), - ]); - expect( - (publishButtonBox?.x ?? 0) + (publishButtonBox?.width ?? 0), - ).toBeLessThan(catalogAccessBox?.x ?? 0); - - await publishButton.click(); - await expect(publishButton).toHaveCount(0); - await expect(catalogAccess).toHaveText("Agent only"); + await expect( + page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`), + ).toHaveCount(0); + await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible(); }); test("a community member can discover and add another member's catalog agent", async ({ @@ -1568,7 +1514,6 @@ test("a community member can discover and add another member's catalog agent", a createCatalogEvent({ ownerPubkey: TEST_IDENTITIES.alice.pubkey, sourcePersonaId: personaId, - sourceUpdatedAt: "2026-07-23T16:00:00.000Z", displayName: "Alice’s Reviewer", systemPrompt: "Review changes for the whole community.", }), @@ -1601,11 +1546,20 @@ test("a community member can discover and add another member's catalog agent", a __BUZZ_E2E_COMMANDS__?: string[]; } ).__BUZZ_E2E_COMMANDS__?.filter( - (command) => command === "confirm_agent_snapshot_import", + (command) => command === "create_persona", ).length ?? 0, ), ) .toBe(1); + const imported = await invokeTauri< + Array<{ display_name: string; system_prompt: string; shared: boolean }> + >(page, "list_personas"); + expect( + imported.find((persona) => persona.display_name === "Alice’s Reviewer"), + ).toMatchObject({ + system_prompt: "Review changes for the whole community.", + shared: false, + }); }); test("share access controls include the selected memories", async ({ @@ -1676,8 +1630,6 @@ test("share access controls include the selected memories", async ({ await expect(page.getByRole("menuitemradio")).toHaveText([ "Not shared", "Agent only", - "Agent + core memory", - "Agent + all memories", ]); await page.keyboard.press("Escape"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 3203010b23..6280070dd2 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -90,6 +90,7 @@ type MockPersonaSeed = { systemPrompt: string; updatedAt?: string; isActive?: boolean; + shared?: boolean; sourceTeam?: string | null; envVars?: Record; /** From ec4ec1444af0565176d508a4de259b5819bfb553 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 27 Jul 2026 09:29:43 +0100 Subject: [PATCH 22/40] Stabilize agent catalog E2E coverage Signed-off-by: kenny lopez --- desktop/tests/e2e/agents.spec.ts | 74 +++++++++++++++++++------------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index be16814609..d47bfa982d 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -361,13 +361,15 @@ test("the new agent card offers create, discover, and import", async ({ await expect(newAgentCard).toHaveText(""); await expect(newAgentCard.locator(".lucide-plus")).toBeVisible(); - const personaCards = page.locator('[data-testid^="persona-agent-row-"]'); - await expect(personaCards.first()).toBeVisible(); + 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 personaCards.evaluateAll((cards) => + const cardBoxes = await agentCards.evaluateAll((cards) => cards.map((card) => { const box = card.getBoundingClientRect(); return { right: box.right, top: box.top }; @@ -931,8 +933,12 @@ test("custom personas share with people and keep export separate", async ({ 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(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"); @@ -1183,25 +1189,29 @@ test("custom personas share with people and keep export separate", async ({ 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); + await waitForAnimations(page); + await expect + .poll(async () => { + const [ + staticRecipientAccessBox, + recipientAccessPaddingRight, + currentRecipientFieldBox, + ] = await Promise.all([ + staticRecipientAccess.boundingBox(), + staticRecipientAccess.evaluate((element) => + Number.parseFloat(getComputedStyle(element).paddingRight), + ), + recipientField.boundingBox(), + ]); + const staticRecipientTextInset = + (currentRecipientFieldBox?.x ?? 0) + + (currentRecipientFieldBox?.width ?? 0) - + ((staticRecipientAccessBox?.x ?? 0) + + (staticRecipientAccessBox?.width ?? 0) - + recipientAccessPaddingRight); + return Math.abs(staticRecipientTextInset - 8); + }) + .toBeLessThanOrEqual(2); await expect(page.getByTestId("persona-share-send")).toBeVisible(); await recipientSearch.fill("bob"); @@ -1777,14 +1787,20 @@ test("share access controls include the selected memories", async ({ await waitForAnimations(page); await expect .poll(async () => { - const expandedRecipientAccessBox = await recipientAccess.boundingBox(); + const [expandedRecipientAccessBox, currentRecipientFieldBox] = + await Promise.all([ + recipientAccess.boundingBox(), + recipientField.boundingBox(), + ]); return Math.abs( - (expandedRecipientAccessBox?.x ?? 0) + - (expandedRecipientAccessBox?.width ?? 0) - - recipientAccessRightEdge, + (currentRecipientFieldBox?.x ?? 0) + + (currentRecipientFieldBox?.width ?? 0) - + 8 - + ((expandedRecipientAccessBox?.x ?? 0) + + (expandedRecipientAccessBox?.width ?? 0)), ); }) - .toBeLessThanOrEqual(1); + .toBeLessThanOrEqual(8); expect( await recipientAccess .locator("span") From d1005cd2922b2438ca3dfe4ec1f06411d966fdd7 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 27 Jul 2026 09:34:17 +0100 Subject: [PATCH 23/40] Keep merged agent files within size limits Signed-off-by: kenny lopez --- desktop/src-tauri/src/managed_agents/discovery/tests.rs | 1 - desktop/src/shared/api/types.ts | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 05410509f4..871b0a9163 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -48,7 +48,6 @@ fn returns_none_for_unknown_commands() { fn default_agent_command_resolves_bundled_buzz_agent() { // 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() diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 5d5d6e3cd5..ad6067a2b1 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -774,8 +774,7 @@ export type AgentPersona = { 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. */ + /** Agent environment variables, layered after desktop parent and persona values. */ envVars: Record; /** NIP-AP behavioral defaults (wire shape). Null/empty = unset. */ respondTo: RespondToMode | null; @@ -786,9 +785,8 @@ 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`. + * 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; From 53c3245600e7aacef3e193dd5938f887b2926b6f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 27 Jul 2026 09:49:54 +0100 Subject: [PATCH 24/40] Fix portable descendant probe fixture Signed-off-by: kenny lopez --- desktop/src-tauri/src/managed_agents/discovery/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 871b0a9163..f7f4d01bdf 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -923,14 +923,14 @@ fn probe_codex_acp_major_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"); From 8eba9226fadbf6771624014eb0d1795bab056fa3 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 27 Jul 2026 16:10:54 +0100 Subject: [PATCH 25/40] Scope catalog sharing to communities Signed-off-by: kenny lopez --- desktop/src-tauri/src/commands/agents.rs | 37 +-- .../src-tauri/src/commands/personas/mod.rs | 16 +- .../src/commands/personas/pending.rs | 243 ++++++++++++--- .../src/commands/personas/sharing.rs | 289 ++++++++++++++++-- .../src/commands/personas/snapshot/import.rs | 8 +- .../src-tauri/src/commands/team_snapshot.rs | 8 +- desktop/src-tauri/src/commands/teams.rs | 40 +-- desktop/src-tauri/src/commands/workspace.rs | 14 + desktop/src-tauri/src/event_sync.rs | 55 +++- desktop/src-tauri/src/lib.rs | 33 +- .../src/managed_agents/persona_events.rs | 41 ++- .../managed_agents/persona_events/tests.rs | 1 + .../src-tauri/src/managed_agents/reconcile.rs | 20 +- .../src-tauri/src/managed_agents/retention.rs | 74 ++++- desktop/src-tauri/src/managed_agents/types.rs | 23 +- desktop/src-tauri/src/relay.rs | 46 +-- desktop/src-tauri/src/relay/submit.rs | 35 ++- desktop/src/features/agents/AGENTS.md | 10 + .../agents/lib/personaCatalogRelay.test.mjs | 6 +- .../agents/lib/personaCatalogRelay.ts | 26 +- .../agents/lib/usePersonaCatalogRelay.ts | 6 +- .../features/agents/ui/usePersonaActions.ts | 32 +- desktop/src/shared/api/tauriPersonas.ts | 21 +- desktop/src/testing/e2eBridge.ts | 37 ++- desktop/tests/e2e/agents.spec.ts | 40 +++ desktop/tests/helpers/bridge.ts | 2 + 26 files changed, 873 insertions(+), 290 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 86c3bba318..a7ddffc825 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, @@ -53,14 +53,15 @@ pub(super) fn retain_managed_agent_pending( 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)?; // The published content is the opt-IN projection JSON, independent of // signing and created_at. Compute it once to drive the no-republish // guard without signing twice. let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize managed-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)?; @@ -74,7 +75,7 @@ pub(super) fn retain_managed_agent_pending( // Monotonic created_at: bump past the retained head (NIP-AP step 3). 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 managed-agent event: {e}"))?; (owner_pubkey, event) }; @@ -125,15 +126,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, @@ -219,13 +217,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 { diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index c3c6e69061..665f7fae56 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -45,7 +45,9 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - load_personas(&app) + let mut personas = load_personas(&app)?; + pending::project_active_persona_sharing(&app, &state, &mut personas)?; + Ok(personas) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -72,6 +74,7 @@ pub async fn create_persona( .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() @@ -173,6 +176,7 @@ pub async fn update_persona( .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) @@ -554,7 +558,7 @@ fn reconcile_inbound_persona_event_blocking( ) -> Result<(), String> { use crate::managed_agents::{ agent_events::managed_agent_content_from_event, - load_managed_agents, load_teams, managed_agents_base_dir, + load_managed_agents, load_teams, persona_events::persona_from_event, retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, save_managed_agents, save_teams, @@ -602,7 +606,8 @@ fn reconcile_inbound_persona_event_blocking( .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 scope = crate::managed_agents::retention::active_retention_scope(&app, &state)?; + let conn = open_retention_db(&scope.db_path)?; let outcome = retain_inbound_event( &conn, &RetainedEvent { @@ -706,7 +711,7 @@ fn reconcile_inbound_tombstone( state: &AppState, ) -> Result<(), String> { use crate::managed_agents::{ - load_managed_agents, load_teams, managed_agents_base_dir, + load_managed_agents, load_teams, retention::{ open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, RetainedEvent, @@ -731,7 +736,8 @@ fn reconcile_inbound_tombstone( // 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 scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; let outcome = retain_inbound_event( &conn, &RetainedEvent { diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index b06983af2a..40779c5d68 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,6 +26,8 @@ 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 @@ -29,46 +41,110 @@ pub(in crate::commands) fn retain_persona_pending( 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)) +} + +pub(super) fn project_active_persona_sharing( + app: &AppHandle, + state: &AppState, + 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 scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let conn = open_retention_db(&scope.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 @@ -89,7 +165,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, @@ -102,15 +177,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)?; @@ -133,3 +205,86 @@ 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, + 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() + )); + } + + #[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 index b2efb3ff97..f336c3486b 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -2,45 +2,276 @@ use tauri::{AppHandle, Manager}; use crate::{ app_state::AppState, - managed_agents::{load_personas, save_personas, AgentDefinition}, - util::now_iso, + managed_agents::{ + load_personas, + retention::{mark_synced, open_retention_db}, + AgentDefinition, + }, }; -use super::retain_persona_pending; +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 { - tokio::task::spawn_blocking(move || { - let state = app.state::(); - 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 == 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()); - } - if persona.shared == shared { - return Ok(persona.clone()); - } +) -> 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"))?; - persona.shared = shared; - persona.updated_at = now_iso(); + if persona.is_builtin { + return Err("Built-in agents cannot be shared to the catalog.".to_string()); + } - let updated = persona.clone(); - save_personas(&app, &personas)?; - retain_persona_pending(&app, &state, &updated); - Ok(updated) + // 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}"))? + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + 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, + 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, + ) -> PreparedPersonaPublication { + let (event, retained, persona) = + prepare_persona_publication_at(db_path, &keys, &persona(), Some(true)).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); + 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); + 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); + 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 + ); + } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index eee8a2443b..8c6b95b3b7 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -603,7 +603,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}, }; @@ -611,11 +610,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)?; @@ -624,7 +624,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.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 688f51c257..d4636f3efc 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -848,7 +848,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}, }; @@ -856,11 +855,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)?; @@ -869,7 +869,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/teams.rs b/desktop/src-tauri/src/commands/teams.rs index ea9a6a4958..4377ddaa43 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 561e901998..53a552b510 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -187,6 +187,20 @@ 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) => 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 2ec5aa1c0e..d9fe6acdb9 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}"); } diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index fb798b24bd..55d7549e9d 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -223,9 +223,34 @@ pub fn persona_from_event(event: &nostr::Event) -> 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, @@ -233,6 +258,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)? @@ -242,6 +269,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 } @@ -275,9 +305,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 39354e3b93..dca3dc7382 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -905,6 +905,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/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index dc73bc9739..66c68321fe 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); @@ -81,9 +94,8 @@ pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Re let owner_pubkey = keys.public_key().to_hex(); - 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 5df566dbbe..5577101ea0 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -5,10 +5,62 @@ //! 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; + +/// 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, +} + +/// 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 = relay_url.trim().trim_end_matches('/'); + 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, + }) +} /// A retained persona event row. #[derive(Debug, Clone)] @@ -368,6 +420,26 @@ 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 concurrent_open_waits_for_initialization_lock() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index f85dc3177c..6415c6d83c 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -40,9 +40,11 @@ pub struct AgentDefinition { pub is_builtin: bool, #[serde(default = "default_record_active")] pub is_active: bool, - /// Whether this persona is discoverable by other members of the active - /// community. Persisted on the definition so every routine kind:30175 - /// republish preserves the relay's exact `["shared", "true"]` tag. + /// 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. @@ -135,7 +137,8 @@ impl AgentDefinition { name_pool: self.name_pool, is_builtin: self.is_builtin, is_active: self.is_active, - shared: self.shared, + // 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, definition_respond_to: self.respond_to, @@ -167,7 +170,8 @@ impl ManagedAgentRecord { name_pool: self.name_pool.clone(), is_builtin: self.is_builtin, is_active: self.is_active, - shared: self.shared, + // 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(), env_vars: self.env_vars.clone(), @@ -375,9 +379,12 @@ pub struct ManagedAgentRecord { /// definition hidden from pickers. Defaults `true` for existing records. #[serde(default = "default_record_active")] pub is_active: bool, - /// Definition-level catalog visibility. Only meaningful for key-less - /// records with a `slug`; instances retain the default `false`. - #[serde(default)] + /// 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 diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1c9ba0095a..f896695624 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 7fb3f94041..2a42d86c2b 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/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 2af9ddb98c..55f9b1adb9 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -102,6 +102,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 @@ -120,6 +128,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/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index b04f1a47a6..646eaccfdf 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -187,7 +187,7 @@ test("foreign allowlist behavior imports as owner-only", () => { assert.deepEqual(personas[0].respondToAllowlist, []); }); -test("a newly shared local persona appears before relay sync completes", () => { +test("a pending local share does not appear before relay confirmation", () => { const localPersona = { id: "local-reviewer", displayName: "Local Reviewer", @@ -210,7 +210,5 @@ test("a newly shared local persona appears before relay sync completes", () => { }; const personas = catalogPersonasFromPublications([], [localPersona], ALICE); - assert.equal(personas.length, 1); - assert.equal(personas[0].catalogSource.eventId, "local:local-reviewer"); - assert.equal(personas[0].catalogSource.isOwn, true); + assert.deepEqual(personas, []); }); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 8c0a998996..62cbd9488c 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -218,6 +218,9 @@ function publicationToPersona( 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, @@ -234,7 +237,6 @@ export function catalogPersonasFromPublications( ): CatalogPersona[] { const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; const personas: CatalogPersona[] = []; - const seenCoordinates = new Set(); for (const publication of publications) { const isOwn = publication.ownerPubkey === normalizedCurrentPubkey; @@ -243,31 +245,9 @@ export function catalogPersonasFromPublications( (persona) => persona.id === publication.sourcePersonaId, ) : undefined; - if (ownLocalPersona && !ownLocalPersona.shared) { - continue; - } - const coordinate = `${publication.ownerPubkey}:${publication.sourcePersonaId}`; - seenCoordinates.add(coordinate); personas.push(publicationToPersona(publication, ownLocalPersona, isOwn)); } - if (normalizedCurrentPubkey) { - for (const persona of localPersonas) { - if (persona.isBuiltIn || !persona.shared) continue; - const coordinate = `${normalizedCurrentPubkey}:${persona.id}`; - if (seenCoordinates.has(coordinate)) continue; - personas.push({ - ...persona, - catalogSource: { - eventId: `local:${persona.id}`, - ownerPubkey: normalizedCurrentPubkey, - isOwn: true, - sourcePersonaId: persona.id, - }, - }); - } - } - return personas.sort((left, right) => left.displayName.localeCompare(right.displayName), ); diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts index 073bf3b432..fa1384d16d 100644 --- a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -71,13 +71,13 @@ export function useSetPersonaCatalogSharedMutation(communityId: string | null) { return useMutation({ mutationFn: ({ id, shared }: { id: string; shared: boolean }) => setPersonaShared(id, shared), - onSuccess: (updated) => { + onSuccess: (result) => { queryClient.setQueryData( ["personas"], (current) => current?.map((persona) => - persona.id === updated.id ? updated : persona, - ) ?? [updated], + persona.id === result.persona.id ? result.persona : persona, + ) ?? [result.persona], ); void queryClient.invalidateQueries({ queryKey: personaCatalogQueryKey(communityId), diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index ff65277df3..fb5a20084a 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -124,12 +124,13 @@ export function usePersonaActions() { const personas = personasQuery.data ?? []; const publications = catalogQuery.data ?? []; const sharedCatalogPersonaIdSet = React.useMemo(() => { + const currentPubkey = identityQuery.data?.pubkey.toLowerCase(); return new Set( - personas - .filter((persona) => !persona.isBuiltIn && persona.shared) - .map((persona) => persona.id), + publications + .filter((publication) => publication.ownerPubkey === currentPubkey) + .map((publication) => publication.sourcePersonaId), ); - }, [personas]); + }, [identityQuery.data?.pubkey, publications]); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -475,16 +476,31 @@ export function usePersonaActions() { clearFeedback("library"); try { const shared = shareLevel !== "not-shared"; - const updated = await setCatalogSharedMutation.mutateAsync({ + const result = await setCatalogSharedMutation.mutateAsync({ id: persona.id, shared, }); setPersonaToShare((current) => - current?.persona.id === updated.id - ? { ...current, persona: updated } + current?.persona.id === result.persona.id + ? { ...current, persona: result.persona } : current, ); - if (!shared) { + 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.`, ); diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index b49efdbc75..9f953eecd5 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -121,12 +121,25 @@ export async function setPersonaActive( export async function setPersonaShared( id: string, shared: boolean, -): Promise { - return fromRawPersona( - await invokeTauri("set_persona_shared", { id, shared }), - ); +): Promise { + const result = await invokeTauri<{ + persona: RawPersona; + publicationStatus: "published" | "queued"; + relayMessage?: string; + }>("set_persona_shared", { id, shared }); + return { + persona: fromRawPersona(result.persona), + publicationStatus: result.publicationStatus, + relayMessage: result.relayMessage ?? null, + }; } +export type PersonaSharePublicationResult = { + persona: AgentPersona; + publicationStatus: "published" | "queued"; + relayMessage: string | null; +}; + export type SnapshotMemoryLevel = "none" | "core" | "everything"; export type SnapshotFormat = "json" | "png"; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6b46673335..c565ed77a4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -222,6 +222,8 @@ type E2eConfig = { 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; @@ -7149,6 +7151,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; @@ -7509,10 +7514,17 @@ function upsertMockPersonaEvent(persona: RawPersona): void { emitMockGlobalEvent(event); } -async function handleSetPersonaShared(args: { - id: string; - shared: boolean; -}): Promise { +async function handleSetPersonaShared( + args: { + id: string; + shared: boolean; + }, + config?: E2eConfig, +): Promise<{ + persona: RawPersona; + publicationStatus: "published" | "queued"; + relayMessage?: string; +}> { const persona = mockPersonas.find((candidate) => candidate.id === args.id); if (!persona) { throw new Error(`agent ${args.id} not found`); @@ -7522,8 +7534,20 @@ async function handleSetPersonaShared(args: { } persona.shared = args.shared; persona.updated_at = new Date().toISOString(); - upsertMockPersonaEvent(persona); - return { ...persona }; + 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" } + : {}), + }; } function ensureMockPersonaIsActive(personaId: string) { @@ -10342,6 +10366,7 @@ export function maybeInstallE2eTauriMocks() { case "set_persona_shared": return handleSetPersonaShared( payload as Parameters[0], + activeConfig, ); case "list_teams": return handleListTeams(); diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index d47bfa982d..e3f2ccaf70 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1488,6 +1488,46 @@ This deliberately long fenced-code example must not establish the minimum width ).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: "Agent only", 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, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 3a42b5182a..d367c44292 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -219,6 +219,8 @@ type MockBridgeOptions = { 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; From fbc9d515be65cc0043f30ae0e3e2e8f15225e6a4 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 13:05:15 -0400 Subject: [PATCH 26/40] fix(desktop): migrate the legacy global retention db into the active scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped retention databases (retention/.db) read a different file than the pre-scoping global retention.db, so an upgrade abandoned every row the previous release left pending — including signed kind:5 tombstones and NIP-IA archive requests queued while offline, which no reconcile can rebuild. The relay kept deleted heads and another device could resurrect them. The copy is claimed by exactly one relay scope (the legacy rows were queued for one relay, so fanning them out to every community would reintroduce the cross-community leak scoping exists to close) and commits its completion marker in the same transaction as the rows, so a crash mid-copy neither double-applies nor drops. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/workspace.rs | 42 +++- .../src-tauri/src/managed_agents/retention.rs | 3 + .../retention/legacy_migration.rs | 212 ++++++++++++++++++ .../retention/legacy_migration/tests.rs | 186 +++++++++++++++ 4 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/retention/legacy_migration.rs create mode 100644 desktop/src-tauri/src/managed_agents/retention/legacy_migration/tests.rs diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 53a552b510..731a99d9d9 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)] @@ -191,11 +216,18 @@ pub async fn apply_workspace( // 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) => crate::event_sync::spawn_event_sync( - restore_app.clone(), - scope.owner_keys, - scope.db_path, - ), + 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}"); } diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 5577101ea0..c7ba2efc36 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -14,6 +14,9 @@ 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 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 0000000000..1975f5d6df --- /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 0000000000..75da221320 --- /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()); +} From 4340f8325c7f0aa26e1dbb86059ead6f142a8b12 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 13:17:23 -0400 Subject: [PATCH 27/40] fix(desktop): keep agent list and edits working in recovery mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share-state projection resolves the relay+owner retention scope, which requires signing keys. In recovery mode (identity lost or keyring locked) those keys are withheld, so the hard `?` at all three call sites failed list_personas, create_persona, and update_persona for EVERY agent — not just catalog-shared ones. Share state is a view projection, so an unresolvable scope now degrades to "not shared" rather than propagating. It can under-report visibility but never presents an unshared persona as published, and the durable state lives in the retention head so the true value returns once signing works. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/personas/mod.rs | 6 +- .../src/commands/personas/pending.rs | 116 +++++++++++++++++- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 665f7fae56..d59035d03b 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -46,7 +46,7 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; - pending::project_active_persona_sharing(&app, &state, &mut personas)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); Ok(personas) }) .await @@ -74,7 +74,7 @@ pub async fn create_persona( .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; - pending::project_active_persona_sharing(&app, &state, &mut personas)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); let name_pool: Vec = input .name_pool .into_iter() @@ -176,7 +176,7 @@ pub async fn update_persona( .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; - pending::project_active_persona_sharing(&app, &state, &mut personas)?; + pending::project_active_persona_sharing(&app, &state, &mut personas); let persona = personas .iter_mut() .find(|record| record.id == input.id) diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 40779c5d68..40a1f9f9b0 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -82,10 +82,49 @@ fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool { .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, @@ -93,16 +132,14 @@ pub(super) fn project_active_persona_sharing( }; use buzz_core_pkg::kind::KIND_PERSONA; - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let conn = open_retention_db(&scope.db_path)?; + 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))?; + get_retained_event(&conn, KIND_PERSONA, owner_pubkey, &persona_d_tag(persona))?; persona.shared = retained_persona_is_shared(retained.as_ref()); } Ok(()) @@ -279,6 +316,77 @@ mod tests { )); } + /// 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(); From b6439c7505b71c714aa41cde5c7400bf4683f862 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 13:28:37 -0400 Subject: [PATCH 28/40] feat(desktop): add update_persona_and_publish for save-and-publish The edit dialog offers to publish catalog updates on save, but the only save command enqueues best-effort and swallows enqueue failures, so the UI could not report whether the relay accepted the edit. The new command takes the same input as update_persona and returns the same published | queued outcome as set_persona_shared. Both commands share the save body through an update_persona_with seam parameterized on how the saved record is retained; only that step differs. It passes no share override, so an edit preserves whatever the scoped head already says. The edit path moves to personas/update.rs, taking mod.rs from 1003 to 779 lines and retiring its file-size exception into a ratchet. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 5 +- .../src-tauri/src/commands/personas/mod.rs | 219 +--------------- .../src/commands/personas/sharing.rs | 121 ++++++++- .../src-tauri/src/commands/personas/update.rs | 243 ++++++++++++++++++ .../{ => update}/name_propagation_tests.rs | 0 desktop/src-tauri/src/lib.rs | 1 + 6 files changed, 373 insertions(+), 216 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/update.rs rename desktop/src-tauri/src/commands/personas/{ => update}/name_propagation_tests.rs (100%) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 55bf334f28..070afebc4a 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -94,7 +94,10 @@ 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], + // Retired-in-place ratchet: the edit path moved to personas/update.rs, + // taking mod.rs from 1003 to 779. Kept as a ratchet so the edit-path split + // cannot silently refill. + ["src-tauri/src/commands/personas/mod.rs", 779], // #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 diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index d59035d03b..289d25d5b0 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -5,12 +5,11 @@ 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, + delete_agent_key, load_managed_agents, load_personas, load_teams, + 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, }, util::now_iso, }; @@ -35,6 +34,9 @@ pub(in crate::commands) use pending::retain_persona_pending; pub(super) use pending::tombstone_persona_pending; mod sharing; pub use sharing::set_persona_shared; +pub use sharing::update_persona_and_publish; +mod update; +pub use update::update_persona; #[tauri::command] pub async fn list_personas(app: AppHandle) -> Result, String> { @@ -114,215 +116,10 @@ pub async fn create_persona( .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)?; - 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)?; - - 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)?; - } - - 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. /// diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index f336c3486b..a6c1c1d1ea 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -63,6 +63,33 @@ pub async fn set_persona_shared( 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, @@ -165,9 +192,10 @@ mod tests { 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(), Some(true)).unwrap(); + prepare_persona_publication_at(db_path, &keys, &persona(), shared_override).unwrap(); PreparedPersonaPublication { scope: RetentionScope { db_path: db_path.to_path_buf(), @@ -186,7 +214,7 @@ mod tests { 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); + 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(); @@ -221,7 +249,7 @@ mod tests { 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); + let prepared = prepared(&db_path, relay_url, keys, Some(true)); let state = build_app_state(); let result = publish_prepared_persona(&state, prepared).await.unwrap(); @@ -253,7 +281,40 @@ mod tests { 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); + 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(); @@ -262,6 +323,10 @@ mod tests { 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(), @@ -274,4 +339,52 @@ mod tests { .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/update.rs b/desktop/src-tauri/src/commands/personas/update.rs new file mode 100644 index 0000000000..c097c772d3 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -0,0 +1,243 @@ +//! 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)?; + } + + 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 100% 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 diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e5f92ccffd..8a254808a2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -803,6 +803,7 @@ pub fn run() { list_personas, create_persona, update_persona, + update_persona_and_publish, delete_persona, set_persona_active, set_persona_shared, From 5fffd16a1ec2d77cd98499ea956435a15737cc07 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 13:53:34 -0400 Subject: [PATCH 29/40] feat(desktop): track catalog provenance on a copied persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding another member's shared agent minted a fresh local persona with a new UUID and no link back to the publication it came from, so the catalog had no way to tell an already-added entry from a new one and every click added another copy. A copied persona now carries the publication's coordinate — publisher pubkey plus the persona's d-tag — through create_persona and the unified agent store, which is enough for the catalog to answer "already added" for a foreign entry. The coordinate is normalized at the command boundary because a value that cannot match a publication silently restores the duplicate it exists to prevent. Absent on every non-catalog path, and skipped when serializing, so existing records neither break nor gain a null key. The create path moves to personas/create.rs, mirroring the edit-path split, and CatalogSource to its own module to keep both files under the size cap. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 16 ++-- .../src-tauri/src/commands/agent_config.rs | 2 + .../src/commands/agent_models_tests.rs | 1 + desktop/src-tauri/src/commands/agents.rs | 1 + .../src-tauri/src/commands/agents_tests.rs | 2 + .../src-tauri/src/commands/personas/create.rs | 85 +++++++++++++++++++ .../commands/personas/delete_cascade_tests.rs | 1 + .../src/commands/personas/inbound_tests.rs | 3 + .../src-tauri/src/commands/personas/mod.rs | 76 ++--------------- .../src/commands/personas/pending.rs | 1 + .../src/commands/personas/sharing.rs | 1 + .../src/commands/personas/snapshot/import.rs | 2 + .../src/commands/personas/snapshot/tests.rs | 1 + .../personas/update/name_propagation_tests.rs | 1 + .../src-tauri/src/commands/team_snapshot.rs | 2 + .../src/commands/team_snapshot/tests.rs | 4 + .../src/managed_agents/agent_events.rs | 1 + .../src/managed_agents/agent_snapshot.rs | 1 + .../config_bridge/reader_tests.rs | 1 + .../src/managed_agents/discovery/tests.rs | 2 + .../managed_agents/effective_config/tests.rs | 2 + .../src/managed_agents/global_config/tests.rs | 3 + .../src/managed_agents/nest/tests.rs | 2 + .../src/managed_agents/persona_events.rs | 1 + .../managed_agents/persona_events/tests.rs | 6 ++ .../src-tauri/src/managed_agents/personas.rs | 1 + .../src/managed_agents/personas/tests.rs | 1 + .../src-tauri/src/managed_agents/readiness.rs | 1 + .../src/managed_agents/runtime/tests.rs | 2 + .../src/managed_agents/spawn_hash/tests.rs | 2 + .../src/managed_agents/team_snapshot.rs | 1 + .../src/managed_agents/teams_tests.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 15 ++++ .../managed_agents/types/catalog_source.rs | 52 ++++++++++++ .../types/catalog_source/tests.rs | 62 ++++++++++++++ .../src/managed_agents/types/requests.rs | 40 ++++++++- .../src/managed_agents/types/tests.rs | 46 +++++++++- desktop/src-tauri/src/mesh_llm/recovery.rs | 1 + .../src-tauri/src/migration_avatar_tests.rs | 1 + 39 files changed, 368 insertions(+), 76 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/create.rs create mode 100644 desktop/src-tauri/src/managed_agents/types/catalog_source.rs create mode 100644 desktop/src-tauri/src/managed_agents/types/catalog_source/tests.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 070afebc4a..8e445209a2 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -94,10 +94,10 @@ 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. - // Retired-in-place ratchet: the edit path moved to personas/update.rs, - // taking mod.rs from 1003 to 779. Kept as a ratchet so the edit-path split - // cannot silently refill. - ["src-tauri/src/commands/personas/mod.rs", 779], + // Retired-in-place ratchet: the edit path moved to personas/update.rs and + // the create path to personas/create.rs, taking mod.rs from 1003 to 719. + // Kept as a ratchet so neither split can silently refill. + ["src-tauri/src/commands/personas/mod.rs", 719], // #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 @@ -381,7 +381,9 @@ const overrides = new Map([ // Available both-present AND adapter-present/CLI-absent — the selectability // regression guard), bound to an injectable resolver so the tests stay // PATH-independent. - ["src-tauri/src/managed_agents/discovery/tests.rs", 1871], + // +2 (1871 -> 1873): the AgentDefinition and ManagedAgentRecord fixtures each + // set the new mandatory `catalog_source` field. + ["src-tauri/src/managed_agents/discovery/tests.rs", 1873], // 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 @@ -576,7 +578,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 ed2bbdaadc..5a26f0f645 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -687,6 +687,7 @@ mod tests { 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, @@ -712,6 +713,7 @@ mod tests { 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 f3560cf910..b65f240900 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -397,6 +397,7 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { 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 a7ddffc825..6028393412 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -938,6 +938,7 @@ pub async fn create_managed_agent( 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 28adc234cd..03389d1d18 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -56,6 +56,7 @@ fn bare_agent_record( 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, @@ -79,6 +80,7 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen 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/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs new file mode 100644 index 0000000000..c00de1c6da --- /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 3519202d10..8ff7cfbd9b 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -64,6 +64,7 @@ fn make_agent( 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_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index 72c7302a6a..1005a83432 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -23,6 +23,7 @@ fn local_in_app() -> AgentDefinition { 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(), @@ -49,6 +50,7 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { 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(), @@ -208,6 +210,7 @@ fn local_agent() -> ManagedAgentRecord { 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 289d25d5b0..26ff08f8bc 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -1,15 +1,14 @@ use tauri::{AppHandle, Emitter, Manager}; -use uuid::Uuid; use crate::{ app_state::AppState, managed_agents::{ - agent_events::ManagedAgentEventContent, apply_persona_behavior, current_instance_id, - delete_agent_key, load_managed_agents, load_personas, load_teams, - 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, + agent_events::ManagedAgentEventContent, current_instance_id, delete_agent_key, + load_managed_agents, load_personas, load_teams, 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, + ManagedAgentRecord, TeamRecord, }, util::now_iso, }; @@ -32,6 +31,8 @@ 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; @@ -55,67 +56,6 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin .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)?; - 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, - 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}"))? -} - #[cfg(test)] mod delete_cascade_tests; #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 40a1f9f9b0..a4003329bc 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -267,6 +267,7 @@ mod tests { 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/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index a6c1c1d1ea..914c56252d 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -156,6 +156,7 @@ mod tests { 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/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 8c6b95b3b7..9d7d238918 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -427,6 +427,7 @@ pub async fn confirm_agent_snapshot_import( 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(), @@ -496,6 +497,7 @@ pub async fn confirm_agent_snapshot_import( 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, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 53d44ed615..4289310280 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -68,6 +68,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { 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/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index 3292acac93..c60215ae4d 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -53,6 +53,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge 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 d4636f3efc..91a0126f58 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -132,6 +132,7 @@ fn definition_from_snapshot( 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, @@ -603,6 +604,7 @@ pub async fn confirm_team_snapshot_import( 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, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index 48ac0dca6a..0616411307 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -68,6 +68,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -89,6 +90,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -151,6 +153,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -221,6 +224,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { 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/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index f30255dc84..4a7b80079d 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -211,6 +211,7 @@ mod tests { 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 733db7e34e..16a0d35b23 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -539,6 +539,7 @@ mod tests { 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, 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 1e4caf2fcf..4ee4ec79c3 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 @@ -109,6 +109,7 @@ fn test_record() -> ManagedAgentRecord { 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 f7f4d01bdf..c3b4d7e1f4 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -286,6 +286,7 @@ fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agent 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(), @@ -361,6 +362,7 @@ fn record_with( 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/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index ea35095e45..c8e437809c 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -21,6 +21,7 @@ fn definition( shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], @@ -85,6 +86,7 @@ fn record( 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 a4b325893b..553596e226 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -346,6 +346,7 @@ fn bare_record() -> ManagedAgentRecord { 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, @@ -369,6 +370,7 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini shared: false, source_team: None, source_team_persona_slug: None, + catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], @@ -629,6 +631,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { 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 910ecfcc38..d2c415e725 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -425,6 +425,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { 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(), @@ -484,6 +485,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { 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 55d7549e9d..ea61a811db 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -195,6 +195,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result ManagedAgentRecord { 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, @@ -153,6 +154,7 @@ fn sample_persona() -> AgentDefinition { 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(), @@ -379,6 +381,7 @@ fn content_matches_nip_ap_vector() { 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(), @@ -409,6 +412,7 @@ fn round_trip_minimal_persona() { 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(), @@ -505,6 +509,7 @@ fn quad_absent_definition_hash_stable_across_activation() { 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(), @@ -548,6 +553,7 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef 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, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index fc7a30e1a3..9bf7ab74b0 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -124,6 +124,7 @@ fn built_in_persona_records(now: &str) -> Vec { 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/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index b478199aa1..387b4d72c6 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -21,6 +21,7 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { 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/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 7a1ae04843..c053d933c5 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1513,6 +1513,7 @@ mod tests { 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/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 465d54d093..3f6ee996f6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -176,6 +176,7 @@ fn fixture( 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, @@ -296,6 +297,7 @@ fn persona_with_provider( 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 06f696acfb..97d8f25438 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -52,6 +52,7 @@ fn record() -> ManagedAgentRecord { 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, @@ -74,6 +75,7 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { 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 1b559e3a30..96082acc76 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -305,6 +305,7 @@ mod tests { 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 543b2b7c1c..1ffa60eda9 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -211,6 +211,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { 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 6415c6d83c..3d8e0ed02b 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -64,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. /// @@ -141,6 +148,7 @@ impl AgentDefinition { 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, @@ -174,6 +182,7 @@ impl ManagedAgentRecord { 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(), @@ -396,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`/ @@ -972,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 0000000000..237ffbbfe9 --- /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 0000000000..1cdb891c0a --- /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 a2c50c192a..e28b0bd461 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)] @@ -278,6 +282,7 @@ mod tests { 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(), @@ -429,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 1b65259cab..96ed556068 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] @@ -485,6 +485,7 @@ fn sample_persona() -> AgentDefinition { 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(), @@ -494,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 ce6d495a47..809fab8993 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 cf4fc0a5ce..39dfc988dd 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -38,6 +38,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati 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(), From ac9f33d218d20f90fb991b84d500a8baece05e2b Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 15:10:23 -0400 Subject: [PATCH 30/40] fix(desktop): scope an inbound persona event to the relay it arrived on The inbound reconcile resolved the retention scope at processing time from whichever workspace was active. A community switch while an event was in flight retained it into the new community's scoped store, attributing another community's agent to the wrong one. The subscription now forwards the relay it was opened on, and the reconcile resolves the write target from that arrival relay instead. A mismatch drops the event: it was already durable in its own community's store, whose next boot reconcile refetches it. Only inbound paths are arrival-scoped; owner authored publishes still target the active scope. Relay-URL normalization is shared with the path hash so "same relay" and "same database" cannot drift, and the arrival check is a pure function over RetentionScope so it is testable without an AppHandle. The reconcile moves to personas/inbound.rs, retiring mod.rs's file-size exception rather than raising it. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 4 - .../src/commands/personas/inbound.rs | 450 ++++++++++++++++++ .../personas/{ => inbound}/inbound_tests.rs | 0 .../src-tauri/src/commands/personas/mod.rs | 414 +--------------- .../src-tauri/src/managed_agents/retention.rs | 81 +++- desktop/src/app/AppShell.tsx | 5 +- .../agents/lib/usePersonaSync.test.mjs | 52 +- .../src/features/agents/lib/usePersonaSync.ts | 41 +- desktop/src/shared/api/tauriPersonas.ts | 10 +- desktop/tests/e2e/persona-sync.spec.ts | 7 + 10 files changed, 633 insertions(+), 431 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/inbound.rs rename desktop/src-tauri/src/commands/personas/{ => inbound}/inbound_tests.rs (100%) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8e445209a2..04a4ce4aea 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -94,10 +94,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. - // Retired-in-place ratchet: the edit path moved to personas/update.rs and - // the create path to personas/create.rs, taking mod.rs from 1003 to 719. - // Kept as a ratchet so neither split can silently refill. - ["src-tauri/src/commands/personas/mod.rs", 719], // #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 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 0000000000..d7ffecef2d --- /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 100% rename from desktop/src-tauri/src/commands/personas/inbound_tests.rs rename to desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 26ff08f8bc..66f7296a25 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -1,14 +1,12 @@ -use tauri::{AppHandle, Emitter, Manager}; +use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - agent_events::ManagedAgentEventContent, current_instance_id, delete_agent_key, - load_managed_agents, load_personas, load_teams, persona_events::persona_d_tag, + 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, team_events::TeamEventContent, try_regenerate_nest, - validate_persona_activation_change, validate_persona_deletion, AgentDefinition, - ManagedAgentRecord, TeamRecord, + sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change, + validate_persona_deletion, AgentDefinition, ManagedAgentRecord, }, util::now_iso, }; @@ -38,6 +36,8 @@ 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> { @@ -58,8 +58,6 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin #[cfg(test)] mod delete_cascade_tests; -#[cfg(test)] -mod inbound_tests; /// Return pubkeys of every managed agent whose definition is the given persona. /// @@ -254,406 +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, - 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 scope = crate::managed_agents::retention::active_retention_scope(&app, &state)?; - 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 — -/// 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, - 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 scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - 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(), - }), - } -} - #[tauri::command] pub async fn set_persona_active( id: String, diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index c7ba2efc36..7e97fa1f56 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -29,12 +29,35 @@ pub struct RetentionScope { 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 = relay_url.trim().trim_end_matches('/'); + 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"); @@ -65,6 +88,24 @@ pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result Result, String> { + Ok(scope_for_arrival( + active_retention_scope(app, state)?, + arrival_relay_url, + )) +} + /// A retained persona event row. #[derive(Debug, Clone)] pub struct RetainedEvent { @@ -443,6 +484,44 @@ mod tests { ); } + #[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/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 7ed9fa9348..802f464652 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/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index a1cbbf93fe..0dc12ddfd1 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 e713ed71d1..f18194c5c6 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/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index 9f953eecd5..623c490759 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -263,9 +263,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/tests/e2e/persona-sync.spec.ts b/desktop/tests/e2e/persona-sync.spec.ts index 5dfa7e1a16..84b24f7eb7 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. From 89b055ecfb9539e89342c5bbc09dab12a9f40c52 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 16:42:54 -0400 Subject: [PATCH 31/40] fix(desktop): honor publishCatalogUpdates on "Save and publish" The dialog's "Save and publish" button built a publishCatalogUpdates flag that usePersonaActions received as `_options` and never read, so the edit went through plain `update_persona`. That command only enqueues the catalog head best-effort for an out-of-band flush, so the button could never report whether the community catalog actually took the change. Route the publishing path through the strict `update_persona_and_publish` command and report the relay's verdict: a queued edit is no longer described as published, because the catalog still shows the old definition until the relay accepts it. `invalidatePersonaEditCaches` is extracted so both edit mutations cannot drift on what a saved edit refreshes. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/agents/lib/personaEditCaches.ts | 42 ++++++ .../agents/lib/personaSaveNotice.test.mjs | 29 +++++ .../features/agents/lib/personaSaveNotice.ts | 24 ++++ .../agents/lib/usePersonaCatalogRelay.ts | 32 ++++- desktop/src/features/agents/ui/AgentsView.tsx | 8 +- .../features/agents/ui/usePersonaActions.ts | 28 +++- desktop/src/shared/api/tauriPersonas.ts | 88 +++++++++---- desktop/src/testing/e2eBridge.ts | 121 +++++++++++------- desktop/tests/e2e/agents.spec.ts | 40 ++++-- 9 files changed, 323 insertions(+), 89 deletions(-) create mode 100644 desktop/src/features/agents/lib/personaEditCaches.ts create mode 100644 desktop/src/features/agents/lib/personaSaveNotice.test.mjs create mode 100644 desktop/src/features/agents/lib/personaSaveNotice.ts diff --git a/desktop/src/features/agents/lib/personaEditCaches.ts b/desktop/src/features/agents/lib/personaEditCaches.ts new file mode 100644 index 0000000000..c5071d1246 --- /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 0000000000..36f3fcdc8a --- /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 0000000000..f75f0e1c68 --- /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 index fa1384d16d..c7835f9373 100644 --- a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -5,9 +5,13 @@ import { fetchPersonaCatalogPublications, type PersonaCatalogPublication, } from "@/features/agents/lib/personaCatalogRelay"; +import { invalidatePersonaEditCaches } from "@/features/agents/lib/personaEditCaches"; import { relayClient } from "@/shared/api/relayClient"; -import { setPersonaShared } from "@/shared/api/tauriPersonas"; -import type { AgentPersona } from "@/shared/api/types"; +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) { @@ -85,3 +89,27 @@ export function useSetPersonaCatalogSharedMutation(communityId: string | null) { }, }); } + +/** + * 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/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 01ea0581cc..6e55f92dfe 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -296,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} diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index fb5a20084a..d8774812a0 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -30,7 +30,9 @@ 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"; @@ -81,6 +83,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(); @@ -170,7 +174,7 @@ export function usePersonaActions() { intent?: AgentCreateIntent, backendIntent?: BackendIntent | null, targetChannel?: Pick | null, - _options?: { publishCatalogUpdates?: boolean }, + options?: { publishCatalogUpdates?: boolean }, ): Promise { if (isPersonaSubmitPending) { return false; @@ -180,8 +184,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, @@ -523,6 +543,7 @@ export function usePersonaActions() { createPersonaMutation.isPending || createAgentMutation.isPending || updatePersonaMutation.isPending || + updatePersonaAndPublishMutation.isPending || deletePersonaMutation.isPending || setPersonaActiveMutation.isPending || exportAgentSnapshotMutation.isPending || @@ -536,6 +557,7 @@ export function usePersonaActions() { acpRuntimesQuery, createPersonaMutation, updatePersonaMutation, + updatePersonaAndPublishMutation, setPersonaActiveMutation, catalogPersonas, libraryPersonas, diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index 623c490759..b46ff5f35d 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -76,26 +76,31 @@ export async function createPersona( ); } +/** 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( @@ -105,6 +110,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 }); } @@ -122,16 +162,12 @@ export async function setPersonaShared( id: string, shared: boolean, ): Promise { - const result = await invokeTauri<{ - persona: RawPersona; - publicationStatus: "published" | "queued"; - relayMessage?: string; - }>("set_persona_shared", { id, shared }); - return { - persona: fromRawPersona(result.persona), - publicationStatus: result.publicationStatus, - relayMessage: result.relayMessage ?? null, - }; + return fromRawPublicationResult( + await invokeTauri("set_persona_shared", { + id, + shared, + }), + ); } export type PersonaSharePublicationResult = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4923236012..4031bd1558 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7389,43 +7389,51 @@ async function handleCreatePersona(args: { 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. + */ +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(); upsertMockPersonaEvent(persona); for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) { callback(); } - return { ...persona }; + return persona; } async function handleDeletePersona(args: { id: string }): Promise { @@ -7531,26 +7539,21 @@ function upsertMockPersonaEvent(persona: RawPersona): void { emitMockGlobalEvent(event); } -async function handleSetPersonaShared( - args: { - id: string; - shared: boolean; - }, - config?: E2eConfig, -): Promise<{ +type MockPersonaPublicationResult = { persona: RawPersona; publicationStatus: "published" | "queued"; relayMessage?: string; -}> { - 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(); +}; + +/** + * 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++ @@ -7567,6 +7570,33 @@ async function handleSetPersonaShared( }; } +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) { @@ -10318,6 +10348,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], diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index e3f2ccaf70..ae883dedd9 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -195,6 +195,22 @@ async function invokeTauriExpectError( ); } +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, }) => { @@ -1462,6 +1478,17 @@ This deliberately long fenced-code example must not establish the minimum width ).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); @@ -1588,18 +1615,7 @@ test("a community member can discover and add another member's catalog agent", a }) .click(); await expect - .poll(() => - page.evaluate( - () => - ( - window as Window & { - __BUZZ_E2E_COMMANDS__?: string[]; - } - ).__BUZZ_E2E_COMMANDS__?.filter( - (command) => command === "create_persona", - ).length ?? 0, - ), - ) + .poll(() => countCommandInvocations(page, "create_persona")) .toBe(1); const imported = await invokeTauri< Array<{ display_name: string; system_prompt: string; shared: boolean }> From 90cba05d56ae7fd7e81941348ce9c2499ab32ebf Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 16:45:11 -0400 Subject: [PATCH 32/40] fix(desktop): stop re-adding an already-added foreign catalog agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding another owner's catalog agent minted a local copy with a fresh UUID and stored nothing linking it back to the publication. The catalog resolved entries by local id, which can only ever match the user's own publications, so a foreign entry always read as not-added — reopening the catalog offered "Add" again and created a second copy. Persist the publication's coordinate on the copy and resolve foreign entries through it. `findLocalPersonaForCatalogEntry` keeps the two lookup rules in one place: own entries by local id, foreign entries by (ownerPubkey, personaId), so the same d-tag under a different publisher stays a distinct agent. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 6 +- .../agents/lib/personaCatalogRelay.test.mjs | 102 ++++++++++++++++++ .../agents/lib/personaCatalogRelay.ts | 53 ++++++--- .../features/agents/ui/usePersonaActions.ts | 25 +++-- desktop/src/shared/api/tauriPersonas.ts | 13 +++ desktop/src/shared/api/types.ts | 21 ++++ desktop/src/testing/e2eBridge.ts | 12 +++ desktop/tests/e2e/agents.spec.ts | 33 +++++- 8 files changed, 242 insertions(+), 23 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 04a4ce4aea..e16dacac8b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -245,7 +245,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); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index 646eaccfdf..4862cf41ad 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -212,3 +212,105 @@ test("a pending local share does not appear before relay confirmation", () => { 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); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 62cbd9488c..7af7cdab75 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -1,6 +1,7 @@ import { relayClient } from "@/shared/api/relayClient"; import type { AgentPersona, + CatalogSourceCoordinate, RelayEvent, RespondToMode, } from "@/shared/api/types"; @@ -31,11 +32,11 @@ export type PersonaCatalogPublication = { }; export type CatalogPersona = AgentPersona & { - catalogSource: { + catalogSource: CatalogSourceCoordinate & { + /** The publication event this projection was built from. */ eventId: string; - ownerPubkey: string; + /** Whether the current identity published it. */ isOwn: boolean; - sourcePersonaId: string; }; }; @@ -191,11 +192,11 @@ export async function fetchPersonaCatalogPublications(): Promise< function publicationToPersona( publication: PersonaCatalogPublication, - ownLocalPersona: AgentPersona | undefined, + localPersona: AgentPersona | undefined, isOwn: boolean, ): CatalogPersona { const timestamp = new Date(publication.createdAt * 1_000).toISOString(); - const basePersona: AgentPersona = ownLocalPersona ?? { + const basePersona: AgentPersona = localPersona ?? { id: `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, displayName: publication.agent.displayName, avatarUrl: publication.agent.avatarUrl, @@ -225,7 +226,7 @@ function publicationToPersona( eventId: publication.eventId, ownerPubkey: publication.ownerPubkey, isOwn, - sourcePersonaId: publication.sourcePersonaId, + personaId: publication.sourcePersonaId, }, }; } @@ -240,12 +241,17 @@ export function catalogPersonasFromPublications( for (const publication of publications) { const isOwn = publication.ownerPubkey === normalizedCurrentPubkey; - const ownLocalPersona = isOwn - ? localPersonas.find( - (persona) => persona.id === publication.sourcePersonaId, - ) - : undefined; - personas.push(publicationToPersona(publication, ownLocalPersona, isOwn)); + personas.push( + publicationToPersona( + publication, + findLocalPersonaForCatalogEntry(localPersonas, { + ownerPubkey: publication.ownerPubkey, + personaId: publication.sourcePersonaId, + isOwn, + }), + isOwn, + ), + ); } return personas.sort((left, right) => @@ -253,6 +259,29 @@ export function catalogPersonasFromPublications( ); } +/** + * 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 { diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index d8774812a0..0c56eeac10 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -24,6 +24,7 @@ import { import { type CatalogPersonaShareLevel, catalogPersonasFromPublications, + findLocalPersonaForCatalogEntry, isCatalogPersona, } from "@/features/agents/lib/personaCatalogRelay"; import { @@ -302,17 +303,15 @@ export function usePersonaActions() { clearFeedback(surface); try { if (active && isCatalogPersona(persona)) { - const ownLocalPersona = persona.catalogSource.isOwn - ? personas.find( - (candidate) => - candidate.id === persona.catalogSource.sourcePersonaId, - ) - : undefined; + const localPersona = findLocalPersonaForCatalogEntry( + personas, + persona.catalogSource, + ); - if (ownLocalPersona) { - if (!ownLocalPersona.isActive) { + if (localPersona) { + if (!localPersona.isActive) { await setPersonaActiveMutation.mutateAsync({ - id: ownLocalPersona.id, + id: localPersona.id, active: true, }); } @@ -330,6 +329,14 @@ export function usePersonaActions() { 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 { diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index b46ff5f35d..66e07f5e88 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -19,6 +19,12 @@ export type RawPersona = { 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[]; @@ -43,6 +49,12 @@ export function fromRawPersona(persona: RawPersona): AgentPersona { 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 ?? [], @@ -71,6 +83,7 @@ export async function createPersona( namePool: input.namePool ?? [], envVars: input.envVars ?? {}, behavior: input.behavior, + catalogSource: input.catalogSource, }, }), ); diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index ad6067a2b1..8f78d9b1d0 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -774,6 +774,12 @@ export type AgentPersona = { shared: boolean; /** Team ID if this persona was imported from a team directory. Team personas are non-editable. */ sourceTeam?: string | null; + /** + * Set only on a local copy of another owner's shared catalog entry. A copy + * carries a fresh local `id`, so this coordinate is the only thing that can + * answer "is this catalog entry already added" without minting a duplicate. + */ + catalogSource?: CatalogSourceCoordinate | null; /** Agent environment variables, layered after desktop parent and persona values. */ envVars: Record; /** NIP-AP behavioral defaults (wire shape). Null/empty = unset. */ @@ -784,6 +790,16 @@ export type AgentPersona = { updatedAt: string; }; +/** + * A catalog publication's coordinate: the owner who published it and the + * `d`-tag identifying the persona within that owner's catalog. Mirrors the + * backend `CatalogSource`. + */ +export type CatalogSourceCoordinate = { + ownerPubkey: string; + personaId: string; +}; + /** * NIP-AP behavioral group for a definition: absent preserves the stored group * for legacy callers; present replaces it as a unit. Mirrors `PersonaBehaviorRequest`. @@ -804,6 +820,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 4031bd1558..e604dd89ea 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -811,6 +811,7 @@ type RawPersona = { 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[]; @@ -7364,6 +7365,7 @@ async function handleCreatePersona(args: { provider?: string; envVars?: Record; behavior?: PersonaBehaviorInput; + catalogSource?: { ownerPubkey: string; personaId: string }; }; }): Promise { const now = new Date().toISOString(); @@ -7379,6 +7381,16 @@ async function handleCreatePersona(args: { 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, diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index ae883dedd9..ba61d8ed97 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1618,14 +1618,45 @@ test("a community member can discover and add another member's catalog agent", a .poll(() => countCommandInvocations(page, "create_persona")) .toBe(1); const imported = await invokeTauri< - Array<{ display_name: string; system_prompt: string; shared: boolean }> + 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("share access controls include the selected memories", async ({ From 97ab227de3d83b696d88a255270b40954f263f45 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 16:45:44 -0400 Subject: [PATCH 33/40] fix(desktop): page the persona catalog past the relay's 1,000-row clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog discovery issued one fetch with `limit: 1000`, which is exactly the relay's `query_events` clamp. A community that publishes more shared agents than that silently loses the overflow: the truncated entries are simply undiscoverable, with no signal that anything was dropped. Walk backwards through `created_at` in 500-event pages. `until` is the only cursor a WS REQ filter carries, and the relay applies it inclusively, so consecutive pages overlap on the boundary second — dedupe by event id absorbs the repeats, and a page that contributes nothing new stops the walk so a run of tied timestamps cannot loop forever. The composite (until, before_id) keyset that would remove the tie limitation exists only on the HTTP /query bridge; the WS REQ path ignores before_id entirely. This degrades only when a single second holds more than a full page of publications, where it stops early rather than spinning. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/lib/personaCatalogRelay.test.mjs | 94 ++++++++++++++++++- .../agents/lib/personaCatalogRelay.ts | 62 ++++++++++-- 2 files changed, 148 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index 4862cf41ad..2fb0368a11 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import test, { mock } from "node:test"; +import { relayClient } from "@/shared/api/relayClient"; import { catalogPersonasFromPublications, catalogPublicationsFromEvents, + fetchPersonaCatalogPublications, personaEventIsShared, } from "./personaCatalogRelay.ts"; @@ -314,3 +316,93 @@ test("test_own_publication_still_resolves_by_local_id", () => { 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 index 7af7cdab75..cde0ad0344 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -7,8 +7,6 @@ import type { } from "@/shared/api/types"; import { KIND_PERSONA } from "@/shared/constants/kinds"; -const MAX_CATALOG_EVENTS = 1_000; - export type CatalogPersonaShareLevel = "not-shared" | "none"; type CatalogAgentProjection = { @@ -180,14 +178,64 @@ export function catalogPublicationsFromEvents( 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 events = await relayClient.fetchEvents({ - kinds: [KIND_PERSONA], - limit: MAX_CATALOG_EVENTS, - }); - return catalogPublicationsFromEvents(events); + 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( From 954cc2328a73478c7d14d19b460a0347d0ce5b0c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 16:46:22 -0400 Subject: [PATCH 34/40] test(desktop): stop the e2e mock publishing from update_persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mock's `update_persona` handler called `upsertMockPersonaEvent` unconditionally, so a catalog event appeared on the relay whether or not the UI ever asked to publish. That is what let the "Save and publish" button pass its specs while calling the non-publishing command: the assertion the specs could make was satisfied by the mock, not by the code under test. The mock now mirrors the real command split — only the publishing commands emit, and both route through the shared publish helper so a `queued` outcome withholds the event exactly as the backend does. `create_persona` stores the normalized catalog coordinate so provenance is assertable end to end. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/testing/e2eBridge.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e604dd89ea..908d7a8a7a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7422,6 +7422,12 @@ async function handleUpdatePersona(args: { /** * 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); @@ -7440,7 +7446,6 @@ function applyMockPersonaUpdate(input: MockUpdatePersonaInput): RawPersona { } applyMockPersonaBehavior(persona, input.behavior); persona.updated_at = new Date().toISOString(); - upsertMockPersonaEvent(persona); for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) { callback(); From ccba0c17f6d55bb0ee6fa1d1b6e27357206b2151 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 19:01:11 -0400 Subject: [PATCH 35/40] fix(desktop): label the catalog share toggle Shared instead of Agent only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog toggle reused the snapshot memory-level vocabulary, where `none` means "share with no memories". In the catalog row that reads as noise: the row description already states that memories and secrets are not included, and "Agent only" collides with the memory-level labels on the link and recipient controls, which use the same words for a different choice. The control is a binary, so it now reads Not shared / Shared. Label only — the `none` wire value and `CatalogPersonaShareLevel` are unchanged, so nothing relay-side moves. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/features/agents/ui/PersonaShareDialog.tsx | 2 +- desktop/tests/e2e/agents.spec.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 07d58c173c..c659f33dc5 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -739,7 +739,7 @@ export function PersonaShareDialog({ const catalogShareLevels = React.useMemo( () => [ { value: "not-shared", label: "Not shared" }, - { value: "none", label: "Agent only" }, + { value: "none", label: "Shared" }, ], [], ); diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 25b023ca98..51383e96ff 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -92,7 +92,7 @@ async function sharePersonaToCatalog( await page.getByRole("menuitem", { name: "Share" }).click(); await page.getByTestId("persona-share-catalog-access").click(); await page - .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .getByRole("menuitemradio", { name: "Shared", exact: true }) .click(); await page .getByTestId("persona-share-dialog") @@ -1431,12 +1431,12 @@ This deliberately long fenced-code example must not establish the minimum width await catalogAccess.click(); await expect(page.getByRole("menuitemradio")).toHaveText([ "Not shared", - "Agent only", + "Shared", ]); await page - .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .getByRole("menuitemradio", { name: "Shared", exact: true }) .click(); - await expect(catalogAccess).toHaveText("Agent only"); + await expect(catalogAccess).toHaveText("Shared"); const storedPersonas = await invokeTauri< Array<{ id: string; shared: boolean }> >(page, "list_personas"); @@ -1524,7 +1524,7 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogAccess).toHaveText("Agent only"); + await expect(catalogAccess).toHaveText("Shared"); await catalogAccess.click(); await page .getByRole("menuitemradio", { name: "Not shared", exact: true }) @@ -1561,7 +1561,7 @@ test("a queued catalog share is not presented as relay-published", async ({ await page.getByRole("menuitem", { name: "Share" }).click(); await page.getByTestId("persona-share-catalog-access").click(); await page - .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .getByRole("menuitemradio", { name: "Shared", exact: true }) .click(); await expect( @@ -1751,7 +1751,7 @@ test("share access controls include the selected memories", async ({ await catalogAccess.click(); await expect(page.getByRole("menuitemradio")).toHaveText([ "Not shared", - "Agent only", + "Shared", ]); await page.keyboard.press("Escape"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); From 36af667292fea096edfb2846570b75258bfdf31a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 19:41:08 -0400 Subject: [PATCH 36/40] fix(desktop): unify the share dialog memory level into one selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share dialog held two independent copies of the same choice — one inside the recipients field and one on the link row — feeding identical options into the same confirmation gate and snapshot encoder. Two controls for one concept read as duplication, and a memory-less agent stated its level twice. A single "What's included" selector above the recipients search now drives both delivery paths, so the rows carry only their action. All six delivery-by-level combinations stay reachable: pick the level once, then send it, link it, or both. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/agents/ui/PersonaShareDialog.tsx | 81 ++++------ .../agents/ui/PersonaShareRecipients.tsx | 7 - desktop/tests/e2e/agents.spec.ts | 142 ++++++------------ desktop/tests/e2e/team-snapshot.spec.ts | 75 ++++++++- 4 files changed, 148 insertions(+), 157 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index c659f33dc5..4a5f19b544 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -22,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, @@ -198,39 +197,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 ); } @@ -238,9 +230,7 @@ function ShareLevelControl({ return ( onChange(nextValue as SnapshotMemoryLevel)} options={options} testId={testId} @@ -272,9 +262,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>(), @@ -293,9 +281,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; @@ -347,8 +333,7 @@ export function SnapshotShareDialog({ setSelectedRecipients([]); setCopyStatus("idle"); setPendingMemoryShare(null); - setLinkShareLevel("none"); - setRecipientShareLevel("none"); + setShareLevel("none"); onReset?.(); snapshotSendController.reset(); } @@ -491,6 +476,23 @@ export function SnapshotShareDialog({
+
+

+ What’s included +

+ +
( - - )} selectedUsers={selectedRecipients} testIdPrefix={testIdPrefix} /> @@ -542,9 +529,7 @@ export function SnapshotShareDialog({ isActionPending || !snapshotSendController.isDmSafetyReady } - onClick={() => - requestMemoryShare("send", recipientShareLevel) - } + onClick={() => requestMemoryShare("send", shareLevel)} type="button" > {isSending ? "Sending…" : "Send"} @@ -610,16 +595,6 @@ export function SnapshotShareDialog({ Anyone with the link can add and use a copy.

-
{afterLink ?
{afterLink}
: null} requestMemoryShare("copy", linkShareLevel)} + onClick={() => requestMemoryShare("copy", shareLevel)} size="sm" type="button" variant="outline" diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx index 4f8874914d..7db7c32bfa 100644 --- a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx +++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx @@ -34,7 +34,6 @@ export function PersonaShareRecipients({ excludedPubkeys = [], onSelectionChange, open, - renderEndControl, selectedUsers, testIdPrefix = "persona-share", }: { @@ -42,7 +41,6 @@ export function PersonaShareRecipients({ excludedPubkeys?: readonly string[]; onSelectionChange: (users: UserSearchResult[]) => void; open: boolean; - renderEndControl?: (onOpenChange: (open: boolean) => void) => React.ReactNode; selectedUsers: UserSearchResult[]; testIdPrefix?: string; }) { @@ -228,11 +226,6 @@ export function PersonaShareRecipients({ value={searchQuery} />
- {selectedUsers.length > 0 && renderEndControl - ? renderEndControl((controlOpen) => { - if (controlOpen) setIsPickerOpen(false); - }) - : null}
{ - const [ - staticRecipientAccessBox, - recipientAccessPaddingRight, - currentRecipientFieldBox, - ] = await Promise.all([ - staticRecipientAccess.boundingBox(), - staticRecipientAccess.evaluate((element) => - Number.parseFloat(getComputedStyle(element).paddingRight), - ), - recipientField.boundingBox(), - ]); - const staticRecipientTextInset = - (currentRecipientFieldBox?.x ?? 0) + - (currentRecipientFieldBox?.width ?? 0) - - ((staticRecipientAccessBox?.x ?? 0) + - (staticRecipientAccessBox?.width ?? 0) - - recipientAccessPaddingRight); - return Math.abs(staticRecipientTextInset - 8); - }) - .toBeLessThanOrEqual(2); + ).toHaveCount(0); await expect(page.getByTestId("persona-share-send")).toBeVisible(); await recipientSearch.fill("bob"); @@ -1684,7 +1665,7 @@ test("a community member can discover and add another member's catalog agent", a expect(await countCommandInvocations(page, "create_persona")).toBe(1); }); -test("share access controls include the selected memories", async ({ +test("one share level selector drives both the link and send paths", async ({ page, }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); @@ -1734,19 +1715,21 @@ 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"); + 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([ @@ -1755,21 +1738,27 @@ test("share access controls include the selected memories", async ({ ]); await page.keyboard.press("Escape"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); - const [linkAccessBox, copyLinkButtonBox] = await Promise.all([ - linkAccess.boundingBox(), + const [shareLevelBox, copyLinkButtonBox] = await Promise.all([ + shareLevel.boundingBox(), copyLinkButton.boundingBox(), ]); expect(copyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkAccessBox?.y ?? 0) + (linkAccessBox?.height ?? 0) + 8, + (shareLevelBox?.y ?? 0) + (shareLevelBox?.height ?? 0) + 8, ); + // The memory choice is stated once, above 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", @@ -1778,7 +1767,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, @@ -1786,6 +1775,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.", @@ -1828,11 +1820,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"); @@ -1845,11 +1837,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"); @@ -1873,48 +1860,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, currentRecipientFieldBox] = - await Promise.all([ - recipientAccess.boundingBox(), - recipientField.boundingBox(), - ]); - return Math.abs( - (currentRecipientFieldBox?.x ?? 0) + - (currentRecipientFieldBox?.width ?? 0) - - 8 - - ((expandedRecipientAccessBox?.x ?? 0) + - (expandedRecipientAccessBox?.width ?? 0)), - ); - }) - .toBeLessThanOrEqual(8); expect( - await recipientAccess + await shareLevel .locator("span") .evaluate((element) => element.scrollWidth <= element.clientWidth), ).toBe(true); diff --git a/desktop/tests/e2e/team-snapshot.spec.ts b/desktop/tests/e2e/team-snapshot.spec.ts index c04a040047..6246c7ac8c 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"); From b248eee1c50b380e0c963cc3acf6e813eb6f44b9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 20:41:50 -0400 Subject: [PATCH 37/40] fix(desktop): group the share dialog's delivery rows above its options Copy link sat alone in a footer at the bottom of the dialog, separated from the link row it belongs to by a divider and the catalog section. The button read as a dialog-level action rather than the link row's own, and the trailing footer left the catalog visually mid-card. Copy link now sits in the link row's end slot, and the link row moves up beside the recipients search so the two delivery actions read together, followed by the two option rows. The footer and divider are gone, leaving the catalog to close the card. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/agents/ui/PersonaShareDialog.tsx | 232 +++++++++--------- desktop/tests/e2e/agents.spec.ts | 100 +++++--- 2 files changed, 179 insertions(+), 153 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 4a5f19b544..af45e8f071 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -40,7 +40,6 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; import { @@ -476,23 +475,6 @@ export function SnapshotShareDialog({
-
-

- What’s included -

- -
+
+ + + +
+

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 ?
{afterLink}
: null} - -
- -
-
+ {afterLink}
- ) : 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 156be00b72..e74d1f3837 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(
-
); From 0b3e93b26315f30df44c574049d43eb220e8b071 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 28 Jul 2026 10:49:18 -0400 Subject: [PATCH 39/40] fix(desktop): keep emoji avatars on catalog entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog projections accepted only http(s) avatar URLs, so an agent with an emoji avatar published to the catalog with no avatar at all — the emoji is persisted as an inline percent-encoded SVG data URL, which the URL guard dropped. A hosted avatar was the only kind that survived being shared. Inline SVG avatars are now accepted alongside http(s), narrowly: exactly the `data:image/svg+xml,` prefix (so base64 payloads and every other MIME stay rejected) under an 8 KiB cap. Catalog avatars render through an `` element, where SVG script never executes, so no sanitizing of the markup is implied. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/lib/personaCatalogRelay.test.mjs | 52 +++++++++++++++++++ .../agents/lib/personaCatalogRelay.ts | 32 ++++++++++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index 2fb0368a11..24f6959b1c 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -2,6 +2,7 @@ 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, @@ -172,6 +173,57 @@ test("catalog avatars keep bounded http URLs and drop unsafe schemes", () => { 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([ diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index cde0ad0344..c85a976ba6 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -77,6 +77,30 @@ function isSafeHttpUrl(value: unknown): value is string { } } +/** + * 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; } @@ -97,11 +121,9 @@ function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { } const avatarUrl = - parsed.avatar_url === null || parsed.avatar_url === undefined - ? null - : isSafeHttpUrl(parsed.avatar_url) - ? parsed.avatar_url - : null; + 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", From 79804288e941d490d9b05d07f6eb74dcfb967e11 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 28 Jul 2026 11:17:44 -0400 Subject: [PATCH 40/40] test(desktop): cover emoji avatars on catalog entries The catalog's avatar seam had no e2e coverage at all: createCatalogEvent hardcoded a null avatar_url, so every existing catalog test would have passed with the projection dropping avatars outright. The helper now takes an optional avatar, defaulting to null so existing callers are unchanged. The expected value comes from emojiAvatarDataUrl rather than a hand-rolled data URL, so the test tracks the real persisted shape instead of a copy of it that could drift. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/tests/e2e/agents.spec.ts | 39 +++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 3a5785e3b8..2e7cdc9e83 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2,6 +2,8 @@ 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"; @@ -12,6 +14,7 @@ function createCatalogEvent(input: { systemPrompt: string; createdAt?: number; shared?: boolean; + avatarUrl?: string; }): RelayEvent { return { id: "1".repeat(64), @@ -25,7 +28,7 @@ function createCatalogEvent(input: { content: JSON.stringify({ display_name: input.displayName, system_prompt: input.systemPrompt, - avatar_url: null, + avatar_url: input.avatarUrl ?? null, runtime: null, model: null, provider: null, @@ -1613,6 +1616,40 @@ test("a foreign reader does not receive an unshared kind 30175 persona", async ( 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, }) => {