mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
fix(desktop): resolve the focus identity row agent through the profiles lookup
The conversation variant's identity row read the raw `agentAvatarUrl` and `agentName` props. The primary channel-opened flow passes `agentAvatarUrl: null`, because `ChannelAgentSessionAgent` (useChannelAgentSessions.ts:21-29) carries no avatar field at all — so every channel-opened focus session showed initials in the identity row while the panel header directly above it showed the real profile avatar. The two disagreed about the same agent on the same surface. Resolve profile-first out of the `profiles` lookup the panel already hands down, exactly as `ToolItem` (ToolItem.tsx:45-52) and the panel header (AgentSessionThreadPanel.tsx:243-249) already do. The props stay as the fallback rather than being replaced: a locally managed agent can hold an avatar its relay profile never published. Found by ss-bugs-02's bug pass on #6720. Three tests: the profile avatar wins over a null prop (the channel path), the profile display name wins over a stale prop name, and the caller's avatar survives when the lookup has none. Proven non-vacuous by three mutations — reverting the resolution fails exactly the two resolution tests, dropping the `?? agentAvatarUrl` fallback fails only the fallback test, and removing the `LoadedImageStub` fails both avatar tests. That stub is load-bearing, not masking: Radix `AvatarImage` renders nothing until its own preloader reports `loaded`, and jsdom never fetches, so both avatar assertions would otherwise have passed vacuously against the initials fallback — the very bug under test. It only affects avatars that have a url, so the byte-for-byte `default`/`compactPreview` baseline fixture stays byte-identical. Verified empirically through the real channel-opened flow in a Chromium build, not just jsdom. Pre-fix the row rendered `hasImg: false` with an "OA" initials fallback while the header showed the real image; post-fix it renders the decoded `<img>` and the resolved name. That probe harness is untracked and not in this commit. Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz> Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
@@ -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/<hex>`
|
||||
* 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
|
||||
|
||||
@@ -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) {
|
||||
<MessageItem
|
||||
agentAvatarUrl={props.agentAvatarUrl}
|
||||
agentName={props.agentName}
|
||||
agentPubkey={props.agentPubkey}
|
||||
item={props.item}
|
||||
profiles={props.profiles}
|
||||
/>
|
||||
@@ -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<TranscriptItem, { type: "message" }>;
|
||||
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. */}
|
||||
<UserAvatar
|
||||
avatarUrl={agentAvatarUrl}
|
||||
avatarUrl={resolvedAgentAvatarUrl}
|
||||
className="shrink-0"
|
||||
displayName={agentName}
|
||||
displayName={resolvedAgentName}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="min-w-0 truncate font-normal text-foreground">
|
||||
{agentName}
|
||||
{resolvedAgentName}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user