From f03de210cd0e384870aaa00cb1fa6985a75640ff Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 6 Aug 2026 17:14:12 -0700 Subject: [PATCH] fix(desktop): preserve authoritative agent avatars (#4984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Agent cards and catalog listings now show the avatar belonging to the identity they represent. **Problem:** Running agent cards could show a stale definition avatar instead of the concrete agent profile, while adding another publisher's catalog entry could let local edits repaint that publisher's listing. This made agent identity look inconsistent across My Agents and the Agent Catalog. **Solution:** Treat the concrete agent pubkey profile as authoritative for running-card avatars, with the linked definition as fallback. Keep relay publications authoritative for foreign catalog presentation while using local copies only for linkage and selection state. | before | after | |--|--| | Screenshot 2026-08-06 at 3 48
43 PM | Screenshot 2026-08-06 at 3 48
40 PM | | agent-set avatar not showing | agent-set avatar is showing | ## Changes
File changes **desktop/src/features/agents/lib/agentCardAvatar.ts** Adds the explicit avatar precedence rule for running agent cards and blocks avatar-dependent actions until the authoritative profile query settles. **desktop/src/features/agents/lib/agentCardAvatar.test.mjs** Covers profile precedence, definition fallback, blank avatar handling, and the profile-loading transition for linked-agent actions. **desktop/src/features/agents/lib/personaCatalogRelay.ts** Keeps publisher-provided catalog identity and behavior fields authoritative after a local copy is added. **desktop/src/features/agents/lib/personaCatalogRelay.test.mjs** Verifies local copies contribute linkage and selection without overriding publisher presentation. **desktop/src/features/agents/ui/UnifiedAgentsSection.tsx** Uses the concrete agent profile avatar before the linked definition avatar on running-agent cards.
## Reproduction Steps ### Running agent card uses the agent profile avatar Use two visibly different, publicly reachable image URLs: **A** for the saved definition and **B** for the running agent profile. 1. In **Settings → Experiments**, enable **Agent-managed profiles**. This prevents Desktop from restoring the definition avatar over an agent's own relay-profile changes. 2. In **Agents**, create an agent with image **A** as its avatar and start it. 3. In a channel containing that agent, ask it to update its own Buzz profile avatar to image **B**. The exact CLI operation under the agent identity is `buzz users set-profile --avatar `. 4. After the agent confirms the update, reopen **Agents → My Agents** (or reload the page so its kind:0 profile is fetched again). 5. Verify the running agent card shows image **B**, not definition image **A**. Open **⋯ → Share** and verify the share flow also uses image **B**. Before this fix, the My Agents card and share flow preferred image **A** whenever the linked definition had an avatar. ### Catalog listing remains publisher-authoritative This scenario requires a second Buzz identity so the entry is foreign to the account under test. 1. As the publisher identity, create an agent definition with a distinctive name, avatar, and instructions, then use **Share → Share to catalog**. 2. As the test identity, open **Agents → Discover agents**, find that publication, and add it. 3. In **My Agents**, open the added copy's **⋯ → Edit**, change its name, avatar, and instructions, and save. 4. Return to **Discover agents** and find the same publisher entry. 5. Verify it remains selected/added but still shows the publisher's original name, avatar, and instructions—not the test identity's local edits. ## Validation - `pnpm test` — 4,376 passed - `pnpm typecheck` — passed - `pnpm check` — passed with existing non-error notices --------- Signed-off-by: Taylor Ho --- .../agents/lib/agentCardAvatar.test.mjs | 37 +++++++++++++++++++ .../features/agents/lib/agentCardAvatar.ts | 29 +++++++++++++++ .../agents/lib/personaCatalogRelay.test.mjs | 17 +++++++-- .../agents/lib/personaCatalogRelay.ts | 12 ++++-- .../agents/ui/UnifiedAgentsSection.tsx | 18 +++------ 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 desktop/src/features/agents/lib/agentCardAvatar.test.mjs create mode 100644 desktop/src/features/agents/lib/agentCardAvatar.ts diff --git a/desktop/src/features/agents/lib/agentCardAvatar.test.mjs b/desktop/src/features/agents/lib/agentCardAvatar.test.mjs new file mode 100644 index 000000000..5acd9ae10 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardAvatar.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isAgentCardAvatarLoading, + resolveAgentCardAvatarUrl, +} from "./agentCardAvatar.ts"; + +test("running agent card prefers the pubkey profile avatar", () => { + assert.equal( + resolveAgentCardAvatarUrl( + "https://relay.example/instance.png", + "https://relay.example/definition.png", + ), + "https://relay.example/instance.png", + ); +}); + +test("running agent card falls back to the definition avatar", () => { + assert.equal( + resolveAgentCardAvatarUrl(null, " https://relay.example/definition.png "), + "https://relay.example/definition.png", + ); +}); + +test("running agent card ignores blank avatar values", () => { + assert.equal(resolveAgentCardAvatarUrl(" ", ""), null); +}); + +test("linked agent actions wait for the authoritative profile avatar", () => { + assert.equal(isAgentCardAvatarLoading(true, true), true); + assert.equal(isAgentCardAvatarLoading(true, false), false); +}); + +test("unlinked persona actions do not wait for a profile", () => { + assert.equal(isAgentCardAvatarLoading(false, true), false); +}); diff --git a/desktop/src/features/agents/lib/agentCardAvatar.ts b/desktop/src/features/agents/lib/agentCardAvatar.ts new file mode 100644 index 000000000..057c413da --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardAvatar.ts @@ -0,0 +1,29 @@ +/** + * Resolve the avatar for a running agent card. + * + * The card opens the concrete agent pubkey's profile, so that profile's kind:0 + * picture is authoritative. The linked definition remains a fallback while the + * profile is missing or has no picture. + */ +export function resolveAgentCardAvatarUrl( + profileAvatarUrl: string | null | undefined, + personaAvatarUrl: string | null | undefined, +): string | null { + for (const candidate of [profileAvatarUrl, personaAvatarUrl]) { + const trimmed = candidate?.trim(); + if (trimmed) return trimmed; + } + return null; +} + +/** + * A linked agent's profile is authoritative even when the definition already + * supplies a fallback. Avatar-dependent actions must wait for that profile + * query so they cannot snapshot the fallback before the profile resolves. + */ +export function isAgentCardAvatarLoading( + hasLinkedAgent: boolean, + isProfilePending: boolean, +): boolean { + return hasLinkedAgent && isProfilePending; +} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index fbaf1f527..ef516f4b0 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -319,12 +319,20 @@ function localPersona(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", () => { +test("test_added_foreign_catalog_entry_keeps_publisher_identity_and_local_selection", () => { + const publisherAvatar = "https://relay.example/publisher.png"; const publications = catalogPublicationsFromEvents([ - personaEvent({ createdAt: 1, id: "alice-reviewer" }), + personaEvent({ + createdAt: 1, + id: "alice-reviewer", + avatarUrl: publisherAvatar, + }), ]); const copy = localPersona({ id: "a-fresh-uuid", + displayName: "Locally Renamed Reviewer", + avatarUrl: "https://relay.example/local-copy.png", + systemPrompt: "Locally edited instructions.", catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" }, }); @@ -334,13 +342,16 @@ test("test_added_foreign_catalog_entry_resolves_to_its_local_copy", () => { assert.equal( personas[0].id, "a-fresh-uuid", - "the projection must resolve to the existing local copy, not a synthetic id", + "the projection must retain the existing local copy's linkage id", ); assert.equal( personas[0].isActive, true, "an added foreign entry must read as already selected", ); + assert.equal(personas[0].displayName, "Relay Reviewer"); + assert.equal(personas[0].avatarUrl, publisherAvatar); + assert.equal(personas[0].systemPrompt, "Review changes."); }); test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 02c3f8e20..a588843b1 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -289,8 +289,14 @@ function publicationToPersona( isOwn: boolean, ): CatalogPersona { const timestamp = new Date(publication.createdAt * 1_000).toISOString(); - const basePersona: AgentPersona = localPersona ?? { - id: `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, + // The publication remains authoritative for catalog presentation. An added + // local copy contributes only the linkage id and selected state; merging the + // whole copy would leak local edits (notably its avatar) into the publisher's + // catalog entry. + const basePersona: AgentPersona = { + id: + localPersona?.id ?? + `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, displayName: publication.agent.displayName, avatarUrl: publication.agent.avatarUrl, systemPrompt: publication.agent.systemPrompt, @@ -299,7 +305,7 @@ function publicationToPersona( provider: publication.agent.provider, namePool: publication.agent.namePool, isBuiltIn: false, - isActive: false, + isActive: localPersona?.isActive ?? false, shared: true, sourceTeam: null, envVars: {}, diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 212d9bc96..73562bda3 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { + isAgentCardAvatarLoading, + resolveAgentCardAvatarUrl, +} from "@/features/agents/lib/agentCardAvatar"; import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; @@ -290,7 +294,7 @@ function AgentPersonaCard({ const isActive = agent ? isManagedAgentActive(agent) : false; const profileQuery = useUserProfileQuery(agent?.pubkey); const avatarUrl = agent - ? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl) + ? resolveAgentCardAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl) : persona.avatarUrl; const friendlyError = agent ? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy @@ -301,7 +305,7 @@ function AgentPersonaCard({ -): string | null { - for (const candidate of candidates) { - const trimmed = candidate?.trim(); - if (trimmed) return trimmed; - } - return null; -} - function NewAgentCard({ isPending, onCreate,