diff --git a/desktop/src/features/channels/mentionAdmissionJourney.test.mjs b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs new file mode 100644 index 00000000000..009b31f724b --- /dev/null +++ b/desktop/src/features/channels/mentionAdmissionJourney.test.mjs @@ -0,0 +1,476 @@ +// Admission against existing root query evidence, without membership freshness production. +// Real mention and picker hooks; Tauri policy/classification are fixture evidence. +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + HTMLElement: dom.window.HTMLElement, + HTMLIFrameElement: dom.window.HTMLIFrameElement, + MutationObserver: dom.window.MutationObserver, + IS_REACT_ACT_ENVIRONMENT: true, + self: dom.window, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, +}); +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +const VIEWER = "a".repeat(64), + AGENT = "b".repeat(64), + OTHER = "c".repeat(64); +const CHANNEL = "11111111-1111-4111-8111-111111111111"; +localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "test", + name: "Test", + relayUrl: "ws://test.invalid", + addedAt: "2026-01-01T00:00:00Z", + }, + ]), +); +localStorage.setItem("buzz-active-community-id", "test"); +let state; +const channel = () => ({ + id: CHANNEL, + name: "fresh", + channel_type: "stream", + visibility: "open", + description: "", + is_member: true, + archived_at: null, + member_pubkeys: state.visible ? [VIEWER, AGENT] : [VIEWER], + member_count: state.visible ? 2 : 1, + participant_pubkeys: [], + participants: [], + last_message_at: null, + ttl_seconds: null, + ttl_deadline: null, +}); +const rawAgent = () => ({ + pubkey: AGENT, + owner_pubkey: state.owner, + name: "Remote Scout", + agent_type: "agent", + channels: [], + channel_ids: state.directoryVisible ? [CHANNEL] : [], + capabilities: [], + status: "offline", + respond_to: state.policy, + respond_to_allowlist: [], +}); +const invoke = async (command, args) => { + if (command.startsWith("plugin:event|")) return 0; + if (command === "search_users") { + return { users: [], next_cursor: null }; + } + if (command === "get_identity") return { pubkey: VIEWER }; + if (command === "create_channel") return channel(); + if (command === "get_channels") + return { + channels: [channel()], + hash: String(state.visible), + last_messages: [], + }; + if (command === "get_channel_members" && state.heldRoster) + return state.heldRoster; + if (command === "get_channel_members") + return { + members: [ + { + pubkey: VIEWER, + role: "owner", + display_name: "Viewer", + is_agent: false, + }, + ...(state.visible + ? [ + { + pubkey: AGENT, + role: state.role, + display_name: "Remote Scout", + is_agent: true, + }, + ] + : []), + ], + }; + if (command === "add_channel_members") { + assert.equal(args.channelId, CHANNEL); + assert.equal(args.role, state.role); + state.accepted = true; + return state.addResult; + } + if (command === "sync_agents_to_active_huddle") return null; + if (command === "list_relay_agents") { + state.directoryCalls += 1; + if (state.failDirectory) throw new Error("Directory unavailable"); + return state.missingDirectory ? [] : [rawAgent()]; + } + if (command === "revalidate_relay_agents") + return state.missingDirectory ? [] : [rawAgent()]; + if (["list_managed_agents", "list_personas", "list_teams"].includes(command)) + return []; + if (command === "get_users_batch") return { profiles: {}, missing: [] }; + if (command === "list_archived_identities") return { archived: [] }; + throw new Error(`Unexpected IPC: ${command}`); +}; +globalThis.__TAURI_INTERNALS__ = { invoke, transformCallback: () => 1 }; +dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; +globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} }; +dom.window.__TAURI_EVENT_PLUGIN_INTERNALS__ = + globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__; + +let React, + act, + createRoot, + QueryClient, + QueryClientProvider, + CommunitiesProvider; +let useMentions; +let root, client, mention, picker; +let useAgentAddressLockPicker, effects; +before(async () => { + ({ useAgentAddressLockPicker } = await import( + "@/features/messages/ui/useAgentAddressLockPicker.ts" + )); + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + )); + ({ useMentions } = await import("@/features/messages/lib/useMentions.ts")); +}); +function Composer() { + mention = useMentions(state.channelId, undefined, undefined, { + channelType: "stream", + }); + picker = useAgentAddressLockPicker({ + mentions: mention, + audience: { + pubkeys: state.locked, + addPubkey: (key) => effects.push(["pin", key]), + removePubkey: (key) => effects.push(["remove", key]), + }, + audienceScope: state.channelId, + richText: { getPlainTextAndCursor: () => ({ text: "@", cursor: 1 }) }, + applyAutocompleteEdit: (edit) => effects.push(["edit", edit]), + onAddressAgentMention: (row) => effects.push(["promote", row.pubkey]), + onPulseAddressLock: () => {}, + }); + return null; +} +async function render(withComposer = true) { + await act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + withComposer ? React.createElement(Composer) : null, + ), + ), + ), + ); +} +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + // React Query notification batching may be enqueued by effects committed above. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} +const rows = () => mention.suggestions.filter((row) => row.pubkey === AGENT); +async function setup(overrides = {}) { + effects = []; + state = { + locked: [], + channelId: CHANNEL, + role: "bot", + owner: VIEWER, + policy: "anyone", + accepted: false, + visible: false, + directoryVisible: false, + directoryCalls: 0, + addResult: { added: [AGENT], errors: [] }, + ...overrides, + }; + client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + for (const [key, data] of [ + [["identity"], { pubkey: VIEWER }], + [["channels"], [channel()]], + [["managed-agents"], []], + [["personas"], []], + [["teams"], []], + [["archivedIdentities"], { archived: [] }], + ]) + client.setQueryData(key, data); + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await render(); + await act(async () => mention.updateMentionQuery("@", 1)); + await settle(); +} +afterEach(async () => { + if (root) await act(async () => root.unmount()); + client?.clear(); + document.body.replaceChildren(); +}); +after(() => dom.window.close()); + +for (const change of [ + "policy-denied", + "late-error", + "directory-removed", + "member-removed", +]) { + test(`a retained callback cannot bind after ${change}`, async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const staleRow = rows()[0]; + const staleInsert = mention.insertMention; + assert.equal(staleRow.isAgent, true); + assert.equal(mention.canSelectMention(staleRow), true); + if (change === "policy-denied") state.policy = "owner-only"; + if (change === "late-error") state.failDirectory = true; + if (change.endsWith("removed")) state.missingDirectory = true; + if (change === "member-removed") { + state.visible = false; + await act(async () => + client.invalidateQueries({ + queryKey: ["channels", CHANNEL, "members"], + }), + ); + } + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + let edit; + await act(async () => { + edit = staleInsert(staleRow, 1); + }); + assert.equal( + edit.insertText, + "", + "old actionable row must not establish intent", + ); + assert.deepEqual(mention.knownNames, []); + }); +} + +test("only an exact current target can be selected", async () => { + await setup({ visible: true, directoryVisible: true }); + assert.equal(mention.canSelectMention(rows()[0]), true); + for (const target of [ + { displayName: "Remote Scout" }, + { displayName: "Remote Scout", pubkey: OTHER }, + ]) { + assert.equal(mention.canSelectMention(target), false); + let edit; + await act(async () => { + edit = mention.insertMention(target, 1); + }); + assert.equal(edit.insertText, ""); + } + assert.deepEqual(mention.knownNames, []); +}); + +// These exercise the real sibling picker + mention hook, not an admission stub. +test("retained explicit pin rejects latest policy denial without draft effects", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const row = rows()[0], + oldPin = picker.toggleAlwaysAddressAgent; + state.policy = "owner-only"; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal(rows().length, 0); + await act(async () => oldPin(row)); + assert.deepEqual(effects, []); + assert.deepEqual(mention.knownNames, []); +}); + +for (const returnToOrigin of [false, true]) { + test(`retained pin and insertion reject another scope visit (return=${returnToOrigin})`, async () => { + await setup({ visible: true, directoryVisible: true }); + const row = rows()[0], + oldPin = picker.toggleAlwaysAddressAgent; + const oldInsert = mention.insertMention; + const oldSelect = picker.selectMentionSuggestion; + state.channelId = "22222222-2222-4222-8222-222222222222"; + await render(); + if (returnToOrigin) { + state.channelId = CHANNEL; + await render(); + } + let edit; + await act(async () => { + oldPin(row); + oldSelect(row); + edit = oldInsert(row, 1); + }); + assert.deepEqual(effects, []); + assert.equal(edit.insertText, ""); + assert.deepEqual(mention.knownNames, []); + }); +} + +test("latest locked state permits removal after denial, including a retained toggle", async () => { + await setup({ owner: OTHER, visible: true, directoryVisible: true }); + const row = rows()[0], + oldPin = picker.toggleAlwaysAddressAgent; + state.locked = [AGENT]; + state.policy = "owner-only"; + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal(mention.canSelectMention(row), false); + await act(async () => oldPin(row)); + assert.ok( + effects.some(([effect, key]) => effect === "remove" && key === AGENT), + ); + assert.ok( + effects.every( + ([effect, edit]) => + effect === "remove" || (effect === "edit" && edit.insertText === ""), + ), + ); + assert.deepEqual(mention.knownNames, []); +}); + +test("retained team cannot bind a removed exact member", async () => { + await setup({ visible: true, directoryVisible: true }); + const persona = { + id: "review-scout", + displayName: "Remote Scout", + isActive: true, + }; + const team = { + id: "team-review", + name: "Review Team", + isBuiltin: false, + personaIds: [persona.id], + }; + await act(async () => { + client.setQueryData(["personas"], [persona]); + client.setQueryData( + ["managed-agents"], + [ + { + pubkey: AGENT, + name: "Remote Scout", + personaId: persona.id, + status: "running", + }, + ], + ); + client.setQueryData(["teams"], [team]); + }); + await settle(); + const row = mention.suggestions.find((s) => s.kind === "team"); + assert.ok(row, JSON.stringify(mention.suggestions)); + assert.equal(row.teamMembers[0].pubkey, AGENT); + const old = mention.insertMention; + state.missingDirectory = true; + await act(async () => client.setQueryData(["managed-agents"], [])); + await act(async () => + client.invalidateQueries({ queryKey: ["relay-agents"] }), + ); + await settle(); + assert.equal( + mention.suggestions.some((s) => s.pubkey === AGENT), + false, + ); + assert.ok(mention.suggestions.find((s) => s.kind === "team")); + let edit; + await act(async () => { + edit = old(row, 1); + }); + assert.deepEqual(mention.knownNames, []); + assert.deepEqual(mention.getDraftMentionRefs(edit.insertText), []); + assert.equal( + edit.insertText, + "", + "removed exact team member must not establish intent", + ); +}); + +test("duplicate team members cannot mask a recipient set change", async () => { + await setup({ visible: true, directoryVisible: true }); + const personas = ["one", "two"].map((id) => ({ + id, + displayName: id, + isActive: true, + })); + const team = { + id: "duplicates", + name: "Duplicates", + isBuiltin: false, + personaIds: ["one", "one"], + }; + await act(async () => { + client.setQueryData(["personas"], personas); + client.setQueryData( + ["managed-agents"], + personas.map((p, i) => ({ + pubkey: i ? OTHER : AGENT, + name: p.displayName, + personaId: p.id, + status: "running", + })), + ); + client.setQueryData(["teams"], [team]); + }); + await settle(); + const row = mention.suggestions.find((s) => s.kind === "team"); + assert.ok(row); + assert.equal( + new Set(row.teamMembers.map((m) => m.pubkey ?? m.personaId)).size, + 1, + ); + const insert = mention.insertMention; + await act(async () => + client.setQueryData(["teams"], [{ ...team, personaIds: ["one", "two"] }]), + ); + await settle(); + assert.equal( + new Set( + mention.suggestions + .find((s) => s.kind === "team") + .teamMembers.map((m) => m.pubkey ?? m.personaId), + ).size, + 2, + ); + let edit; + await act(async () => { + edit = insert(row, 1); + }); + assert.equal(edit.insertText, ""); + assert.deepEqual(mention.knownNames, []); + assert.deepEqual(mention.getDraftMentionRefs(edit.insertText), []); +}); diff --git a/desktop/src/features/messages/lib/mentionCandidates.test.mjs b/desktop/src/features/messages/lib/mentionCandidates.test.mjs index 93402e92bac..b8b3f92e463 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/mentionCandidates.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { buildTeamMentionCandidates, formatTeamMention, + sameTeamMentionRecipients, } from "./mentionCandidates.ts"; function persona(id, displayName, isActive = true) { @@ -171,3 +172,27 @@ test("teams with identity and persona display-name collisions are not suggested" [], ); }); + +test("team recipient equality ignores multiplicity, order and names, not exact identity", () => { + const a = { kind: "identity", displayName: "A", pubkey: "ab".repeat(32) }; + const b = { ...a, pubkey: "cd".repeat(32) }; + const p = { kind: "persona", displayName: "A", personaId: "a" }; + for (const [left, right, equal] of [ + [[], [], true], + [[a], [], false], + [[], [a], false], + [[a, a], [a, b], false], + [[a, b], [a, a], false], + [[a, a], [a], true], + [[a], [a, a], true], + [ + [a, b], + [b, { ...a, displayName: "Renamed", pubkey: a.pubkey.toUpperCase() }], + true, + ], + [[p, p], [p], true], + [[p], [{ ...p, personaId: "b" }], false], + [[p], [{ ...p, pubkey: a.pubkey }], false], + ]) + assert.equal(sameTeamMentionRecipients(left, right), equal); +}); diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 2ee46b01415..4dd179285aa 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -1,3 +1,4 @@ +import type { MentionAction } from "./mentionPresentation"; import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; import type { AgentPersona, @@ -5,7 +6,7 @@ import type { ChannelRole, UserSearchResult, } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; export function formatSearchUserDisplayName(user: UserSearchResult) { return user.displayName?.trim() || user.nip05Handle?.trim() || null; @@ -33,6 +34,7 @@ export type TeamMentionMember = { }; export type MentionCandidate = { + action?: MentionAction; kind: "identity" | "persona" | "team"; pubkey?: string; personaId?: string; @@ -157,3 +159,20 @@ export function formatTeamMention( ) { return `${teamName}(${members.map((member) => `@${member.displayName}`).join(" ")}) `; } + +/** Compare exact team recipient sets; duplicate members and presentation order are irrelevant. */ +export function sameTeamMentionRecipients( + selected: readonly TeamMentionMember[], + current: readonly TeamMentionMember[] = [], +): boolean { + const identity = (member: TeamMentionMember) => + member.pubkey + ? `key:${normalizePubkey(member.pubkey)}` + : `persona:${member.personaId}`; + const selectedSet = new Set(selected.map(identity)); + const currentSet = new Set(current.map(identity)); + return ( + selectedSet.size === currentSet.size && + [...selectedSet].every((key) => currentSet.has(key)) + ); +} diff --git a/desktop/src/features/messages/lib/mentionPresentation.ts b/desktop/src/features/messages/lib/mentionPresentation.ts new file mode 100644 index 00000000000..70ac5218853 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionPresentation.ts @@ -0,0 +1,6 @@ +/** Presentation only. Publication still performs fresh authorization. */ +export type MentionAction = "mention" | "invite" | "checking" | "unavailable"; + +export function isMentionActionable(candidate: { action?: MentionAction }) { + return candidate.action !== "checking" && candidate.action !== "unavailable"; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 2bca5a0453f..8d005eee4df 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -1,3 +1,4 @@ +import { isMentionActionable } from "./mentionPresentation"; import * as React from "react"; import { useManagedAgentsQuery, @@ -58,6 +59,7 @@ import { appendUniqueName, buildTeamMentionCandidates, formatTeamMention, + sameTeamMentionRecipients, type MentionCandidate, } from "./mentionCandidates"; import { buildMentionCandidates } from "./buildMentionCandidates"; @@ -447,12 +449,75 @@ export function useMentions( const { mentionSelectedIndex, setMentionSelectedIndex: setSelected } = mentionSelection; const isMentionOpen = mentionQuery !== null && suggestions.length > 0; + // Recheck against this render's exact-key evidence even if a child retained + // an older row/callback. A rejected selection must not establish draft intent. + const admissionScope = React.useMemo( + () => ({ currentPubkey, channelId }), + [currentPubkey, channelId], + ); + const admissionRef = React.useRef({ + scope: admissionScope, + candidates: mentionCandidatesWithTeams, + }); + admissionRef.current = { + scope: admissionScope, + candidates: mentionCandidatesWithTeams, + }; + const canSelectMention = React.useCallback( + (suggestion: MentionSuggestion) => { + const current = admissionRef.current.candidates.find((candidate) => + suggestion.pubkey + ? candidate.pubkey === normalizePubkey(suggestion.pubkey) + : suggestion.teamId + ? candidate.teamId === suggestion.teamId + : !!suggestion.personaId && + candidate.personaId === suggestion.personaId, + ); + return ( + admissionRef.current.scope === admissionScope && + !!current && + (current.kind !== "team" || + (suggestion.kind === "team" && + !!suggestion.teamMembers?.length && + sameTeamMentionRecipients( + suggestion.teamMembers, + current.teamMembers, + ) && + suggestion.teamMembers.every((member) => { + const matches = (target: { + pubkey?: string; + personaId?: string | null; + }) => + member.pubkey + ? target.pubkey === normalizePubkey(member.pubkey) + : !!member.personaId && + !target.pubkey && + target.personaId === member.personaId; + return ( + current.teamMembers?.some(matches) && + admissionRef.current.candidates.some( + (target) => matches(target) && isMentionActionable(target), + ) + ); + }))) && + isMentionActionable(current) && + isMentionActionable(suggestion) + ); + }, + [admissionScope], + ); const insertMention = React.useCallback( (suggestion: MentionSuggestion, selectionEnd: number): AutocompleteEdit => { if (debounceTimerRef.current !== null) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } + if (!canSelectMention(suggestion)) + return { + replaceFromOffset: selectionEnd, + replaceToOffset: selectionEnd, + insertText: "", + }; const [boundSuggestion] = selectedMentionLabels( [suggestion], mentionMapRef.current, @@ -525,7 +590,7 @@ export function useMentions( insertText, }; }, - [knownAgentPubkeys, mentionStartIndex, setSelected], + [canSelectMention, knownAgentPubkeys, mentionStartIndex, setSelected], ); const registerMentionPubkey = React.useCallback( (displayName: string, pubkey: string, options?: { isAgent?: boolean }) => { @@ -837,6 +902,7 @@ export function useMentions( ], ); return { + canSelectMention, cancelMentionAutocomplete, clearMentions, getDefaultAgentSuggestion, diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 0b873a4074c..412dcef96bc 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,3 +1,4 @@ +import type { MentionAction } from "@/features/messages/lib/mentionPresentation"; import * as React from "react"; import { Bot, ChevronRight, Pin, Users } from "lucide-react"; import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; @@ -20,6 +21,7 @@ import { truncatePubkey } from "@/shared/lib/pubkey"; import { getPlatformKeysById } from "@/shared/lib/keyboard-shortcuts"; export type MentionSuggestion = { + action?: MentionAction; pubkey?: string; personaId?: string; teamId?: string; diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 3dbf8f3a884..6c0b4de8216 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -43,6 +43,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds isInlineMentionSelection: () => false, isMentionOpen: true, openMentionPicker: (...args) => openPickerCalls.push(args), + canSelectMention: () => true, registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; @@ -117,6 +118,7 @@ test("always addressing a new agent delegates the first add for immediate confir getMentionDisplayName: () => "Agent Ada", isInlineMentionSelection: () => false, isMentionOpen: false, + canSelectMention: () => true, registerMentionPubkey: () => {}, }, onAddressAgentMention: (value) => addressedSuggestions.push(value), @@ -156,6 +158,7 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }, ], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; @@ -216,6 +219,7 @@ test("selecting an already addressed agent from the explicit picker pulses its b cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, insertMention: () => ({ @@ -275,6 +279,7 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, registerMentionPubkey: () => {}, isInlineMentionSelection: () => true, insertMention: () => ({ @@ -342,6 +347,7 @@ test("selecting a human mention never changes automatic addressing", async () => audienceScope: "channel-scope", mentions: { getMentionDisplayName: () => "Alice", + isInlineMentionSelection: () => true, insertMention: () => ({ replaceFromOffset: 0, replaceToOffset: 3, @@ -394,6 +400,7 @@ test("restoring a multi-word automatic mention into an empty composer focuses af mentions: { getDraftMentionRefs: () => [], getMentionDisplayName: () => "claude code", + canSelectMention: () => true, registerMentionPubkey: (...args) => { registeredMentions.push(args); return args[0]; @@ -443,6 +450,7 @@ test("restoring before authored text preserves its selection", async () => { mentions: { getDraftMentionRefs: () => [], getMentionDisplayName: () => "Morgarita", + canSelectMention: () => true, registerMentionPubkey: () => {}, }, onPulseAddressLock: () => {}, @@ -497,6 +505,7 @@ test("restoring an existing automatic mention re-registers its agent chip", asyn ] : [], getMentionDisplayName: () => "claude code", + canSelectMention: () => true, registerMentionPubkey: (...args) => { registeredMentions.push(args); return args[0]; @@ -638,6 +647,7 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, insertMention: () => ({ @@ -704,6 +714,7 @@ test("repeatedly selecting an explicitly unpinned agent keeps its mentions manua { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, ], getMentionDisplayName: () => "Agent Ada", + canSelectMention: () => true, registerMentionPubkey: () => {}, isInlineMentionSelection: () => true, insertMention: () => ({ @@ -818,6 +829,7 @@ test("restoring after an agent rename keeps the existing automatic mention", asy { displayName: oldName, pubkey: "agent-pubkey", isAgent: true }, ], getMentionDisplayName: () => displayName, + canSelectMention: () => true, registerMentionPubkey: (...args) => { registeredMentions.push(args); return args[0]; @@ -899,6 +911,7 @@ test("automatic mention insertion and restoration use the registered collision-s snapshotDraftMentionRefs(value, bindings, [...bindings.keys()]), getMentionDisplayName: (pubkey) => [...bindings].find(([, key]) => key === pubkey)?.[0] ?? "carl", + canSelectMention: () => true, registerMentionPubkey: (name, pubkey) => { const label = selectedMentionLabel(name, pubkey, bindings); bindings.set(label, pubkey); @@ -998,6 +1011,7 @@ test("inverse deletion and toggle preserve B and exclude A from the composed sen snapshotDraftMentionRefs(value, bindings, [...bindings.keys()]), getMentionDisplayName: (key) => [...bindings].find(([, k]) => k === key)?.[0], + canSelectMention: () => true, registerMentionPubkey: (name, key) => { const label = selectedMentionLabel(name, key, bindings); bindings.set(label, key); @@ -1097,3 +1111,39 @@ test("implicit prefix removal uses the present exact label rather than a stale a act(() => result.current.removeAddressedAgent(key)); assert.equal(text, "hello"); }); + +test("rejected stale selection never pins, tracks, announces or edits", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const effects = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => effects.push("edit"), + audience: { pubkeys: [], addPubkey: () => effects.push("audience") }, + audienceScope: "room", + mentions: { + getMentionDisplayName: () => "Scout", + isInlineMentionSelection: () => true, + insertMention: () => ({ + replaceFromOffset: 1, + replaceToOffset: 1, + insertText: "", + }), + }, + onAutoPinAgentMention: () => effects.push("pin"), + onPulseAddressLock: () => effects.push("pulse"), + richText: { getPlainTextAndCursor: () => ({ text: "@", cursor: 1 }) }, + }), + ); + act(() => + result.current.selectMentionSuggestion({ + pubkey: "a".repeat(64), + displayName: "Scout", + isAgent: true, + }), + ); + assert.deepEqual(effects, []); + assert.equal(result.current.announcement, ""); +}); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index cd501479d27..3894e4af6e0 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -91,6 +91,10 @@ export function useAgentAddressLockPicker({ () => new Set(audience.pubkeys), [audience.pubkeys], ); + // A retained callback belongs to one audience visit, including A -> B -> A. + const scopeOwner = React.useMemo(() => ({ audienceScope }), [audienceScope]); + const currentAudience = React.useRef({ scopeOwner, lockedAgentPubkeys }); + currentAudience.current = { scopeOwner, lockedAgentPubkeys }; const unpinnedAgentPubkeysRef = React.useRef(new Set()); const unpinnedAudienceScopeRef = React.useRef(audienceScope); if (unpinnedAudienceScopeRef.current !== audienceScope) { @@ -246,14 +250,21 @@ export function useAgentAddressLockPicker({ const toggleAlwaysAddressAgent = React.useCallback( (suggestion: MentionSuggestion) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (!audienceScope || !pubkey || !suggestion.isAgent) return; + if ( + !audienceScope || + !pubkey || + !suggestion.isAgent || + currentAudience.current.scopeOwner !== scopeOwner + ) + return; - if (lockedAgentPubkeys.has(pubkey)) { + if (currentAudience.current.lockedAgentPubkeys.has(pubkey)) { removeAddressedAgentMentions(pubkey); setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); } else { + if (!mentions.canSelectMention(suggestion)) return; unpinnedAgentPubkeysRef.current.delete(pubkey); const label = mentions.registerMentionPubkey(suggestion.displayName, pubkey, { @@ -320,13 +331,14 @@ export function useAgentAddressLockPicker({ applyAutocompleteEdit, audience.addPubkey, audienceScope, - lockedAgentPubkeys, mentions.getDraftMentionRefs, mentions.isInlineMentionSelection, mentions.isMentionOpen, mentions.mentionStartIndex, mentions.openMentionPicker, mentions.registerMentionPubkey, + mentions.canSelectMention, + scopeOwner, onAddressAgentMention, onImplicitPrefixInserted, onPulseAddressLock, @@ -338,14 +350,19 @@ export function useAgentAddressLockPicker({ const selectMentionSuggestion = React.useCallback( (suggestion: MentionSuggestion) => { + if (currentAudience.current.scopeOwner !== scopeOwner) return; + const { cursor } = richText.getPlainTextAndCursor(); + const wasInlineSelection = mentions.isInlineMentionSelection(); + const edit = mentions.insertMention(suggestion, cursor); + // Rejected stale selections must not establish automatic audience intent. + if (!edit.insertText) return; const pubkey = normalizePubkey(suggestion.pubkey ?? ""); if (suggestion.isAgent && pubkey && audienceScope) { - const { cursor } = richText.getPlainTextAndCursor(); const wasUnpinned = !lockedAgentPubkeys.has(pubkey) && unpinnedAgentPubkeysRef.current.has(pubkey); - if (mentions.isInlineMentionSelection() || wasUnpinned) { - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + if (wasInlineSelection || wasUnpinned) { + applyAutocompleteEdit(edit); trackMentionAddressedAgent(pubkey); onAutoPinAgentMention?.(suggestion, { reinstateExcluded: !wasUnpinned, @@ -353,7 +370,7 @@ export function useAgentAddressLockPicker({ return; } - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + applyAutocompleteEdit(edit); if (!lockedAgentPubkeys.has(pubkey)) { trackMentionAddressedAgent(pubkey); if (onAddressAgentMention) { @@ -369,8 +386,7 @@ export function useAgentAddressLockPicker({ return; } - const { cursor } = richText.getPlainTextAndCursor(); - applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + applyAutocompleteEdit(edit); }, [ applyAutocompleteEdit, @@ -379,6 +395,7 @@ export function useAgentAddressLockPicker({ lockedAgentPubkeys, mentions.isInlineMentionSelection, mentions.insertMention, + scopeOwner, onAddressAgentMention, onAutoPinAgentMention, onPulseAddressLock, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs index 192fe5b7e6d..3b8f01016bf 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs @@ -3,11 +3,31 @@ import test from "node:test"; import { formatMessageSendError, + formatMentionSendError, getErrorMessage, mergeMentionRecipients, mentionRevalidationOptions, } from "./useMentionSendFlow.helpers.ts"; +import { AgentMentionAuthorizationError } from "../lib/agentMentionRevalidation.ts"; + +test("mention send errors preserve authorization guidance and generic failure details", () => { + const denied = new AgentMentionAuthorizationError(); + assert.equal(formatMentionSendError(denied), denied.message); + assert.equal( + formatMentionSendError(new Error("relay rejected")), + "Message failed to send: relay rejected", + ); + assert.equal( + formatMentionSendError("upload rejected"), + "Message failed to send: upload rejected", + ); + assert.equal( + formatMentionSendError({}), + "Message failed to send: Unknown error", + ); +}); + test("formatMessageSendError preserves the publication failure", () => { assert.equal( formatMessageSendError(new Error("relay rejected voice note")), diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index d70186bc446..fd326734a4d 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -1,4 +1,7 @@ -import type { MentionRevalidationOptions } from "@/features/messages/lib/agentMentionRevalidation"; +import { + AgentMentionAuthorizationError, + type MentionRevalidationOptions, +} from "@/features/messages/lib/agentMentionRevalidation"; import type { ManagedAgent } from "@/shared/api/types"; import { type ImetaMedia, @@ -110,6 +113,13 @@ export function formatMessageSendError(error: unknown) { return `Message failed to send: ${getErrorMessage(error, "Unknown error")}`; } +/** Preserve actionable authorization errors without the generic send-failure prefix. */ +export function formatMentionSendError(error: unknown) { + return error instanceof AgentMentionAuthorizationError + ? error.message + : formatMessageSendError(error); +} + export function uniqueNormalizedPubkeys(pubkeys: Iterable) { return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); } diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 4585a2360a1..ff6a2a9bb53 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -31,7 +31,7 @@ import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { - formatMessageSendError, + formatMentionSendError, getErrorMessage, mentionRevalidationOptions, withoutInvitingRecipients, @@ -44,7 +44,6 @@ import { uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; -import { AgentMentionAuthorizationError } from "@/features/messages/lib/agentMentionRevalidation"; import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; export function useMentionSendFlow({ @@ -650,11 +649,7 @@ export function useMentionSendFlow({ await finishSend(uploaded, signal); } catch (error) { restoreComposerAfterFailure(); - toast.error( - error instanceof AgentMentionAuthorizationError - ? error.message - : formatMessageSendError(error), - ); + toast.error(formatMentionSendError(error)); } finally { settleUpload(); } @@ -682,11 +677,7 @@ export function useMentionSendFlow({ await finishSend([]); } catch (error) { restoreComposerAfterFailure(); - toast.error( - error instanceof AgentMentionAuthorizationError - ? error.message - : formatMessageSendError(error), - ); + toast.error(formatMentionSendError(error)); } } } catch (error) { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index fc35fb7c133..502178be233 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -931,6 +931,7 @@ type RawManagedAgent = { pubkey: string; name: string; persona_id: string | null; + team_id?: string | null; /** Record-level harness/runtime pin (`null` when inheriting from the persona). */ runtime: string | null; relay_url: string; @@ -1841,6 +1842,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { pubkey: agent.pubkey, name: agent.name, persona_id: agent.persona_id, + team_id: agent.team_id ?? null, runtime: agent.runtime ?? null, relay_url: agent.relay_url, acp_command: agent.acp_command, @@ -9380,6 +9382,7 @@ async function handleCreateManagedAgent( input: { name: string; personaId?: string; + teamId?: string; relayUrl?: string; acpCommand?: string; agentCommand?: string; @@ -9456,6 +9459,7 @@ async function handleCreateManagedAgent( pubkey, name, persona_id: args.input.personaId ?? null, + team_id: args.input.teamId ?? null, // Create never pins a harness id — the record inherits from the persona. runtime: null, relay_url: args.input.relayUrl ?? DEFAULT_RELAY_WS_URL, diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 9cc17c11acb..c8fb9b6ed54 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -9,6 +9,10 @@ import { } from "../helpers/bridge"; import { expectEmojiMartStylesInstalled } from "../helpers/css"; import { installFakeCamera } from "../helpers/fakeCamera"; +import { + addWelcomeCollision, + WELCOME_COLLISION, +} from "../helpers/welcomeCollision"; import { E2E_IDENTITY_OVERRIDE_STORAGE_KEY, seedActiveIdentity, @@ -490,7 +494,7 @@ async function expectWelcomeComposerBannerCompletesAfterPersonaMention( // Make selection intent explicit; do not remove the colliding fixture or // relax extraction. The resulting event must tag only our starter identity. await input.fill(""); - await input.fill(content); + await input.pressSequentially(content); await page.getByTestId(`mention-suggestion-${fizz[0].pubkey}`).click(); await page.getByTestId("send-message").click(); await expect.poll(sentRecipients).toEqual([[fizz[0].pubkey]]); @@ -3349,7 +3353,11 @@ test("finishing onboarding creates starter channels and focuses welcome-everyone page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); - await installMockBridge(page, undefined, { skipOnboardingSeed: true }); + await installMockBridge( + page, + { managedAgents: [WELCOME_COLLISION] }, + { skipOnboardingSeed: true }, + ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); @@ -3359,6 +3367,10 @@ test("finishing onboarding creates starter channels and focuses welcome-everyone await expect(page.getByTestId("channel-general")).toBeVisible(); await expectStarterChannels(page); await expectWelcomeGuideIntro(page); + expect(await commandCount(page, "create_managed_agent")).toBe(3); + const channelId = await getWelcomeChannelId(page); + if (!channelId) throw new Error("Missing Welcome channel"); + await addWelcomeCollision(page, channelId); await expectWelcomeComposerBannerCompletesAfterPersonaMention(page); }); diff --git a/desktop/tests/helpers/welcomeCollision.ts b/desktop/tests/helpers/welcomeCollision.ts new file mode 100644 index 00000000000..c50fbc03e62 --- /dev/null +++ b/desktop/tests/helpers/welcomeCollision.ts @@ -0,0 +1,71 @@ +import { expect, type Page } from "@playwright/test"; +import { TEST_IDENTITIES } from "./bridge"; + +export const WELCOME_COLLISION = { + pubkey: "c".repeat(64), + name: "Fizz", + personaId: "builtin:fizz", + status: "stopped" as const, +}; + +/** Add a deliberate same-name member and deliver the relay event mock IPC omits. */ +export async function addWelcomeCollision(page: Page, channelId: string) { + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "Welcome", + }) ?? false, + ), + ) + .toBe(true); + await page.evaluate( + async ({ channelId, pubkey, actor }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!invoke || !emit) + throw new Error("Mock membership seams unavailable"); + const result = (await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + })) as { added: string[]; errors: unknown[] }; + if (result.errors.length || !result.added.includes(pubkey)) { + throw new Error("Collision fixture membership was not added"); + } + // Real relay side_effects emits member_joined after kind:9000. Mock + // add_channel_members only mutates backend arrays. Model delivery, not + // the resulting QueryClient state: production owns roster invalidation. + emit({ + channelName: "Welcome", + kind: 40099, + content: JSON.stringify({ + type: "member_joined", + actor, + target: pubkey, + }), + }); + }, + { + channelId, + pubkey: WELCOME_COLLISION.pubkey, + actor: TEST_IDENTITIES.tyler.pubkey, + }, + ); + await expect + .poll(() => + page.evaluate( + ({ channelId, pubkey }) => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + getQueryData: (key: string[]) => { pubkey: string }[] | undefined; + }; + return client + .getQueryData(["channels", channelId, "members"]) + ?.some((member) => member.pubkey === pubkey); + }, + { channelId, pubkey: WELCOME_COLLISION.pubkey }, + ), + ) + .toBe(true); +}