From d125a761d28f91743344bb1e9dabdbcf7978f20d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Schr=C3=B6dinger=E2=80=99s=20Cat?= <62413+cmyk@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:20:49 +0200 Subject: [PATCH 01/10] fix(desktop): ingest remote-owned channel agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Schrödinger’s Cat <62413+cmyk@users.noreply.github.com> (cherry picked from commit 0aa87f1ff884526993be5e92fb4f2890f8fcf336) Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- .../agents/useAgentObserverIngestion.test.mjs | 24 ++++- .../agents/useAgentObserverIngestion.ts | 93 +++++++++++++++---- desktop/src/features/profile/lib/identity.ts | 19 ++++ .../features/profile/ui/UserProfilePanel.tsx | 16 +++- .../profile/ui/UserProfilePanelUtils.test.mjs | 51 ++++++++++ .../profile/ui/UserProfilePanelUtils.ts | 9 +- 6 files changed, 190 insertions(+), 22 deletions(-) diff --git a/desktop/src/features/agents/useAgentObserverIngestion.test.mjs b/desktop/src/features/agents/useAgentObserverIngestion.test.mjs index 4a20be9be3c..a966c08eb76 100644 --- a/desktop/src/features/agents/useAgentObserverIngestion.test.mjs +++ b/desktop/src/features/agents/useAgentObserverIngestion.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { combineObserverIngestionAgents } from "./useAgentObserverIngestion.ts"; +import { + combineObserverIngestionAgents, + projectObserverIngestionAgents, +} from "./useAgentObserverIngestion.ts"; const ME = "aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234"; const OTHER = @@ -86,3 +89,22 @@ 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" }]); + }); +}); diff --git a/desktop/src/features/agents/useAgentObserverIngestion.ts b/desktop/src/features/agents/useAgentObserverIngestion.ts index 386b7621427..2d8e25ef090 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/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index d2e0a4fdd38..84161d886ad 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 3fae4da266a..e2281de338b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -282,15 +282,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); @@ -362,8 +361,17 @@ export function UserProfilePanel({ relayAgent, managedAgent, channelsQuery.data, + profileSummary, + currentPubkey, ), - [pubkeyLower, relayAgent, managedAgent, channelsQuery.data], + [ + 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 c3ff723375c..dc4de03745c 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, @@ -10,6 +11,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), @@ -83,6 +87,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 16999816c36..4beb90af7c6 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 }; @@ -124,6 +126,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( @@ -136,7 +140,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, From 690c8da7e04cc424de46a8ffb32f9fee1d757d6f Mon Sep 17 00:00:00 2001 From: Reinhold <310554180+reinhold-ph@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:21:50 +0000 Subject: [PATCH 02/10] fix(desktop): chunk relay profile lookups Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> (cherry picked from commit faf16b9213d8c9cc27b7f9696a27cfaabccdb857) Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- .../agents/useAgentObserverIngestion.test.mjs | 116 ++++++++++++++++++ desktop/src/features/profile/hooks.ts | 34 +++-- 2 files changed, 139 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/agents/useAgentObserverIngestion.test.mjs b/desktop/src/features/agents/useAgentObserverIngestion.test.mjs index a966c08eb76..8f653eaa140 100644 --- a/desktop/src/features/agents/useAgentObserverIngestion.test.mjs +++ b/desktop/src/features/agents/useAgentObserverIngestion.test.mjs @@ -1,10 +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, projectObserverIngestionAgents, } from "./useAgentObserverIngestion.ts"; +import { useUsersBatchQuery } from "@/features/profile/hooks.ts"; +import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx"; const ME = "aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234"; const OTHER = @@ -107,4 +113,114 @@ describe("projectObserverIngestionAgents", () => { 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/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 7a456fb2591..f559e0b1c60 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 }; From 849a52eeec4530a70cc9d3d2881df0d3d4a83b13 Mon Sep 17 00:00:00 2001 From: Reinhold <310554180+reinhold-ph@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:49:40 +0000 Subject: [PATCH 03/10] refactor(desktop): extract profile channel memo Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> (cherry picked from commit 4055c027bcbc798124ae26b3849a4b02992ef7ec) Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- .../features/profile/ui/UserProfilePanel.tsx | 27 +++++------------ .../profile/ui/UserProfilePanelUtils.ts | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index e2281de338b..b3241f3a951 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -70,7 +70,6 @@ import { UserProfilePersonaDialogs, } from "@/features/profile/ui/UserProfilePersonaDialogs"; import { - deriveProfileChannels, type ProfilePanelTab, type ProfilePanelView, profilePanelTargetKey, @@ -79,6 +78,7 @@ import { resolveProfileDisplayName, truncatePubkey, type UserProfilePanelProps, + useDerivedProfileChannels, useRetainedPersona, } from "@/features/profile/ui/UserProfilePanelUtils"; import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions"; @@ -354,24 +354,13 @@ export function UserProfilePanel({ ) ?? false); - const profileChannels = React.useMemo( - () => - deriveProfileChannels( - pubkeyLower, - relayAgent, - managedAgent, - channelsQuery.data, - profileSummary, - currentPubkey, - ), - [ - pubkeyLower, - relayAgent, - managedAgent, - channelsQuery.data, - profileSummary, - currentPubkey, - ], + const profileChannels = useDerivedProfileChannels( + pubkeyLower, + relayAgent, + managedAgent, + channelsQuery.data, + profileSummary, + currentPubkey, ); const channelIdToName = React.useMemo(() => { diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 4beb90af7c6..3125dac7517 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -159,6 +159,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, From c97dc94e6314eecf9a57708b34972b272aa87df6 Mon Sep 17 00:00:00 2001 From: Reinhold <310554180+reinhold-ph@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:05:50 +0000 Subject: [PATCH 04/10] fix(desktop): hydrate remote agent tray labels Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> (cherry picked from commit 47b06088dfd83b286cba3417cda070feceb8b268) Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- desktop/src/app/useTrayMenu.test.mjs | 51 ++++++++++++++ desktop/src/app/useTrayMenu.ts | 102 +++++++++++++++++++++++---- 2 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 desktop/src/app/useTrayMenu.test.mjs diff --git a/desktop/src/app/useTrayMenu.test.mjs b/desktop/src/app/useTrayMenu.test.mjs new file mode 100644 index 00000000000..a5e6701b089 --- /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 355c8e5d4f2..eea0f28bed9 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; From 921b3623a0da1467b2a8f44e0547b3d33ae08274 Mon Sep 17 00:00:00 2001 From: Reinhold <310554180+reinhold-ph@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:15:14 +0000 Subject: [PATCH 05/10] test(desktop): verify remote agent channel profile Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> (cherry picked from commit a847f38886e9db0829470847ab593a39ddb87d33) Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- desktop/tests/e2e/profile.spec.ts | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 9f17f9e500b..9ed34c0aa93 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -2070,6 +2070,46 @@ test("non-owner agent profile shows only reported public agent data", 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, }) => { From ee345bbc82abab5ed015ac1fd595871a23fdfebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Schr=C3=B6dinger=E2=80=99s=20Cat?= <62413+cmyk@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:42:33 +0200 Subject: [PATCH 06/10] fix(desktop): show observer activity under thread composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Schrödinger’s Cat <62413+cmyk@users.noreply.github.com> (cherry picked from commit e03db9cdb48d4c993bd257a6f5018eb9d4ee74ad) Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- .../channels/ui/ChannelPane.activity.test.mjs | 25 +++++++++++++++++++ .../src/features/channels/ui/ChannelPane.tsx | 13 ++++++++-- .../channels/ui/threadComposerActivity.ts | 12 +++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelPane.activity.test.mjs create mode 100644 desktop/src/features/channels/ui/threadComposerActivity.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.activity.test.mjs b/desktop/src/features/channels/ui/ChannelPane.activity.test.mjs new file mode 100644 index 00000000000..26e044085a9 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.activity.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { composeThreadActivityPubkeys } from "./threadComposerActivity.ts"; + +test("observer-only channel activity reaches the open thread composer", () => { + const observerAgent = "ABCDEF"; + + const channelComposerPubkeys = [observerAgent]; + const matchingThreadTypingPubkeys = []; + const threadComposerPubkeys = composeThreadActivityPubkeys( + channelComposerPubkeys, + matchingThreadTypingPubkeys, + ); + + assert.deepEqual(threadComposerPubkeys, [observerAgent]); + assert.deepEqual(threadComposerPubkeys, channelComposerPubkeys); +}); + +test("thread activity unions matching typing without case-insensitive duplicates", () => { + assert.deepEqual( + composeThreadActivityPubkeys(["ABCDEF"], ["abcdef", "123456"]), + ["ABCDEF", "123456"], + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 2acf4fe29b6..9242fe926a2 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -44,6 +44,7 @@ import { WelcomeComposerGuidanceLayer, } from "@/features/channels/ui/WelcomeComposerBanner"; import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; +import { composeThreadActivityPubkeys } from "@/features/channels/ui/threadComposerActivity"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; @@ -363,8 +364,16 @@ export const ChannelPane = React.memo(function ChannelPane({ ) === index, ); }, [botTypingEntries, openThreadHeadId]); + const threadComposerWorkingBotPubkeys = React.useMemo( + () => + composeThreadActivityPubkeys( + composerWorkingBotPubkeys, + threadComposerBotTypingPubkeys, + ), + [composerWorkingBotPubkeys, threadComposerBotTypingPubkeys], + ); const hasThreadComposerBotActivity = - threadComposerBotTypingPubkeys.length > 0; + threadComposerWorkingBotPubkeys.length > 0; const directMessageIntro = React.useMemo( () => buildDirectMessageIntro({ @@ -832,7 +841,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onOpenAgentSession={onOpenAgentSession} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} - workingBotPubkeys={threadComposerBotTypingPubkeys} + workingBotPubkeys={threadComposerWorkingBotPubkeys} variant="inline" /> ) : null diff --git a/desktop/src/features/channels/ui/threadComposerActivity.ts b/desktop/src/features/channels/ui/threadComposerActivity.ts new file mode 100644 index 00000000000..3d2191dc43e --- /dev/null +++ b/desktop/src/features/channels/ui/threadComposerActivity.ts @@ -0,0 +1,12 @@ +export function composeThreadActivityPubkeys( + channelWorkingPubkeys: readonly string[], + threadTypingPubkeys: readonly string[], +): string[] { + const seen = new Set(); + return [...channelWorkingPubkeys, ...threadTypingPubkeys].filter((pubkey) => { + const normalized = pubkey.toLowerCase(); + if (seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); +} From 550deb4633cc874c9f294c7aa936a7ebf6898875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Schr=C3=B6dinger=E2=80=99s=20Cat?= <62413+cmyk@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:44:38 +0200 Subject: [PATCH 07/10] fix(desktop): show external agent activity in composers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Schrödinger’s Cat <62413+cmyk@users.noreply.github.com> (cherry picked from commit 1eb220c39092b2ef46990529ae0fa77552cfee52) Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- .../channels/ui/ChannelPane.activity.test.mjs | 22 ++++++++++++++++- .../src/features/channels/ui/ChannelPane.tsx | 18 +++++++++++--- .../channels/ui/threadComposerActivity.ts | 24 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.activity.test.mjs b/desktop/src/features/channels/ui/ChannelPane.activity.test.mjs index 26e044085a9..11e1a95c76c 100644 --- a/desktop/src/features/channels/ui/ChannelPane.activity.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.activity.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { composeThreadActivityPubkeys } from "./threadComposerActivity.ts"; +import { + completeActivityAgentRoster, + composeThreadActivityPubkeys, +} from "./threadComposerActivity.ts"; test("observer-only channel activity reaches the open thread composer", () => { const observerAgent = "ABCDEF"; @@ -23,3 +26,20 @@ test("thread activity unions matching typing without case-insensitive duplicates ["ABCDEF", "123456"], ); }); + +test("externally owned working profiles complete the Activity display roster", () => { + assert.deepEqual( + completeActivityAgentRoster( + [{ pubkey: "LOCAL", name: "Local agent" }], + ["EXTERNAL", "local"], + { + external: { displayName: "Habeler" }, + local: { displayName: "Ignored duplicate" }, + }, + ), + [ + { pubkey: "LOCAL", name: "Local agent" }, + { pubkey: "EXTERNAL", name: "Habeler" }, + ], + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 9242fe926a2..ddcf6ff7be4 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -44,7 +44,10 @@ import { WelcomeComposerGuidanceLayer, } from "@/features/channels/ui/WelcomeComposerBanner"; import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; -import { composeThreadActivityPubkeys } from "@/features/channels/ui/threadComposerActivity"; +import { + completeActivityAgentRoster, + composeThreadActivityPubkeys, +} from "@/features/channels/ui/threadComposerActivity"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; @@ -349,6 +352,15 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel?.id ?? null, ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; + const composerActivityAgents = React.useMemo( + () => + completeActivityAgentRoster( + activityAgents, + composerWorkingBotPubkeys, + profiles ?? {}, + ), + [activityAgents, composerWorkingBotPubkeys, profiles], + ); const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; @@ -734,7 +746,7 @@ export const ChannelPane = React.memo(function ChannelPane({ overlay height or move the conversation. Its natural content height remains responsive. */} >, +): ActivityAgent[] { + const completed = [...agents]; + const known = new Set(agents.map((agent) => agent.pubkey.toLowerCase())); + + for (const pubkey of workingPubkeys) { + const normalized = pubkey.toLowerCase(); + if (known.has(normalized)) continue; + known.add(normalized); + completed.push({ + pubkey, + name: profiles[normalized]?.displayName || `${pubkey.slice(0, 8)}…`, + }); + } + + return completed; +} + export function composeThreadActivityPubkeys( channelWorkingPubkeys: readonly string[], threadTypingPubkeys: readonly string[], From 1f32c43f9d01f84ff02f192492095640761b6146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Schr=C3=B6dinger=E2=80=99s=20Cat?= <62413+cmyk@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:37:25 +0200 Subject: [PATCH 08/10] fixup! fix(desktop): show external agent activity in composers Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- desktop/src/features/channels/ui/threadComposerActivity.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/channels/ui/threadComposerActivity.ts b/desktop/src/features/channels/ui/threadComposerActivity.ts index 4d22a279a90..4e8cac32b11 100644 --- a/desktop/src/features/channels/ui/threadComposerActivity.ts +++ b/desktop/src/features/channels/ui/threadComposerActivity.ts @@ -1,3 +1,5 @@ +import { truncatePubkey } from "@/shared/lib/pubkey"; + type ActivityAgent = { pubkey: string; name: string }; type ActivityProfile = { displayName?: string | null }; @@ -15,7 +17,7 @@ export function completeActivityAgentRoster( known.add(normalized); completed.push({ pubkey, - name: profiles[normalized]?.displayName || `${pubkey.slice(0, 8)}…`, + name: profiles[normalized]?.displayName || truncatePubkey(pubkey), }); } From f4adb42fafc7df96f9bd440431eb240346e1dccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Schr=C3=B6dinger=E2=80=99s=20Cat?= <62413+cmyk@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:36:11 +0200 Subject: [PATCH 09/10] fix(desktop): replay trusted observer startup frames Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- .../agents/activeAgentTurnsStore.test.mjs | 48 +++ .../ingestArchivedObserverEvents.test.mjs | 249 ++++++++++++- .../src/features/agents/observerRelayStore.ts | 328 ++++++++++++++++-- .../agents/useAgentObserverIngestion.ts | 11 +- 4 files changed, 596 insertions(+), 40 deletions(-) diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index 44e43525b6d..e9d0f848dd2 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -19,6 +19,7 @@ import { getAgentTranscript, subscribeAgentObserverStore, subscribeAgentManagementRequests, + syncAgentObserverEvents, resetAgentObserverStore, _testProcessLiveObserverEvents, } from "./observerRelayStore.ts"; @@ -1719,6 +1720,34 @@ describe("observer → active-turns bridge sync", () => { assert.equal(observerNotifications, 1); }); + it("does not re-dispatch management callbacks for duplicate replay", () => { + const managementFrame = makeEvent({ + seq: 2, + kind: "acp_message", + timestamp: "2024-01-01T00:00:01Z", + payload: { + type: "agent_management_request", + action: "create", + requestId: "request-duplicate", + request: { + channelId: "chan-1", + displayName: "Fleet Observer", + systemPrompt: "Observe the fleet.", + }, + }, + }); + let callbacks = 0; + const unsubscribe = subscribeAgentManagementRequests(() => { + callbacks += 1; + }); + + _testProcessLiveObserverEvents(AGENT, [managementFrame]); + _testProcessLiveObserverEvents(AGENT, [managementFrame]); + unsubscribe(); + + assert.equal(callbacks, 1); + }); + it("does not publish when a replay batch is entirely duplicate", () => { const events = [makeEvent({ seq: 1, kind: "turn_started" })]; injectObserverEventsForE2E(AGENT, events); @@ -1734,6 +1763,25 @@ describe("observer → active-turns bridge sync", () => { assert.equal(getAgentObserverSnapshot(AGENT, true).events.length, 1); }); + it("does not publish duplicate decoded replay through the sync bridge", () => { + const events = [ + makeEvent({ + seq: 41, + timestamp: "2024-01-01T00:00:41Z", + kind: "turn_started", + }), + ]; + syncAgentObserverEvents(AGENT, events); + let observerNotifications = 0; + const unsubscribeObserver = subscribeAgentObserverStore(() => { + observerNotifications += 1; + }); + syncAgentObserverEvents(AGENT, events); + unsubscribeObserver(); + + assert.equal(observerNotifications, 0); + }); + it("skips agents that are neither running nor deployed", () => { injectObserverEventsForE2E(AGENT, [ makeEvent({ seq: 1, kind: "turn_started" }), diff --git a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs index 343ce241335..c8e54032dfd 100644 --- a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs +++ b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs @@ -16,8 +16,15 @@ import { injectObserverEventsForE2E, getAgentObserverSnapshot, resetAgentObserverStore, - _testRegisterKnownAgents, + subscribeAgentManagementRequests, _testGetArchivedChannelEvents, + _testHandleRelayObserverEvent, + _testPendingUnknownAgentFrameCount, + _testPendingUnknownAgentFrameBytes, + _testPendingUnknownAgentPubkeys, + _testRegisterAgentResolution, + _testRegisterKnownAgents, + _testResolveKnownAgents, } from "@/features/agents/observerRelayStore.ts"; // ── Constants ───────────────────────────────────────────────────────────────── @@ -1186,4 +1193,244 @@ describe("raw-event-level merge: stateful aggregates across live/archive boundar assert.equal(archived[0].kind, "batch"); assert.equal(archived[0].seq, 31); }); + + it("buffers a remote live frame until later owned-profile trust hydration", async () => { + _testRegisterKnownAgents("local-first", [OTHER_PUBKEY]); + + const frame = makeRawEvent({ created_at: 2 }); + await _testHandleRelayObserverEvent( + frame, + makeDecrypt(makeObserverEvent()), + 1_000, + ); + + assert.equal( + _testPendingUnknownAgentFrameCount(), + 1, + "a remote turn frame received before its owned profile hydrates must wait for the later trust expansion", + ); + + await _testResolveKnownAgents( + "remote", + [AGENT_PUBKEY], + [AGENT_PUBKEY], + 1_001, + ); + assert.equal(_testPendingUnknownAgentFrameCount(), 0); + assert.equal(getAgentObserverSnapshot(AGENT_PUBKEY, true).events.length, 1); + }); + + it("replays an encrypted batch envelope after trust hydration", async () => { + _testRegisterKnownAgents("local-first", [OTHER_PUBKEY]); + const batch = makeObserverEvent({ + kind: "batch", + payload: { + events: [ + makeObserverEvent({ seq: 2, timestamp: "2026-01-01T00:00:02.000Z" }), + makeObserverEvent({ seq: 1, timestamp: "2026-01-01T00:00:01.000Z" }), + ], + }, + }); + await _testHandleRelayObserverEvent( + makeRawEvent(), + makeDecrypt(batch), + 1_000, + ); + await _testResolveKnownAgents( + "remote", + [AGENT_PUBKEY], + [AGENT_PUBKEY], + 1_001, + ); + assert.deepEqual( + getAgentObserverSnapshot(AGENT_PUBKEY, true).events.map( + (event) => event.seq, + ), + [1, 2], + ); + }); + + it("discards frames for a profile resolved as foreign", async () => { + _testRegisterKnownAgents("local-first", [OTHER_PUBKEY]); + await _testHandleRelayObserverEvent( + makeRawEvent(), + makeDecrypt(makeObserverEvent()), + 1_000, + ); + await _testResolveKnownAgents("remote", [], [AGENT_PUBKEY], 1_001); + assert.equal(_testPendingUnknownAgentFrameCount(), 0); + assert.equal(getAgentObserverSnapshot(AGENT_PUBKEY, true).events.length, 0); + }); + + it("bounds pending frames by count and bytes", async () => { + _testRegisterKnownAgents("local-first", [OTHER_PUBKEY]); + for (let index = 0; index < 140; index += 1) { + await _testHandleRelayObserverEvent( + makeRawEvent({ + id: index.toString(16).padStart(64, "0"), + content: "x".repeat(20_000), + }), + makeDecrypt(makeObserverEvent()), + 1_000 + index, + ); + } + assert.ok(_testPendingUnknownAgentFrameCount() <= 100); + assert.ok(_testPendingUnknownAgentFrameBytes() <= 1_000_000); + }); + + it("expires unresolved frames before trust hydration", async () => { + _testRegisterKnownAgents("local-first", [OTHER_PUBKEY]); + await _testHandleRelayObserverEvent( + makeRawEvent(), + makeDecrypt(makeObserverEvent()), + 1_000, + ); + await _testResolveKnownAgents( + "remote", + [AGENT_PUBKEY], + [AGENT_PUBKEY], + 62_001, + ); + assert.equal(_testPendingUnknownAgentFrameCount(), 0); + assert.equal(getAgentObserverSnapshot(AGENT_PUBKEY, true).events.length, 0); + }); + + it("replays only the unresolved pubkey that becomes trusted", async () => { + const secondAgent = "c".repeat(64); + _testRegisterKnownAgents("local-first", [OTHER_PUBKEY]); + await _testHandleRelayObserverEvent( + makeRawEvent(), + makeDecrypt(makeObserverEvent()), + 1_000, + ); + await _testHandleRelayObserverEvent( + makeRawEvent({ + pubkey: secondAgent, + tags: [ + ["p", OTHER_PUBKEY], + ["agent", secondAgent], + ["frame", "telemetry"], + ], + }), + makeDecrypt(makeObserverEvent({ seq: 2 })), + 1_001, + ); + await _testResolveKnownAgents( + "remote", + [AGENT_PUBKEY], + [AGENT_PUBKEY], + 1_002, + ); + assert.deepEqual(_testPendingUnknownAgentPubkeys(), [secondAgent]); + assert.equal(getAgentObserverSnapshot(AGENT_PUBKEY, true).events.length, 1); + assert.equal(getAgentObserverSnapshot(secondAgent, true).events.length, 0); + }); + + it("does not replay a pending frame twice after repeated hydration", async () => { + _testRegisterKnownAgents("local-first", [OTHER_PUBKEY]); + await _testHandleRelayObserverEvent( + makeRawEvent(), + makeDecrypt(makeObserverEvent()), + 1_000, + ); + await _testResolveKnownAgents( + "remote", + [AGENT_PUBKEY], + [AGENT_PUBKEY], + 1_001, + ); + await _testResolveKnownAgents( + "remote", + [AGENT_PUBKEY], + [AGENT_PUBKEY], + 1_002, + ); + assert.equal(getAgentObserverSnapshot(AGENT_PUBKEY, true).events.length, 1); + }); + + it("one subscriber cannot discard another subscriber's unresolved candidate", async () => { + await _testRegisterAgentResolution( + "still-loading", + [], + [AGENT_PUBKEY], + [], + 1_000, + ); + await _testHandleRelayObserverEvent( + makeRawEvent(), + makeDecrypt(makeObserverEvent()), + 1_000, + ); + + await _testRegisterAgentResolution( + "already-loaded", + [], + [AGENT_PUBKEY], + [AGENT_PUBKEY], + 1_001, + ); + assert.deepEqual(_testPendingUnknownAgentPubkeys(), [AGENT_PUBKEY]); + + await _testRegisterAgentResolution( + "still-loading", + [AGENT_PUBKEY], + [AGENT_PUBKEY], + [AGENT_PUBKEY], + 1_002, + ); + assert.equal(getAgentObserverSnapshot(AGENT_PUBKEY, true).events.length, 1); + }); + + it("does not reprocess a live envelope after its event leaves retained state", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + let callbacks = 0; + const unsubscribe = subscribeAgentManagementRequests(() => { + callbacks += 1; + }); + const management = makeObserverEvent({ + kind: "acp_message", + payload: { + type: "agent_management_request", + action: "create", + requestId: "old-envelope", + request: { + channelId: "chan-1", + displayName: "Old Envelope", + systemPrompt: "Do not replay.", + }, + }, + }); + const raw = makeRawEvent({ id: "1".repeat(64) }); + await _testHandleRelayObserverEvent(raw, makeDecrypt(management), 1_000); + + injectObserverEventsForE2E( + AGENT_PUBKEY, + Array.from({ length: 3_001 }, (_, index) => + makeObserverEvent({ + seq: index + 2, + timestamp: new Date(1_800_000_000_000 + index).toISOString(), + }), + ), + ); + assert.equal( + getAgentObserverSnapshot(AGENT_PUBKEY, true).events.some( + (event) => event.seq === management.seq, + ), + false, + ); + + await _testHandleRelayObserverEvent(raw, makeDecrypt(management), 2_000); + unsubscribe(); + assert.equal(callbacks, 1); + }); + + it("never queues a sender that does not match the claimed agent", async () => { + _testRegisterKnownAgents("local-first", [OTHER_PUBKEY]); + await _testHandleRelayObserverEvent( + makeRawEvent({ pubkey: OTHER_PUBKEY }), + makeDecrypt(makeObserverEvent()), + 1_000, + ); + assert.equal(_testPendingUnknownAgentFrameCount(), 0); + }); }); diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index a5495d33e0d..e98e87c1994 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -37,6 +37,11 @@ const MAX_OBSERVER_EVENTS = 3000; // ever made per-agent, where a fixed headroom could exceed a smaller cap. const OBSERVER_EVENTS_LOW_WATER = Math.floor(MAX_OBSERVER_EVENTS * 0.9); const MAX_PENDING_UNKNOWN_AGENT_FRAMES = 100; +const MAX_PENDING_UNKNOWN_AGENT_FRAMES_PER_PUBKEY = 25; +const MAX_PENDING_UNKNOWN_AGENT_FRAME_BYTES = 1_000_000; +const PENDING_UNKNOWN_AGENT_FRAME_TTL_MS = 60_000; +const PROCESSED_LIVE_ENVELOPE_TTL_MS = 10 * 60_000; +const MAX_PROCESSED_LIVE_ENVELOPES = 2_000; export type ObserverSnapshot = { connectionState: ConnectionState; @@ -138,7 +143,19 @@ const agentManagementListeners = new Set< // recompute the union, so co-mounted callers no longer clobber each other. const knownAgentPubkeys = new Set(); const knownAgentsBySubscription = new Map>(); -const pendingUnknownAgentFrames: RelayEvent[] = []; +const candidateAgentsBySubscription = new Map>(); +const resolvedAgentsBySubscription = new Map>(); +type ObserverDecryptor = (event: RelayEvent) => Promise; +type PendingUnknownAgentFrame = { + event: RelayEvent; + queuedAt: number; + byteLength: number; + decrypt: ObserverDecryptor; +}; +const pendingUnknownAgentFrames = new Map(); +let pendingUnknownAgentFrameCount = 0; +let pendingUnknownAgentFrameBytes = 0; +const processedLiveEnvelopeIds = new Map(); // Callback invoked when session_config_captured is received, so React Query // can invalidate the config-surface query for the affected agent. Wired up @@ -154,33 +171,182 @@ export function setSessionConfigCapturedCallback( function recomputeKnownAgentPubkeys() { knownAgentPubkeys.clear(); for (const subscriptionAgents of knownAgentsBySubscription.values()) { - for (const pubkey of subscriptionAgents) { - knownAgentPubkeys.add(pubkey); + for (const pubkey of subscriptionAgents) knownAgentPubkeys.add(pubkey); + } +} + +function unresolvedAgentPubkeys(): Set { + const unresolved = new Set(); + for (const [subscriptionId, candidates] of candidateAgentsBySubscription) { + const resolved = + resolvedAgentsBySubscription.get(subscriptionId) ?? new Set(); + for (const pubkey of candidates) { + if (!resolved.has(pubkey)) unresolved.add(pubkey); + } + } + return unresolved; +} + +function definitivelyForeignAgentPubkeys(): Set { + const unresolved = unresolvedAgentPubkeys(); + const foreign = new Set(); + for (const resolved of resolvedAgentsBySubscription.values()) { + for (const pubkey of resolved) { + if (!knownAgentPubkeys.has(pubkey) && !unresolved.has(pubkey)) + foreign.add(pubkey); + } + } + return foreign; +} + +function removePendingFrame(pubkey: string, index: number) { + const frames = pendingUnknownAgentFrames.get(pubkey); + if (!frames) return; + const [removed] = frames.splice(index, 1); + if (removed) { + pendingUnknownAgentFrameCount -= 1; + pendingUnknownAgentFrameBytes -= removed.byteLength; + } + if (frames.length === 0) pendingUnknownAgentFrames.delete(pubkey); +} + +function pruneExpiredPendingFrames(now: number) { + for (const [pubkey, frames] of pendingUnknownAgentFrames) { + for (let index = frames.length - 1; index >= 0; index -= 1) { + if (now - frames[index].queuedAt > PENDING_UNKNOWN_AGENT_FRAME_TTL_MS) { + removePendingFrame(pubkey, index); + } + } + } +} + +function oldestPendingFrame(): { + pubkey: string; + index: number; + queuedAt: number; +} | null { + let oldest: { pubkey: string; index: number; queuedAt: number } | null = null; + for (const [pubkey, frames] of pendingUnknownAgentFrames) { + for (let index = 0; index < frames.length; index += 1) { + const queuedAt = frames[index].queuedAt; + if (!oldest || queuedAt < oldest.queuedAt) + oldest = { pubkey, index, queuedAt }; } } + return oldest; +} + +function queuePendingFrame( + pubkey: string, + event: RelayEvent, + decrypt: ObserverDecryptor, + now: number, +) { + pruneExpiredPendingFrames(now); + const byteLength = new TextEncoder().encode(JSON.stringify(event)).byteLength; + if (byteLength > MAX_PENDING_UNKNOWN_AGENT_FRAME_BYTES) return; + const frames = pendingUnknownAgentFrames.get(pubkey) ?? []; + if (frames.some((pending) => pending.event.id === event.id)) return; + frames.push({ event, queuedAt: now, byteLength, decrypt }); + pendingUnknownAgentFrames.set(pubkey, frames); + pendingUnknownAgentFrameCount += 1; + pendingUnknownAgentFrameBytes += byteLength; + while (frames.length > MAX_PENDING_UNKNOWN_AGENT_FRAMES_PER_PUBKEY) { + removePendingFrame(pubkey, 0); + } + while ( + pendingUnknownAgentFrameCount > MAX_PENDING_UNKNOWN_AGENT_FRAMES || + pendingUnknownAgentFrameBytes > MAX_PENDING_UNKNOWN_AGENT_FRAME_BYTES + ) { + const oldest = oldestPendingFrame(); + if (!oldest) break; + removePendingFrame(oldest.pubkey, oldest.index); + } +} + +function takePendingFrames(pubkey: string, now: number) { + pruneExpiredPendingFrames(now); + const frames = pendingUnknownAgentFrames.get(pubkey) ?? []; + pendingUnknownAgentFrames.delete(pubkey); + for (const frame of frames) { + pendingUnknownAgentFrameCount -= 1; + pendingUnknownAgentFrameBytes -= frame.byteLength; + } + return frames.sort( + (left, right) => + left.event.created_at - right.event.created_at || + left.event.id.localeCompare(right.event.id), + ); +} + +function discardPendingFrames(pubkey: string, now: number) { + takePendingFrames(pubkey, now); +} + +function hasProcessedLiveEnvelope(eventId: string, now: number): boolean { + for (const [id, processedAt] of processedLiveEnvelopeIds) { + if (now - processedAt > PROCESSED_LIVE_ENVELOPE_TTL_MS) { + processedLiveEnvelopeIds.delete(id); + } + } + return processedLiveEnvelopeIds.has(eventId); +} + +function markLiveEnvelopeProcessed(eventId: string, now: number) { + processedLiveEnvelopeIds.set(eventId, now); + while (processedLiveEnvelopeIds.size > MAX_PROCESSED_LIVE_ENVELOPES) { + const oldest = processedLiveEnvelopeIds.keys().next().value; + if (typeof oldest !== "string") break; + processedLiveEnvelopeIds.delete(oldest); + } } function registerKnownAgents( subscriptionId: string, pubkeys: readonly string[], + candidatePubkeys: readonly string[] = pubkeys, + resolvedPubkeys: readonly string[] = candidatePubkeys, + now = Date.now(), ) { + const previouslyKnown = new Set(knownAgentPubkeys); knownAgentsBySubscription.set( subscriptionId, new Set(pubkeys.map((pubkey) => normalizePubkey(pubkey))), ); + candidateAgentsBySubscription.set( + subscriptionId, + new Set(candidatePubkeys.map((pubkey) => normalizePubkey(pubkey))), + ); + resolvedAgentsBySubscription.set( + subscriptionId, + new Set(resolvedPubkeys.map((pubkey) => normalizePubkey(pubkey))), + ); recomputeKnownAgentPubkeys(); - if (knownAgentPubkeys.size > 0 && pendingUnknownAgentFrames.length > 0) { - const pending = pendingUnknownAgentFrames.splice(0); - for (const event of pending) { + pruneExpiredPendingFrames(now); + for (const pubkey of knownAgentPubkeys) { + if (previouslyKnown.has(pubkey)) continue; + for (const pending of takePendingFrames(pubkey, now)) { eventProcessingQueue = eventProcessingQueue.then(() => - handleRelayObserverEvent(event, generation), + handleRelayObserverEvent( + pending.event, + generation, + pending.decrypt, + now, + ), ); } } + for (const pubkey of definitivelyForeignAgentPubkeys()) { + discardPendingFrames(pubkey, now); + } } function unregisterKnownAgents(subscriptionId: string) { - if (knownAgentsBySubscription.delete(subscriptionId)) { + const knownChanged = knownAgentsBySubscription.delete(subscriptionId); + const candidatesChanged = + candidateAgentsBySubscription.delete(subscriptionId); + const resolvedChanged = resolvedAgentsBySubscription.delete(subscriptionId); + if (knownChanged || candidatesChanged || resolvedChanged) { recomputeKnownAgentPubkeys(); } } @@ -219,8 +385,8 @@ function observerTag(event: RelayEvent, tagName: string) { function appendAgentEvents( agentPubkey: string, events: readonly ObserverEvent[], -): boolean { - if (events.length === 0) return false; +): ObserverEvent[] { + if (events.length === 0) return []; const key = normalizePubkey(agentPubkey); const current = eventsByAgent.get(key) ?? []; @@ -248,7 +414,7 @@ function appendAgentEvents( seen.add(eventKey); added.push(event); } - if (added.length === 0) return false; + if (added.length === 0) return []; const sortedAdded = added.sort(compareObserverEvents); const sorted = [...current, ...sortedAdded].sort(compareObserverEvents); @@ -289,11 +455,11 @@ function appendAgentEvents( } invalidateSnapshot(key); - return true; + return sortedAdded; } function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { - if (appendAgentEvents(agentPubkey, [event])) { + if (appendAgentEvents(agentPubkey, [event]).length > 0) { notifyListeners(); } } @@ -435,9 +601,12 @@ function processLiveObserverEvents( // callbacks. Those callbacks historically observed their triggering frame // in the raw/transcript stores; batching must preserve that visibility while // deferring only the global external-store publication. - const observerChanged = appendAgentEvents(agentPubkey, events); + const addedEvents = appendAgentEvents(agentPubkey, events); - for (const parsed of events) { + // Dispatch specialized callbacks only for events newly admitted to retained + // state. Relay reconnects and pending-frame replay may deliver the same + // authenticated envelope again; deduplication must cover side effects too. + for (const parsed of addedEvents) { // Track the latest-live-session-id per (agent, channel) on the live path. // Only set when the parsed event carries both a sessionId and channelId, // so we never attribute a session to the wrong channel. @@ -479,7 +648,7 @@ function processLiveObserverEvents( // Preserve the harness's envelope backpressure: retained state was committed // before specialized callbacks, but external-store subscribers publish once. - if (observerChanged) { + if (addedEvents.length > 0) { notifyListeners(); } } @@ -487,6 +656,8 @@ function processLiveObserverEvents( async function handleRelayObserverEvent( event: RelayEvent, activeGeneration: number, + decrypt: ObserverDecryptor = decryptObserverEvent, + now = Date.now(), ) { const agentPubkey = observerTag(event, "agent"); const frame = observerTag(event, "frame"); @@ -494,30 +665,27 @@ async function handleRelayObserverEvent( return; } - // Ownership data arrives asynchronously during startup. Buffer raw signed - // frames until the first trusted-agent set is registered, then re-run this - // same gate. Once initialized, unknown agents are rejected immediately. - if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) { - if (knownAgentsBySubscription.size === 0 || knownAgentPubkeys.size === 0) { - pendingUnknownAgentFrames.push(event); - if (pendingUnknownAgentFrames.length > MAX_PENDING_UNKNOWN_AGENT_FRAMES) { - pendingUnknownAgentFrames.shift(); - } - } + const normalizedAgentPubkey = normalizePubkey(agentPubkey); + // Reject spoofed envelopes before either retaining or decrypting them. + if (normalizePubkey(event.pubkey) !== normalizedAgentPubkey) { return; } - // Defense-in-depth: verify the event sender matches the claimed agent pubkey. - // The relay gates on is_agent_owner, but a compromised relay could misroute. - if (normalizePubkey(event.pubkey) !== normalizePubkey(agentPubkey)) { + if (!knownAgentPubkeys.has(normalizedAgentPubkey)) { + if (!definitivelyForeignAgentPubkeys().has(normalizedAgentPubkey)) { + queuePendingFrame(normalizedAgentPubkey, event, decrypt, now); + } return; } + if (hasProcessedLiveEnvelope(event.id, now)) return; + try { - const parsed = (await decryptObserverEvent(event)) as ObserverEvent; + const parsed = (await decrypt(event)) as ObserverEvent; if (activeGeneration !== generation) { return; } + markLiveEnvelopeProcessed(event.id, now); processLiveObserverEvents(agentPubkey, unwrapObserverBatch(parsed)); } catch (error) { if (activeGeneration !== generation) { @@ -707,6 +875,8 @@ export function shouldObserveManagedAgents( export function useManagedAgentObserverBridge( agents: readonly Pick[], + candidatePubkeys: readonly string[] = agents.map((agent) => agent.pubkey), + resolvedPubkeys: readonly string[] = candidatePubkeys, ) { const subscriptionId = React.useId(); const hasManagedAgent = shouldObserveManagedAgents(agents); @@ -716,15 +886,35 @@ export function useManagedAgentObserverBridge( [agents], ); - // Keep this subscriber's slice of the trusted-pubkey set in sync with its - // own agent list. The store recomputes the union across all subscribers, so - // a co-mounted caller no longer wipes out this caller's agents. + const candidatePubkeyKey = candidatePubkeys.join(","); + const normalizedCandidatePubkeys = React.useMemo( + () => candidatePubkeyKey.split(",").filter(Boolean), + [candidatePubkeyKey], + ); + const resolvedPubkeyKey = resolvedPubkeys.join(","); + const normalizedResolvedPubkeys = React.useMemo( + () => resolvedPubkeyKey.split(",").filter(Boolean), + [resolvedPubkeyKey], + ); + + // Keep this subscriber's trusted and fully-resolved candidate sets in sync. + // A resolved candidate absent from agentPubkeys is definitively foreign. React.useEffect(() => { - registerKnownAgents(subscriptionId, agentPubkeys); + registerKnownAgents( + subscriptionId, + agentPubkeys, + normalizedCandidatePubkeys, + normalizedResolvedPubkeys, + ); return () => { unregisterKnownAgents(subscriptionId); }; - }, [subscriptionId, agentPubkeys]); + }, [ + subscriptionId, + agentPubkeys, + normalizedCandidatePubkeys, + normalizedResolvedPubkeys, + ]); React.useEffect(() => { if (!hasManagedAgent) { @@ -827,7 +1017,7 @@ export function injectObserverEventsForE2E( agentPubkey: string, events: ObserverEvent[], ) { - if (appendAgentEvents(agentPubkey, events)) { + if (appendAgentEvents(agentPubkey, events).length > 0) { notifyListeners(); } } @@ -840,7 +1030,7 @@ export function syncAgentObserverEvents( agentPubkey: string, events: ObserverEvent[], ) { - if (appendAgentEvents(agentPubkey, events)) { + if (appendAgentEvents(agentPubkey, events).length > 0) { notifyListeners(); } } @@ -858,7 +1048,12 @@ export function resetAgentObserverStore() { archiveEventsByChannel.clear(); knownAgentPubkeys.clear(); knownAgentsBySubscription.clear(); - pendingUnknownAgentFrames.length = 0; + candidateAgentsBySubscription.clear(); + resolvedAgentsBySubscription.clear(); + pendingUnknownAgentFrames.clear(); + pendingUnknownAgentFrameCount = 0; + pendingUnknownAgentFrameBytes = 0; + processedLiveEnvelopeIds.clear(); latestLiveSessionByAgentChannel.clear(); agentManagementListeners.clear(); onSessionConfigCaptured = null; @@ -880,6 +1075,63 @@ export function _testRegisterKnownAgents( registerKnownAgents(subscriptionId, pubkeys); } +/** Test-only: drive the real pre-decrypt live trust gate. */ +export async function _testHandleRelayObserverEvent( + event: RelayEvent, + decrypt: ObserverDecryptor = decryptObserverEvent, + now = Date.now(), +): Promise { + await handleRelayObserverEvent(event, generation, decrypt, now); +} + +/** Test-only: resolve a profile-candidate batch and drain the event queue. */ +export async function _testResolveKnownAgents( + subscriptionId: string, + trustedPubkeys: readonly string[], + resolvedPubkeys: readonly string[], + now = Date.now(), +): Promise { + registerKnownAgents( + subscriptionId, + trustedPubkeys, + resolvedPubkeys, + resolvedPubkeys, + now, + ); + await eventProcessingQueue; +} + +/** Test-only: model one subscriber's independent candidate/resolution slice. */ +export async function _testRegisterAgentResolution( + subscriptionId: string, + trustedPubkeys: readonly string[], + candidatePubkeys: readonly string[], + resolvedPubkeys: readonly string[], + now = Date.now(), +): Promise { + registerKnownAgents( + subscriptionId, + trustedPubkeys, + candidatePubkeys, + resolvedPubkeys, + now, + ); + await eventProcessingQueue; +} + +/** Test-only: inspect bounded startup retention. */ +export function _testPendingUnknownAgentFrameCount(): number { + return pendingUnknownAgentFrameCount; +} + +export function _testPendingUnknownAgentFrameBytes(): number { + return pendingUnknownAgentFrameBytes; +} + +export function _testPendingUnknownAgentPubkeys(): string[] { + return [...pendingUnknownAgentFrames.keys()].sort(); +} + /** Test-only: exercise live envelope ordering without relay/decryption setup. */ export function _testProcessLiveObserverEvents( agentPubkey: string, diff --git a/desktop/src/features/agents/useAgentObserverIngestion.ts b/desktop/src/features/agents/useAgentObserverIngestion.ts index 2d8e25ef090..9d9a3aa6ce4 100644 --- a/desktop/src/features/agents/useAgentObserverIngestion.ts +++ b/desktop/src/features/agents/useAgentObserverIngestion.ts @@ -172,6 +172,15 @@ export function useAgentObserverIngestion() { relayAgentPubkeys, ]); - useManagedAgentObserverBridge(ingestionAgents); + const resolvedProfileCandidatePubkeys = React.useMemo( + () => (profilesQuery.dataUpdatedAt > 0 ? profileCandidatePubkeys : []), + [profileCandidatePubkeys, profilesQuery.dataUpdatedAt], + ); + + useManagedAgentObserverBridge( + ingestionAgents, + profileCandidatePubkeys, + resolvedProfileCandidatePubkeys, + ); useActiveAgentTurnsBridge(ingestionAgents); } From aaed72cc104a4c063cd1699a8d8d13827a680905 Mon Sep 17 00:00:00 2001 From: Reinhold <310554180+reinhold-ph@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:38:04 +0000 Subject: [PATCH 10/10] fix(desktop): reconcile observer retention result type Signed-off-by: Reinhold <310554180+reinhold-ph@users.noreply.github.com> --- desktop/src/features/agents/observerRelayStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index e98e87c1994..af54052eec5 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -400,7 +400,7 @@ function appendAgentEvents( const admissible = floor ? events.filter((event) => isObserverEventAfter(event, floor)) : events; - if (admissible.length === 0) return false; + if (admissible.length === 0) return []; const seen = new Set( current.map(