From 978d69f70f28a367906530af62c7ef633a6385e8 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 2 Sep 2026 11:35:24 -0400 Subject: [PATCH 1/2] fix(desktop): refresh mention choices after agent creation and addition Package existing reviewed repairs in the authorized seven-slice dependency stack. Preserved model A; experiment excluded. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/playwright.config.ts | 1 + desktop/src/features/channels/hooks.ts | 31 +- .../channels/membershipDirectorySync.test.mjs | 29 ++ .../channels/membershipDirectorySync.ts | 34 ++ .../membershipMentionJourney.test.mjs | 361 ++++++++++++++++++ .../channels/useChannelMembersQuery.ts | 38 ++ desktop/tests/e2e/mention-picker.spec.ts | 69 ++++ 7 files changed, 541 insertions(+), 22 deletions(-) create mode 100644 desktop/src/features/channels/membershipMentionJourney.test.mjs create mode 100644 desktop/src/features/channels/useChannelMembersQuery.ts create mode 100644 desktop/tests/e2e/mention-picker.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index d530e0a1a2a..4ea18e4bf73 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -78,6 +78,7 @@ export default defineConfig({ "**/mention-spacing.spec.ts", "**/mention-recipients.spec.ts", "**/message-edit-focus.spec.ts", + "**/mention-picker.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", 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..5131fc75d3f --- /dev/null +++ b/desktop/src/features/channels/membershipMentionJourney.test.mjs @@ -0,0 +1,361 @@ +// 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 first @ 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)", + ); + // 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.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/tests/e2e/mention-picker.spec.ts b/desktop/tests/e2e/mention-picker.spec.ts new file mode 100644 index 00000000000..cb68f7533ed --- /dev/null +++ b/desktop/tests/e2e/mention-picker.spec.ts @@ -0,0 +1,69 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const OWNER = "deadbeef".repeat(8); +const A = "11".repeat(32); +const parent = process.env.MENTION_PARENT === "1"; +async function capture(page: Page, name: string) { + await waitForAnimations(page); + await page.screenshot({ + path: `test-results/mention-picker/${parent ? "before" : "after"}-${name}.png`, + clip: { x: 256, y: 380, width: 1024, height: 520 }, + }); +} +test.use({ viewport: { width: 1280, height: 900 } }); + +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); + // Same settled camera on the unchanged parent, even if no row is admitted. + await page.waitForTimeout(400); + await capture(page, "fresh-add"); +}); From a304f090409e12b4028208ea7738d5224ac13380 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 2 Sep 2026 12:29:30 -0400 Subject: [PATCH 2/2] fix(desktop): refresh welcome rosters after starter provisioning Invalidate the composer member cache at both direct welcome-team creation paths. Bind the onboarding collision regression to both exact cached member keys before preserving the ambiguity, no-send, draft and selected-recipient checks. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/features/onboarding/hooks.ts | 3 ++ .../src/features/onboarding/welcomeKickoff.ts | 3 ++ desktop/tests/e2e/onboarding.spec.ts | 38 ++++++++++++++----- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index daf162c0aa1..f3e5895ca07 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness"; import { useQueryClient, type QueryStatus } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -57,6 +58,8 @@ function seedWelcomeExperience( const promise = (async () => { try { await ensureWelcomeTeam(channelId, communityScope); + // Team setup writes membership directly, outside the member mutations. + await invalidateChannelMembersRosters(queryClient, [channelId]); await ensureWelcomeCanvas(channelId); await Promise.all([ queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }), diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index f46aeb3b213..face66461d7 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { invalidateChannelMembersRosters } from "@/features/channels/rosterFreshness"; import { managedAgentsQueryKey, @@ -579,6 +580,8 @@ export function useWelcomeKickoff( channelId, activeCommunity?.relayUrl, ); + // Kickoff can add starters after the composer cached the initial roster. + await invalidateChannelMembersRosters(queryClient, [channelId]); await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey, }); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 9cc17c11acb..f5a51ba1c1e 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -466,16 +466,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"); @@ -487,6 +477,34 @@ 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); + 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 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([]); + // 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("");