From 144276cbad17776f1c613ea9fe055220e7f2e0b8 Mon Sep 17 00:00:00 2001 From: Ali Madad Date: Wed, 29 Jul 2026 14:59:45 +0000 Subject: [PATCH] fix(desktop): show externally managed owned agents Signed-off-by: Ali Madad --- .../src-tauri/src/commands/agent_discovery.rs | 38 +--- .../commands/agent_discovery/relay_agents.rs | 50 +++++ desktop/src-tauri/src/nostr_convert.rs | 144 +------------- .../src/nostr_convert/agent_discovery.rs | 182 ++++++++++++++++++ desktop/src/features/agents/ui/AgentsView.tsx | 7 + .../agents/ui/UnifiedAgentsSection.tsx | 74 ++++++- .../agents/useOwnedExternalAgents.test.mjs | 72 +++++++ .../features/agents/useOwnedExternalAgents.ts | 98 ++++++++++ desktop/tests/e2e/agents.spec.ts | 37 ++++ 9 files changed, 528 insertions(+), 174 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_discovery/relay_agents.rs create mode 100644 desktop/src-tauri/src/nostr_convert/agent_discovery.rs create mode 100644 desktop/src/features/agents/useOwnedExternalAgents.test.mjs create mode 100644 desktop/src/features/agents/useOwnedExternalAgents.ts diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index d6429e0454..3ab47077e4 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,17 +1,12 @@ -use tauri::State; - -use crate::{ - app_state::AppState, - managed_agents::{ - command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, - RelayAgentInfo, DEFAULT_ACP_COMMAND, - }, - nostr_convert, - relay::query_relay, +use crate::managed_agents::{ + command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, + DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, + DEFAULT_ACP_COMMAND, }; mod post_install_verification; +mod relay_agents; +pub use relay_agents::list_relay_agents; fn active_installs() -> &'static std::sync::Mutex> { use std::collections::HashSet; @@ -1053,27 +1048,6 @@ pub async fn discover_managed_agent_prereqs( .map_err(|e| format!("spawn_blocking failed: {e}")) } -#[tauri::command] -pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { - // Query kind:10100 agent profile events from the relay. - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [10100], - })], - ) - .await?; - - // The convert helper returns `{"agents": [...]}`. Extract and re-deserialize - // into the strongly-typed `Vec` the frontend expects. - let value = nostr_convert::agents_from_events(&events); - let agents = value - .get("agents") - .cloned() - .unwrap_or_else(|| serde_json::json!([])); - serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_agents.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_agents.rs new file mode 100644 index 0000000000..2365599796 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_agents.rs @@ -0,0 +1,50 @@ +use std::collections::HashSet; + +use tauri::State; + +use crate::{ + app_state::AppState, managed_agents::RelayAgentInfo, nostr_convert, relay::query_relay, +}; + +#[tauri::command] +pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { + let owner_pubkey = state + .keys + .lock() + .map_err(|error| error.to_string())? + .public_key() + .to_hex(); + let events = query_relay( + &state, + &[ + serde_json::json!({ "kinds": [10100] }), + serde_json::json!({ "kinds": [0], "#auth": [&owner_pubkey] }), + ], + ) + .await?; + + let parse_agents = |value: serde_json::Value| { + serde_json::from_value::>( + value + .get("agents") + .cloned() + .unwrap_or_else(|| serde_json::json!([])), + ) + .map_err(|error| format!("agent parse failed: {error}")) + }; + let mut agents = parse_agents(nostr_convert::agents_from_events(&events))?; + let owned_profile_agents = parse_agents(nostr_convert::owned_agent_profiles_from_events( + &events, + &owner_pubkey, + ))?; + let mut known_pubkeys: HashSet = agents + .iter() + .map(|agent| agent.pubkey.to_lowercase()) + .collect(); + agents.extend( + owned_profile_agents + .into_iter() + .filter(|agent| known_pubkeys.insert(agent.pubkey.to_lowercase())), + ); + Ok(agents) +} diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c9..57bce2c358 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -9,12 +9,14 @@ use std::collections::{BTreeSet, HashMap}; -use nostr::{Event, ToBech32}; +use nostr::Event; use serde_json::{json, Value}; use crate::models::*; +mod agent_discovery; mod user_search; +pub use agent_discovery::{agents_from_events, owned_agent_profiles_from_events}; pub use user_search::{ list_user_search_results, rank_user_search_results, search_users_from_events, user_search_result_from_event, @@ -436,65 +438,6 @@ pub fn search_response_from_events(events: &[Event]) -> SearchResponse { } } -// ── kind:10100 (agent profiles) ───────────────────────────────────────────── - -/// Convert kind:10100 agent profile events to the agent discovery format. -/// -/// Returns a JSON array of `{pubkey, name, ...}` objects parsed from each -/// event's content. -pub fn agents_from_events(events: &[Event]) -> Value { - let arr: Vec = events - .iter() - .map(|ev| { - let mut v: Value = serde_json::from_str(&ev.content).unwrap_or_else(|_| json!({})); - let pubkey = ev.pubkey.to_hex(); - // Full npub fallback — truncated prefixes are grindable (see pubkey-display). - let npub = ev.pubkey.to_bech32().unwrap_or_else(|_| pubkey.clone()); - // Always overwrite the pubkey with the event author — it's the - // authoritative source even if the content claims otherwise. - if let Some(obj) = v.as_object_mut() { - obj.insert("pubkey".to_string(), json!(pubkey.clone())); - let fallback_name = obj - .get("display_name") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - .unwrap_or_else(|| npub.clone()); - if !obj.get("name").is_some_and(Value::is_string) { - obj.insert("name".to_string(), json!(fallback_name)); - } - if !obj.get("agent_type").is_some_and(Value::is_string) { - obj.insert("agent_type".to_string(), json!("agent")); - } - if !obj.get("channels").is_some_and(Value::is_array) { - obj.insert("channels".to_string(), json!([])); - } - if !obj.get("channel_ids").is_some_and(Value::is_array) { - obj.insert("channel_ids".to_string(), json!([])); - } - if !obj.get("capabilities").is_some_and(Value::is_array) { - obj.insert("capabilities".to_string(), json!([])); - } - if !obj.get("status").is_some_and(Value::is_string) { - obj.insert("status".to_string(), json!("offline")); - } - } else { - v = json!({ - "pubkey": pubkey, - "name": npub, - "agent_type": "agent", - "channels": [], - "channel_ids": [], - "capabilities": [], - "status": "offline", - }); - } - v - }) - .collect(); - json!({ "agents": arr }) -} - // ── kind:13534 (relay membership list) ────────────────────────────────────── /// Convert a kind:13534 relay membership list to the relay members format. @@ -882,87 +825,6 @@ mod tests { assert_eq!(r.hits[0].score, 1.0); } - #[test] - fn agents_overwrites_pubkey_from_event_author() { - let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]); - let v = agents_from_events(std::slice::from_ref(&e)); - let arr = v.get("agents").and_then(Value::as_array).unwrap(); - assert_eq!(arr.len(), 1); - assert_eq!( - arr[0].get("pubkey").and_then(Value::as_str).unwrap(), - e.pubkey.to_hex() - ); - assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1")); - } - - #[test] - fn agents_handles_invalid_content() { - let e = ev(10100, "not-json", vec![]); - let v = agents_from_events(std::slice::from_ref(&e)); - let arr = v.get("agents").and_then(Value::as_array).unwrap(); - assert_eq!( - arr[0].get("pubkey").and_then(Value::as_str).unwrap(), - e.pubkey.to_hex() - ); - } - - #[test] - fn agents_default_sparse_agent_profiles_for_directory_parse() { - let e = ev( - 10100, - r#"{"channel_add_policy":"owner-only","display_name":"Scout"}"#, - vec![], - ); - let v = agents_from_events(std::slice::from_ref(&e)); - let agents = v.get("agents").cloned().unwrap(); - let parsed: Vec = - serde_json::from_value(agents).unwrap(); - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].pubkey, e.pubkey.to_hex()); - assert_eq!(parsed[0].name, "Scout"); - assert_eq!(parsed[0].agent_type, "agent"); - assert_eq!(parsed[0].channels, Vec::::new()); - assert_eq!(parsed[0].capabilities, Vec::::new()); - assert_eq!(parsed[0].status, "offline"); - assert_eq!(parsed[0].respond_to, None); - } - - #[test] - fn agents_preserves_public_respond_to_mode_for_directory_parse() { - let e = ev(10100, r#"{"name":"Scout","respond_to":"anyone"}"#, vec![]); - let v = agents_from_events(std::slice::from_ref(&e)); - let agents = v.get("agents").cloned().unwrap(); - let parsed: Vec = - serde_json::from_value(agents).unwrap(); - - assert_eq!(parsed.len(), 1); - assert_eq!( - parsed[0].respond_to, - Some(crate::managed_agents::RespondTo::Anyone) - ); - } - - #[test] - fn agents_preserves_allowlist_metadata_for_directory_parse() { - let e = ev( - 10100, - r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, - vec![], - ); - let v = agents_from_events(std::slice::from_ref(&e)); - let agents = v.get("agents").cloned().unwrap(); - let parsed: Vec = - serde_json::from_value(agents).unwrap(); - - assert_eq!(parsed.len(), 1); - assert_eq!( - parsed[0].respond_to, - Some(crate::managed_agents::RespondTo::Allowlist) - ); - assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]); - } - #[test] fn relay_members_dedupes_and_defaults_role() { let pk1 = "a".repeat(64); diff --git a/desktop/src-tauri/src/nostr_convert/agent_discovery.rs b/desktop/src-tauri/src/nostr_convert/agent_discovery.rs new file mode 100644 index 0000000000..a16aeee739 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/agent_discovery.rs @@ -0,0 +1,182 @@ +use nostr::{Event, ToBech32}; +use serde_json::{json, Value}; + +use super::profile_valid_oa_owner_pubkey; + +/// Convert kind:10100 agent profile events to the agent discovery format. +pub fn agents_from_events(events: &[Event]) -> Value { + let agents: Vec = events + .iter() + .filter(|event| event.kind.as_u16() == 10100) + .map(agent_value_from_event) + .collect(); + json!({ "agents": agents }) +} + +/// Convert verified NIP-OA kind:0 profiles owned by `owner_pubkey` into sparse +/// relay-agent records. Their lifecycle remains external to Desktop. +pub fn owned_agent_profiles_from_events(events: &[Event], owner_pubkey: &str) -> Value { + let owner_pubkey = owner_pubkey.to_lowercase(); + let agents: Vec = events + .iter() + .filter(|event| event.kind.as_u16() == 0) + .filter(|event| { + profile_valid_oa_owner_pubkey(event) + .is_some_and(|owner| owner.to_lowercase() == owner_pubkey) + }) + .map(agent_value_from_event) + .collect(); + json!({ "agents": agents }) +} + +fn agent_value_from_event(event: &Event) -> Value { + let mut value: Value = serde_json::from_str(&event.content).unwrap_or_else(|_| json!({})); + let pubkey = event.pubkey.to_hex(); + // Full npub fallback — truncated prefixes are grindable (see pubkey-display). + let npub = event.pubkey.to_bech32().unwrap_or_else(|_| pubkey.clone()); + + if let Some(object) = value.as_object_mut() { + // Event authorship is authoritative even if content claims another key. + object.insert("pubkey".to_string(), json!(pubkey.clone())); + let fallback_name = object + .get("display_name") + .and_then(Value::as_str) + .filter(|candidate| !candidate.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| npub.clone()); + if !object.get("name").is_some_and(Value::is_string) { + object.insert("name".to_string(), json!(fallback_name)); + } + if !object.get("agent_type").is_some_and(Value::is_string) { + object.insert("agent_type".to_string(), json!("agent")); + } + if !object.get("channels").is_some_and(Value::is_array) { + object.insert("channels".to_string(), json!([])); + } + if !object.get("channel_ids").is_some_and(Value::is_array) { + object.insert("channel_ids".to_string(), json!([])); + } + if !object.get("capabilities").is_some_and(Value::is_array) { + object.insert("capabilities".to_string(), json!([])); + } + if !object.get("status").is_some_and(Value::is_string) { + object.insert("status".to_string(), json!("offline")); + } + return value; + } + + json!({ + "pubkey": pubkey, + "name": npub, + "agent_type": "agent", + "channels": [], + "channel_ids": [], + "capabilities": [], + "status": "offline", + }) +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + use crate::managed_agents::{RelayAgentInfo, RespondTo}; + + use super::*; + + fn event(kind: u16, content: &str) -> Event { + EventBuilder::new(Kind::from_u16(kind), content) + .sign_with_keys(&Keys::generate()) + .expect("sign") + } + + fn oa_profile_event(content: &str) -> (Event, String) { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "") + .expect("compute auth tag"); + let tag_values: Vec = serde_json::from_str(&tag_json).expect("parse auth tag"); + let auth_tag = Tag::parse(tag_values).expect("parse auth tag"); + let event = EventBuilder::new(Kind::Metadata, content) + .tags(vec![auth_tag]) + .sign_with_keys(&agent_keys) + .expect("sign"); + (event, owner_keys.public_key().to_hex()) + } + + #[test] + fn overwrites_content_pubkey_with_event_author() { + let event = event(10100, r#"{"pubkey":"forged","name":"agent-1"}"#); + let value = agents_from_events(std::slice::from_ref(&event)); + let agents = value.get("agents").and_then(Value::as_array).unwrap(); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0]["pubkey"], event.pubkey.to_hex()); + assert_eq!(agents[0]["name"], "agent-1"); + } + + #[test] + fn owned_profiles_require_verified_matching_owner() { + let (owned, owner_pubkey) = oa_profile_event(r#"{"display_name":"External Pi"}"#); + let (somebody_elses, _) = oa_profile_event(r#"{"display_name":"Other Pi"}"#); + let value = + owned_agent_profiles_from_events(&[owned.clone(), somebody_elses], &owner_pubkey); + let agents = value.get("agents").and_then(Value::as_array).unwrap(); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0]["pubkey"], owned.pubkey.to_hex()); + assert_eq!(agents[0]["name"], "External Pi"); + assert_eq!(agents[0]["status"], "offline"); + } + + #[test] + fn handles_invalid_content() { + let event = event(10100, "not-json"); + let value = agents_from_events(std::slice::from_ref(&event)); + let agents = value.get("agents").and_then(Value::as_array).unwrap(); + + assert_eq!(agents[0]["pubkey"], event.pubkey.to_hex()); + } + + #[test] + fn defaults_sparse_directory_profiles() { + let event = event( + 10100, + r#"{"channel_add_policy":"owner-only","display_name":"Scout"}"#, + ); + let value = agents_from_events(std::slice::from_ref(&event)); + let agents: Vec = serde_json::from_value(value["agents"].clone()).unwrap(); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].pubkey, event.pubkey.to_hex()); + assert_eq!(agents[0].name, "Scout"); + assert_eq!(agents[0].agent_type, "agent"); + assert!(agents[0].channels.is_empty()); + assert!(agents[0].capabilities.is_empty()); + assert_eq!(agents[0].status, "offline"); + assert_eq!(agents[0].respond_to, None); + } + + #[test] + fn preserves_public_respond_to_mode() { + let event = event(10100, r#"{"name":"Scout","respond_to":"anyone"}"#); + let value = agents_from_events(std::slice::from_ref(&event)); + let agents: Vec = serde_json::from_value(value["agents"].clone()).unwrap(); + + assert_eq!(agents[0].respond_to, Some(RespondTo::Anyone)); + } + + #[test] + fn preserves_allowlist_metadata() { + let event = event( + 10100, + r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, + ); + let value = agents_from_events(std::slice::from_ref(&event)); + let agents: Vec = serde_json::from_value(value["agents"].clone()).unwrap(); + + assert_eq!(agents[0].respond_to, Some(RespondTo::Allowlist)); + assert_eq!(agents[0].respond_to_allowlist, vec!["a".repeat(64)]); + } +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 6e55f92dfe..4d13ca42c1 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -31,6 +31,7 @@ import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { useBakedBuildEnvQuery } from "@/features/agents/hooks"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; +import { useOwnedExternalAgents } from "@/features/agents/useOwnedExternalAgents"; import { Button } from "@/shared/ui/button"; import { PageHeader } from "@/shared/ui/PageHeader"; import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; @@ -41,6 +42,10 @@ export function AgentsView() { const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: true }); const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv); const agents = useManagedAgentActions(); + const externalAgents = useOwnedExternalAgents( + agents.managedAgents, + agents.relayAgentsQuery.data ?? [], + ); const personas = usePersonaActions(); const teamImportInputRef = React.useRef(null); const aiDefaultsTriggerRef = React.useRef(null); @@ -155,6 +160,8 @@ export function AgentsView() { actionErrorMessage={agents.actionErrorMessage} actionNoticeMessage={agents.actionNoticeMessage} agents={agents.managedAgents} + externalAgents={externalAgents.agents} + externalPresence={externalAgents.presenceQuery.data} agentsError={ agents.managedAgentsQuery.error instanceof Error ? agents.managedAgentsQuery.error diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 34f9f9819f..b3e7a25228 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -10,7 +10,18 @@ import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModel import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { useUserProfileQuery } from "@/features/profile/hooks"; -import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; +import { + DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, + ProfileAvatarWithStatus, + scaleProfileAvatarStatusGeometry, +} from "@/features/profile/ui/ProfileAvatarWithStatus"; +import type { + AgentPersona, + ManagedAgent, + PresenceLookup, +} from "@/shared/api/types"; +import type { OwnedExternalAgent } from "@/features/agents/useOwnedExternalAgents"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; @@ -33,6 +44,8 @@ type UnifiedAgentsSectionProps = { actionErrorMessage: string | null; actionNoticeMessage: string | null; agents: ManagedAgent[]; + externalAgents: OwnedExternalAgent[]; + externalPresence: PresenceLookup | undefined; agentsError: Error | null; isActionPending: boolean; isAgentsLoading: boolean; @@ -75,6 +88,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { actionNoticeMessage, defaultModel, agents, + externalAgents, + externalPresence, agentsError, isActionPending, isAgentsLoading, @@ -217,6 +232,13 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onStartAgent={onStartAgent} /> ) : null} + {externalAgents.length > 0 ? ( + + ) : null} ) : null} @@ -418,6 +440,56 @@ function StandaloneAgentCard({ ); } +const EXTERNAL_AGENT_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry( + DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, + 96, +); + +function ExternalAgentsGroup({ + agents, + presence, + onOpenAgentProfile, +}: { + agents: OwnedExternalAgent[]; + presence: PresenceLookup | undefined; + onOpenAgentProfile: (pubkey: string) => void; +}) { + return ( +
+
+ Externally managed agents + ({agents.length}) +
+
+ {agents.map((agent) => ( + + } + avatarUrl={agent.avatarUrl} + dataTestId={`external-agent-${agent.pubkey}`} + key={agent.pubkey} + label={agent.name} + onClick={() => onOpenAgentProfile(agent.pubkey)} + statusBadge={Externally managed} + /> + ))} +
+
+ ); +} + function firstAvatarUrl( ...candidates: Array ): string | null { diff --git a/desktop/src/features/agents/useOwnedExternalAgents.test.mjs b/desktop/src/features/agents/useOwnedExternalAgents.test.mjs new file mode 100644 index 0000000000..6d16a65f98 --- /dev/null +++ b/desktop/src/features/agents/useOwnedExternalAgents.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { selectOwnedExternalAgents } from "./useOwnedExternalAgents.ts"; + +const OWNER = "a".repeat(64); +const EXTERNAL = "b".repeat(64); +const LOCAL = "c".repeat(64); +const OTHER = "d".repeat(64); + +function relayAgent(pubkey, name) { + return { pubkey, name }; +} + +test("selects owner-attested relay agents that are not locally managed", () => { + const selected = selectOwnedExternalAgents({ + currentPubkey: OWNER.toUpperCase(), + managedAgents: [{ pubkey: LOCAL }], + relayAgents: [ + relayAgent(EXTERNAL, "relay fallback"), + relayAgent(LOCAL, "local duplicate"), + relayAgent(OTHER, "other owner's agent"), + ], + profiles: { + [EXTERNAL]: { + avatarUrl: "https://example.com/external.png", + displayName: "External Pi", + ownerPubkey: OWNER, + }, + [LOCAL]: { + avatarUrl: null, + displayName: "Local Pi", + ownerPubkey: OWNER, + }, + [OTHER]: { + avatarUrl: null, + displayName: "Other Pi", + ownerPubkey: "e".repeat(64), + }, + }, + }); + + assert.deepEqual(selected, [ + { + avatarUrl: "https://example.com/external.png", + name: "External Pi", + pubkey: EXTERNAL, + }, + ]); +}); + +test("falls back to the relay name and deduplicates normalized pubkeys", () => { + const selected = selectOwnedExternalAgents({ + currentPubkey: OWNER, + managedAgents: [], + relayAgents: [ + relayAgent(EXTERNAL.toUpperCase(), "Relay Pi"), + relayAgent(EXTERNAL, "Duplicate"), + ], + profiles: { + [EXTERNAL]: { + avatarUrl: null, + displayName: " ", + ownerPubkey: OWNER, + }, + }, + }); + + assert.deepEqual(selected, [ + { avatarUrl: null, name: "Relay Pi", pubkey: EXTERNAL }, + ]); +}); diff --git a/desktop/src/features/agents/useOwnedExternalAgents.ts b/desktop/src/features/agents/useOwnedExternalAgents.ts new file mode 100644 index 0000000000..3ae8a53114 --- /dev/null +++ b/desktop/src/features/agents/useOwnedExternalAgents.ts @@ -0,0 +1,98 @@ +import * as React from "react"; + +import { usePresenceQuery } from "@/features/presence/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { + ManagedAgent, + RelayAgent, + UserProfileSummary, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type OwnedExternalAgent = { + avatarUrl: string | null; + name: string; + pubkey: string; +}; + +type SelectOwnedExternalAgentsInput = { + currentPubkey: string | null | undefined; + managedAgents: readonly Pick[]; + relayAgents: readonly Pick[]; + profiles: Readonly< + Record< + string, + Pick + > + >; +}; + +export function selectOwnedExternalAgents({ + currentPubkey, + managedAgents, + relayAgents, + profiles, +}: SelectOwnedExternalAgentsInput): OwnedExternalAgent[] { + if (!currentPubkey) return []; + + const ownerPubkey = normalizePubkey(currentPubkey); + const managedPubkeys = new Set( + managedAgents.map((agent) => normalizePubkey(agent.pubkey)), + ); + const seen = new Set(); + const externalAgents: OwnedExternalAgent[] = []; + + for (const relayAgent of relayAgents) { + const pubkey = normalizePubkey(relayAgent.pubkey); + if (managedPubkeys.has(pubkey) || seen.has(pubkey)) continue; + + const profile = profiles[pubkey]; + if ( + !profile?.ownerPubkey || + normalizePubkey(profile.ownerPubkey) !== ownerPubkey + ) { + continue; + } + + seen.add(pubkey); + externalAgents.push({ + avatarUrl: profile.avatarUrl, + name: profile.displayName?.trim() || relayAgent.name.trim() || pubkey, + pubkey, + }); + } + + return externalAgents.sort((left, right) => + left.name.localeCompare(right.name), + ); +} + +export function useOwnedExternalAgents( + managedAgents: readonly ManagedAgent[], + relayAgents: readonly RelayAgent[], +) { + const currentPubkey = useIdentityQuery().data?.pubkey; + const relayAgentPubkeys = React.useMemo( + () => relayAgents.map((agent) => normalizePubkey(agent.pubkey)), + [relayAgents], + ); + const profilesQuery = useUsersBatchQuery(relayAgentPubkeys, { + enabled: Boolean(currentPubkey) && relayAgentPubkeys.length > 0, + }); + const agents = React.useMemo( + () => + selectOwnedExternalAgents({ + currentPubkey, + managedAgents, + relayAgents, + profiles: profilesQuery.data?.profiles ?? {}, + }), + [currentPubkey, managedAgents, profilesQuery.data?.profiles, relayAgents], + ); + const presenceQuery = usePresenceQuery( + React.useMemo(() => agents.map((agent) => agent.pubkey), [agents]), + ); + + return { agents, presenceQuery, profilesQuery }; +} diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 2e7cdc9e83..bc247cf3ae 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -280,6 +280,43 @@ test("catalog empty state remains available after reopening", async ({ ); }); +test("owner-attested external agents appear without local lifecycle controls", async ({ + page, +}) => { + const externalPubkey = "7".repeat(64); + await installMockBridge(page, { + relayAgents: [ + { + pubkey: externalPubkey, + name: "relay fallback", + status: "online", + }, + ], + searchProfiles: [ + { + pubkey: externalPubkey, + displayName: "External Pi", + isAgent: true, + ownerPubkey: "deadbeef".repeat(8), + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const card = page.getByTestId(`external-agent-${externalPubkey}`); + await expect(card).toBeVisible(); + await expect(card).toContainText("External Pi"); + await expect(card).toContainText("Externally managed"); + await expect(card.getByLabel("Start External Pi")).toHaveCount(0); + await expect(page.getByTestId(`managed-agent-${externalPubkey}`)).toHaveCount( + 0, + ); + + await card.getByRole("button", { name: "External Pi agent profile" }).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); +}); + test("built-in persona edits persist", async ({ page }) => { await installMockBridge(page, { activePersonaIds: ["builtin:fizz"],