fix(desktop): detect owned agents via kind:0 OA-owner signal in profile panel

Owned agents rendered as humans in the profile panel's archive flow: the
Archive button + confirm modal showed the human variant even for an agent
the viewer owns (repro: tho's agent Edna).

Root cause: two gates disagreed. The archive button's canArchive gate
resolves correctly via OA-ownership, but the human-vs-agent framing used a
separate signal — isBot = Boolean(relayAgent || managedAgent) — that checks
the relay-agents registry + the local managed-agents list. An owned agent
deployed elsewhere can miss BOTH lists, so isBot was false and the panel
rendered the human framing while the button still showed.

Fix: OR in the kind:0-derived agent flag (isAgent on the users-batch
summary, which the backend sets from profile_has_valid_oa_owner — a verified
NIP-OA auth tag on the target's kind:0). That's the same authoritative
signal the archive gate's resolveOaOwner trusts, so isBot can no longer
drift from the gate. Client-only change, no relay/registry change.

- UserProfilePanel: query useUsersBatchQuery([pubkey]) and OR its isAgent
  into isBot (keyed by lowercased pubkey, matching the house pattern).
- BotIdenticon: forward an optional data-testid to its wrapper.
- UserProfilePanelSections: tag the profile bot indicator with
  data-testid=profile-bot-indicator for the regression test.
- profile.spec.ts: regression test — an owned agent seeded with the kind:0
  agent flag but absent from relay/managed lists now renders agent framing.

Pre-existing, unrelated: profile.spec.ts 'updates the relay-backed profile
from settings' fails on clean origin/main too (avatar-url assertion) — not
touched by this change.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-14 13:23:31 -07:00
co-authored by Taylor Ho
parent b2ad3074ba
commit 462d2190a5
4 changed files with 69 additions and 1 deletions
@@ -7,6 +7,7 @@ type BotIdenticonProps = {
/** Size in pixels (default 20) */
size?: number;
className?: string;
"data-testid"?: string;
};
/**
@@ -17,6 +18,7 @@ export const BotIdenticon = React.memo(function BotIdenticon({
value,
size = 20,
className,
"data-testid": dataTestid,
}: BotIdenticonProps) {
const svgHtml = React.useMemo(() => toSvg(value, size), [value, size]);
@@ -24,6 +26,7 @@ export const BotIdenticon = React.memo(function BotIdenticon({
<div
aria-hidden
className={className}
data-testid={dataTestid}
// biome-ignore lint/security/noDangerouslySetInnerHtml: jdenticon produces safe SVG
dangerouslySetInnerHTML={{ __html: svgHtml }}
style={{ width: size, height: size, flexShrink: 0 }}
@@ -22,6 +22,7 @@ import {
useProfileQuery,
useUnfollowMutation,
useUserProfileQuery,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import {
ChannelsFocusedView,
@@ -156,6 +157,12 @@ export function UserProfilePanel({
const relayAgentsQuery = useRelayAgentsQuery({ enabled: true });
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
// kind:0-derived agent flag (a verified NIP-OA `auth` tag on the target's
// profile) — the same authoritative signal the archive gate's OA-owner
// resolution trusts. The relay-agents registry + local managed-agents list
// below can both miss an owned agent that was deployed elsewhere, so we OR
// this in to keep `isBot` from drifting from the gate.
const usersBatchQuery = useUsersBatchQuery([pubkey]);
const channelsQuery = useChannelsQuery();
const presenceQuery = usePresenceQuery([pubkey]);
const userStatusQuery = useUserStatusQuery([pubkey]);
@@ -176,7 +183,10 @@ export function UserProfilePanel({
const managedAgent = managedAgentsQuery.data?.find(
(agent) => agent.pubkey.toLowerCase() === pubkeyLower,
);
const isBot = Boolean(relayAgent || managedAgent);
const isAgentByOaOwner = Boolean(
usersBatchQuery.data?.profiles[pubkeyLower]?.isAgent,
);
const isBot = Boolean(relayAgent || managedAgent) || isAgentByOaOwner;
const isOwner = useIsManagedAgent(isBot ? pubkey : null);
// Populate the active-turns store for this agent so useActiveAgentTurns works
@@ -305,6 +305,7 @@ function ProfileHero({
{isBot ? (
<BotIdenticon
className="shrink-0 rounded"
data-testid="profile-bot-indicator"
size={20}
value={displayName}
/>
+54
View File
@@ -615,6 +615,60 @@ test("renders agent memories seeded through the Playwright mock bridge", async (
await expect(page.getByTestId("agent-memory-list")).toContainText("orphan");
});
test("owned agent absent from relay/managed lists still renders agent framing", async ({
page,
}) => {
// Regression: bot-detection used to rely solely on the relay-agents registry
// + the local managed-agents list. An owned agent deployed elsewhere can miss
// BOTH lists, so the panel rendered it as a human (wrong archive framing).
// The fix ORs in the kind:0 NIP-OA agent flag (same signal the archive gate
// trusts), surfaced via the users-batch summary's `isAgent`.
const ednaPubkey =
"16aaadcf39011edbd887e4abefe5837170621db277e234f3f6c220d38ba75ecf";
await installMockBridge(page, {
// Seeded as an agent (kind:0 NIP-OA owner) but NOT as a managed agent and
// NOT in the relay-agents registry — exactly the bug scenario.
searchProfiles: [
{ pubkey: ednaPubkey, displayName: "Edna", isAgent: true },
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ pubkey }) => {
const emit = (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
pubkey: string;
}) => unknown;
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) {
throw new Error("Mock message emitter is unavailable.");
}
emit({ channelName: "general", content: "Edna check-in", pubkey });
},
{ pubkey: ednaPubkey },
);
const messageRow = page
.getByTestId("message-row")
.filter({ hasText: "Edna check-in" });
await expect(messageRow).toBeVisible();
await messageRow.locator("button").first().click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
// The bot indicator only renders when isBot resolves true — the assertion
// that the OA-owner signal now drives agent framing.
await expect(page.getByTestId("profile-bot-indicator")).toBeVisible();
});
test("renders settings in the app shell with a back button", async ({
page,
}) => {