diff --git a/desktop/src/app/useTrayMenu.test.mjs b/desktop/src/app/useTrayMenu.test.mjs new file mode 100644 index 0000000000..a5e6701b08 --- /dev/null +++ b/desktop/src/app/useTrayMenu.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveTrayActivities, resolveTrayAgentName } from "./useTrayMenu.ts"; + +const REMOTE_AGENT_PUBKEY = "1".repeat(64); + +test("resolveTrayAgentName uses a hydrated remote-agent profile", () => { + assert.equal( + resolveTrayAgentName({ + knownAgentName: undefined, + profile: { + avatarUrl: null, + displayName: "Hermes", + isAgent: true, + nip05Handle: null, + ownerPubkey: "2".repeat(64), + }, + pubkey: REMOTE_AGENT_PUBKEY, + }), + "Hermes", + ); +}); + +test("resolveTrayActivities replaces a completed activity fallback after profile hydration", () => { + const activities = resolveTrayActivities({ + activities: [ + { + activityId: `recent:channel:${REMOTE_AGENT_PUBKEY}:1`, + agentName: "Agent 111111…111111", + agentPubkey: REMOTE_AGENT_PUBKEY, + channelId: "channel", + channelName: "hermes-acceptance", + elapsed: "1s", + }, + ], + knownAgentNames: new Map(), + profiles: { + [REMOTE_AGENT_PUBKEY]: { + avatarUrl: null, + displayName: "Hermes", + isAgent: true, + nip05Handle: null, + ownerPubkey: "2".repeat(64), + }, + }, + }); + + assert.equal(activities[0].agentName, "Hermes"); + assert.equal("agentPubkey" in activities[0], false); +}); diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 355c8e5d4f..eea0f28bed 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -10,6 +10,9 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { UserProfileSummary } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; @@ -23,12 +26,52 @@ type TrayAgentActivity = { elapsed: string; }; +type TrayAgentActivityState = TrayAgentActivity & { + agentPubkey: string; +}; + type TrayAction = | { kind: "newChannel" } | { kind: "openChannel"; channelId: string }; const MAX_RECENT_TRAY_ACTIVITIES = 5; +export function resolveTrayAgentName({ + knownAgentName, + profile, + pubkey, +}: { + knownAgentName?: string; + profile?: Pick; + pubkey: string; +}): string { + return ( + profile?.displayName?.trim() || + profile?.name?.trim() || + knownAgentName?.trim() || + `Agent ${truncatePubkey(pubkey)}` + ); +} + +export function resolveTrayActivities({ + activities, + knownAgentNames, + profiles, +}: { + activities: TrayAgentActivityState[]; + knownAgentNames: Map; + profiles?: UserProfileLookup; +}): TrayAgentActivity[] { + return activities.map(({ agentPubkey, ...activity }) => ({ + ...activity, + agentName: resolveTrayAgentName({ + knownAgentName: knownAgentNames.get(normalizePubkey(agentPubkey)), + profile: profiles?.[normalizePubkey(agentPubkey)], + pubkey: agentPubkey, + }), + })); +} + /** * Keeps Buzz's native tray menu synchronized with active agent turns and * forwards its navigation actions into the React app. @@ -47,20 +90,40 @@ export function useTrayMenu({ const managedAgents = useManagedAgentsQuery().data; const relayAgents = useRelayAgentsQuery().data; const previousActivitiesRef = React.useRef( - new Map(), + new Map(), ); const [recentActivities, setRecentActivities] = React.useState< - TrayAgentActivity[] + TrayAgentActivityState[] >([]); + const activityAgentPubkeys = React.useMemo( + () => [ + ...new Set( + [ + ...activeTurns.flatMap((turn) => turn.agentPubkeys), + ...recentActivities.map((activity) => activity.agentPubkey), + ].map((pubkey) => normalizePubkey(pubkey)), + ), + ], + [activeTurns, recentActivities], + ); + const profiles = useUsersBatchQuery(activityAgentPubkeys, { + enabled: activityAgentPubkeys.length > 0, + }).data?.profiles; + const knownAgentNames = React.useMemo( + () => + new Map( + [...(managedAgents ?? []), ...(relayAgents ?? [])].map((agent) => [ + normalizePubkey(agent.pubkey), + agent.name, + ]), + ), + [managedAgents, relayAgents], + ); - const activities = React.useMemo(() => { + const activities = React.useMemo(() => { const channelNames = new Map( channels.map((channel) => [channel.id, channel.name]), ); - const agentNames = new Map(); - for (const agent of [...(managedAgents ?? []), ...(relayAgents ?? [])]) { - agentNames.set(normalizePubkey(agent.pubkey), agent.name); - } return activeTurns.flatMap((channelTurn) => channelTurn.agentPubkeys.map((pubkey) => { @@ -70,9 +133,12 @@ export function useTrayMenu({ return { activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`, - agentName: - agentNames.get(normalizePubkey(pubkey)) ?? - `Agent ${truncatePubkey(pubkey)}`, + agentPubkey: pubkey, + agentName: resolveTrayAgentName({ + knownAgentName: knownAgentNames.get(normalizePubkey(pubkey)), + profile: profiles?.[normalizePubkey(pubkey)], + pubkey, + }), channelId: channelTurn.channelId, channelName: channelNames.get(channelTurn.channelId) ?? "Unknown channel", @@ -82,7 +148,7 @@ export function useTrayMenu({ }; }), ); - }, [activeTurns, channels, managedAgents, now, relayAgents]); + }, [activeTurns, channels, knownAgentNames, now, profiles]); React.useEffect(() => { const currentActivities = new Map( @@ -109,12 +175,20 @@ export function useTrayMenu({ React.useEffect(() => { if (!isTauri()) return; void invoke("update_tray_agent_activity", { - activities, - recentActivities, + activities: resolveTrayActivities({ + activities, + knownAgentNames, + profiles, + }), + recentActivities: resolveTrayActivities({ + activities: recentActivities, + knownAgentNames, + profiles, + }), }).catch((error) => { console.error("Failed to update the macOS tray menu", error); }); - }, [activities, recentActivities]); + }, [activities, knownAgentNames, profiles, recentActivities]); React.useEffect(() => { if (!isTauri()) return; diff --git a/desktop/src/features/agents/useAgentObserverIngestion.test.mjs b/desktop/src/features/agents/useAgentObserverIngestion.test.mjs index 4a20be9be3..8f653eaa14 100644 --- a/desktop/src/features/agents/useAgentObserverIngestion.test.mjs +++ b/desktop/src/features/agents/useAgentObserverIngestion.test.mjs @@ -1,7 +1,16 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { combineObserverIngestionAgents } from "./useAgentObserverIngestion.ts"; +import { + combineObserverIngestionAgents, + projectObserverIngestionAgents, +} from "./useAgentObserverIngestion.ts"; +import { useUsersBatchQuery } from "@/features/profile/hooks.ts"; +import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx"; const ME = "aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234"; const OTHER = @@ -86,3 +95,132 @@ describe("combineObserverIngestionAgents", () => { assert.deepEqual(result, [{ pubkey: AGENT_LOCAL, status: "running" }]); }); }); + +describe("projectObserverIngestionAgents", () => { + it("includes an owned agent profile from channel membership when listRelayAgents omits it", () => { + const result = projectObserverIngestionAgents( + [], + [], + [AGENT_REMOTE], + { + [AGENT_REMOTE]: { + isAgent: true, + ownerPubkey: ME, + }, + }, + ME, + ); + + assert.deepEqual(result, [{ pubkey: AGENT_REMOTE, status: "deployed" }]); + }); + + it("keeps an owned agent when combined profile candidates exceed the relay query cap", async () => { + const dom = new JSDOM("", { + url: "http://localhost", + }); + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const previousLocalStorage = globalThis.localStorage; + const previousNavigatorDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ); + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.localStorage = dom.window.localStorage; + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + configurable: true, + }); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + + const ownedAgent = "f".repeat(64); + const channelMembers = Array.from({ length: 1_001 }, (_, index) => + index.toString(16).padStart(64, "0"), + ); + channelMembers.push(ownedAgent); + const relayAgents = [ownedAgent.toUpperCase()]; + const requestedBatches = []; + + dom.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + assert.equal(command, "get_users_batch"); + requestedBatches.push(args.pubkeys); + const visiblePubkeys = args.pubkeys.slice(0, 1_000); + const profiles = {}; + if (visiblePubkeys.includes(ownedAgent)) { + profiles[ownedAgent] = { + display_name: "Owned agent", + avatar_url: null, + nip05_handle: null, + owner_pubkey: ME, + is_agent: true, + }; + } + return Promise.resolve({ + profiles, + missing: args.pubkeys.filter((pubkey) => !(pubkey in profiles)), + }); + }, + transformCallback: () => 1, + }; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + let latestQuery; + function Probe() { + latestQuery = useUsersBatchQuery([...relayAgents, ...channelMembers]); + return null; + } + + const root = createRoot(dom.window.document.createElement("div")); + try { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Probe), + ), + ), + ); + }); + for (let index = 0; index < 10 && latestQuery?.isFetching; index += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + const result = projectObserverIngestionAgents( + [], + relayAgents, + channelMembers, + latestQuery?.data?.profiles, + ME, + ); + assert.deepEqual(result, [{ pubkey: ownedAgent, status: "deployed" }]); + assert.ok(requestedBatches.length > 1); + assert.ok(requestedBatches.every((batch) => batch.length <= 1_000)); + } finally { + await act(async () => root.unmount()); + queryClient.clear(); + dom.window.close(); + globalThis.window = previousWindow; + globalThis.document = previousDocument; + globalThis.localStorage = previousLocalStorage; + if (previousNavigatorDescriptor) { + Object.defineProperty( + globalThis, + "navigator", + previousNavigatorDescriptor, + ); + } else { + delete globalThis.navigator; + } + } + }); +}); diff --git a/desktop/src/features/agents/useAgentObserverIngestion.ts b/desktop/src/features/agents/useAgentObserverIngestion.ts index 386b762142..2d8e25ef09 100644 --- a/desktop/src/features/agents/useAgentObserverIngestion.ts +++ b/desktop/src/features/agents/useAgentObserverIngestion.ts @@ -6,12 +6,18 @@ import { useRelayAgentsQuery, } from "@/features/agents/hooks"; import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; +import { useChannelsQuery } from "@/features/channels/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { isVerifiedOwnedAgentProfile } from "@/features/profile/lib/identity"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; type IngestionAgent = Pick; +type ObserverProfileSummary = { + isAgent?: boolean; + ownerPubkey?: string | null; +}; /** * Combine locally managed agents with relay agents the current identity @@ -55,6 +61,50 @@ export function combineObserverIngestionAgents( return [...managed, ...owned]; } +/** + * Build the owner-global observer projection from both relay-published agent + * profiles and agent profiles discovered through channel membership. + * + * External agents do not necessarily publish the relay-agent descriptor read + * by `listRelayAgents`. Channel membership supplies their candidate pubkeys; + * the users-batch profile remains the authority for both `isAgent` and NIP-OA + * ownership before the candidate reaches the observer trust gate. + */ +export function projectObserverIngestionAgents( + managedAgents: readonly IngestionAgent[], + relayAgentPubkeys: readonly string[], + channelMemberPubkeys: readonly string[], + profiles: Readonly> | undefined, + currentPubkey: string | null | undefined, +): IngestionAgent[] { + const candidateByPubkey = new Map(); + for (const pubkey of relayAgentPubkeys) { + candidateByPubkey.set(normalizePubkey(pubkey), pubkey); + } + + const channelMembers = new Set(channelMemberPubkeys.map(normalizePubkey)); + const ownerByPubkey = new Map(); + for (const [pubkey, profile] of Object.entries(profiles ?? {})) { + const normalizedPubkey = normalizePubkey(pubkey); + if ( + channelMembers.has(normalizedPubkey) && + isVerifiedOwnedAgentProfile(profile, currentPubkey) + ) { + candidateByPubkey.set(normalizedPubkey, pubkey); + } + if (profile.ownerPubkey) { + ownerByPubkey.set(normalizedPubkey, normalizePubkey(profile.ownerPubkey)); + } + } + + return combineObserverIngestionAgents( + managedAgents, + [...candidateByPubkey.values()], + ownerByPubkey, + currentPubkey, + ); +} + /** * App-level owner-global observer ingestion. * @@ -86,30 +136,41 @@ export function useAgentObserverIngestion() { [relayAgentsQuery.data], ); - const profilesQuery = useUsersBatchQuery(relayAgentPubkeys, { - enabled: Boolean(currentPubkey) && relayAgentPubkeys.length > 0, + const channelsQuery = useChannelsQuery(); + const channelMemberPubkeys = React.useMemo( + () => [ + ...new Set( + (channelsQuery.data ?? []).flatMap((channel) => channel.memberPubkeys), + ), + ], + [channelsQuery.data], + ); + + const profileCandidatePubkeys = React.useMemo( + () => [...new Set([...relayAgentPubkeys, ...channelMemberPubkeys])], + [channelMemberPubkeys, relayAgentPubkeys], + ); + + const profilesQuery = useUsersBatchQuery(profileCandidatePubkeys, { + enabled: Boolean(currentPubkey) && profileCandidatePubkeys.length > 0, }); const profiles = profilesQuery.data?.profiles; const ingestionAgents = React.useMemo(() => { - const ownerByPubkey = new Map(); - for (const [pubkey, summary] of Object.entries(profiles ?? {})) { - if (summary.ownerPubkey) { - // Store both key and value normalized so lookups and ownership - // comparisons never depend on the casing the relay happened to send. - ownerByPubkey.set( - normalizePubkey(pubkey), - normalizePubkey(summary.ownerPubkey), - ); - } - } - return combineObserverIngestionAgents( + return projectObserverIngestionAgents( managedAgents ?? [], relayAgentPubkeys, - ownerByPubkey, + channelMemberPubkeys, + profiles, currentPubkey, ); - }, [currentPubkey, managedAgents, profiles, relayAgentPubkeys]); + }, [ + channelMemberPubkeys, + currentPubkey, + managedAgents, + profiles, + relayAgentPubkeys, + ]); useManagedAgentObserverBridge(ingestionAgents); useActiveAgentTurnsBridge(ingestionAgents); diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 7a456fb259..f559e0b1c6 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -291,6 +291,8 @@ type UsersBatchEntry = { fetchedAt: number; }; +const USERS_BATCH_RELAY_QUERY_LIMIT = 1_000; + const usersBatchEntryKey = (pubkey: string) => ["users-batch-entry", pubkey]; /** @@ -356,18 +358,28 @@ export function useUsersBatchQuery( } } if (toFetch.length > 0) { - const fresh = await getUsersBatch(toFetch); - if (relayUrl) { - writeCachedUserLabels(relayUrl, fresh.profiles, fresh.missing); - } - for (const pubkey of toFetch) { - const summary = fresh.profiles[pubkey] ?? null; - queryClient.setQueryData( - usersBatchEntryKey(pubkey), - { summary, fetchedAt: now }, + for ( + let offset = 0; + offset < toFetch.length; + offset += USERS_BATCH_RELAY_QUERY_LIMIT + ) { + const batch = toFetch.slice( + offset, + offset + USERS_BATCH_RELAY_QUERY_LIMIT, ); - if (summary) profiles[pubkey] = summary; - else missing.push(pubkey); + const fresh = await getUsersBatch(batch); + if (relayUrl) { + writeCachedUserLabels(relayUrl, fresh.profiles, fresh.missing); + } + for (const pubkey of batch) { + const summary = fresh.profiles[pubkey] ?? null; + queryClient.setQueryData( + usersBatchEntryKey(pubkey), + { summary, fetchedAt: now }, + ); + if (summary) profiles[pubkey] = summary; + else missing.push(pubkey); + } } } return { profiles, missing }; diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index d2e0a4fdd3..84161d886a 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -148,6 +148,25 @@ export function ownsAuthorAgent( ); } +/** + * Returns true only for a users-batch profile securely classified as an agent + * owned by the current Desktop identity. + */ +export function isVerifiedOwnedAgentProfile( + profile: + | Partial> + | null + | undefined, + currentPubkey: string | null | undefined, +): boolean { + return ( + profile?.isAgent === true && + !!profile.ownerPubkey && + !!currentPubkey && + normalizePubkey(profile.ownerPubkey) === normalizePubkey(currentPubkey) + ); +} + export function resolveUserSecondaryLabel(input: { pubkey: string; profiles?: UserProfileLookup; diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index cb188dd008..fce2d68b27 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -76,7 +76,6 @@ import { UserProfilePersonaDialogs, } from "@/features/profile/ui/UserProfilePersonaDialogs"; import { - deriveProfileChannels, type ProfilePanelTab, type ProfilePanelView, resolveAgentInstruction, @@ -84,6 +83,7 @@ import { resolveProfileDisplayName, truncatePubkey, type UserProfilePanelProps, + useDerivedProfileChannels, useRetainedPersona, } from "@/features/profile/ui/UserProfilePanelUtils"; import { useProfileDmAction } from "@/features/profile/ui/useProfileDmAction"; @@ -287,15 +287,14 @@ export function UserProfilePanel({ const relayAgent = relayAgentsQuery.data?.find( (agent) => agent.pubkey.toLowerCase() === pubkeyLower, ); + const profileSummary = usersBatchQuery.data?.profiles[pubkeyLower]; const managedAgentLogQuery = useManagedAgentLogQuery( (view === "diagnostics" || view === "logs") && managedAgent?.backend.type === "local" ? managedAgent.pubkey : null, ); - const isAgentByOaOwner = Boolean( - usersBatchQuery.data?.profiles[pubkeyLower]?.isAgent, - ); + const isAgentByOaOwner = Boolean(profileSummary?.isAgent); const isBot = Boolean(relayAgent || managedAgent || resolvedPersona) || isAgentByOaOwner; const managedAgentOwner = useIsManagedAgent(isBot ? effectivePubkey : null); @@ -367,15 +366,13 @@ export function UserProfilePanel({ ) ?? false); - const profileChannels = React.useMemo( - () => - deriveProfileChannels( - pubkeyLower, - relayAgent, - managedAgent, - channelsQuery.data, - ), - [pubkeyLower, relayAgent, managedAgent, channelsQuery.data], + const profileChannels = useDerivedProfileChannels( + pubkeyLower, + relayAgent, + managedAgent, + channelsQuery.data, + profileSummary, + currentPubkey, ); const channelIdToName = React.useMemo(() => { diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 89837f6017..deeb05ef56 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + deriveProfileChannels, parseProfilePanelTab, parseProfilePanelView, personaManagedAgentUpdate, @@ -9,6 +10,9 @@ import { profilePanelViewFromSearch, } from "./UserProfilePanelUtils.ts"; +const OWNER_PUBKEY = "a".repeat(64); +const REMOTE_AGENT_PUBKEY = "b".repeat(64); + function agent(overrides = {}) { return { pubkey: "deadbeef".repeat(8), @@ -82,6 +86,53 @@ function runtime(overrides = {}) { }; } +test("deriveProfileChannels includes authoritative membership for a verified remote-owned agent", () => { + const channel = { + id: "channel-1", + name: "Activity acceptance", + memberPubkeys: [REMOTE_AGENT_PUBKEY], + }; + const profile = { + isAgent: true, + ownerPubkey: OWNER_PUBKEY, + }; + + assert.deepEqual( + deriveProfileChannels( + REMOTE_AGENT_PUBKEY, + undefined, + undefined, + [channel], + profile, + OWNER_PUBKEY, + ), + [{ id: channel.id, name: channel.name }], + ); + + assert.deepEqual( + deriveProfileChannels( + REMOTE_AGENT_PUBKEY, + undefined, + undefined, + [channel], + { ...profile, isAgent: false }, + OWNER_PUBKEY, + ), + [], + ); + assert.deepEqual( + deriveProfileChannels( + REMOTE_AGENT_PUBKEY, + undefined, + undefined, + [channel], + profile, + "c".repeat(64), + ), + [], + ); +}); + test("personaManagedAgentUpdate syncs edited persona identity to linked agent", () => { assert.deepEqual(personaManagedAgentUpdate(agent(), persona()), { pubkey: "deadbeef".repeat(8), diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4..7857eb5be1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -7,7 +7,9 @@ import type { Profile, RelayAgent, UpdateManagedAgentInput, + UserProfileSummary, } from "@/shared/api/types"; +import { isVerifiedOwnedAgentProfile } from "@/features/profile/lib/identity"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; export { truncatePubkey }; @@ -118,6 +120,8 @@ export function deriveProfileChannels( relayAgent: RelayAgent | undefined, managedAgent: ManagedAgent | undefined, channels: Channel[] | undefined, + profileSummary?: Pick, + currentPubkey?: string, ): ProfileChannelLink[] { const links = new Map(); const channelsByName = new Map( @@ -130,7 +134,10 @@ export function deriveProfileChannels( links.set(id, { id, name }); }); - if (managedAgent && channels) { + const useAuthoritativeMembership = + managedAgent !== undefined || + isVerifiedOwnedAgentProfile(profileSummary, currentPubkey); + if (useAuthoritativeMembership && channels) { for (const channel of channels) { const isMember = channel.memberPubkeys.some( (memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower, @@ -146,6 +153,35 @@ export function deriveProfileChannels( ); } +export function useDerivedProfileChannels( + pubkeyLower: string, + relayAgent: RelayAgent | undefined, + managedAgent: ManagedAgent | undefined, + channels: Channel[] | undefined, + profileSummary?: Pick, + currentPubkey?: string, +): ProfileChannelLink[] { + return React.useMemo( + () => + deriveProfileChannels( + pubkeyLower, + relayAgent, + managedAgent, + channels, + profileSummary, + currentPubkey, + ), + [ + pubkeyLower, + relayAgent, + managedAgent, + channels, + profileSummary, + currentPubkey, + ], + ); +} + export function getRelayAgentChannelIds( relayAgents: readonly RelayAgent[] | undefined, agentPubkey: string, diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index eefdef1fdd..b7a3aef53b 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1070,6 +1070,46 @@ test("declared owner sees runtime tab without a relay-agent record", async ({ ).toHaveCount(0); }); +test("remote-owned Hermes profile lists its authoritative channel", async ({ + page, +}) => { + const remoteAgentPubkey = + "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00"; + await installMockBridge(page, { + searchProfiles: [ + { + pubkey: remoteAgentPubkey, + displayName: "Hermes", + isAgent: true, + ownerPubkey: "deadbeef".repeat(8), + }, + ], + }); + await page.goto("/"); + + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + const messageRow = page.getByTestId("message-row").filter({ + has: page.getByText("Indexing remotely for my owner."), + }); + await expect(messageRow.first()).toBeVisible({ timeout: 5_000 }); + await messageRow.first().getByRole("button").first().click(); + + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + await expect(panel.getByRole("heading", { name: "Hermes" })).toBeVisible(); + await panel.getByTestId("user-profile-tab-channels").click(); + + const channelList = panel.getByTestId("user-profile-channels-list"); + await expect(channelList).toContainText("#agents"); + await expect(channelList).not.toContainText("#general"); + await panel.screenshot({ + animations: "disabled", + path: "test-results/remote-owned-agent/profile-channels-hermes.png", + }); +}); + test("owned agent absent from relay/managed lists still renders agent framing", async ({ page, }) => {