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 0000000000..5acd9ae109 --- /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 0000000000..057c413daa --- /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 fbaf1f5274..ef516f4b01 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 02c3f8e202..a588843b1e 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 cf39b0859e..26825873e5 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"; @@ -277,7 +281,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 @@ -288,7 +292,7 @@ function AgentPersonaCard({ -): string | null { - for (const candidate of candidates) { - const trimmed = candidate?.trim(); - if (trimmed) return trimmed; - } - return null; -} - function NewAgentCard({ isPending, onCreate,