fix(desktop): preserve authoritative agent avatars (#4984)

**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 |
|--|--|
| <img width="874" height="592" alt="Screenshot 2026-08-06 at 3 48
43 PM"
src="https://github.com/user-attachments/assets/2cc6c9f7-ea50-413c-9c7b-4d34bd8b4ec7"
/> | <img width="884" height="597" alt="Screenshot 2026-08-06 at 3 48
40 PM"
src="https://github.com/user-attachments/assets/b14a865c-65c4-458f-9c30-d1a557c877d7"
/> |
| agent-set avatar not showing | agent-set avatar is showing |

## Changes

<details>
<summary>File changes</summary>

**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.

</details>

## 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 <image-B-url>`.
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 <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-06 17:14:12 -07:00
committed by GitHub
parent 67b77344d6
commit f03de210cd
5 changed files with 95 additions and 18 deletions
@@ -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);
});
@@ -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;
}
@@ -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", () => {
@@ -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: {},
@@ -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({
<AgentIdentityCard
actions={actions?.(
avatarUrl,
Boolean(agent && !persona.avatarUrl && profileQuery.isPending),
isAgentCardAvatarLoading(Boolean(agent), profileQuery.isPending),
)}
ariaLabel={`${title} agent profile`}
avatar={
@@ -444,16 +448,6 @@ function StandaloneAgentCard({
);
}
function firstAvatarUrl(
...candidates: Array<string | null | undefined>
): string | null {
for (const candidate of candidates) {
const trimmed = candidate?.trim();
if (trimmed) return trimmed;
}
return null;
}
function NewAgentCard({
isPending,
onCreate,