From 371c6086bca3ee6e3a825db8e5858eec9201ca98 Mon Sep 17 00:00:00 2001 From: Codex Hall Date: Tue, 8 Sep 2026 00:04:56 +0200 Subject: [PATCH 1/2] fix(desktop): dedupe remote agent personas in mentions Signed-off-by: Codex Hall --- .../agent_discovery/relay_directory.rs | 3 + desktop/src-tauri/src/managed_agents/types.rs | 2 + .../src/nostr_convert/agent_directory.rs | 1 + .../src/nostr_convert/runtime_policy_tests.rs | 1 + desktop/src-tauri/src/nostr_convert/tests.rs | 1 + .../lib/buildMentionCandidates.test.mjs | 77 +++++++++++++++++++ .../messages/lib/buildMentionCandidates.ts | 27 ++++++- desktop/src/features/pulse/ui/PulseView.tsx | 1 + desktop/src/shared/api/tauri.ts | 2 + desktop/src/shared/api/tauriRelayAgents.ts | 2 + desktop/src/shared/api/types.ts | 1 + desktop/src/testing/e2eBridge.ts | 3 + 12 files changed, 120 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index 8f7493e1e8b..3aeb7296c8b 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -342,6 +342,7 @@ mod tests { RelayAgentInfo { pubkey: "a".repeat(64), owner_pubkey: Some(cross_owner.clone()), + persona_id: None, name: "Verified cross-owner".to_string(), agent_type: "agent".to_string(), channels: Vec::new(), @@ -354,6 +355,7 @@ mod tests { RelayAgentInfo { pubkey: "c".repeat(64), owner_pubkey: None, + persona_id: None, name: "Ownerless legacy".to_string(), agent_type: "agent".to_string(), channels: Vec::new(), @@ -380,6 +382,7 @@ mod tests { let mut agents = vec![RelayAgentInfo { pubkey: "a".repeat(64), owner_pubkey: None, + persona_id: None, name: "Ownerless legacy".to_string(), agent_type: "agent".to_string(), channels: Vec::new(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2620f0337fc..e999d80dbb9 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -215,6 +215,8 @@ pub struct RelayAgentInfo { pub pubkey: String, #[serde(default)] pub owner_pubkey: Option, + #[serde(default)] + pub persona_id: Option, pub name: String, pub agent_type: String, pub channels: Vec, diff --git a/desktop/src-tauri/src/nostr_convert/agent_directory.rs b/desktop/src-tauri/src/nostr_convert/agent_directory.rs index 1429efa4fa6..09e48cb17e4 100644 --- a/desktop/src-tauri/src/nostr_convert/agent_directory.rs +++ b/desktop/src-tauri/src/nostr_convert/agent_directory.rs @@ -159,6 +159,7 @@ fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option< Some(RelayAgentInfo { pubkey: agent_pubkey.to_string(), owner_pubkey: Some(event.pubkey.to_hex()), + persona_id: content.persona_id, name: content.name, agent_type: "agent".to_string(), channels: Vec::new(), diff --git a/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs b/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs index dd0dda7f11e..eb8db814362 100644 --- a/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs +++ b/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs @@ -53,6 +53,7 @@ fn assert_merge(directory: &[Event], profile: &Event, policy: &Event, status: &s assert_eq!(serde_json::to_value(agent).unwrap()["status"], status); assert_eq!(agent.pubkey, profile.pubkey.to_hex()); assert_eq!(agent.owner_pubkey, Some(policy.pubkey.to_hex())); + assert_eq!(agent.persona_id.as_deref(), Some("persona-1")); assert_eq!(agent.name, "Policy name"); assert_eq!( agent.respond_to, diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs index 68d8cb7dcbb..c878f78adb2 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -45,6 +45,7 @@ fn managed_agent_event( ) -> Event { let content = serde_json::json!({ "name": name, + "persona_id": "persona-1", "parallelism": 1, "respond_to": respond_to, "respond_to_allowlist": respond_to_allowlist, diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs index a89f51399cb..04f7df3ce09 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs @@ -110,6 +110,83 @@ test("active personas join unless a managed agent already carries them", () => { ); }); +test("an owned relay agent suppresses its mintable persona on a second desktop", () => { + const persona = { + id: "planner", + displayName: "Claude Hall", + avatarUrl: null, + isActive: true, + }; + const candidates = buildMentionCandidates( + input({ + activePersonas: [persona], + currentPubkey: MEMBER_PUBKEY, + memberPubkeys: new Set([AGENT_PUBKEY]), + members: [ + { + pubkey: AGENT_PUBKEY, + displayName: "Claude Hall", + isAgent: true, + role: "bot", + }, + ], + mentionableAgentPubkeys: new Set([AGENT_PUBKEY]), + relayAgents: [ + { + pubkey: AGENT_PUBKEY, + ownerPubkey: MEMBER_PUBKEY, + personaId: persona.id, + name: "Claude Hall", + status: "online", + channelIds: [], + }, + ], + }), + ); + + assert.equal(candidates.length, 1); + assert.equal(candidates[0].kind, "identity"); + assert.equal(candidates[0].pubkey, AGENT_PUBKEY); + assert.equal(candidates[0].personaId, persona.id); +}); + +test("a foreign relay agent cannot suppress a colliding local persona", () => { + const persona = { + id: "planner", + displayName: "Planner", + avatarUrl: null, + isActive: true, + }; + const candidates = buildMentionCandidates( + input({ + activePersonas: [persona], + currentPubkey: MEMBER_PUBKEY, + mentionableAgentPubkeys: new Set([AGENT_PUBKEY]), + relayAgents: [ + { + pubkey: AGENT_PUBKEY, + ownerPubkey: SEARCHED_PUBKEY, + personaId: persona.id, + name: "Remote Planner", + status: "online", + channelIds: [], + }, + ], + }), + ); + + assert.equal(candidates.length, 2); + assert.equal( + candidates.filter((candidate) => candidate.kind === "persona").length, + 1, + ); + assert.equal( + candidates.find((candidate) => candidate.pubkey === AGENT_PUBKEY) + ?.personaId, + undefined, + ); +}); + test("global search results join only while global search is enabled", () => { const userSearchResults = [ { diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 61373886352..98d4aa17dd4 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -76,6 +76,20 @@ export function buildMentionCandidates({ relayAgents, userSearchResults, }: BuildMentionCandidatesInput): MentionCandidate[] { + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const ownedRelayPersonaIds = new Set(); + for (const agent of relayAgents ?? []) { + if ( + agent.personaId && + normalizedCurrentPubkey && + agent.ownerPubkey && + normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey + ) { + ownedRelayPersonaIds.add(agent.personaId); + } + } const candidatesByPubkey = new Map(); const addCandidate = (candidate: MentionCandidate & { pubkey: string }) => { const pubkey = normalizePubkey(candidate.pubkey); @@ -166,6 +180,12 @@ export function buildMentionCandidates({ } for (const agent of relayAgents ?? []) { const pubkey = normalizePubkey(agent.pubkey); + const ownedRelayPersonaId = + normalizedCurrentPubkey && + agent.ownerPubkey && + normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey + ? (agent.personaId ?? undefined) + : undefined; addCandidate({ kind: "identity", pubkey, @@ -179,6 +199,7 @@ export function buildMentionCandidates({ agent.channelIds.includes(mentionChannelId)), personaId: managedAgentPersonaIdsByPubkey.get(pubkey) ?? + ownedRelayPersonaId ?? (activePersonaById.has(pubkey) ? pubkey : undefined), ownerPubkey: agent.ownerPubkey, isAgent: true, @@ -226,7 +247,11 @@ export function buildMentionCandidates({ } } const personaCandidates: MentionCandidate[] = activePersonas - .filter((persona) => !managedAgentPersonaIds.has(persona.id)) + .filter( + (persona) => + !managedAgentPersonaIds.has(persona.id) && + !ownedRelayPersonaIds.has(persona.id), + ) .map((persona) => ({ kind: "persona" as const, personaId: persona.id, diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 2b595ed2ccb..64aa72f97e1 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -103,6 +103,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { agentsByPubkey.set(agent.pubkey, { pubkey: agent.pubkey, ownerPubkey: null, + personaId: null, name: agent.name, agentType: agent.agentCommand, channels: [], diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 984b9d176df..eb67f5e45fb 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -101,6 +101,7 @@ type RawSearchResponse = { type RawRelayAgent = { pubkey: string; owner_pubkey?: string | null; + persona_id?: string | null; name: string; agent_type: string; channels: string[]; @@ -614,6 +615,7 @@ function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { return { pubkey: agent.pubkey, ownerPubkey: agent.owner_pubkey ?? null, + personaId: agent.persona_id ?? null, name: agent.name, agentType: agent.agent_type, channels: agent.channels, diff --git a/desktop/src/shared/api/tauriRelayAgents.ts b/desktop/src/shared/api/tauriRelayAgents.ts index 8ae6766f79a..85a29e25045 100644 --- a/desktop/src/shared/api/tauriRelayAgents.ts +++ b/desktop/src/shared/api/tauriRelayAgents.ts @@ -4,6 +4,7 @@ import type { RelayAgent } from "@/shared/api/types"; type RawRelayAgent = { pubkey: string; owner_pubkey?: string | null; + persona_id?: string | null; name: string; agent_type: string; channels: string[]; @@ -25,6 +26,7 @@ export async function revalidateRelayAgents( return agents.map((agent) => ({ pubkey: agent.pubkey, ownerPubkey: agent.owner_pubkey ?? null, + personaId: agent.persona_id ?? null, name: agent.name, agentType: agent.agent_type, channels: agent.channels, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index b988843d60b..9bfa031b5af 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -267,6 +267,7 @@ export type RelayMember = { export type RelayAgent = { pubkey: string; ownerPubkey: string | null; + personaId: string | null; name: string; agentType: string; channels: string[]; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6ddc1111b03..e616dccea61 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -923,6 +923,7 @@ type RawSendChannelMessageResponse = { type RawRelayAgent = { pubkey: string; owner_pubkey?: string | null; + persona_id?: string | null; name: string; agent_type: string; channels: string[]; @@ -4239,6 +4240,8 @@ function syncMockRelayAgentsFromManagedAgents() { return { pubkey: agent.pubkey, + owner_pubkey: MOCK_IDENTITY_PUBKEY, + persona_id: agent.persona_id, name: agent.name, agent_type: agent.agent_command, channels: memberships.channels, From a83ead2479f74e9d1d66a478764eb3846cae327e Mon Sep 17 00:00:00 2001 From: Codex Hall Date: Tue, 8 Sep 2026 00:30:39 +0200 Subject: [PATCH 2/2] fix(desktop): retain personas for hidden remote agents Signed-off-by: Codex Hall --- desktop/src-tauri/src/managed_agents/types.rs | 2 + .../lib/buildMentionCandidates.test.mjs | 37 +++++++++++++++++++ .../messages/lib/buildMentionCandidates.ts | 20 +++++----- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e999d80dbb9..9b032f8455d 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -215,6 +215,8 @@ pub struct RelayAgentInfo { pub pubkey: String, #[serde(default)] pub owner_pubkey: Option, + /// Persona declared by the owner-authenticated managed-agent policy. + /// Legacy and persona-less agents leave this unset. #[serde(default)] pub persona_id: Option, pub name: String, diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs index 04f7df3ce09..3173abc1d94 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs @@ -187,6 +187,43 @@ test("a foreign relay agent cannot suppress a colliding local persona", () => { ); }); +for (const hiddenBy of ["archive", "eligibility"]) { + test(`a relay agent hidden by ${hiddenBy} does not suppress its persona`, () => { + const persona = { + id: "planner", + displayName: "Planner", + avatarUrl: null, + isActive: true, + }; + const candidates = buildMentionCandidates( + input({ + activePersonas: [persona], + currentPubkey: MEMBER_PUBKEY, + isArchived: + hiddenBy === "archive" + ? (pubkey) => pubkey === AGENT_PUBKEY + : () => false, + mentionableAgentPubkeys: + hiddenBy === "eligibility" ? new Set() : new Set([AGENT_PUBKEY]), + relayAgents: [ + { + pubkey: AGENT_PUBKEY, + ownerPubkey: MEMBER_PUBKEY, + personaId: persona.id, + name: "Remote Planner", + status: "online", + channelIds: [], + }, + ], + }), + ); + + assert.equal(candidates.length, 1); + assert.equal(candidates[0].kind, "persona"); + assert.equal(candidates[0].personaId, persona.id); + }); +} + test("global search results join only while global search is enabled", () => { const userSearchResults = [ { diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 98d4aa17dd4..ace70a8731d 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -79,18 +79,8 @@ export function buildMentionCandidates({ const normalizedCurrentPubkey = currentPubkey ? normalizePubkey(currentPubkey) : null; - const ownedRelayPersonaIds = new Set(); - for (const agent of relayAgents ?? []) { - if ( - agent.personaId && - normalizedCurrentPubkey && - agent.ownerPubkey && - normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey - ) { - ownedRelayPersonaIds.add(agent.personaId); - } - } const candidatesByPubkey = new Map(); + const ownedRelayPersonaIds = new Set(); const addCandidate = (candidate: MentionCandidate & { pubkey: string }) => { const pubkey = normalizePubkey(candidate.pubkey); if (isArchived(pubkey)) { @@ -205,6 +195,14 @@ export function buildMentionCandidates({ isAgent: true, isActiveAgent: agent.status === "online" || agent.status === "away", }); + const relayCandidate = candidatesByPubkey.get(pubkey); + if ( + ownedRelayPersonaId && + relayCandidate?.isAgent === true && + relayCandidate.personaId === ownedRelayPersonaId + ) { + ownedRelayPersonaIds.add(ownedRelayPersonaId); + } } for (const agent of managedAgents ?? []) { const pubkey = normalizePubkey(agent.pubkey);