From d6fe9de6787dd396a9599ab3fe69f7cc2c25acc2 Mon Sep 17 00:00:00 2001
From: kenny lopez
Date: Wed, 22 Jul 2026 16:59:00 -0700
Subject: [PATCH 01/20] 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}
+ />
}
>
) : 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/20] 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