diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 40abe82ee95..72df8e2ca24 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -79,6 +79,7 @@ export default defineConfig({ "**/mentions.spec.ts", "**/mention-spacing.spec.ts", "**/mention-clipboard.spec.ts", + "**/mention-descriptions-screenshots.spec.ts", "**/cloud-provenance.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 9693f1563ac..5e69dfeed11 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -50,6 +50,13 @@ pub struct UserProfileSummaryInfo { #[serde(default)] pub name: Option, pub avatar_url: Option, + /// Kind-0 `about` field — one-line role/description surfaced in the + /// @-mention selector for agents. Untrusted, and capped to + /// `nostr_convert::MENTION_ABOUT_MAX_BYTES` bytes at the point it is + /// built from the event (`nostr_convert::users_batch_from_events`), not + /// merely at render — see that constant's doc comment. + #[serde(default)] + pub about: Option, pub nip05_handle: Option, pub owner_pubkey: Option, #[serde(default)] @@ -67,6 +74,13 @@ pub struct UserSearchResultInfo { pub pubkey: String, pub display_name: Option, pub avatar_url: Option, + /// Kind-0 `about` field — one-line role/description surfaced in the + /// @-mention selector for agents. Untrusted, and capped to + /// `nostr_convert::MENTION_ABOUT_MAX_BYTES` bytes at the point it is + /// built from the event (`nostr_convert::user_search_result_from_event`), + /// not merely at render — see that constant's doc comment. + #[serde(default)] + pub about: Option, pub nip05_handle: Option, pub owner_pubkey: Option, #[serde(default)] diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 51f648769d3..15e2ec6d51c 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -55,6 +55,42 @@ fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator) -> Option { + about.map(|s| { + if s.len() <= MENTION_ABOUT_MAX_BYTES { + return s; + } + let mut end = MENTION_ABOUT_MAX_BYTES; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() + }) +} + /// Return the owner pubkey from a valid NIP-OA owner tag on a kind:0 profile. /// /// NIP-OA requires a valid event and exactly one well-formed `auth` tag whose @@ -358,6 +394,9 @@ pub fn users_batch_from_events( .map(str::to_string), name: v.get("name").and_then(Value::as_str).map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), + about: truncate_mention_about( + v.get("about").and_then(Value::as_str).map(str::to_string), + ), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), owner_pubkey, diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs index 68d8cb7dcbb..2253ce011d8 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -278,6 +278,75 @@ fn users_batch_marks_valid_nip_oa_profiles_as_agents() { ); } +#[test] +fn users_batch_carries_about_when_present() { + let with_about = ev( + 0, + r#"{"display_name":"Bumble","about":"Researcher — deep dives & sourcing"}"#, + vec![], + ); + let without_about = ev(0, r#"{"display_name":"Fizz"}"#, vec![]); + let pk_with = with_about.pubkey.to_hex(); + let pk_without = without_about.pubkey.to_hex(); + + let resp = users_batch_from_events( + &[with_about, without_about], + &[pk_with.clone(), pk_without.clone()], + ); + assert_eq!( + resp.profiles[&pk_with].about.as_deref(), + Some("Researcher — deep dives & sourcing") + ); + assert_eq!(resp.profiles[&pk_without].about, None); +} + +#[test] +fn users_batch_truncates_oversized_about_at_the_seam() { + // A batch response has no per-request limit (`get_users_batch` sends the + // filter with no `limit`) and a kind:0 `content` may reach 256 KiB + // (`MAX_EVENT_CONTENT_BYTES`), so the DTO must not carry the untrusted + // `about` verbatim across the Tauri IPC boundary — the frontend's + // 120-grapheme render cap runs downstream and does not bound this. + let oversized_about = "a".repeat(MENTION_ABOUT_MAX_BYTES * 4); + let event = ev( + 0, + &format!(r#"{{"display_name":"Bumble","about":"{oversized_about}"}}"#), + vec![], + ); + let pk = event.pubkey.to_hex(); + + let resp = users_batch_from_events(std::slice::from_ref(&event), std::slice::from_ref(&pk)); + + let capped = resp.profiles[&pk].about.as_deref().expect("about present"); + assert!( + capped.len() <= MENTION_ABOUT_MAX_BYTES, + "capped about is {} bytes, expected <= {MENTION_ABOUT_MAX_BYTES}", + capped.len() + ); + assert!(capped.len() < oversized_about.len()); +} + +#[test] +fn truncate_mention_about_cuts_on_a_char_boundary() { + // Every grapheme here is a 4-byte UTF-8 scalar (outside the BMP), chosen + // so a naive byte-index slice would land mid-character and panic. + let about = "\u{1F600}".repeat(MENTION_ABOUT_MAX_BYTES); // far over the cap + let truncated = truncate_mention_about(Some(about)).expect("some"); + assert!(truncated.len() <= MENTION_ABOUT_MAX_BYTES); + assert!(truncated.is_char_boundary(truncated.len())); + // Sanity: the result is still valid UTF-8 (would already panic above if not). + assert!(!truncated.is_empty()); +} + +#[test] +fn truncate_mention_about_leaves_short_strings_untouched() { + assert_eq!( + truncate_mention_about(Some("short bio".to_string())).as_deref(), + Some("short bio") + ); + assert_eq!(truncate_mention_about(None), None); +} + #[test] fn user_notes_builds_cursor_from_last() { let e1 = ev(1, "first", vec![]); diff --git a/desktop/src-tauri/src/nostr_convert/user_search.rs b/desktop/src-tauri/src/nostr_convert/user_search.rs index 43b4288abbb..3bff7921f5b 100644 --- a/desktop/src-tauri/src/nostr_convert/user_search.rs +++ b/desktop/src-tauri/src/nostr_convert/user_search.rs @@ -19,6 +19,9 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), + about: super::truncate_mention_about( + v.get("about").and_then(Value::as_str).map(str::to_string), + ), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), owner_pubkey, @@ -238,6 +241,37 @@ mod tests { assert_eq!(r.users[1].display_name.as_deref(), Some("B")); } + #[test] + fn user_search_result_carries_about_when_present() { + let with_about = ev(0, r#"{"name":"honey","about":"Writer bee"}"#, vec![]); + let without_about = ev(0, r#"{"name":"fizz"}"#, vec![]); + + assert_eq!( + user_search_result_from_event(&with_about).about.as_deref(), + Some("Writer bee") + ); + assert_eq!(user_search_result_from_event(&without_about).about, None); + } + + #[test] + fn user_search_result_truncates_oversized_about_at_the_seam() { + // `search_users` is bounded to 500 rows (not unbounded like the batch + // command) but each row's `about` was still uncapped before crossing + // the IPC boundary — cap it here too, matching `users_batch`. + let oversized_about = "a".repeat(super::super::MENTION_ABOUT_MAX_BYTES * 4); + let event = ev( + 0, + &format!(r#"{{"name":"honey","about":"{oversized_about}"}}"#), + vec![], + ); + + let capped = user_search_result_from_event(&event) + .about + .expect("about present"); + assert!(capped.len() <= super::super::MENTION_ABOUT_MAX_BYTES); + assert!(capped.len() < oversized_about.len()); + } + #[test] fn user_search_result_marks_valid_nip_oa_profile_as_agent() { let event = oa_profile_event(r#"{"display_name":"Mira"}"#); diff --git a/desktop/src/features/channels/ui/useMessageProfiles.test.mjs b/desktop/src/features/channels/ui/useMessageProfiles.test.mjs new file mode 100644 index 00000000000..024ac5478e2 --- /dev/null +++ b/desktop/src/features/channels/ui/useMessageProfiles.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// Regression coverage for the `about` branch of `profileLookupsEqual` +// (identity.ts) through its one production caller. `about` is what makes +// the mention selector's role line pick up an about-only kind-0 update +// (mentionSuggestionMapping.ts): the stabilized reference this hook returns +// must be RELEASED (a new object identity) when only `about` changes, or +// MessageRow's `prev.profiles === next.profiles` memo keeps rendering the +// stale bio. See identity.test.mjs for the field-by-field coverage of +// `profileLookupsEqual` itself. + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +const PUBKEY = "a".repeat(64); + +function profiles(about) { + return { + [PUBKEY]: { + displayName: "Ada", + name: null, + avatarUrl: null, + about, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }, + }; +} + +async function renderMessageProfiles(initialProps) { + const { renderHook } = await import("@testing-library/react"); + const { useMessageProfiles } = await import("./useMessageProfiles.ts"); + + return renderHook((props) => useMessageProfiles(props), { initialProps }); +} + +const baseProps = { + channelMembers: undefined, + currentProfile: undefined, + currentPubkey: undefined, + managedAgents: [], + relayAgents: [], +}; + +test("releases the stabilized reference when only `about` changes", async () => { + const { result, rerender } = await renderMessageProfiles({ + ...baseProps, + profiles: profiles("Researcher — deep dives"), + }); + + const first = result.current; + assert.equal(first[PUBKEY].about, "Researcher — deep dives"); + + rerender({ ...baseProps, profiles: profiles("Now doing something else") }); + + const second = result.current; + assert.notEqual( + second, + first, + "an about-only change must release the stabilized reference", + ); + assert.equal(second[PUBKEY].about, "Now doing something else"); +}); + +test("keeps the stabilized reference when no profile value actually changed", async () => { + const { result, rerender } = await renderMessageProfiles({ + ...baseProps, + profiles: profiles("Researcher — deep dives"), + }); + + const first = result.current; + + // A fresh `profiles` object with value-identical content — the shape a + // re-keyed `users-batch` query produces on typing churn. + rerender({ ...baseProps, profiles: profiles("Researcher — deep dives") }); + + assert.equal( + result.current, + first, + "a value-identical re-key must keep the stabilized reference", + ); +}); diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 61373886352..f8467647728 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -117,6 +117,9 @@ export function buildMentionCandidates({ role: current.role ?? candidate.role ?? null, secondaryLabel: current.secondaryLabel ?? candidate.secondaryLabel ?? null, + // `??` (not `?? null`) so both-undefined stays undefined — that is + // the "still unknown, resolve from the profile lookup" signal. + description: current.description ?? candidate.description, ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? @@ -219,6 +222,7 @@ export function buildMentionCandidates({ relayAgentNamesByPubkey.has(pubkey), personaName: personaNameByPubkey.get(pubkey) ?? null, secondaryLabel: formatSearchUserSecondaryLabel(user), + description: user.about ?? null, ownerPubkey: user.ownerPubkey ?? null, isGlobalSearchResult: true, isManagedAgent: managedAgentNamesByPubkey.has(pubkey), diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 659832deadf..f8259731968 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -44,6 +44,10 @@ export type MentionCandidate = { role?: ChannelRole | null; personaName?: string | null; secondaryLabel?: string | null; + /** Kind-0 `about` when already resolved at candidate-build time (e.g. from + * a user-search result). `undefined` means "unknown — resolve from the + * profile lookup at suggestion-mapping time"; `null` means "known absent". */ + description?: string | null; ownerPubkey?: string | null; isAgent: boolean; isActiveAgent?: boolean; diff --git a/desktop/src/features/messages/lib/mentionFallbackWindow.test.mjs b/desktop/src/features/messages/lib/mentionFallbackWindow.test.mjs new file mode 100644 index 00000000000..40f4469041e --- /dev/null +++ b/desktop/src/features/messages/lib/mentionFallbackWindow.test.mjs @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { rankMentionCandidates } from "./mentionRanking.ts"; +import { + MENTION_SUGGESTION_LIMIT, + rankVisibleMentionCandidates, + selectAgentProfileFallbackPubkeys, +} from "./mentionFallbackWindow.ts"; + +function agentCandidate(n, overrides = {}) { + return { + displayName: `Agent${String(n).padStart(3, "0")}`, + isAgent: true, + isMember: false, + kind: "identity", + pubkey: `${n.toString(16).padStart(4, "0")}`.padEnd(64, "0"), + ...overrides, + }; +} + +test("bounds the fallback batch to MENTION_SUGGESTION_LIMIT even with 150 mentionable agents", () => { + // Production seam: useMentions.ts's agentProfilePubkeys calls exactly this + // function on exactly the output of rankVisibleMentionCandidates. A + // community with 150 mentionable agents and no caller-scoped `profiles` + // must never turn into a 150-author get_users_batch request — the relay + // clamps a limit-less filter to 100 rows, and a truncated page gets + // cached as confirmed-missing (see the docblock on + // selectAgentProfileFallbackPubkeys). + const candidates = Array.from({ length: 150 }, (_, i) => agentCandidate(i)); + const visible = rankVisibleMentionCandidates(candidates, "", new Set()); + assert.equal(visible.length, MENTION_SUGGESTION_LIMIT); + + const pubkeys = selectAgentProfileFallbackPubkeys(visible, undefined); + assert.equal(pubkeys.length, MENTION_SUGGESTION_LIMIT); + // Every returned pubkey actually belongs to a candidate in the input — + // never a synthesized or out-of-window pubkey. + const visiblePubkeys = new Set(visible.map((r) => r.candidate.pubkey)); + for (const pubkey of pubkeys) { + assert.ok(visiblePubkeys.has(pubkey)); + } +}); + +test("an agent past the first page is excluded from the bare-query window but resolves once the query ranks it into view", () => { + const candidates = Array.from({ length: 150 }, (_, i) => agentCandidate(i)); + const lastCandidate = candidates[149]; + + const bareVisible = rankVisibleMentionCandidates(candidates, "", new Set()); + const barePubkeys = selectAgentProfileFallbackPubkeys(bareVisible, undefined); + assert.ok( + !barePubkeys.includes(lastCandidate.pubkey), + "the 150th agent should be outside the default-ranked first page", + ); + + // Narrowing the query to its exact display name ranks it first (an + // exact-match label always scores ahead of a mere prefix or substring + // match — see mentionRanking.ts's scoreMentionCandidateLabel) and brings + // it into the window, so it gets its own bounded request. + const narrowedVisible = rankVisibleMentionCandidates( + candidates, + lastCandidate.displayName, + new Set(), + ); + const narrowedPubkeys = selectAgentProfileFallbackPubkeys( + narrowedVisible, + undefined, + ); + assert.ok(narrowedPubkeys.includes(lastCandidate.pubkey)); +}); + +test("excludes agents whose about is already known", () => { + const known = agentCandidate(1, { description: "Already known" }); + const unknown = agentCandidate(2); + const visible = rankVisibleMentionCandidates([known, unknown], "", new Set()); + const pubkeys = selectAgentProfileFallbackPubkeys(visible, undefined); + assert.deepEqual(pubkeys, [unknown.pubkey]); +}); + +test("excludes agents already covered by the caller's profiles prop", () => { + const covered = agentCandidate(1); + const uncovered = agentCandidate(2); + const visible = rankVisibleMentionCandidates( + [covered, uncovered], + "", + new Set(), + ); + const pubkeys = selectAgentProfileFallbackPubkeys(visible, { + [covered.pubkey]: { + displayName: null, + name: null, + avatarUrl: null, + about: "Known via the caller", + nip05Handle: null, + ownerPubkey: null, + }, + }); + assert.deepEqual(pubkeys, [uncovered.pubkey]); +}); + +test("includes an agent whose lookup entry exists but carries no about — the mergeAgentNamesIntoProfiles shape", () => { + // Production seam: mergeAgentNamesIntoProfiles (useChannelActivityTyping.ts) + // synthesizes a profile-lookup entry for every managed/relay agent with + // displayName / avatarUrl / nip05Handle / ownerPubkey / isAgent and NO + // `about` key at all — a real entry, not a hypothetical one. Treating + // entry presence as "about known" would suppress the fallback for exactly + // the agents it exists to cover; the fix must key off `about` being + // present, not the entry. + const candidate = agentCandidate(1); + const visible = rankVisibleMentionCandidates([candidate], "", new Set()); + const pubkeys = selectAgentProfileFallbackPubkeys(visible, { + [candidate.pubkey]: { + displayName: "Bumble", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }, + }); + assert.deepEqual(pubkeys, [candidate.pubkey]); +}); + +test("matches the caller's profiles lookup by normalized pubkey, like the mapper does", () => { + const candidate = agentCandidate(1, { + pubkey: agentCandidate(1).pubkey.toUpperCase(), + }); + const visible = rankVisibleMentionCandidates([candidate], "", new Set()); + const pubkeys = selectAgentProfileFallbackPubkeys(visible, { + [candidate.pubkey.toLowerCase()]: { + displayName: null, + name: null, + avatarUrl: null, + about: "Known via the caller, keyed lowercase", + nip05Handle: null, + ownerPubkey: null, + }, + }); + assert.deepEqual(pubkeys, []); +}); + +test("excludes non-agent candidates even when their description is unknown", () => { + const person = agentCandidate(1, { isAgent: false }); + const agent = agentCandidate(2); + const visible = rankVisibleMentionCandidates([person, agent], "", new Set()); + const pubkeys = selectAgentProfileFallbackPubkeys(visible, undefined); + assert.deepEqual(pubkeys, [agent.pubkey]); +}); + +test("dedupes repeated pubkeys in the ranked window", () => { + const agent = agentCandidate(1); + const ranked = rankMentionCandidates([agent, { ...agent }], "", new Set()); + const pubkeys = selectAgentProfileFallbackPubkeys(ranked, undefined); + assert.deepEqual(pubkeys, [agent.pubkey]); +}); diff --git a/desktop/src/features/messages/lib/mentionFallbackWindow.ts b/desktop/src/features/messages/lib/mentionFallbackWindow.ts new file mode 100644 index 00000000000..21bc5b97c3a --- /dev/null +++ b/desktop/src/features/messages/lib/mentionFallbackWindow.ts @@ -0,0 +1,89 @@ +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + type MentionCandidateForRanking, + rankMentionCandidates, + type RankedMentionCandidate, +} from "./mentionRanking"; + +/** + * Cap on the mention picker's rendered suggestion list, and — via + * {@link rankVisibleMentionCandidates} — on the agent profile fallback batch + * in useMentions.ts's `agentProfilePubkeys`. The two must share one bound: + * on a surface with no scoped `profiles` prop the mentionable set can be a + * community's whole agent directory. `get_users_batch` sends a `kind: 0` + * filter with no `limit` (desktop/src-tauri/src/commands/profile.rs), and + * the relay clamps a limit-less filter to `DEFAULT_MAX_PAGE_LIMIT` — 1,000 + * rows (crates/buzz-db/src/store/event.rs), also the advertised NIP-11 + * `max_limit` (crates/buzz-relay/src/nip11.rs). This bound is not sized + * against that 1,000-row ceiling; it is sized to match what the picker + * renders, so the fallback never fetches an about for an agent the user + * can't see. Requesting every unknown agent in the directory instead risks + * a truncated page (past 1,000 mentionable agents) being cached as + * confirmed-missing. Keeping the fallback to this same 50-row window keeps + * every request an order of magnitude under that clamp. + */ +export const MENTION_SUGGESTION_LIMIT = 50; + +/** Ranks `candidates` against `query` and cuts to {@link MENTION_SUGGESTION_LIMIT}. */ +export function rankVisibleMentionCandidates< + T extends MentionCandidateForRanking, +>( + candidates: readonly T[], + query: string, + activePersonaIds: ReadonlySet, +): RankedMentionCandidate[] { + return rankMentionCandidates(candidates, query, activePersonaIds).slice( + 0, + MENTION_SUGGESTION_LIMIT, + ); +} + +export type AgentProfileFallbackCandidate = MentionCandidateForRanking & { + description?: string | null; +}; + +/** + * Agent pubkeys whose kind-0 `about` is not already known — neither + * resolved at candidate-build time (search results) nor present in the + * caller's profile lookup — drawn ONLY from the already-ranked, already- + * bounded visible window (`rankedVisibleCandidates`), never the full + * mentionable-agent set. Batch-resolving just this window lets the selector + * show a role line even for agents who haven't authored anything in the + * loaded timeline, without the unbounded-batch failure mode + * {@link MENTION_SUGGESTION_LIMIT} documents. + * + * Coverage is judged by `about` being present, not by the lookup entry + * existing: `mergeAgentNamesIntoProfiles` and `mergeMemberAgentFlagsIntoProfiles` + * (`useChannelActivityTyping.ts`) synthesize a profile-lookup entry for every + * managed/relay/member-flagged agent with `displayName` / `avatarUrl` / + * `nip05Handle` / `ownerPubkey` / `isAgent` and **no `about` key at all** — + * that entry is a real production shape, not a hypothetical one. Treating + * entry presence as "about known" (the old `!profiles?.[...]` check) skips + * those candidates permanently, so the fallback never runs the one time it + * would recover their role line. Checking `about === undefined` instead + * distinguishes "profile known, about absent" (skip — there is nothing to + * fetch) from "profile not fetched" (include). The key is normalized to + * match {@link mapMentionCandidateToSuggestion}'s lookup + * (`profiles?.[normalizePubkey(pubkey)]?.about`) in mentionSuggestionMapping.ts. + */ +export function selectAgentProfileFallbackPubkeys< + T extends AgentProfileFallbackCandidate, +>( + rankedVisibleCandidates: readonly RankedMentionCandidate[], + profiles: UserProfileLookup | undefined, +): string[] { + return [ + ...new Set( + rankedVisibleCandidates + .filter( + ({ candidate }) => + candidate.isAgent && + candidate.pubkey && + candidate.description === undefined && + profiles?.[normalizePubkey(candidate.pubkey)]?.about === undefined, + ) + .map(({ candidate }) => candidate.pubkey as string), + ), + ]; +} diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs b/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs index 599b59a8117..b3c44681780 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs @@ -4,11 +4,12 @@ import test from "node:test"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping.ts"; const OWNER = "a".repeat(64); +const AGENT_PUBKEY = "b".repeat(64); function candidate(overrides = {}) { return { kind: "identity", - pubkey: "b".repeat(64), + pubkey: AGENT_PUBKEY, isAgent: true, isMember: true, ownerPubkey: OWNER, @@ -16,6 +17,10 @@ function candidate(overrides = {}) { }; } +function agentCandidate(overrides = {}) { + return candidate(overrides); +} + function suggestion(overrides = {}, agentProvenanceReady = true) { return mapMentionCandidateToSuggestion({ agentProvenanceReady, @@ -25,6 +30,18 @@ function suggestion(overrides = {}, agentProvenanceReady = true) { }); } +function profileSummary(about) { + return { + displayName: "Bumble", + name: null, + avatarUrl: null, + about, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }; +} + test("labels Desktop-managed agent identities as managed here", () => { assert.equal( suggestion({ isManagedAgent: true }).agentProvenance, @@ -58,3 +75,121 @@ test("does not attribute people or personas to a device", () => { undefined, ); }); + +test("agent description comes from the candidate when resolved at build time", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ description: "Researcher — deep dives" }), + label: "Bumble", + profiles: { [AGENT_PUBKEY]: profileSummary("stale profile about") }, + }); + + assert.equal(result.description, "Researcher — deep dives"); +}); + +test("agent description falls back to the profile lookup's about", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate(), + label: "Bumble", + profiles: { [AGENT_PUBKEY]: profileSummary("Researcher — deep dives") }, + }); + + assert.equal(result.description, "Researcher — deep dives"); +}); + +test("agent description is null when about is missing everywhere", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate(), + label: "Bumble", + profiles: { [AGENT_PUBKEY]: profileSummary(null) }, + }); + + assert.equal(result.description, null); +}); + +test("non-agent suggestions never carry a description", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ isAgent: false }), + label: "Alice", + profiles: { [AGENT_PUBKEY]: profileSummary("A human bio") }, + }); + + assert.equal(result.description, null); +}); + +test("multi-line about collapses to a single trimmed line", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ + description: " Writer bee.\nDrafts docs\n\tand posts. ", + }), + label: "Honey", + }); + + assert.equal(result.description, "Writer bee. Drafts docs and posts."); +}); + +test("whitespace-only about degrades to null (name-only row)", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ description: " \n " }), + label: "Fizz", + }); + + assert.equal(result.description, null); +}); + +test("an unbounded about is capped to 120 graphemes with an ellipsis", () => { + // A plausible non-hostile case: an agent whose `about` is its full system + // prompt. Also stands in for the relay's 256 KiB kind-0 content ceiling — + // this suite doesn't build a string that large, it proves the cap applies + // to any input longer than the bound, regardless of size. + const hugeAbout = "S".repeat(200_000); + + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ description: hugeAbout }), + label: "Codex", + }); + + assert.equal(result.description?.length, 120); + assert.ok(result.description?.endsWith("…")); + assert.equal(result.description, `${"S".repeat(119)}…`); +}); + +test("an about exactly at the cap is left untouched", () => { + const exact = "A".repeat(120); + + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ description: exact }), + label: "Codex", + }); + + assert.equal(result.description, exact); +}); + +test("description length cap counts graphemes, not UTF-16 code units", () => { + // Each family emoji is one grapheme cluster spanning multiple UTF-16 code + // units (ZWJ sequence) — a code-unit-based cap would truncate mid-cluster. + const family = "\u{1F468}‍\u{1F469}‍\u{1F467}‍\u{1F466}"; + const about = family.repeat(130); + + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ description: about }), + label: "Codex", + }); + + assert.ok(result.description); + const graphemeCount = Array.from( + new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment( + result.description, + ), + ).length; + assert.equal(graphemeCount, 120); + assert.ok(result.description.endsWith("…")); +}); diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index b0983cee52d..2758b719375 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -8,6 +8,35 @@ import type { MentionCandidate, TeamMentionMember } from "./mentionCandidates"; import { mentionCandidateLabel } from "./mentionCandidates"; import { pickDefaultAgentCandidate } from "./mentionRanking"; +// Grapheme cap on the rendered role line. `about` is untrusted kind-0 +// content with no upstream length bound (the relay allows up to 256 KiB of +// kind-0 content) — this is what keeps a hostile or merely verbose bio from +// reaching the DOM (text node, `title` attribute, and the accessible +// description) at full length. The full bio remains available on the +// profile surface; this is a quick-picker row, not a profile card. +export const MENTION_DESCRIPTION_MAX_GRAPHEMES = 120; + +const mentionDescriptionSegmenter = + typeof Intl.Segmenter === "function" + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; + +function mentionDescriptionGraphemes(text: string): string[] { + return mentionDescriptionSegmenter + ? Array.from( + mentionDescriptionSegmenter.segment(text), + ({ segment }) => segment, + ) + : Array.from(text); +} + +/** Caps a role-line description to {@link MENTION_DESCRIPTION_MAX_GRAPHEMES}. */ +function truncateMentionDescription(text: string): string { + const graphemes = mentionDescriptionGraphemes(text); + if (graphemes.length <= MENTION_DESCRIPTION_MAX_GRAPHEMES) return text; + return `${graphemes.slice(0, MENTION_DESCRIPTION_MAX_GRAPHEMES - 1).join("")}…`; +} + export type MentionSuggestionCandidate = { kind: "identity" | "persona" | "team"; pubkey?: string; @@ -19,6 +48,7 @@ export type MentionSuggestionCandidate = { isManagedAgent?: boolean; isMember: boolean; role?: ChannelRole | null; + description?: string | null; ownerPubkey?: string | null; }; @@ -44,6 +74,21 @@ export function mapMentionCandidateToSuggestion(opts: { ? formatOwnerLabel(candidate.ownerPubkey, currentPubkey, ownerProfiles) : null; + // Agent role line: prefer an `about` resolved at candidate-build time + // (search results), else fall back to the profile lookup like avatarUrl. + // Collapsed to a single line — the selector is a quick picker, not a + // profile card. + const rawDescription = candidate.isAgent + ? (candidate.description ?? + (candidate.pubkey + ? profiles?.[normalizePubkey(candidate.pubkey)]?.about + : null)) + : null; + const collapsedDescription = rawDescription?.replace(/\s+/g, " ").trim(); + const description = collapsedDescription + ? truncateMentionDescription(collapsedDescription) + : null; + return { pubkey: candidate.pubkey, personaId: candidate.personaId ?? undefined, @@ -77,6 +122,7 @@ export function mapMentionCandidateToSuggestion(opts: { candidate.isMember === false, ownerLabel, role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, + description, }; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 38e6ed2212f..452848c4616 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -54,7 +54,11 @@ import { type MentionPickerMode, useMentionSelection, } from "./useMentionSelection"; -import { rankMentionCandidates } from "./mentionRanking"; +import { + MENTION_SUGGESTION_LIMIT, + rankVisibleMentionCandidates, + selectAgentProfileFallbackPubkeys, +} from "./mentionFallbackWindow"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; import { getMentionMemberPubkeys } from "./mentionMemberPubkeys"; import { @@ -64,8 +68,7 @@ import { type MentionCandidate, } from "./mentionCandidates"; import { buildMentionCandidates } from "./buildMentionCandidates"; -const MENTION_DEBOUNCE_MS = 120, - MENTION_SUGGESTION_LIMIT = 50; +const MENTION_DEBOUNCE_MS = 120; type UseMentionsOptions = { channelType?: ChannelType | null; recentMentionPubkeys?: readonly string[]; @@ -320,6 +323,50 @@ export function useMentions( const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, { enabled: ownerPubkeys.length > 0, }); + // Candidates ranked against the in-progress query and cut to + // MENTION_SUGGESTION_LIMIT — the same window the picker renders. Empty + // while the picker is closed (`mentionQuery === null`). Shared below by + // the agent profile fallback and by `matchingSuggestions`, so both agree + // on exactly which candidates are visible. See mentionFallbackWindow.ts + // for why the fallback must stay bounded to this window. + const rankedVisibleMentionCandidates = React.useMemo( + () => + mentionQuery === null + ? [] + : rankVisibleMentionCandidates( + mentionCandidatesWithTeams, + mentionQuery, + activePersonaIds, + ), + [activePersonaIds, mentionCandidatesWithTeams, mentionQuery], + ); + // Agent candidates whose kind-0 `about` is not already known — neither + // resolved at candidate-build time (search results) nor present in the + // caller's profile lookup. Batch-resolve them so the selector can show a + // role line even for agents who haven't authored anything in the loaded + // timeline. The per-pubkey entry cache behind useUsersBatchQuery keeps + // repeat lookups off the network. See mentionFallbackWindow.ts's + // selectAgentProfileFallbackPubkeys for why this is bounded to + // `rankedVisibleMentionCandidates` rather than every mentionable agent. + const agentProfilePubkeys = React.useMemo( + () => + selectAgentProfileFallbackPubkeys( + rankedVisibleMentionCandidates, + profiles, + ), + [rankedVisibleMentionCandidates, profiles], + ); + const agentProfilesQuery = useUsersBatchQuery(agentProfilePubkeys, { + enabled: agentProfilePubkeys.length > 0, + }); + const mentionProfiles = React.useMemo(() => { + const agentProfiles = agentProfilesQuery.data?.profiles; + if (!agentProfiles || Object.keys(agentProfiles).length === 0) { + return profiles; + } + // Caller-provided profiles win on conflict. + return { ...agentProfiles, ...profiles }; + }, [agentProfilesQuery.data?.profiles, profiles]); const searchableNames = React.useMemo( () => uniqueAutocompleteLabels(mentionCandidatesWithTeams), [mentionCandidatesWithTeams], @@ -368,17 +415,9 @@ export function useMentions( }, [], ); - const matchingSuggestions = React.useMemo(() => { - if (mentionQuery === null) { - return []; - } - return rankMentionCandidates( - mentionCandidatesWithTeams, - mentionQuery, - activePersonaIds, - ) - .slice(0, MENTION_SUGGESTION_LIMIT) - .map(({ candidate, label }) => + const matchingSuggestions = React.useMemo( + () => + rankedVisibleMentionCandidates.map(({ candidate, label }) => mapMentionCandidateToSuggestion({ agentProvenanceReady: agentDirectoriesReady, candidate, @@ -386,19 +425,18 @@ export function useMentions( channelType: options?.channelType, currentPubkey, ownerProfiles: ownerProfilesQuery.data?.profiles, - profiles, + profiles: mentionProfiles, }), - ); - }, [ - activePersonaIds, - agentDirectoriesReady, - currentPubkey, - mentionCandidatesWithTeams, - mentionQuery, - options?.channelType, - ownerProfilesQuery.data?.profiles, - profiles, - ]); + ), + [ + agentDirectoriesReady, + currentPubkey, + mentionProfiles, + options?.channelType, + ownerProfilesQuery.data?.profiles, + rankedVisibleMentionCandidates, + ], + ); const getDefaultAgentSuggestion = useDefaultAgentSuggestion({ activePersonaIds, agentProvenanceReady: agentDirectoriesReady, @@ -846,7 +884,7 @@ export function useMentions( channelType: options?.channelType, currentPubkey, ownerProfiles: ownerProfilesQuery.data?.profiles, - profiles, + profiles: mentionProfiles, requireExact: exactMentionSpace, }); if (exactMentionSpace && flushed?.type !== "match") @@ -880,10 +918,10 @@ export function useMentions( currentPubkey, isMentionOpen, mentionCandidatesWithTeams, + mentionProfiles, mentionSelectedIndex, options?.channelType, ownerProfilesQuery.data?.profiles, - profiles, setSelected, suggestions, ], diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs index c5eca20a048..410cf394521 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -275,6 +275,74 @@ test("collision npubs sit inline with agent metadata", async () => { assert.match(npub.className, /(?:^|\s)leading-none(?:\s|$)/); assert.match(agentMetadata?.className ?? "", /(?:^|\s)min-h-3\.5(?:\s|$)/); } + + // The visible npub must also be wired into aria-describedby: it's the + // one channel a screen-reader user has to tell the two "Same Name" rows + // apart, so its id has to actually be referenced, not just present in + // the DOM. + const rows = view.getAllByRole("button", { name: "Mention Same Name" }); + assert.equal(rows.length, 2); + for (const [index, row] of rows.entries()) { + assert.match(accessibleDescription(row), /npub1/); + assert.equal( + row.getAttribute("aria-describedby")?.includes(collisionNpubs[index].id), + true, + ); + } +}); + +test("the collision npub still reaches the accessible description when the owner label is nulled", async () => { + // Reproduces the Sol-flagged gap directly: `hasNameCollision && + // suggestion.agentProvenance` nulls `ownerLabel` in the collision branch + // (MentionAutocomplete.tsx), so the npub becomes the ONLY trusted + // disambiguator between two rows a screen reader would otherwise announce + // identically as "Mention Rex" with a self-authored-only description. + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const suggestions = [ + { + pubkey: "a".repeat(64), + displayName: "Rex", + isAgent: true, + ownerLabel: "you", + agentProvenance: "managed-elsewhere", + description: "managed by you · admin", + }, + { + pubkey: "b".repeat(64), + displayName: "Rex", + isAgent: true, + ownerLabel: "you", + agentProvenance: "managed-elsewhere", + description: "managed by you · admin", + }, + ]; + const view = render( + React.createElement(MentionAutocomplete, { + suggestions, + selectedIndex: 0, + onSelect: () => {}, + }), + ); + + const rows = view.getAllByRole("button", { name: "Mention Rex" }); + assert.equal(rows.length, 2); + const descriptions = rows.map(accessibleDescription); + + // Each row still names its trusted disambiguator (the npub) even though + // ownerLabel was nulled by the collision branch... + for (const description of descriptions) { + assert.match(description, /npub1/); + } + // ...alongside the self-authored bio, which is not hidden — just no + // longer the only thing announced. + for (const description of descriptions) { + assert.match(description, /managed by you · admin/); + } + // ...and the two rows are distinguishable from one another, which is the + // entire point of the npub existing in the collision branch. + assert.notEqual(descriptions[0], descriptions[1]); }); test("does not intercept Tab from the editor", async () => { @@ -387,3 +455,71 @@ for (const duplicate of [false, true]) { assert.equal(view.queryByTitle("From another Buzz setup"), null); }); } + +/** Resolves `element`'s accessible description via `aria-describedby`, + * concatenating the text content of every referenced id in order — the same + * mechanism screen readers use. */ +function accessibleDescription(element) { + const ids = (element.getAttribute("aria-describedby") ?? "") + .split(/\s+/) + .filter(Boolean); + return ids + .map((id) => element.ownerDocument.getElementById(id)?.textContent ?? "") + .join(" "); +} + +test("verified provenance is announced alongside a provenance-shaped `about`", async () => { + // Adversarial fixture: the agent's own (untrusted) `about` reads like a + // provenance claim for a DIFFERENT owner than the real one. If only the + // self-authored half were wired into aria-describedby, a screen-reader + // user would hear a fabricated "managed by you · admin" and never hear + // the real "managed by sarah" — the exact failure this guards against. + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const suggestion = { + pubkey: "a".repeat(64), + displayName: "Rex", + isAgent: true, + description: "managed by you · admin", + ownerLabel: "sarah", + }; + const view = render( + React.createElement(MentionAutocomplete, { + suggestions: [suggestion], + selectedIndex: 0, + onSelect: () => {}, + }), + ); + + const row = view.getByRole("button", { name: "Mention Rex" }); + const description = accessibleDescription(row); + + // The trusted owner label must reach the accessible description... + assert.match(description, /managed by sarah/); + // ...alongside the agent's own bio (self-authored text is not hidden, + // just no longer the ONLY thing announced). + assert.match(description, /managed by you · admin/); +}); + +test("a name-only agent with no description still announces provenance", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const suggestion = { + pubkey: "b".repeat(64), + displayName: "Buzzy", + isAgent: true, + ownerLabel: "sarah", + }; + const view = render( + React.createElement(MentionAutocomplete, { + suggestions: [suggestion], + selectedIndex: 0, + onSelect: () => {}, + }), + ); + + const row = view.getByRole("button", { name: "Mention Buzzy" }); + assert.match(accessibleDescription(row), /managed by sarah/); +}); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index a380ac83a09..b79a43e85ac 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -31,6 +31,8 @@ export type MentionSuggestion = { notInChannel?: boolean; ownerLabel?: string | null; role?: string | null; + /** One-line agent role/description from the kind-0 `about` field. */ + description?: string | null; }; type MentionAutocompleteProps = { @@ -279,6 +281,39 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ suggestion.pubkey && lockedAgentPubkeys?.has(suggestion.pubkey.toLowerCase()), ); + // The row's `aria-label` overrides all descendant text as the + // accessible name, so neither metadata line below is announced + // to screen-reader users without `aria-describedby`. Both the + // agent's self-authored `about` and the verified + // `managed by ` provenance get an id here so the + // description carries both — promoting only the untrusted + // self-authored half while silently dropping verified + // provenance would let an agent's `about` fabricate ownership + // unchallenged. + const descriptionId = + suggestion.isAgent && suggestion.description + ? `mention-agent-description-${index}` + : undefined; + const provenanceId = + ownerLabel || suggestion.notInChannel + ? `mention-agent-provenance-${index}` + : undefined; + // In the name-collision branch `ownerLabel` is deliberately + // nulled above and the npub becomes the only trusted + // disambiguator between two rows that otherwise render and + // announce identically ("Mention Rex" for both). It needs the + // same treatment as `provenanceId`: without an id wired into + // `aria-describedby`, a screen-reader user hears two identical, + // self-authored-only descriptions and has no way to tell the + // agents apart, even though the sighted user sees the distinct + // npub. + const collisionNpubId = collisionNpub + ? `mention-collision-npub-${index}` + : undefined; + const describedBy = + [descriptionId, provenanceId, collisionNpubId] + .filter(Boolean) + .join(" ") || undefined; return (