diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 5f6f48eb14..9ff81b8971 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -93,7 +93,9 @@ const overrides = new Map([ // build_managed_agent_summary into callers, dangling-harness summaries render // the deleted id, and spawn errors surface as sentences (tests included). // +1: merge of the two deltas above (actual post-merge count). - ["src-tauri/src/commands/agents.rs", 1418], + // +7 (#2515): mint guard call in create_managed_agent (guard itself lives in + // managed_agents/mint_guard.rs). + ["src-tauri/src/commands/agents.rs", 1425], // agent-lifecycle-fixes: cascade-delete in delete_persona restructured into // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for // retry-safety. Load-bearing reviewer-required change; queued to split. diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 16272ac28b..7723f4b684 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -636,16 +636,6 @@ pub async fn create_managed_agent( let personas = load_personas(&app)?; ensure_persona_is_active(&personas, persona_id)?; } - let keys = Keys::generate(); - let pubkey = keys.public_key().to_hex(); - if records.iter().any(|record| record.pubkey == pubkey) { - return Err(format!("agent {pubkey} already exists")); - } - let private_key_nsec = keys - .secret_key() - .to_bech32() - .map_err(|error| format!("failed to encode private key: {error}"))?; - // Store the relay override exactly as supplied (trimmed). An explicit // value pins the agent; empty stays empty and resolves to the active // workspace relay at read-time. Uniform for Local and Provider. @@ -656,6 +646,23 @@ pub async fn create_managed_agent( .unwrap_or("") .to_string(); + // #2515 mint guard: one instance of an agent per relay scope. + crate::managed_agents::mint_guard::ensure_no_duplicate_instance( + &records, + &name, + &resolved_relay_url, + )?; + + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + if records.iter().any(|record| record.pubkey == pubkey) { + return Err(format!("agent {pubkey} already exists")); + } + let private_key_nsec = keys + .secret_key() + .to_bech32() + .map_err(|error| format!("failed to encode private key: {error}"))?; + (keys, private_key_nsec, pubkey, resolved_relay_url, input) }; diff --git a/desktop/src-tauri/src/managed_agents/mint_guard.rs b/desktop/src-tauri/src/managed_agents/mint_guard.rs new file mode 100644 index 0000000000..09ed996043 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/mint_guard.rs @@ -0,0 +1,98 @@ +//! Mint guard for managed-agent instances (#2515). + +use super::ManagedAgentRecord; + +/// Refuse minting a record whose name matches an existing instance in the +/// same relay scope (same pin, or both unpinned). A second keypair for one +/// @Name on a relay is exactly the duplicate-identity state from #2515: +/// mentions resolve to one pubkey while the other instance answers. The same +/// name on a different relay stays allowed — one identity per agent per +/// community. +pub(crate) fn ensure_no_duplicate_instance( + records: &[ManagedAgentRecord], + name: &str, + relay_url: &str, +) -> Result<(), String> { + let relay = relay_url.trim(); + let Some(existing) = records.iter().find(|record| { + !record.pubkey.is_empty() + && record.name.trim().eq_ignore_ascii_case(name) + && record.relay_url.trim() == relay + }) else { + return Ok(()); + }; + let scope = if relay.is_empty() { + "the workspace relay" + } else { + relay + }; + Err(format!( + "agent '{name}' already has an instance on {scope} (pubkey {}) — start that instance instead, or create the new one under a different name or relay", + existing.pubkey + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(name: &str, relay_url: &str) -> ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{}", + "name": "{name}", + "relay_url": "{relay_url}", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"#, + "aa".repeat(32) + )) + .unwrap() + } + + #[test] + fn same_name_same_relay_is_refused() { + let records = [record("Neo", "wss://one.example")]; + assert!(ensure_no_duplicate_instance(&records, "Neo", "wss://one.example").is_err()); + } + + #[test] + fn name_match_is_case_insensitive_and_trimmed() { + let records = [record(" Neo ", "wss://one.example")]; + assert!(ensure_no_duplicate_instance(&records, "neo", "wss://one.example").is_err()); + } + + #[test] + fn same_name_different_relay_is_allowed() { + // One identity per agent per community: a second instance on another + // relay is the intended multi-community shape. + let records = [record("Neo", "wss://one.example")]; + assert!(ensure_no_duplicate_instance(&records, "Neo", "wss://two.example").is_ok()); + } + + #[test] + fn both_unpinned_share_the_workspace_scope() { + let records = [record("Neo", "")]; + let error = ensure_no_duplicate_instance(&records, "Neo", "").unwrap_err(); + assert!(error.contains("the workspace relay")); + } + + #[test] + fn key_less_definitions_do_not_block_minting() { + let mut definition = record("Neo", "wss://one.example"); + definition.pubkey = String::new(); + assert!(ensure_no_duplicate_instance(&[definition], "Neo", "wss://one.example").is_ok()); + } + + #[test] + fn different_name_is_allowed() { + let records = [record("Neo", "wss://one.example")]; + assert!(ensure_no_duplicate_instance(&records, "Trinity", "wss://one.example").is_ok()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index b0e86f8edb..e48fff83b7 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -14,6 +14,7 @@ mod env_vars; pub(crate) mod git_bash; pub(crate) mod global_config; mod managed_node_paths; +pub(crate) mod mint_guard; mod nest; mod persona_avatars; pub(crate) mod persona_events; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..1ea2509930 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -99,11 +99,11 @@ fn persona_drift_state( (out_of_date, false) } -/// Resolve the runtime-pair key this record maps to for the active -/// workspace: always the active workspace relay (the legacy per-record relay -/// pin is ignored — see `effective_agent_relay_url`). Returns `None` for -/// records that cannot form a valid pair key yet (e.g. key-less agents that -/// mint keys on first start). +/// Resolve the runtime-pair key this record maps to: the record's pinned +/// relay when set, else the active workspace relay — see +/// `effective_agent_relay_url`. Returns `None` for records that cannot form +/// a valid pair key yet (e.g. key-less agents that mint keys on first +/// start). pub(crate) fn workspace_pair_key( app: &AppHandle, record: &ManagedAgentRecord, @@ -117,9 +117,9 @@ pub(crate) fn workspace_pair_key( ) } -/// Pure core of [`workspace_pair_key`]: workspace-relay resolution (legacy -/// record pins ignored) plus canonical key construction, kept `AppHandle`-free -/// so summary/stop scoping semantics are unit-testable. +/// Pure core of [`workspace_pair_key`]: relay resolution (record pin wins, +/// workspace relay as fallback) plus canonical key construction, kept +/// `AppHandle`-free so summary/stop scoping semantics are unit-testable. pub(crate) fn resolve_workspace_pair_key( pubkey: &str, record_relay_url: &str, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 8deb0c4da9..e14ac3efd8 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1008,10 +1008,10 @@ fn unpinned_record_resolves_pair_key_per_workspace() { } #[test] -fn stored_relay_pin_is_ignored_in_pair_key_resolution() { - // Legacy pins are ignored (#2122): a record carrying a creation-era - // `relay_url` resolves the same per-workspace pair key an unpinned record - // does, so summaries/stop act on the community being viewed. +fn stored_relay_pin_wins_pair_key_resolution() { + // #2515: a pinned record resolves the same pair key in every workspace — + // summaries, start, and stop all act on the instance's own community no + // matter which one is being viewed. let pubkey = "aa".repeat(32); let from_a = super::resolve_workspace_pair_key(&pubkey, "wss://pinned.example", "wss://one.example") @@ -1019,9 +1019,8 @@ fn stored_relay_pin_is_ignored_in_pair_key_resolution() { let from_b = super::resolve_workspace_pair_key(&pubkey, "wss://pinned.example", "wss://two.example") .unwrap(); - assert_ne!(from_a, from_b); - assert_eq!(from_a.relay_url, "wss://one.example"); - assert_eq!(from_b.relay_url, "wss://two.example"); + assert_eq!(from_a, from_b); + assert_eq!(from_a.relay_url, "wss://pinned.example"); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..8db39c0a5f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -469,9 +469,11 @@ pub async fn reconcile_managed_agent_runtimes( for record in records .iter() .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) - // The legacy per-record relay pin is deliberately ignored here — see - // `effective_agent_relay_url`. Every local auto-start agent fans out - // to every configured community. + // Relay resolution happens per job via `effective_agent_relay_url`: + // an unpinned local auto-start agent fans out to every configured + // community, while a pinned record resolves every job to its pinned + // relay — the duplicate jobs collapse in start_pair's already-running + // check, so the instance starts once, on its own community (#2515). { jobs.push((record.clone(), community.relay_url.clone())); } @@ -607,19 +609,21 @@ mod tests { } #[test] - fn legacy_relay_pin_is_ignored_for_fan_out() { - // Zero-touch cutover (#2122): a record carrying a creation-era - // `relay_url` pin must fan out exactly like an unpinned one — the - // stored field is parsed but never consulted. See + fn relay_pin_scopes_fan_out() { + // #2515: an unpinned record fans out to the requested community, while + // a pinned record resolves every fan-out job to its pinned relay so + // the instance only ever connects to its own community. See // `effective_agent_relay_url`. let unpinned = record_with_relay(""); let pinned = record_with_relay("wss://one.example"); - for record in [&unpinned, &pinned] { - assert_eq!( - crate::relay::effective_agent_relay_url(&record.relay_url, "wss://two.example"), - "wss://two.example" - ); - } + assert_eq!( + crate::relay::effective_agent_relay_url(&unpinned.relay_url, "wss://two.example"), + "wss://two.example" + ); + assert_eq!( + crate::relay::effective_agent_relay_url(&pinned.relay_url, "wss://two.example"), + "wss://one.example" + ); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 648cc62bbe..88991225da 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -16,9 +16,9 @@ //! re-snapshot. Harness command, args/mcp, env layering, and the record //! fields the spawn env writes read are hashed as spawn resolves them. //! - The relay URL is hashed in resolved form (`effective_agent_relay_url`): -//! every record spawns against the active workspace relay (legacy per-record -//! pins are ignored), so a workspace relay change means a restart would -//! change what runs. +//! a pinned record spawns against its pinned relay (#2515) and an unpinned +//! one against the active workspace relay, so the hash trips exactly when a +//! restart would change which relay the child connects to. //! - Channel membership is not an input: agents pick up channel changes live //! (#1468), never via restart. //! diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 686ad52d4f..ef9cf0d468 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -178,15 +178,24 @@ fn persona_prompt_edit_changes_hash() { } #[test] -fn workspace_relay_change_trips_hash_even_for_stored_record_relay() { - // The legacy per-record relay pin is ignored (#2122): every record spawns - // against the active workspace relay, so a workspace relay change means a - // restart would change what runs — pinned records included. +fn workspace_relay_change_does_not_trip_hash_for_pinned_record() { + // #2515: a pinned record spawns against its pinned relay in every + // workspace, so a workspace relay change leaves what a restart would run + // untouched — no restart badge. let rec = record(); - assert!( - !rec.relay_url.is_empty(), - "fixture should carry a legacy pin" + assert!(!rec.relay_url.is_empty(), "fixture should carry a pin"); + assert_eq!( + spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), + spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) ); +} + +#[test] +fn workspace_relay_change_trips_hash_for_unpinned_record() { + // An unpinned record still follows the active workspace relay, so a + // workspace change means a restart would change what runs. + let mut rec = record(); + rec.relay_url = String::new(); assert_ne!( spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) @@ -194,14 +203,14 @@ fn workspace_relay_change_trips_hash_even_for_stored_record_relay() { } #[test] -fn stored_record_relay_does_not_affect_hash() { - // Editing the (ignored) stored pin must not badge a restart: what a - // restart would run is identical either way. +fn stored_record_relay_affects_hash() { + // Editing the pin changes which relay a restart would connect to, so it + // must badge a restart. let mut a = record(); let mut b = record(); a.relay_url = String::new(); - b.relay_url = "wss://legacy-pin.example".into(); - assert_eq!( + b.relay_url = "wss://pin.example".into(); + assert_ne!( spawn_config_hash(&a, &[], &[], "wss://ws.example", &Default::default()), spawn_config_hash(&b, &[], &[], "wss://ws.example", &Default::default()) ); diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1c9ba0095a..bc9f1cf454 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -56,18 +56,23 @@ pub fn relay_api_base_url_with_override(state: &AppState) -> String { /// Selects the relay a managed agent should use for a relay operation. /// -/// Always the active workspace relay. The legacy per-record `relay_url` pin is -/// deliberately IGNORED (agents-everywhere, #2122): every agent is eligible on -/// every community, and the pair the caller is acting on is identified by the -/// workspace relay, never by a stored pin. The record field is still parsed -/// and persisted untouched — old records need no migration and a rollback to a -/// pin-honoring build reads the same file — so the parameter stays in the -/// signature as documentation of what is being ignored at the one choke point -/// all agent relay resolution flows through. Resolving at read-time also means -/// a stale stored value can never leak into reconcile, spawn, or profile sync. -/// Uniform for both Local and Provider backends. -pub fn effective_agent_relay_url(_record_relay: &str, workspace_relay: &str) -> String { - workspace_relay.to_string() +/// A non-empty per-record `relay_url` pin wins: an agent instance minted for a +/// community keeps its keypair bound to that community's relay, no matter +/// which workspace is active when it is started (#2515 — otherwise switching +/// communities connects an instance under a pubkey the target relay has never +/// seen, while mentions keep tagging the pubkey that is now offline). +/// Records with no pin fall back to the active workspace relay +/// (agents-everywhere, #2122), so pin-less records behave exactly as before. +/// This is the one choke point all agent relay resolution flows through +/// (reconcile, spawn, profile sync), uniform for both Local and Provider +/// backends. +pub fn effective_agent_relay_url(record_relay: &str, workspace_relay: &str) -> String { + let pinned = record_relay.trim(); + if pinned.is_empty() { + workspace_relay.to_string() + } else { + pinned.to_string() + } } pub fn relay_http_base_url(relay_url: &str) -> String { @@ -484,8 +489,8 @@ pub async fn sync_managed_agent_profile( /// /// Queries the relay identified by `relay_url`. Callers uniformly pass the /// relay resolved by `effective_agent_relay_url` for every agent regardless of -/// backend — always the active workspace relay — so the query targets the host -/// the profile is actually published to. +/// backend — the record's pinned relay, falling back to the active workspace +/// relay — so the query targets the host the profile is actually published to. /// /// Returns the parsed profile content (display_name, picture) if a kind:0 event /// exists for the given pubkey, or `None` if no profile is published. @@ -742,15 +747,23 @@ mod tests { ); } - // ── effective_agent_relay_url: legacy pin ignored ───────────────────────── + // ── effective_agent_relay_url: pin honored when set ────────────────────── #[test] - fn stored_relay_pin_is_ignored() { - // Zero-touch cutover (#2122): a creation-era per-record relay pin is - // parsed and persisted but never consulted — the workspace relay wins. + fn stored_relay_pin_is_honored() { + // #2515: an instance minted for a community stays bound to that + // community's relay even when another workspace is active. assert_eq!( effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"), - "wss://staging.example.com" + "wss://relay.other.com" + ); + } + + #[test] + fn stored_relay_pin_is_trimmed() { + assert_eq!( + effective_agent_relay_url(" wss://relay.other.com ", "wss://staging.example.com"), + "wss://relay.other.com" ); } diff --git a/desktop/src/features/messages/lib/foreignPinnedAgentPubkeys.test.mjs b/desktop/src/features/messages/lib/foreignPinnedAgentPubkeys.test.mjs new file mode 100644 index 0000000000..524ad8132a --- /dev/null +++ b/desktop/src/features/messages/lib/foreignPinnedAgentPubkeys.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { foreignPinnedAgentPubkeys } from "./foreignPinnedAgentPubkeys.ts"; + +const A = "a".repeat(64); +const B = "b".repeat(64); +const C = "c".repeat(64); + +test("instances pinned to another relay are foreign", () => { + const foreign = foreignPinnedAgentPubkeys( + [ + { pubkey: A, relayUrl: "wss://one.example" }, + { pubkey: B, relayUrl: "wss://two.example" }, + ], + "wss://one.example", + ); + assert.deepEqual([...foreign], [B]); +}); + +test("unpinned instances are never foreign", () => { + const foreign = foreignPinnedAgentPubkeys( + [ + { pubkey: A, relayUrl: "" }, + { pubkey: B, relayUrl: " " }, + { pubkey: C, relayUrl: null }, + ], + "wss://one.example", + ); + assert.equal(foreign.size, 0); +}); + +test("pin comparison tolerates scheme, trailing slash, and case", () => { + const foreign = foreignPinnedAgentPubkeys( + [ + { pubkey: A, relayUrl: "one.example" }, + { pubkey: B, relayUrl: "WSS://One.Example/" }, + { pubkey: C, relayUrl: "wss://two.example/" }, + ], + "wss://one.example", + ); + assert.deepEqual([...foreign], [C]); +}); + +test("no active relay yields no foreign pins", () => { + const agents = [{ pubkey: A, relayUrl: "wss://one.example" }]; + assert.equal(foreignPinnedAgentPubkeys(agents, null).size, 0); + assert.equal(foreignPinnedAgentPubkeys(agents, "").size, 0); + assert.equal(foreignPinnedAgentPubkeys(agents, undefined).size, 0); +}); diff --git a/desktop/src/features/messages/lib/foreignPinnedAgentPubkeys.ts b/desktop/src/features/messages/lib/foreignPinnedAgentPubkeys.ts new file mode 100644 index 0000000000..082af73b9a --- /dev/null +++ b/desktop/src/features/messages/lib/foreignPinnedAgentPubkeys.ts @@ -0,0 +1,33 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** Scheme-defaulted, slash-trimmed, case-folded form for pin comparison. */ +function canonicalRelayUrl(url: string): string { + const trimmed = url.trim().replace(/\/+$/, "").toLowerCase(); + return trimmed.startsWith("ws://") || trimmed.startsWith("wss://") + ? trimmed + : `wss://${trimmed}`; +} + +/** + * Pubkeys of the user's own managed-agent instances that are pinned to a + * different community's relay (#2515). A pinned instance runs — and answers + * mentions — only on its own relay, so it must not be a mention candidate in + * the active community, even when a stale channel membership still lists it. + * Unpinned instances follow the active workspace relay and are never foreign. + */ +export function foreignPinnedAgentPubkeys( + agents: readonly { pubkey: string; relayUrl?: string | null }[], + activeRelayUrl: string | null | undefined, +): Set { + const foreign = new Set(); + const activeRelay = activeRelayUrl?.trim(); + if (!activeRelay) return foreign; + const active = canonicalRelayUrl(activeRelay); + for (const agent of agents) { + const pin = agent.relayUrl?.trim(); + if (pin && canonicalRelayUrl(pin) !== active) { + foreign.add(normalizePubkey(agent.pubkey)); + } + } + return foreign; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..f033107471 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -9,6 +9,8 @@ import { useChannelMembersQuery, useChannelsQuery, } from "@/features/channels/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { foreignPinnedAgentPubkeys } from "./foreignPinnedAgentPubkeys"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import { @@ -56,6 +58,19 @@ export type PersonaMentionTarget = { type UseMentionsOptions = { channelType?: ChannelType | null; }; +function dedupeTrimmedNames(names: Iterable) { + const output: string[] = []; + const seen = new Set(); + for (const name of names) { + const trimmed = name?.trim(); + if (trimmed && !seen.has(trimmed.toLowerCase())) { + output.push(trimmed); + seen.add(trimmed.toLowerCase()); + } + } + return output; +} + function formatSearchUserDisplayName(user: UserSearchResult) { return user.displayName?.trim() || user.nip05Handle?.trim() || null; } @@ -169,6 +184,16 @@ export function useMentions( ), [managedAgentsQuery.data], ); + // #2515: instances pinned to another community answer mentions only there. + const { activeCommunity } = useCommunities(); + const foreignAgentPubkeys = React.useMemo( + () => + foreignPinnedAgentPubkeys( + managedAgentsQuery.data ?? [], + activeCommunity?.relayUrl, + ), + [activeCommunity?.relayUrl, managedAgentsQuery.data], + ); const relayAgentNamesByPubkey = React.useMemo( () => new Map( @@ -246,6 +271,9 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } + if (foreignAgentPubkeys.has(pubkey)) { + return; + } if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { return; } @@ -416,6 +444,7 @@ export function useMentions( canSearchGlobalUsers, currentPubkey, directoryAgentPubkeys, + foreignAgentPubkeys, isArchivedDiscovery, managedAgentNamesByPubkey, managedAgentPersonaIds, @@ -457,56 +486,27 @@ export function useMentions( enabled: ownerPubkeys.length > 0, }); - const searchableNames = React.useMemo(() => { - const names: string[] = []; - const seen = new Set(); - - for (const candidate of mentionCandidatesWithTeams) { - for (const name of [ - candidate.displayName, - candidate.personaName, - candidate.secondaryLabel, - ]) { - const trimmed = name?.trim(); - if (trimmed && !seen.has(trimmed.toLowerCase())) { - names.push(trimmed); - seen.add(trimmed.toLowerCase()); - } - } - } - - return names; - }, [mentionCandidatesWithTeams]); - - const highlightNames = React.useMemo(() => { - const names: string[] = []; - const seen = new Set(); - - for (const name of selectedMentionNames) { - const trimmed = name.trim(); - if (trimmed && !seen.has(trimmed.toLowerCase())) { - names.push(trimmed); - seen.add(trimmed.toLowerCase()); - } - } - - return names; - }, [selectedMentionNames]); - - const agentHighlightNames = React.useMemo(() => { - const names: string[] = []; - const seen = new Set(); + const searchableNames = React.useMemo( + () => + dedupeTrimmedNames( + mentionCandidatesWithTeams.flatMap((candidate) => [ + candidate.displayName, + candidate.personaName, + candidate.secondaryLabel, + ]), + ), + [mentionCandidatesWithTeams], + ); - for (const name of selectedAgentMentionNames) { - const trimmed = name.trim(); - if (trimmed && !seen.has(trimmed.toLowerCase())) { - names.push(trimmed); - seen.add(trimmed.toLowerCase()); - } - } + const highlightNames = React.useMemo( + () => dedupeTrimmedNames(selectedMentionNames), + [selectedMentionNames], + ); - return names; - }, [selectedAgentMentionNames]); + const agentHighlightNames = React.useMemo( + () => dedupeTrimmedNames(selectedAgentMentionNames), + [selectedAgentMentionNames], + ); const searchableNamesLower = React.useMemo( () => searchableNames.map((n) => n.toLowerCase()),