From 604690841c4f036196eef0bc0cbd1c4909e255d9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 21 Oct 2026 16:40:03 -0400 Subject: [PATCH 1/3] feat(messages): show whose agent wrote a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent's byline now carries the robot mark used by agent mentions and, when its profile attests an owner, a quiet "· Alex’s agent" (or "· Your agent") label. The owner comes from the NIP-OA auth tag on the agent's retained signed profile, verified once per head, so a forged tag shows nothing. Rows start no agent profile reads. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/features/messages/MessageRow.test.tsx | 114 +++++++++++++++++++++- src/features/messages/MessageRow.tsx | 31 +++++- src/features/messages/Messages.module.css | 12 +++ src/features/profiles/useProfileOwner.ts | 60 ++++++++++++ 4 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/features/profiles/useProfileOwner.ts diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 5c9f7d4a84..c4ab55de7d 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom import { StrictMode } from "react"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { bytesToHex } from "nostr-tools/utils"; import { npubEncode } from "nostr-tools/nip19"; import "@testing-library/jest-dom/vitest"; import { stubAvatarBrowserApis } from "../agents/avatar-testing"; @@ -23,7 +25,7 @@ import { ToastProvider } from "../../shared/design-system/ui/Toast"; import { ConversationPresentation } from "../conversation/ConversationPresentation"; import { MessageRow } from "./MessageRow"; import { MessageManagement } from "./MessageManagement"; -import type { ChannelMessage } from "../relay/contracts"; +import type { ChannelMessage, Profile } from "../relay/contracts"; import type { UnreadCapability, UnreadSnapshot } from "../relay/unread"; import type { RelaySession } from "../relay/session"; import type { TypingEntry } from "../relay/typing"; @@ -3617,3 +3619,113 @@ it("keeps a failed card's attachments listed under StrictMode", () => { error.mockRestore(); } }); + +const listed = { channels: [], status: "ready" }; + +it.each([ + { + case: "signed owner", + forged: false, + viewer: undefined, + label: "Logan’s agent", + }, + { + case: "viewer's agent", + forged: false, + viewer: "owner", + label: "Your agent", + }, + { case: "forged owner", forged: true, viewer: undefined, label: undefined }, +])( + "names an agent's owner from its signed profile ($case)", + async ({ forged, viewer, label }) => { + const agent = keypair(); + const owner = keypair(); + const signer = forged ? keypair() : owner; + const conditions = ""; + const sig = bytesToHex( + schnorr.sign( + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode( + `nostr:agent-auth:${agent.pubkey}:${conditions}`, + ), + ), + ), + signer.secret, + ), + ); + const head = signed(agent, { + kind: 0, + content: JSON.stringify({ name: "Quinn" }), + tags: [["auth", owner.pubkey, conditions, sig]], + }); + const quinn: Profile = { name: "Quinn", isAgent: true }; + const profiles = new Map([ + [agent.pubkey, quinn], + [owner.pubkey, { name: "Logan" }], + ]); + const ensure = vi.fn(async () => {}); + const subscribe = () => () => {}; + const session = { + channels: { list: () => listed, subscribeList: subscribe }, + messages: {}, + profiles: { + snapshot: () => profiles, + subscribe, + ensure, + event: (key: string) => (key === agent.pubkey ? head : undefined), + }, + } as unknown as RelaySession; + const view = renderMessage({ + row: { ...row, authorId: agent.pubkey }, + profile: quinn, + // Timelines pass row-selected profiles, which never include the owner. + participantProfiles: new Map([[agent.pubkey, quinn]]), + session, + viewer: viewer === "owner" ? owner.pubkey : row.authorId, + }); + try { + const byline = view.container.querySelector("strong")?.parentElement; + // The mention-chip agent mark sits beside the name either way. + expect(byline?.querySelector("svg")).not.toBeNull(); + if (label) { + await waitFor(() => + expect(view.container).toHaveTextContent(`Quinn· ${label}`), + ); + expect(ensure).toHaveBeenCalledWith([owner.pubkey], "background"); + } else { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(view.container).not.toHaveTextContent("agent"); + expect(ensure).not.toHaveBeenCalled(); + } + } finally { + view.unmount(); + } + }, +); + +it("shows no agent mark or owner for a person", () => { + const event = vi.fn(); + const subscribe = () => () => {}; + const none = new Map(); + const session = { + channels: { list: () => listed, subscribeList: subscribe }, + messages: {}, + profiles: { snapshot: () => none, subscribe, event, ensure: vi.fn() }, + } as unknown as RelaySession; + const view = renderMessage({ session, profile: { name: "Ana" } }); + try { + expect( + view.container + .querySelector("strong") + ?.parentElement?.querySelector("svg"), + ).toBeNull(); + expect(event).not.toHaveBeenCalled(); + } finally { + view.unmount(); + } +}); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 2cc9d83bd3..de8fb13f6b 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -11,7 +11,9 @@ import { Avatar } from "../../shared/design-system/ui/Avatar"; import { ArrowRightIcon, LightningIcon, + RobotIcon, } from "../../shared/design-system/icons"; +import { useProfileOwner } from "../profiles/useProfileOwner"; import { messagePreviewText } from "../conversation/message-preview-text"; import { profileMentionParts } from "./profile-mentions"; import { addressedMessage } from "./audience-address"; @@ -310,6 +312,18 @@ function MessageRowContent({ : undefined, row.authorId, ); + const agentAuthor = !row.workflowOwnerId && avatarShape === "squircle"; + const owner = useProfileOwner( + session, + agentAuthor && !stackPrevious ? row.authorId : undefined, + ); + const ownerLabel = !owner + ? undefined + : owner.id === viewer + ? "Your agent" + : owner.profile && !owner.profile.nameIsFallback + ? `${resolveName(owner.id, owner.profile.name)}’s agent` + : undefined; const presenceId = useId(); const shown = shownRow ?? row; const timeReply = shown.diff ? undefined : parseMediaTimeReply(shown.content); @@ -884,7 +898,16 @@ function MessageRowContent({ /> ) : ( - {name} + + {/* Inside the name, so the byline keeps the text baseline. */} + {agentAuthor && !stackPrevious && ( + {session && !stackPrevious && ( )} + {ownerLabel && ( + + + {ownerLabel} + + )} {!stackPrevious && ( )} diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index d9fa80d6f2..a99847e4b4 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -205,6 +205,18 @@ font-weight: var(--type-weight-medium); min-width: 0; } +.authorAgentIcon { + display: inline-block; + width: 1em; + height: 1em; + margin-inline-end: var(--space-1); + vertical-align: -0.125em; +} +.authorOwner { + color: var(--text-subtle); + min-width: 0; + overflow-wrap: anywhere; +} .text { white-space: normal; } diff --git a/src/features/profiles/useProfileOwner.ts b/src/features/profiles/useProfileOwner.ts new file mode 100644 index 0000000000..2f417d6c2e --- /dev/null +++ b/src/features/profiles/useProfileOwner.ts @@ -0,0 +1,60 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { attestedOwner } from "../agents/owner-attestation"; +import type { Profile } from "../relay/contracts"; +import type { RelayEvent } from "../relay/events"; +import type { RelaySession } from "../relay/session"; + +const noSubscribe = () => () => {}; +const none = () => undefined; +// One verification per retained signed head, shared by every row that shows it. +const checks = new WeakMap>(); +const results = new WeakMap(); + +function verify(head: RelayEvent) { + let check = checks.get(head); + if (!check) { + check = attestedOwner(head).then((owner) => { + results.set(head, { owner }); + return owner; + }); + checks.set(head, check); + } + return check; +} + +/** Verified NIP-OA owner of the profile head the session already retains, for + * display only, never authorization. Reads no agent profile: the profile pane + * owns live evidence (`useAgentOwnerEvidence`). Reads the owner's profile from + * the session directory, since row-selected profiles do not include owners. */ +export function useProfileOwner( + session: RelaySession | undefined, + pubkey: string | undefined, +): { id: string; profile: Profile | undefined } | undefined { + const profiles = pubkey ? session?.profiles : undefined; + const head = useSyncExternalStore( + profiles?.subscribe ?? noSubscribe, + profiles?.event && pubkey ? () => profiles.event?.(pubkey) : none, + profiles?.event && pubkey ? () => profiles.event?.(pubkey) : none, + ); + const [, settled] = useState(); + useEffect(() => { + if (!head || results.has(head)) return; + let active = true; + void verify(head).then(() => { + if (active) settled(head); + }); + return () => { + active = false; + }; + }, [head]); + const owner = head ? results.get(head)?.owner : undefined; + useEffect(() => { + if (owner) void profiles?.ensure([owner], "background").catch(() => {}); + }, [profiles, owner]); + const profile = useSyncExternalStore( + owner && profiles ? profiles.subscribe : noSubscribe, + owner && profiles ? () => profiles.snapshot().get(owner) : none, + owner && profiles ? () => profiles.snapshot().get(owner) : none, + ); + return owner ? { id: owner, profile } : undefined; +} From 6481a9869429d8cc1f3529c5a90aa62d5c979601 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 22 Oct 2026 13:04:15 -0400 Subject: [PATCH 2/3] test: wait for the owner verdict before asserting a hidden label The forged-owner case slept 20 ms and then checked for no label, so it could pass before verification finished. Observe the real attestedOwner and await its verdict instead. The owner's name now also loads after verification, so the signed case covers the owner-profile subscription. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/features/messages/MessageRow.test.tsx | 39 +++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index c4ab55de7d..563fd625bc 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -30,6 +30,13 @@ import type { UnreadCapability, UnreadSnapshot } from "../relay/unread"; import type { RelaySession } from "../relay/session"; import type { TypingEntry } from "../relay/typing"; import { LinkLabel } from "../../bundled/links/InlineLink"; +import { attestedOwner } from "../agents/owner-attestation"; + +// Real verification, observed so tests can wait for its verdict. +vi.mock("../agents/owner-attestation", async (original) => { + const actual = await original(); + return { ...actual, attestedOwner: vi.fn(actual.attestedOwner) }; +}); const row: ChannelMessage = { id: "root", @@ -3662,18 +3669,27 @@ it.each([ tags: [["auth", owner.pubkey, conditions, sig]], }); const quinn: Profile = { name: "Quinn", isAgent: true }; - const profiles = new Map([ - [agent.pubkey, quinn], - [owner.pubkey, { name: "Logan" }], - ]); - const ensure = vi.fn(async () => {}); + let profiles = new Map([[agent.pubkey, quinn]]); + const listeners = new Set<() => void>(); + // The owner's name arrives after verification, as a background load. + const ensure = vi.fn(async () => { + // Later than the render that requested it, so only a subscription shows it. + await new Promise((resolve) => setTimeout(resolve, 0)); + profiles = new Map(profiles).set(owner.pubkey, { name: "Logan" }); + for (const listener of listeners) listener(); + }); const subscribe = () => () => {}; + const verifications = vi.mocked(attestedOwner); + verifications.mockClear(); const session = { channels: { list: () => listed, subscribeList: subscribe }, messages: {}, profiles: { snapshot: () => profiles, - subscribe, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, ensure, event: (key: string) => (key === agent.pubkey ? head : undefined), }, @@ -3690,15 +3706,20 @@ it.each([ const byline = view.container.querySelector("strong")?.parentElement; // The mention-chip agent mark sits beside the name either way. expect(byline?.querySelector("svg")).not.toBeNull(); + expect(verifications).toHaveBeenCalledWith(head); + // Barrier: the real signature check has returned its verdict. + await act(async () => { + await verifications.mock.results[0]?.value; + }); if (label) { await waitFor(() => expect(view.container).toHaveTextContent(`Quinn· ${label}`), ); expect(ensure).toHaveBeenCalledWith([owner.pubkey], "background"); } else { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); + await expect(verifications.mock.results[0]?.value).resolves.toBe( + undefined, + ); expect(view.container).not.toHaveTextContent("agent"); expect(ensure).not.toHaveBeenCalled(); } From 08caad9aa832f564818d5713a659495ae58bde9c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 23 Oct 2026 15:35:19 -0400 Subject: [PATCH 3/3] fix(messages): show only the agent mark on the viewer's own agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Your own agent's messages now carry just the robot mark. Another person's agent still reads "· Alex’s agent". Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/messages/MessageRow.test.tsx | 15 +++++++++------ src/features/messages/MessageRow.tsx | 15 ++++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 563fd625bc..fc5298f322 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -3636,12 +3636,8 @@ it.each([ viewer: undefined, label: "Logan’s agent", }, - { - case: "viewer's agent", - forged: false, - viewer: "owner", - label: "Your agent", - }, + // The viewer's own agent shows only the mark. + { case: "viewer's agent", forged: false, viewer: "owner", label: undefined }, { case: "forged owner", forged: true, viewer: undefined, label: undefined }, ])( "names an agent's owner from its signed profile ($case)", @@ -3716,6 +3712,13 @@ it.each([ expect(view.container).toHaveTextContent(`Quinn· ${label}`), ); expect(ensure).toHaveBeenCalledWith([owner.pubkey], "background"); + } else if (viewer === "owner") { + await expect(verifications.mock.results[0]?.value).resolves.toBe( + owner.pubkey, + ); + // Barrier: the owner's name has loaded, so a label would show now. + await waitFor(() => expect(profiles.has(owner.pubkey)).toBe(true)); + expect(view.container).not.toHaveTextContent("agent"); } else { await expect(verifications.mock.results[0]?.value).resolves.toBe( undefined, diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index de8fb13f6b..a7791ae2d2 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -317,13 +317,14 @@ function MessageRowContent({ session, agentAuthor && !stackPrevious ? row.authorId : undefined, ); - const ownerLabel = !owner - ? undefined - : owner.id === viewer - ? "Your agent" - : owner.profile && !owner.profile.nameIsFallback - ? `${resolveName(owner.id, owner.profile.name)}’s agent` - : undefined; + // The viewer's own agents carry only the agent mark. + const ownerLabel = + owner && + owner.id !== viewer && + owner.profile && + !owner.profile.nameIsFallback + ? `${resolveName(owner.id, owner.profile.name)}’s agent` + : undefined; const presenceId = useId(); const shown = shownRow ?? row; const timeReply = shown.diff ? undefined : parseMediaTimeReply(shown.content);