diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 6aa397edb..213cefa19 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -128,6 +128,16 @@ function observerTag(event: RelayEvent, tagName: string) { return event.tags.find((tag) => tag[0] === tagName)?.[1] ?? null; } +function pillDiagBridgeAgents( + agents: readonly Pick[], +) { + return agents.map((agent) => ({ + pubkey: normalizePubkey(agent.pubkey), + status: agent.status, + startsObserver: agent.status === "running" || agent.status === "deployed", + })); +} + function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { const key = normalizePubkey(agentPubkey); const current = eventsByAgent.get(key) ?? []; @@ -390,6 +400,13 @@ export function useManagedAgentObserverBridge( [agents], ); + console.log("[pill-diag] observer bridge render", { + subscriptionId, + hasActiveAgent, + agents: pillDiagBridgeAgents(agents), + at: new Date().toISOString(), + }); + const agentPubkeys = React.useMemo( () => agents.map((agent) => agent.pubkey), [agents], diff --git a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.test.mjs b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.test.mjs index 5f022df53..cbfc3718d 100644 --- a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.test.mjs +++ b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.test.mjs @@ -1,10 +1,19 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { resolveActiveWorkingChannelNames } from "./useActiveWorkingChannelsById.ts"; +import { + getOwnedRelayWorkingAgents, + mergeWorkingAgents, + resolveActiveWorkingChannelNames, +} from "./useActiveWorkingChannelsById.ts"; + +const VIEWER_PUBKEY = + "80c5f18be5aafa62cf6198c6335963ba3306b595288117c8ea2f805fc9bdc94a"; +const OWNED_RELAY_AGENT_PUBKEY = + "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00"; describe("resolveActiveWorkingChannelNames", () => { - it("resolves active agent pubkeys to managed agent names", () => { + it("resolves active agent pubkeys to working agent names", () => { const resolved = resolveActiveWorkingChannelNames( { channelId: "chan-1", @@ -34,4 +43,126 @@ describe("resolveActiveWorkingChannelNames", () => { assert.deepEqual(resolved.agentNames, ["Ned"]); }); + + it("resolves owned relay agent names", () => { + const resolved = resolveActiveWorkingChannelNames( + { + channelId: "chan-1", + anchorAt: 0, + agentCount: 1, + agentPubkeys: [OWNED_RELAY_AGENT_PUBKEY.toUpperCase()], + }, + [{ pubkey: OWNED_RELAY_AGENT_PUBKEY, name: "nadia" }], + ); + + assert.deepEqual(resolved.agentNames, ["nadia"]); + }); +}); + +describe("getOwnedRelayWorkingAgents", () => { + it("keeps relay agents whose NIP-OA owner is the current viewer", () => { + assert.deepEqual( + getOwnedRelayWorkingAgents( + [ + { pubkey: OWNED_RELAY_AGENT_PUBKEY, name: "nadia" }, + { pubkey: "other-agent", name: "nelson" }, + ], + { + [OWNED_RELAY_AGENT_PUBKEY]: { + displayName: "nadia", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: VIEWER_PUBKEY.toUpperCase(), + isAgent: true, + }, + "other-agent": { + displayName: "nelson", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: "someone-else", + isAgent: true, + }, + }, + VIEWER_PUBKEY, + ), + [{ pubkey: OWNED_RELAY_AGENT_PUBKEY, name: "nadia", status: "deployed" }], + ); + }); + + it("returns no relay agents without a current viewer", () => { + assert.deepEqual( + getOwnedRelayWorkingAgents( + [{ pubkey: OWNED_RELAY_AGENT_PUBKEY, name: "nadia" }], + {}, + undefined, + ), + [], + ); + }); + + it("drops relay agents with missing profiles or null owners", () => { + assert.deepEqual( + getOwnedRelayWorkingAgents( + [ + { pubkey: OWNED_RELAY_AGENT_PUBKEY, name: "nadia" }, + { pubkey: "ownerless-agent", name: "ralph" }, + ], + { + "ownerless-agent": { + displayName: "ralph", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }, + }, + VIEWER_PUBKEY, + ), + [], + ); + }); +}); + +describe("mergeWorkingAgents", () => { + it("keeps active managed agents ahead of owned relay duplicates", () => { + assert.deepEqual( + mergeWorkingAgents( + [{ pubkey: "AAAA", name: "Ned", status: "running" }], + [ + { pubkey: "aaaa", name: "Relay Ned", status: "deployed" }, + { + pubkey: OWNED_RELAY_AGENT_PUBKEY, + name: "nadia", + status: "deployed", + }, + ], + ), + [ + { pubkey: "AAAA", name: "Ned", status: "running" }, + { pubkey: OWNED_RELAY_AGENT_PUBKEY, name: "nadia", status: "deployed" }, + ], + ); + }); + + it("uses owned relay status when a duplicate managed agent is stopped", () => { + assert.deepEqual( + mergeWorkingAgents( + [ + { + pubkey: OWNED_RELAY_AGENT_PUBKEY.toUpperCase(), + name: "Local Nadia", + status: "stopped", + }, + ], + [ + { + pubkey: OWNED_RELAY_AGENT_PUBKEY, + name: "nadia", + status: "deployed", + }, + ], + ), + [{ pubkey: OWNED_RELAY_AGENT_PUBKEY, name: "nadia", status: "deployed" }], + ); + }); }); diff --git a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts index 03f245f64..81aa5b323 100644 --- a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts +++ b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts @@ -5,16 +5,36 @@ import { useActiveAgentTurnsBridge, useActiveAgentTurnsByChannel, } from "@/features/agents/activeAgentTurnsStore"; -import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { + ManagedAgent, + RelayAgent, + UserProfileSummary, +} from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +type WorkingAgentName = Pick; +type WorkingAgent = Pick; +type PillDiagProfile = UserProfileSummary & { + pubkey?: string; +}; +type OwnedRelayWorkingAgent = Pick & { + status: "deployed"; +}; + export function resolveActiveWorkingChannelNames( summary: ActiveChannelTurnSummary, - managedAgents: readonly { pubkey: string; name: string }[], + workingAgents: readonly WorkingAgentName[], ): ActiveChannelTurnSummary { const namesByPubkey = new Map( - managedAgents.map((agent) => [normalizePubkey(agent.pubkey), agent.name]), + workingAgents.map((agent) => [normalizePubkey(agent.pubkey), agent.name]), ); return { @@ -26,31 +46,201 @@ export function resolveActiveWorkingChannelNames( }; } +export function getOwnedRelayWorkingAgents( + relayAgents: readonly Pick[], + profiles: Record | undefined, + currentPubkey: string | undefined, +): OwnedRelayWorkingAgent[] { + if (!currentPubkey) return []; + + return relayAgents.flatMap((agent) => { + const profile = profiles?.[normalizePubkey(agent.pubkey)]; + if (!ownsAuthorAgent(profile, currentPubkey)) { + return []; + } + + return [{ pubkey: agent.pubkey, name: agent.name, status: "deployed" }]; + }); +} + +function agentCanStartObserver(agent: WorkingAgent) { + return agent.status === "running" || agent.status === "deployed"; +} + +export function mergeWorkingAgents( + managedAgents: readonly WorkingAgent[], + ownedRelayAgents: readonly WorkingAgent[], +): WorkingAgent[] { + const mergedByPubkey = new Map(); + + for (const agent of managedAgents) { + mergedByPubkey.set(normalizePubkey(agent.pubkey), agent); + } + + for (const agent of ownedRelayAgents) { + const pubkey = normalizePubkey(agent.pubkey); + const managedAgent = mergedByPubkey.get(pubkey); + if (!managedAgent || !agentCanStartObserver(managedAgent)) { + mergedByPubkey.set(pubkey, agent); + } + } + + return [...mergedByPubkey.values()]; +} + +function summarizeAgent(agent: WorkingAgentName & { status?: string }) { + return { + pubkey: normalizePubkey(agent.pubkey), + name: agent.name, + status: agent.status ?? null, + }; +} + +function summarizeRelayProfile( + pubkey: string, + profile: PillDiagProfile | undefined, + currentPubkey: string | undefined, +) { + return { + pubkey: normalizePubkey(pubkey), + profileKeyPubkey: profile?.pubkey ? normalizePubkey(profile.pubkey) : null, + displayName: profile?.displayName ?? null, + isAgent: profile?.isAgent ?? null, + ownerPubkey: profile?.ownerPubkey + ? normalizePubkey(profile.ownerPubkey) + : null, + ownsAuthorAgent: ownsAuthorAgent(profile, currentPubkey), + }; +} + +function logPillDiagnostics({ + currentPubkey, + managedAgents, + relayAgents, + profiles, + ownedRelayAgents, + workingAgents, + activeWorkingChannels, +}: { + currentPubkey: string | undefined; + managedAgents: readonly WorkingAgent[]; + relayAgents: readonly Pick[]; + profiles: Record | undefined; + ownedRelayAgents: readonly WorkingAgent[]; + workingAgents: readonly WorkingAgent[]; + activeWorkingChannels: readonly ActiveChannelTurnSummary[]; +}) { + const normalizedProfiles = Object.fromEntries( + relayAgents.map((agent) => { + const pubkey = normalizePubkey(agent.pubkey); + const profile = profiles?.[pubkey] as PillDiagProfile | undefined; + return [ + pubkey, + summarizeRelayProfile(agent.pubkey, profile, currentPubkey), + ]; + }), + ); + + console.groupCollapsed("[pill-diag] active working channels inputs", { + currentPubkey: currentPubkey ? normalizePubkey(currentPubkey) : null, + managedAgentCount: managedAgents.length, + relayAgentCount: relayAgents.length, + ownedRelayAgentCount: ownedRelayAgents.length, + workingAgentCount: workingAgents.length, + activeChannelCount: activeWorkingChannels.length, + }); + console.log("[pill-diag] currentPubkey", { + currentPubkey: currentPubkey ? normalizePubkey(currentPubkey) : null, + }); + console.table(managedAgents.map(summarizeAgent)); + console.log("[pill-diag] managedAgents", managedAgents.map(summarizeAgent)); + console.table(relayAgents.map(summarizeAgent)); + console.log("[pill-diag] relayAgents", relayAgents.map(summarizeAgent)); + console.log("[pill-diag] profilesByRelayAgent", normalizedProfiles); + console.table(ownedRelayAgents.map(summarizeAgent)); + console.log( + "[pill-diag] ownedRelayAgents", + ownedRelayAgents.map(summarizeAgent), + ); + console.table(workingAgents.map(summarizeAgent)); + console.log("[pill-diag] workingAgents", workingAgents.map(summarizeAgent)); + console.log("[pill-diag] activeWorkingChannels", activeWorkingChannels); + console.groupEnd(); +} + export function useActiveWorkingChannelsById(): ReadonlyMap< string, ActiveChannelTurnSummary > { + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; const managedAgentsQuery = useManagedAgentsQuery(); const managedAgents = React.useMemo( () => managedAgentsQuery.data ?? [], [managedAgentsQuery.data], ); + const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgents = React.useMemo( + () => relayAgentsQuery.data ?? [], + [relayAgentsQuery.data], + ); + const relayAgentPubkeys = React.useMemo( + () => relayAgents.map((agent) => agent.pubkey), + [relayAgents], + ); + const relayAgentProfilesQuery = useUsersBatchQuery(relayAgentPubkeys, { + enabled: relayAgentPubkeys.length > 0, + }); + const ownedRelayAgents = React.useMemo( + () => + getOwnedRelayWorkingAgents( + relayAgents, + relayAgentProfilesQuery.data?.profiles, + currentPubkey, + ), + [currentPubkey, relayAgentProfilesQuery.data?.profiles, relayAgents], + ); + const workingAgents = React.useMemo( + () => mergeWorkingAgents(managedAgents, ownedRelayAgents), + [managedAgents, ownedRelayAgents], + ); - useManagedAgentObserverBridge(managedAgents); - useActiveAgentTurnsBridge(managedAgents); + useManagedAgentObserverBridge(workingAgents); + useActiveAgentTurnsBridge(workingAgents); const activeWorkingChannels = useActiveAgentTurnsByChannel(); + + React.useEffect(() => { + logPillDiagnostics({ + currentPubkey, + managedAgents, + relayAgents, + profiles: relayAgentProfilesQuery.data?.profiles, + ownedRelayAgents, + workingAgents, + activeWorkingChannels, + }); + }, [ + activeWorkingChannels, + currentPubkey, + managedAgents, + ownedRelayAgents, + relayAgentProfilesQuery.data?.profiles, + relayAgents, + workingAgents, + ]); + return React.useMemo( () => new Map( activeWorkingChannels.map((summary) => { const resolvedSummary = resolveActiveWorkingChannelNames( summary, - managedAgents, + workingAgents, ); return [resolvedSummary.channelId, resolvedSummary]; }), ), - [activeWorkingChannels, managedAgents], + [activeWorkingChannels, workingAgents], ); }