From 63a1c47aa42cc50509c3164c1740c018ee3f22f1 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 11 Sep 2026 14:06:01 -0400 Subject: [PATCH] fix(desktop): bind welcome provisioning per signer and refresh each client Share welcome-team provisioning only per normalized relay, channel, and initiating signer: each participant reads its native identity before consulting the shared map, and the membership batch passes the captured expectedRelayUrl/expectedSignerPubkey guards that native admission enforces. A signer or relay switch at the production seam fails closed instead of joining another identity's in-flight batch. Every caller still settles its own captured QueryClient's channel roster on full, partial, zero, and rejected admission, so a replacement community client joining a pending operation discovers accepted members on explicit reopen. Regression journeys in desktop/src/features/channels/welcomeMembershipMentionJourney.test.mjs. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/features/channels/hooks.ts | 31 +- .../channels/membershipDirectorySync.test.mjs | 29 + .../channels/membershipDirectorySync.ts | 34 ++ .../membershipMentionJourney.test.mjs | 370 +++++++++++ .../channels/useChannelMembersQuery.ts | 38 ++ .../welcomeMembershipMentionJourney.test.mjs | 576 ++++++++++++++++++ desktop/src/features/onboarding/hooks.ts | 17 +- .../src/features/onboarding/welcomeGuide.ts | 59 +- .../src/features/onboarding/welcomeKickoff.ts | 1 + desktop/tests/e2e/mention-picker.spec.ts | 54 +- desktop/tests/e2e/onboarding.spec.ts | 52 +- 11 files changed, 1212 insertions(+), 49 deletions(-) create mode 100644 desktop/src/features/channels/membershipMentionJourney.test.mjs create mode 100644 desktop/src/features/channels/useChannelMembersQuery.ts create mode 100644 desktop/src/features/channels/welcomeMembershipMentionJourney.test.mjs diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 18eb2699d2e..7332857796b 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -13,7 +13,6 @@ import { deleteChannel, getCanvas, getChannelDetails, - getChannelMembers, getChannels, hideDm, joinChannel, @@ -49,11 +48,9 @@ import { type ChannelSnapshot, writeChannelSnapshot, } from "@/features/channels/channelSnapshot"; -import { - CHANNEL_MEMBERS_STALE_TIME_MS, - channelMembersQueryKey, -} from "@/features/channels/rosterFreshness"; +import { channelMembersQueryKey } from "@/features/channels/rosterFreshness"; import { dmVisibilityQueryKeyFor } from "@/features/channels/useHiddenDmIds"; +import { refreshDirectoryAfterMembershipChange } from "./membershipDirectorySync"; export const channelsQueryKey = ["channels"] as const; /** Keeps focused polling at the established one-minute cadence. */ @@ -516,6 +513,7 @@ export function useCreateChannelMutation() { queryClient.setQueryData(channelsQueryKey, (current) => upsertCachedChannel(current, createdChannel), ); + refreshDirectoryAfterMembershipChange(queryClient); }, onSettled: () => { // refetchType "none": onSuccess already cached the relay-returned channel; @@ -642,23 +640,7 @@ export function useChannelDetailsQuery( }); } -export function useChannelMembersQuery( - channelId: string | null, - enabled = true, -) { - return useQuery({ - enabled: enabled && channelId !== null, - queryKey: ["channels", channelId ?? "none", "members"], - queryFn: async () => { - if (!channelId) { - throw new Error("No channel selected."); - } - - return getChannelMembers(channelId); - }, - staleTime: CHANNEL_MEMBERS_STALE_TIME_MS, - }); -} +export { useChannelMembersQuery } from "./useChannelMembersQuery"; export function useUpdateChannelMutation(channelId: string | null) { const queryClient = useQueryClient(); @@ -841,6 +823,9 @@ export function useAddChannelMembersMutation(channelId: string | null) { return addChannelMembers({ ...rest, channelId: effectiveChannelId }); }, onSuccess: (result, variables) => { + if (result.added.length > 0) { + refreshDirectoryAfterMembershipChange(queryClient); + } const effectiveChannelId = variables.channelId ?? channelId; if ( effectiveChannelId && @@ -896,6 +881,7 @@ export function useJoinChannelMutation(channelId: string | null) { await joinChannel(channelId); }, + onSuccess: () => refreshDirectoryAfterMembershipChange(queryClient), onSettled: async () => { await invalidateChannelState(queryClient, channelId); }, @@ -913,6 +899,7 @@ export function useLeaveChannelMutation(channelId: string | null) { await leaveChannel(channelId); }, + onSuccess: () => refreshDirectoryAfterMembershipChange(queryClient), onSettled: async () => { await invalidateChannelState(queryClient, channelId); }, diff --git a/desktop/src/features/channels/membershipDirectorySync.test.mjs b/desktop/src/features/channels/membershipDirectorySync.test.mjs index 105db44a8aa..2fadfe2ecc2 100644 --- a/desktop/src/features/channels/membershipDirectorySync.test.mjs +++ b/desktop/src/features/channels/membershipDirectorySync.test.mjs @@ -3,6 +3,7 @@ import { afterEach, test } from "node:test"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { refreshDirectoryAfterMembershipChange as refresh, + refreshDirectoryForRosterChange as rosterChanged, resetMembershipDirectorySync, } from "./membershipDirectorySync.ts"; @@ -118,6 +119,34 @@ test("failed refresh ends in error and does not schedule a self-sustaining retry assert.equal(calls, 1); }); +test("only changed membership or agent classification refreshes; names and ordering do not", async () => { + const value = client(); + let calls = 0; + observe(value, async () => { + calls += 1; + return []; + }); + const person = { pubkey: "a".repeat(64), role: "member", isAgent: false }; + const agent = { pubkey: "b".repeat(64), role: "member", isAgent: true }; + rosterChanged(value, undefined, [person]); + rosterChanged( + value, + [person, agent], + [{ ...agent, displayName: "Renamed" }, person], + ); + await settle(); + assert.equal(calls, 0); + rosterChanged(value, [person], [person, agent]); + await settle(); + assert.equal(calls, 1); + rosterChanged(value, [person, agent], [person]); + await settle(); + assert.equal(calls, 2); + rosterChanged(value, undefined, [agent]); + await settle(); + assert.equal(calls, 3); +}); + test("community reset cancels queued work and event deduplication is client-scoped", async () => { const first = client(); const second = client(); diff --git a/desktop/src/features/channels/membershipDirectorySync.ts b/desktop/src/features/channels/membershipDirectorySync.ts index 4c962e399e9..4d251243b30 100644 --- a/desktop/src/features/channels/membershipDirectorySync.ts +++ b/desktop/src/features/channels/membershipDirectorySync.ts @@ -1,5 +1,8 @@ import type { QueryClient } from "@tanstack/react-query"; +import type { ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + const directoryQueryKey = ["relay-agents"] as const; const COALESCE_MS = 200; const MAX_EVENT_IDS = 256; @@ -78,3 +81,34 @@ export function refreshDirectoryAfterMembershipChange( }); }, COALESCE_MS); } + +function membershipFingerprint(members: readonly ChannelMember[]): string { + return members + .map( + (member) => + `${normalizePubkey(member.pubkey)}:${member.role ?? ""}:${member.isAgent === true}`, + ) + .sort() + .join("|"); +} + +/** + * A later roster can observe a write that the acceptance-time directory read + * could not yet see. Refresh once for that semantic change, not for names, + * ordering, repeated snapshots, or every local store notification. An initial + * agent-bearing roster also repairs discovery when no previous roster exists. + */ +export function refreshDirectoryForRosterChange( + queryClient: QueryClient, + previous: readonly ChannelMember[] | undefined, + current: readonly ChannelMember[], +): void { + if ( + previous + ? membershipFingerprint(previous) === membershipFingerprint(current) + : !current.some((member) => member.isAgent || member.role === "bot") + ) { + return; + } + refreshDirectoryAfterMembershipChange(queryClient); +} diff --git a/desktop/src/features/channels/membershipMentionJourney.test.mjs b/desktop/src/features/channels/membershipMentionJourney.test.mjs new file mode 100644 index 00000000000..4be89f2a2fe --- /dev/null +++ b/desktop/src/features/channels/membershipMentionJourney.test.mjs @@ -0,0 +1,370 @@ +// Real create/add/roster/directory/mention hooks with a mocked Tauri boundary. +// Agent classification and verified policy are supplied fixtures, not native proof. +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; + return [rawAgent()]; + } + if (command === "revalidate_relay_agents") return [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 useCreateChannelMutation, + useAddChannelMembersMutation, + useMentions, + resetMembershipDirectorySync; +let root, client, mutations, mention; +before(async () => { + ({ 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" + )); + ({ useCreateChannelMutation, useAddChannelMembersMutation } = await import( + "./hooks.ts" + )); + ({ useMentions } = await import("@/features/messages/lib/useMentions.ts")); + ({ resetMembershipDirectorySync } = await import( + "./membershipDirectorySync.ts" + )); +}); +function Mutations() { + // Captured destination must win over a subsequently selected channel. + mutations = { + create: useCreateChannelMutation(), + add: useAddChannelMembersMutation("different-channel"), + }; + return null; +} +function Composer() { + mention = useMentions(state.channelId, undefined, undefined, { + channelType: "stream", + }); + return null; +} +async function render(withComposer = true) { + await act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Mutations), + 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 = {}) { + state = { + 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"], []], + [["managed-agents"], []], + [["relay-agents"], []], + [["personas"], []], + [["teams"], []], + [["archivedIdentities"], { archived: [] }], + ]) + client.setQueryData(key, data); + const container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await render(false); + await act(async () => + mutations.create.mutateAsync({ + name: "fresh", + channelType: "stream", + visibility: "open", + }), + ); + await render(); + await act(async () => mention.updateMentionQuery("@", 1)); + await settle(); +} +afterEach(async () => { + if (root) await act(async () => root.unmount()); + resetMembershipDirectorySync(); + client?.clear(); + document.body.replaceChildren(); +}); +after(() => dom.window.close()); + +for (const [owner, role] of [ + [VIEWER, "bot"], + [OTHER, "bot"], + [VIEWER, "member"], + [OTHER, "member"], +]) { + test(`fresh create/add then next @ catches up without a manual directory refresh (${owner === VIEWER ? "owned" : "nonowned"}, ${role})`, async () => { + await setup({ owner, role }); + assert.equal( + rows().filter((row) => row.isAgent && !row.notInChannel).length, + 0, + ); + assert.equal( + state.directoryCalls, + 1, + "create refreshes the warm directory cache", + ); + await act(async () => + mutations.add.mutateAsync({ pubkeys: [AGENT], role, channelId: CHANNEL }), + ); + await settle(); + assert.equal(state.accepted, true); + assert.equal( + state.directoryCalls, + 2, + "accepted add refreshes even while the roster lags", + ); + assert.equal( + rows().filter((row) => row.isAgent && !row.notInChannel).length, + 0, + "acceptance does not fabricate membership (owned preparation may offer Invite)", + ); + const installedKeys = mention.suggestions.map((row) => row.pubkey); + // The same roster invalidation that the existing live event handlers perform. + // Do not manually invalidate relay-agents: that would mask the defect. + state.visible = true; + state.directoryVisible = true; + await act(async () => + client.invalidateQueries({ queryKey: ["channels", CHANNEL, "members"] }), + ); + await settle(); + assert.equal( + state.directoryCalls, + 3, + "later semantic roster change retries discovery", + ); + assert.deepEqual( + mention.suggestions.map((row) => row.pubkey), + installedKeys, + "membership refresh must not expand the installed chooser", + ); + await act(async () => mention.updateMentionQuery("", 0)); + await act(async () => mention.updateMentionQuery("@", 1)); + await settle(); + assert.equal(rows().length, 1); + assert.equal(rows()[0].isAgent, true); + assert.equal(rows()[0].notInChannel, false); + await act(async () => + client.invalidateQueries({ queryKey: ["channels", CHANNEL, "members"] }), + ); + await settle(); + assert.equal( + state.directoryCalls, + 3, + "replayed unchanged roster does not loop", + ); + await render(false); + await render(); + await act(async () => mention.updateMentionQuery("@", 1)); + await settle(); + assert.equal(rows().length, 1); + assert.equal( + state.directoryCalls, + 3, + "immediate reopen reuses fresh evidence", + ); + }); +} + +test("an all-rejected add does not trigger a directory rebuild", async () => { + await setup({ + addResult: { added: [], errors: [{ pubkey: AGENT, error: "denied" }] }, + }); + const beforeCalls = state.directoryCalls; + await act(async () => + mutations.add.mutateAsync({ + pubkeys: [AGENT], + role: state.role, + channelId: CHANNEL, + }), + ); + await settle(); + assert.equal(state.directoryCalls, beforeCalls); +}); + +test("a cancelled late roster cannot trigger discovery or resurrect its member", async () => { + await setup(); + let release; + state.heldRoster = new Promise((resolve) => { + release = resolve; + }); + const beforeCalls = state.directoryCalls; + const key = ["channels", CHANNEL, "members"]; + await act(async () => { + void client.invalidateQueries({ queryKey: key }); + }); + await act(async () => { + await client.cancelQueries({ queryKey: key }); + }); + await act(async () => + release({ members: [{ pubkey: AGENT, role: "bot", is_agent: true }] }), + ); + await settle(); + assert.equal(state.directoryCalls, beforeCalls); + assert.equal(mention.memberPubkeys.has(AGENT), false); + assert.equal( + rows().filter((row) => row.isAgent && !row.notInChannel).length, + 0, + ); +}); diff --git a/desktop/src/features/channels/useChannelMembersQuery.ts b/desktop/src/features/channels/useChannelMembersQuery.ts new file mode 100644 index 00000000000..ffc9854f27b --- /dev/null +++ b/desktop/src/features/channels/useChannelMembersQuery.ts @@ -0,0 +1,38 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getChannelMembers } from "@/shared/api/tauri"; +import type { ChannelMember } from "@/shared/api/types"; +import { + CHANNEL_MEMBERS_STALE_TIME_MS, + channelMembersQueryKey, +} from "./rosterFreshness"; +import { refreshDirectoryForRosterChange } from "./membershipDirectorySync"; + +/** Read the authoritative destination roster and reconcile directory freshness. */ +export function useChannelMembersQuery( + channelId: string | null, + enabled = true, +) { + const queryClient = useQueryClient(); + return useQuery({ + enabled: enabled && channelId !== null, + queryKey: ["channels", channelId ?? "none", "members"], + queryFn: async ({ signal }) => { + if (!channelId) { + throw new Error("No channel selected."); + } + + const members = await getChannelMembers(channelId); + if (!signal.aborted) { + refreshDirectoryForRosterChange( + queryClient, + queryClient.getQueryData( + channelMembersQueryKey(channelId), + ), + members, + ); + } + return members; + }, + staleTime: CHANNEL_MEMBERS_STALE_TIME_MS, + }); +} diff --git a/desktop/src/features/channels/welcomeMembershipMentionJourney.test.mjs b/desktop/src/features/channels/welcomeMembershipMentionJourney.test.mjs new file mode 100644 index 00000000000..f7f6fac1691 --- /dev/null +++ b/desktop/src/features/channels/welcomeMembershipMentionJourney.test.mjs @@ -0,0 +1,576 @@ +// Real Welcome provisioning and mention discovery with fixture Tauri IPC. +// Native admission is modeled as independent per-key acceptance, not proved here. +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 starterKeys = [AGENT, OTHER, "d".repeat(64)]; +const starters = ["Fizz", "Honey", "Pollen"].map((name, index) => ({ + pubkey: starterKeys[index], + name, + persona_id: ["builtin:fizz", "builtin:honey", "builtin:bumble"][index], + team_id: "builtin-team:welcome", + relay_url: "ws://test.invalid", + agent_command: "buzz-agent", + agent_args: [], + mcp_command: "", + model: null, + provider: null, + status: "stopped", + backend: { type: "local" }, + respond_to: "owner-only", + respond_to_allowlist: [], +})); +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 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: state.signer }; + 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.rosterCalls += 1; + if (state.rosterGate) { + const gate = state.rosterGate; + state.rosterGate = null; + state.rosterWaiting = true; + await gate; + } + return { + members: [ + { + pubkey: VIEWER, + role: "owner", + display_name: "Viewer", + is_agent: false, + }, + ...starters + .filter((agent) => state.members.has(agent.pubkey)) + .map((agent) => ({ + pubkey: agent.pubkey, + role: "bot", + display_name: agent.name, + is_agent: true, + })), + ], + }; + } + if (command === "add_channel_members") { + assert.equal(args.channelId, CHANNEL); + assert.equal(args.role, "bot"); + state.addCalls.push(args.pubkeys); + state.addScopes.push([args.expectedRelayUrl, args.expectedSignerPubkey]); + // Native checks and captures authority BEFORE the per-key admission awaits. + if (args.expectedRelayUrl && args.expectedRelayUrl !== state.relay) + throw new Error("relay changed"); + if (args.expectedSignerPubkey && args.expectedSignerPubkey !== state.signer) + throw new Error("signer changed"); + if (state.addGate) await state.addGate; + if (state.reject) throw new Error("transport rejected"); + const added = args.pubkeys.filter((key) => state.allowed.has(key)); + for (const key of added) state.members.add(key); + if (state.rejectAfterCommit) + throw new Error("transport rejected after commit"); + return { + added, + errors: args.pubkeys + .filter((key) => !state.allowed.has(key)) + .map((pubkey) => ({ pubkey, error: "admission denied" })), + }; + } + if (command === "list_managed_agents") return starters; + if (command === "list_personas") + return starters.map((agent) => ({ + id: agent.persona_id, + display_name: agent.name, + is_builtin: true, + is_active: true, + system_prompt: "Welcome", + avatar_url: null, + })); + if (command === "discover_acp_providers") + return [ + { + id: "buzz-agent", + label: "Buzz Agent", + availability: "available", + command: "buzz-agent", + default_args: [], + mcp_command: null, + }, + ]; + if (command === "get_global_agent_config") + return { preferred_runtime: "buzz-agent" }; + if (command === "agent_access_owner_only") return true; + if (command === "sync_agents_to_active_huddle") return null; + if (command === "list_relay_agents") { + state.directoryCalls += 1; + return []; + } + if (command === "revalidate_relay_agents") return []; + 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, resetMembershipDirectorySync; +let root, client, mention, ensureWelcomeTeam, waitFor; +before(async () => { + ({ ensureWelcomeTeam } = await import( + "@/features/onboarding/welcomeGuide.ts" + )); + ({ waitFor } = await import("@testing-library/react")); + ({ 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")); + ({ resetMembershipDirectorySync } = await import( + "./membershipDirectorySync.ts" + )); +}); +function Composer() { + mention = useMentions(state.channelId, undefined, undefined, { + channelType: "stream", + }); + 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 settled(assertion) { + await waitFor(assertion); +} +async function setup(overrides = {}) { + state = { + signer: VIEWER, + relay: "ws://test.invalid", + addScopes: [], + members: new Set(), + allowed: new Set(), + addCalls: [], + rosterCalls: 0, + channelId: CHANNEL, + role: "bot", + owner: VIEWER, + policy: "anyone", + visible: false, + directoryCalls: 0, + ...overrides, + }; + client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + for (const [key, data] of [ + [["identity"], { pubkey: VIEWER }], + [["channels"], []], + [["managed-agents"], []], + [["relay-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 settled(() => assert.equal(mention.isMentionLoading, false)); +} +afterEach(async () => { + if (root) await act(async () => root.unmount()); + resetMembershipDirectorySync(); + client?.clear(); + document.body.replaceChildren(); +}); +after(() => dom.window.close()); + +for (const outcome of ["partial", "none", "rejected", "full"]) { + test(`Welcome ${outcome} admission refreshes authoritative discovery and permits retry`, async () => { + await setup({ + allowed: new Set( + outcome === "full" ? starterKeys : outcome === "partial" ? [AGENT] : [], + ), + reject: outcome === "rejected", + }); + const installed = mention.suggestions.map((row) => [row.pubkey, row.name]); + const beforeCalls = state.rosterCalls; + await act(async () => { + const attempt = ensureWelcomeTeam(CHANNEL, "ws://test.invalid", client); + const duplicate = ensureWelcomeTeam(CHANNEL, "ws://test.invalid", client); + const results = await Promise.allSettled([attempt, duplicate]); + if (outcome === "full") { + assert.equal(results[0].status, "fulfilled"); + assert.deepEqual(results[1], results[0]); + } else { + assert.equal(results[0].status, "rejected"); + assert.match( + results[0].reason.message, + outcome === "rejected" ? /transport rejected/ : /admission denied/, + ); + assert.equal(results[1].status, "rejected"); + assert.equal(results[1].reason, results[0].reason); + } + }); + assert.deepEqual(state.addCalls, [starterKeys]); + await settled(() => { + assert.ok( + state.rosterCalls >= beforeCalls + 2, + "provision read plus cache refetch", + ); + assert.deepEqual( + [...mention.memberPubkeys].sort(), + [VIEWER, ...state.allowed].sort(), + ); + }); + assert.deepEqual( + mention.suggestions.map((row) => [row.pubkey, row.name]), + installed, + "background refresh must preserve installed choice identities and order", + ); + await act(async () => mention.updateMentionQuery("", 0)); + await act(async () => mention.updateMentionQuery("@", 1)); + await settled(() => { + assert.equal(mention.isMentionLoading, false); + for (const key of starterKeys) { + const memberRows = mention.suggestions.filter( + (row) => row.pubkey === key && !row.notInChannel, + ); + assert.equal(memberRows.length, state.allowed.has(key) ? 1 : 0); + } + }); + // Provisioning failures release the in-flight slot. Retry only missing keys. + const previouslyAccepted = [...state.members]; + state.allowed = new Set(starterKeys); + state.reject = false; + await act(async () => + ensureWelcomeTeam(CHANNEL, "ws://test.invalid", client), + ); + assert.deepEqual( + state.addCalls.at(-1), + outcome === "full" + ? starterKeys + : starterKeys.filter((key) => !previouslyAccepted.includes(key)), + ); + await settled(() => + assert.deepEqual( + [...mention.memberPubkeys].sort(), + [VIEWER, ...starterKeys].sort(), + ), + ); + await render(false); + await render(); + await act(async () => mention.updateMentionQuery("@", 1)); + await settled(() => { + assert.equal(mention.isMentionLoading, false); + for (const key of starterKeys) + assert.equal( + mention.suggestions.filter( + (row) => row.pubkey === key && !row.notInChannel, + ).length, + 1, + ); + }); + }); +} + +// Reproduces App's replacement scoped QueryClient while admission is pending. +for (const outcome of [ + "partial", + "none", + "rejected", + "rejected-after-commit", + "full", +]) { + test(`replacement scoped client discovers settled Welcome ${outcome} admission on reopen`, async () => { + await setup({ + allowed: new Set( + outcome === "full" + ? starterKeys + : ["partial", "rejected-after-commit"].includes(outcome) + ? [AGENT] + : [], + ), + reject: outcome === "rejected", + rejectAfterCommit: outcome === "rejected-after-commit", + }); + const oldClient = client; + const { promise, resolve } = Promise.withResolvers(); + state.addGate = promise; + const first = ensureWelcomeTeam(CHANNEL, "ws://test.invalid", oldClient); + const firstResult = first.then( + (value) => ({ value }), + (error) => ({ error }), + ); + try { + await settled(() => assert.equal(state.addCalls.length, 1)); + await render(false); + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + assert.notEqual(client, oldClient); + await render(); + await act(async () => mention.updateMentionQuery("@", 1)); + await settled(() => { + assert.equal(mention.isMentionLoading, false); + assert.deepEqual([...mention.memberPubkeys], [VIEWER]); + }); + const installed = mention.suggestions.map((row) => [ + row.pubkey, + row.name, + ]); + const beforeSettlementCalls = state.rosterCalls; + const unrelatedKey = ["channels", "unrelated", "members"]; + oldClient.setQueryData(unrelatedKey, []); + client.setQueryData(unrelatedKey, []); + const second = ensureWelcomeTeam(CHANNEL, "ws://test.invalid/", client); + const secondResult = second.then( + (value) => ({ value }), + (error) => ({ error }), + ); + await act(async () => { + resolve(); + const [a, b] = await Promise.all([firstResult, secondResult]); + if (outcome === "full") assert.equal(a.value, b.value); + else { + assert.match( + a.error.message, + outcome.startsWith("rejected") + ? /transport rejected/ + : /admission denied/, + ); + assert.equal( + a.error, + b.error, + "both participants retain the provisioning rejection", + ); + } + }); + assert.deepEqual( + state.addCalls, + [starterKeys], + "late participant shares one admission batch", + ); + assert.deepEqual( + [...state.members].sort(), + [...state.allowed].sort(), + "fixture authority accepted only allowed starters", + ); + assert.deepEqual( + mention.suggestions.map((row) => [row.pubkey, row.name]), + installed, + "settlement does not replace the installed picker", + ); + await act(async () => mention.updateMentionQuery("", 0)); + await act(async () => mention.updateMentionQuery("@", 1)); + await settled(() => { + assert.equal(mention.isMentionLoading, false); + for (const key of starterKeys) + assert.equal( + mention.suggestions.filter( + (row) => row.pubkey === key && !row.notInChannel, + ).length, + state.allowed.has(key) ? 1 : 0, + "replacement client discovers accepted starters on explicit reopen", + ); + }); + await settled(() => + assert.deepEqual( + [...mention.memberPubkeys].sort(), + [VIEWER, ...state.allowed].sort(), + ), + ); + assert.ok( + state.rosterCalls > beforeSettlementCalls, + "replacement active roster refetched even with no accepted keys", + ); + for (const participant of [oldClient, client]) + assert.equal( + participant.getQueryState(unrelatedKey).isInvalidated, + false, + "refresh targets only captured channel", + ); + assert.equal( + oldClient.getQueryState(["channels", CHANNEL, "members"]).isInvalidated, + true, + "retired participant's inactive roster is also stale", + ); + } finally { + resolve(); + await firstResult; + oldClient.clear(); + } + }); +} + +for (const scope of ["signer", "relay"]) { + test(`Welcome rejects ${scope} switch before membership IPC and refreshes captured channel`, async () => { + await setup({ allowed: new Set(starterKeys) }); + const gate = Promise.withResolvers(); + state.rosterGate = gate.promise; + const attempt = ensureWelcomeTeam(CHANNEL, "ws://test.invalid", client); + const result = attempt.then( + () => ({ ok: true }), + (error) => ({ error }), + ); + try { + await settled(() => assert.equal(state.rosterWaiting, true)); + state[scope] = scope === "signer" ? OTHER : "ws://other.invalid"; + state.channelId = "other-channel"; + client.setQueryData(["channels", "other-channel", "members"], []); + await act(async () => { + gate.resolve(); + const outcome = await result; + assert.match( + outcome.error?.message ?? "fulfilled", + new RegExp(`${scope} changed`), + ); + }); + assert.equal(state.members.size, 0); + assert.deepEqual(state.addScopes, [["ws://test.invalid", VIEWER]]); + assert.equal( + client.getQueryState(["channels", "other-channel", "members"]) + .isInvalidated, + false, + ); + state.signer = VIEWER; + state.relay = "ws://test.invalid"; + state.channelId = CHANNEL; + await act(async () => + ensureWelcomeTeam(CHANNEL, "ws://test.invalid", client), + ); + await settled(() => + assert.deepEqual( + [...mention.memberPubkeys].sort(), + [VIEWER, ...starterKeys].sort(), + ), + ); + } finally { + gate.resolve(); + await result; + } + }); +} + +test("new signer cannot join an already admitted Welcome batch; same-signer participants still deduplicate", async () => { + await setup({ allowed: new Set(starterKeys) }); + const gate = Promise.withResolvers(); + state.addGate = gate.promise; + const first = ensureWelcomeTeam(CHANNEL, "ws://test.invalid", client); + const results = [first]; + try { + await settled(() => assert.equal(state.addCalls.length, 1)); + state.signer = OTHER; + results.push(ensureWelcomeTeam(CHANNEL, "ws://test.invalid", client)); + results.push(ensureWelcomeTeam(CHANNEL, "ws://test.invalid/", client)); + await settled(() => assert.equal(state.addCalls.length, 2)); + assert.deepEqual(state.addScopes, [ + ["ws://test.invalid", VIEWER], + ["ws://test.invalid", OTHER], + ]); + await act(async () => { + gate.resolve(); + await Promise.all(results); + }); + await settled(() => + assert.deepEqual( + [...mention.memberPubkeys].sort(), + [VIEWER, ...starterKeys].sort(), + ), + ); + } finally { + gate.resolve(); + await Promise.allSettled(results); + } +}); diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index daf162c0aa1..2c3a23f8ebc 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -6,6 +6,8 @@ import { managedAgentsQueryKey, relayAgentsQueryKey, } from "@/features/agents/hooks"; +import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { channelsQueryKey } from "@/features/channels/hooks"; import { ensureStarterChannels, @@ -50,13 +52,22 @@ function seedWelcomeExperience( pubkey: string | null, communityScope: string | null, ) { - const key = `${communityScope ?? ""}:${channelId}`; + const key = JSON.stringify([ + communityScope, + channelId, + normalizePubkey(pubkey ?? ""), + ]); const current = welcomeSeedPromises.get(key); - if (current) return current; + // Seed callers can also arrive with a replacement scoped client. Do not + // let this outer dedup bypass that participant's roster settlement. + if (current) + return current.finally(() => + invalidateChannelMembersRosters(queryClient, [channelId]), + ); const promise = (async () => { try { - await ensureWelcomeTeam(channelId, communityScope); + await ensureWelcomeTeam(channelId, communityScope, queryClient); await ensureWelcomeCanvas(channelId); await Promise.all([ queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }), diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index a6057025f24..29f746c9897 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -1,3 +1,5 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness"; import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, @@ -20,6 +22,7 @@ import type { ManagedAgent, UpdateManagedAgentInput, } from "@/shared/api/types"; +import { getIdentity } from "@/shared/api/tauriIdentity"; import { normalizePubkey } from "@/shared/lib/pubkey"; export const WELCOME_GUIDE_AGENT_NAME = "Fizz"; @@ -183,6 +186,8 @@ async function ensureWelcomeTeamPersonasActive() { async function ensureWelcomeTeamMembership( channelId: string, agents: WelcomeTeamAgents, + expectedRelayUrl: string, + expectedSignerPubkey: string, ) { const members = await getChannelMembers(channelId).catch(() => []); const memberPubkeys = new Set( @@ -199,6 +204,8 @@ async function ensureWelcomeTeamMembership( channelId, pubkeys: missingAgents.map((agent) => agent.pubkey), role: "bot", + expectedRelayUrl, + expectedSignerPubkey, }); const unexpectedError = result.errors.find( ({ error }) => !error.toLowerCase().includes("already"), @@ -321,7 +328,8 @@ export function welcomeTeammateAccessUpdate( */ async function provisionWelcomeTeam( channelId: string, - relayUrl?: string | null, + relayUrl: string, + signerPubkey: string, ): Promise { const existingAgents = await listManagedAgents(); await ensureWelcomeTeamPersonasActive(); @@ -388,21 +396,50 @@ async function provisionWelcomeTeam( welcomeAgents[index] = updated.agent; } } - await ensureWelcomeTeamMembership(channelId, welcomeAgents); + await ensureWelcomeTeamMembership( + channelId, + welcomeAgents, + relayUrl, + signerPubkey, + ); return welcomeAgents; } -export function ensureWelcomeTeam( +/** Share provisioning per relay/channel/signer; each caller refreshes its scoped roster. */ +export async function ensureWelcomeTeam( channelId: string, - relayUrl?: string | null, + relayUrl: string | null | undefined, + queryClient: QueryClient, ): Promise { - const key = `${normalizeRelayUrl(relayUrl) ?? ""}:${channelId}`; - const current = welcomeTeamPromises.get(key); - if (current) return current; + // Resolve native identity for EVERY participant, before consulting the shared + // operation. A replacement identity must never inherit the old signer's work. + const expectedRelayUrl = normalizeRelayUrl(relayUrl); + const operation = async () => { + if (!expectedRelayUrl) + throw new Error("Welcome provisioning requires a relay."); + const signerPubkey = normalizePubkey((await getIdentity()).pubkey); + if (!signerPubkey) + throw new Error("Welcome provisioning requires a signer."); + const key = JSON.stringify([expectedRelayUrl, channelId, signerPubkey]); + let promise = welcomeTeamPromises.get(key); + if (!promise) { + promise = provisionWelcomeTeam( + channelId, + expectedRelayUrl, + signerPubkey, + ).finally(() => { + welcomeTeamPromises.delete(key); + }); + welcomeTeamPromises.set(key, promise); + } + return promise; + }; - const promise = provisionWelcomeTeam(channelId, relayUrl).finally(() => - welcomeTeamPromises.delete(key), + // A remounted community can join this operation with a new QueryClient. + // Keep cache settlement per caller, not in the shared provisioning promise. + // Native batches commit keys independently, even when provisioning rejects; + // refetch authority only, never insert requested agents into the roster. + return operation().finally(() => + invalidateChannelMembersRosters(queryClient, [channelId]), ); - welcomeTeamPromises.set(key, promise); - return promise; } diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index f46aeb3b213..94315f1e955 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -578,6 +578,7 @@ export function useWelcomeKickoff( const welcomeTeam = await ensureWelcomeTeam( channelId, activeCommunity?.relayUrl, + queryClient, ); await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey, diff --git a/desktop/tests/e2e/mention-picker.spec.ts b/desktop/tests/e2e/mention-picker.spec.ts index 0479bf80ae0..42975860d01 100644 --- a/desktop/tests/e2e/mention-picker.spec.ts +++ b/desktop/tests/e2e/mention-picker.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; -import { installMockBridge } from "../helpers/bridge"; +import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; const A = "11".repeat(32), @@ -279,3 +279,55 @@ test("already visible checking and denied members remain disabled beside permitt await expect(page.getByTestId("message-input")).toHaveText("@Verify"); } }); + +test("fresh create and Add member then first @ uses governing directory refresh", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [], + relayAgents: [ + { + pubkey: A, + name: "Fresh Scout", + ownerPubkey: OWNER, + respondTo: "anyone", + }, + ], + searchProfiles: [ + { + pubkey: A, + displayName: "Fresh Scout", + isAgent: true, + ownerPubkey: OWNER, + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + // A successful warm-but-old directory is fixture input, not a manual repair. + await page.evaluate(() => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + setQueryData: (key: string[], data: unknown) => void; + }; + client.setQueryData(["relay-agents"], []); + }); + await openCreateChannelDialog(page); + await page.getByTestId("create-channel-name").fill("fresh-picker-fixture"); + await page.getByTestId("create-channel-submit").click(); + await expect(page.getByTestId("chat-title")).toHaveText( + "fresh-picker-fixture", + ); + await page.getByTestId("channel-members-trigger").click(); + await page.getByTestId("channel-management-search-users").fill("Fresh Scout"); + await page.getByTestId(`channel-user-search-result-${A}`).click(); + await expect(page.getByTestId(`sidebar-member-${A}`)).toBeVisible(); + await page + .getByRole("dialog", { name: "Channel members" }) + .getByRole("button", { name: "Close", exact: true }) + .click(); + await page.getByTestId("message-input").fill("@Fresh"); + const row = page.getByTestId(`mention-suggestion-${A}`); + await expect(row).toBeVisible(); + await expect(row).not.toContainText(/not in channel/i); + await capture(page, "fresh-add"); +}); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index c8fb9b6ed54..0695ad0978e 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -470,16 +470,6 @@ async function expectWelcomeComposerBannerCompletesAfterPersonaMention( ), content, ); - await input.fill(content); - await input.press("Escape"); - await page.getByTestId("send-message").click(); - await expect( - page.getByText("The mention @Fizz is ambiguous.", { exact: false }), - ).toBeVisible(); - await expect(input).toHaveText(content); - await expect(banner).toHaveAttribute("data-state", "prompt"); - expect(await sentRecipients()).toEqual([]); - const agents = await invokeMockCommand< Array<{ pubkey: string; persona_id: string | null; status: string }> >(page, "list_managed_agents"); @@ -491,8 +481,46 @@ async function expectWelcomeComposerBannerCompletesAfterPersonaMention( // stopped. This identifies the fixture key, not a production routing rule. const fizz = sameNameAgents.filter((agent) => agent.status === "running"); expect(fizz).toHaveLength(1); - // Make selection intent explicit; do not remove the colliding fixture or - // relax extraction. The resulting event must tag only our starter identity. + const welcomeChannel = (await getMockChannels(page)).find( + (channel) => channel.name === "Welcome", + ); + expect(welcomeChannel).toBeDefined(); + // Direct team setup must refresh the composer's cached roster, not just the + // directory. Otherwise typed Fizz could silently target only the old starter. + await expect + .poll(() => + page.evaluate((channelId) => { + const members = window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryData< + Array<{ pubkey: string }> + >(["channels", channelId, "members"]); + return members?.map((member) => member.pubkey) ?? []; + }, welcomeChannel?.id), + ) + .toEqual( + expect.arrayContaining(sameNameAgents.map((agent) => agent.pubkey)), + ); + await expect(banner).toHaveAttribute("data-state", "prompt"); + expect(await sentRecipients()).toEqual([]); + + // Baseline regression restored from the fixed root: a manually typed + // colliding name must fail visibly, keep the draft, and publish nothing. + // The production fix for that visible failure is the independent composer + // recovery work; until it lands, this sequence is expected to fail on the + // fixed root. + await input.fill(content); + await input.press("Escape"); + await page.getByTestId("send-message").click(); + await expect( + page.getByText("The mention @Fizz is ambiguous.", { exact: false }), + ).toBeVisible(); + await expect(input).toHaveText(content); + await expect(banner).toHaveAttribute("data-state", "prompt"); + expect(await sentRecipients()).toEqual([]); + + // This freshness prefix retains both colliding starters in the roster and + // selects the exact current starter. 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.pressSequentially(content); await page.getByTestId(`mention-suggestion-${fizz[0].pubkey}`).click();