Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
d6fe9de
Refine agent catalog sharing
klopez4212 Jul 22, 2026
ea89b93
Stack team card avatars
klopez4212 Jul 23, 2026
d41e6cc
Simplify team avatar stack styling
klopez4212 Jul 23, 2026
1320d64
Add catalog update publishing
klopez4212 Jul 23, 2026
eddd919
Clarify catalog copy behavior
klopez4212 Jul 23, 2026
b561105
Use community in catalog sharing copy
klopez4212 Jul 23, 2026
691b305
Update agent creation test selectors
klopez4212 Jul 23, 2026
40301c3
Constrain shared agent catalog details
klopez4212 Jul 23, 2026
c7aa81a
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 23, 2026
f08ab69
Align catalog publish option with dialog footer
klopez4212 Jul 23, 2026
6699102
Place catalog publish action before toggle
klopez4212 Jul 23, 2026
a1570f1
Group catalog sharing with link controls
klopez4212 Jul 23, 2026
20afa18
Use catalog sharing access menu
klopez4212 Jul 23, 2026
5dfc793
Keep copy link action last
klopez4212 Jul 23, 2026
597bdf0
Default catalog sharing to off
klopez4212 Jul 23, 2026
4873d35
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 23, 2026
0520fe0
Publish catalog updates on save
klopez4212 Jul 23, 2026
453dd7a
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 23, 2026
2b8f1d7
Publish agent catalog entries to relay
klopez4212 Jul 23, 2026
d89b7db
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 23, 2026
e17d283
Fix catalog snapshot test after main merge
klopez4212 Jul 23, 2026
2a780d4
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 24, 2026
a7c5be1
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 24, 2026
e2abf33
fix(desktop): update agent creation e2e selector
klopez4212 Jul 24, 2026
b72e024
fix(desktop): align agent header with card grid
klopez4212 Jul 24, 2026
e09fa64
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 24, 2026
dd22a7c
fix(desktop): harden catalog publication imports
klopez4212 Jul 24, 2026
e549945
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 25, 2026
d211ace
Use persona events for catalog sharing
klopez4212 Jul 26, 2026
a305bc9
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 26, 2026
ec4ec14
Stabilize agent catalog E2E coverage
klopez4212 Jul 27, 2026
fe36d15
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 27, 2026
d1005cd
Keep merged agent files within size limits
klopez4212 Jul 27, 2026
53c3245
Fix portable descendant probe fixture
klopez4212 Jul 27, 2026
8eba922
Scope catalog sharing to communities
klopez4212 Jul 27, 2026
9f082be
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
klopez4212 Jul 27, 2026
fbc9d51
fix(desktop): migrate the legacy global retention db into the active …
wpfleger96 Jul 27, 2026
4340f83
fix(desktop): keep agent list and edits working in recovery mode
wpfleger96 Jul 27, 2026
b6439c7
feat(desktop): add update_persona_and_publish for save-and-publish
wpfleger96 Jul 27, 2026
5fffd16
feat(desktop): track catalog provenance on a copied persona
wpfleger96 Jul 27, 2026
ac9f33d
fix(desktop): scope an inbound persona event to the relay it arrived on
wpfleger96 Jul 27, 2026
89b055e
fix(desktop): honor publishCatalogUpdates on "Save and publish"
wpfleger96 Jul 27, 2026
90cba05
fix(desktop): stop re-adding an already-added foreign catalog agent
wpfleger96 Jul 27, 2026
97ab227
fix(desktop): page the persona catalog past the relay's 1,000-row clamp
wpfleger96 Jul 27, 2026
954cc23
test(desktop): stop the e2e mock publishing from update_persona
wpfleger96 Jul 27, 2026
09afe06
Merge branch 'main' into kennylopez-agent-catalog-sharing
wpfleger96 Jul 27, 2026
184deb2
Merge branch 'main' into kennylopez-agent-catalog-sharing
wpfleger96 Jul 27, 2026
ccba0c1
fix(desktop): label the catalog share toggle Shared instead of Agent …
wpfleger96 Jul 27, 2026
36af667
fix(desktop): unify the share dialog memory level into one selector
wpfleger96 Jul 27, 2026
b248eee
fix(desktop): group the share dialog's delivery rows above its options
wpfleger96 Jul 28, 2026
2a38069
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
wpfleger96 Jul 28, 2026
9080a79
refactor(desktop): remove the Active in communities settings card
wpfleger96 Jul 28, 2026
0b3e93b
fix(desktop): keep emoji avatars on catalog entries
wpfleger96 Jul 28, 2026
7980428
test(desktop): cover emoji avatars on catalog entries
wpfleger96 Jul 28, 2026
cd3e8c2
Merge remote-tracking branch 'origin/main' into kennylopez-agent-cata…
wpfleger96 Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,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).
///
Expand Down Expand Up @@ -586,6 +601,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,
Expand Down Expand Up @@ -784,6 +800,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
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/commands/media_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/media_snapshot_png.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/personas/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
60 changes: 38 additions & 22 deletions desktop/src-tauri/src/commands/personas/snapshot/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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<String>,
/// Preferred runtime from the exported definition.
pub runtime: Option<String>,
/// System prompt, if any.
pub system_prompt: Option<String>,
/// Effective avatar: data URL if present, otherwise the source URL fallback.
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 28 additions & 2 deletions desktop/src-tauri/src/commands/personas/snapshot/tests.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -614,6 +632,14 @@ fn import_non_allowlist_mode_preserved_when_keep_false() {
);
}

