mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user