diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs index a0676857d8..dd57a7f1f5 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs @@ -120,6 +120,36 @@ dom.window.cancelAnimationFrame = (id) => clearTimeout(id); globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; +/** + * Radix's `AvatarImage` renders nothing until its own preloader reports + * `loaded` (`react-avatar/dist/index.mjs` `useImageLoadingStatus`), and jsdom + * never fetches, so a real avatar URL would otherwise stay in `loading` forever + * and every avatar assertion would see only the initials fallback — the exact + * bug under test, passing vacuously. This stub reports a decoded image as soon + * as `src` is assigned. It only affects avatars that HAVE a url: with + * `avatarUrl: null` radix resolves to `error` and skips the preloader entirely, + * so the byte-for-byte baseline (whose agent and author carry no avatar) is + * untouched. + */ +class LoadedImageStub { + constructor() { + this._src = ""; + this.complete = false; + this.naturalWidth = 0; + } + addEventListener() {} + removeEventListener() {} + get src() { + return this._src; + } + set src(value) { + this._src = value; + this.complete = true; + this.naturalWidth = 1; + } +} +dom.window.Image = LoadedImageStub; + let act; let cleanup; let render; @@ -158,6 +188,20 @@ const AUTHOR_PROFILES = { ownerPubkey: null, }, }; +/** + * A relay-resolved profile for the *agent*. Deliberately not a `/media/` + * relay URL: `UserAvatar` routes those through the localhost media proxy + * (`rewriteRelayUrl`), which would make the rendered `src` a moving target. + */ +const AGENT_AVATAR_URL = "https://cdn.example.test/agent-profile.png"; +const AGENT_PROFILES = { + [AGENT.agentPubkey]: { + displayName: "Test Agent", + avatarUrl: AGENT_AVATAR_URL, + nip05Handle: null, + ownerPubkey: null, + }, +}; function items() { const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" }; @@ -592,6 +636,76 @@ test("conversation labels the agent turn with a berd-style identity row", async assert.doesNotMatch(message.innerHTML, /rounded-2xl/); }); +test("the conversation identity row resolves the agent avatar from the profiles lookup", async () => { + // Regression guard for the primary channel flow. `ChannelAgentSessionAgent` + // (useChannelAgentSessions.ts:21-29) has no avatar field at all, so when the + // focus conversation is opened from a channel the panel passes + // `agentAvatarUrl: null` — which is exactly this mount. The row must still + // show the configured avatar by resolving it out of the `profiles` lookup the + // panel already hands down, the same way the ToolItem row + // (ToolItem.tsx:45-52) and the panel header above it already do. Before the + // fix, every channel-opened session fell back to initials. + const { container } = await renderTranscript("conversation", { + agentAvatarUrl: null, + profiles: AGENT_PROFILES, + }); + const identity = container.querySelector( + '[data-testid="transcript-assistant-identity"]', + ); + assert.ok(identity, "the identity row should render"); + const image = identity.querySelector("img"); + assert.ok( + image, + "the profile avatar must win over the caller's null agent record avatar", + ); + assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL); +}); + +test("the conversation identity row resolves the agent name the same way the header does", async () => { + // The row sits directly under the panel header, which labels the same agent + // through `resolveUserLabel` (AgentSessionThreadPanel.tsx:244-249). Reading + // the raw `agentName` prop instead let the two disagree whenever the relay + // profile's display name differed from the caller's agent record. + const { container } = await renderTranscript("conversation", { + agentName: "stale-record-name", + profiles: { + [AGENT.agentPubkey]: { + displayName: "Profile Display Name", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + const identity = container.querySelector( + '[data-testid="transcript-assistant-identity"]', + ); + assert.equal(identity.textContent, "Profile Display Name"); +}); + +test("the conversation identity row keeps the caller's avatar when the lookup has none", async () => { + // The managed-agent path is the other direction: a locally managed agent can + // carry an avatar its relay profile never published. The prop stays the + // fallback, so resolving profile-first must not drop it. + const localAvatar = "https://cdn.example.test/local-managed.png"; + const { container } = await renderTranscript("conversation", { + agentAvatarUrl: localAvatar, + profiles: { + [AGENT.agentPubkey]: { + displayName: "Test Agent", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }, + }); + const image = container + .querySelector('[data-testid="transcript-assistant-identity"]') + .querySelector("img"); + assert.ok(image, "the caller-supplied avatar should still render"); + assert.equal(image.getAttribute("src"), localAvatar); +}); + test("conversation frames fenced code with berd's header row", async () => { // berd puts the language in a real header row above the frame, with the copy // action opposite it (`code-block.tsx` CodeBlockHeader:388-402), and the code diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx index 8451eaa9ce..6b42a637a5 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -1,4 +1,8 @@ -import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; @@ -21,6 +25,7 @@ export function MessageActivity(props: ActivityRenderClassItemProps) { @@ -30,11 +35,13 @@ export function MessageActivity(props: ActivityRenderClassItemProps) { function MessageItem({ agentAvatarUrl, agentName, + agentPubkey, item, profiles, }: { agentAvatarUrl: string | null; agentName: string; + agentPubkey: string; item: Extract; profiles?: UserProfileLookup; }) { @@ -44,6 +51,25 @@ function MessageItem({ const isAssistant = item.role === "assistant"; const text = item.text.trim(); const messageLink = getTranscriptMessageLink(item); + // The identity row must resolve the agent through the profiles lookup first, + // exactly as `ToolItem` (ToolItem.tsx:45-52) and the panel header + // (AgentSessionThreadPanel.tsx:243-249) already do for the same agent on the + // same surface. The `agentAvatarUrl`/`agentName` props only carry what the + // *caller's* agent record holds, and the primary channel flow's record + // (`ChannelAgentSessionAgent`) has no avatar field at all — so relying on the + // prop alone showed initials for every channel-opened session while the + // managed-agent panel showed the real avatar. Resolving here keeps the row + // agreeing with the header directly above it; the props stay as the fallback + // for callers that have an avatar the lookup does not (a locally managed + // agent whose avatar was never published to a relay profile). + const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; + const resolvedAgentAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; + const resolvedAgentName = resolveUserLabel({ + pubkey: agentPubkey, + fallbackName: agentName, + profiles, + preferResolvedSelfLabel: true, + }); if (!isAssistant) { return ( @@ -80,13 +106,13 @@ function MessageItem({ > {/* `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar. */} - {agentName} + {resolvedAgentName} ) : null}