#[test]
fn import_catalog_owner_only_without_allowlist_succeeds() {
let minted = resolve_snapshot_import_behavior(Some("owner-only"), &[], None, false).unwrap();

assert_eq!(minted.respond_to, RespondTo::OwnerOnly);
assert!(minted.respond_to_allowlist.is_empty());
}

/// Non-allowlist mode with a non-empty list and keep=true: preserve mode + list.
/// The toggle WAS shown (list is non-empty) so keep_allowlist applies.
#[test]
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/team_snapshot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion desktop/src-tauri/src/managed_agents/agent_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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.")
Expand Down
5 changes: 1 addition & 4 deletions desktop/src-tauri/src/managed_agents/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
5 changes: 1 addition & 4 deletions desktop/src-tauri/src/managed_agents/personas/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/agents/assets/agent-outline.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 0 additions & 78 deletions desktop/src/features/agents/lib/catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -25,62 +22,6 @@ function createPersona(id, displayName, overrides = {}) {
};
}

test("getCatalogPersonas keeps built-ins visible whether selected or not", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }),
createPersona("custom:builder", "Builder"),
];

assert.deepEqual(
getCatalogPersonas(personas).map((persona) => persona.id),
["builtin:fizz"],
);
});

test("getCatalogSelectionState keeps built-in selection rules in one place", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
createPersona("custom:builder", "Builder"),
];

const state = getCatalogSelectionState(personas);

assert.deepEqual(
state.catalogPersonas.map((persona) => persona.id),
["builtin:fizz"],
);
assert.deepEqual(
state.selectedCatalogPersonas.map((persona) => persona.id),
["builtin:fizz"],
);
assert.deepEqual(
state.unselectedCatalogPersonas.map((persona) => persona.id),
[],
);
});

test("getCatalogPersonas keeps chooser order stable when selection changes", () => {
const inactive = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }),
createPersona("builtin:reviewer", "Reviewer", {
isBuiltIn: true,
isActive: true,
}),
];
const active = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
createPersona("builtin:reviewer", "Reviewer", {
isBuiltIn: true,
isActive: false,
}),
];

assert.deepEqual(
getCatalogPersonas(inactive).map((persona) => persona.id),
getCatalogPersonas(active).map((persona) => persona.id),
);
});

test("isCatalogPersonaSelected treats active catalog personas as selected", () => {
assert.equal(
isCatalogPersonaSelected(
Expand Down Expand Up @@ -118,25 +59,6 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => {
});
});

test("getPersonaLibraryState keeps the working library and full catalog in one place", () => {
const personas = [
createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }),
createPersona("custom:builder", "Builder"),
];

const state = getPersonaLibraryState(personas);

assert.deepEqual(
state.libraryPersonas.map((persona) => persona.id),
["builtin:fizz", "custom:builder"],
);
assert.deepEqual(
state.catalogPersonas.map((persona) => persona.id),
["builtin:fizz"],
);
assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz");
});

test("getLibraryPersonas keeps active custom personas even when catalog entries are similar", () => {
const avatarUrl = "https://example.test/coordinator.png";
const personas = [
Expand Down
Loading
Loading