🤖 fix(desktop): show an agent in @ search after you add it to a channel (#7192)

🤖
## Summary
Makes a newly added agent findable with @ in the channel you added it
to, and makes the welcome flow's new agent mentionable right away.

- **After Add members (or creating a channel), the first @ search finds
the new agent.** Accepting the addition refreshes the member lists
behind @ search immediately, including choosing the exact agent when its
name is ambiguous. Refreshes refetch the authoritative roster only —
nothing is inserted optimistically. (Roster reconciliation wiring lives
in #7191.)
- **The welcome flow's new starter agent is mentionable on the first
try.** The roster refreshes after provisioning; a view remounted
mid-provisioning refetches its own roster and discovers what actually
got created, even when provisioning partially failed. Repeated calls
share one operation, so the team is never double-created.
- **Switching accounts mid-flow can't cross wires.** If you switch
identity while the welcome flow is provisioning, the in-flight work
stays bound to the account that started it; the new account gets its own
fresh attempt instead of joining or reusing the other one's operation.

Part of the mention-editor stack: #7190 → #7191 → this PR (review in
order).

### Related issue
Continues the merged mention-editor work from #7124 and #7128. No
separate tracking issue for this slice.

### Testing
- New unit tests cover the accepted channel-creation and Add-member
refresh paths, roster reconciliation, and welcome-team admission
outcomes (partial, zero, rejected, full), including the remounted-client
case and identity/relay switches mid-provisioning. Focused welcome
journeys: 12/12; full desktop package: 6,183/6,183.
- Browser tests cover both journeys: add the agent, then the first @
search finds it; and the welcome-flow roster after starter provisioning,
including the exact same-name choice.

### Before / after
Captured on an earlier revision of this change; the behavior is the
same.

Before — creating a channel does not refresh the agent list, so Add
members finds no matching agent:


![before-fresh-add-blocked](https://raw.githubusercontent.com/block/buzz/90dd8f7e1a7a344dc9bad75cf3275d31976d3071/pr-7192--before-fresh-add-blocked.png)

After — Add succeeds and the first `@Fresh` search shows Fresh Scout as
a channel member:


![after-fresh-add](https://raw.githubusercontent.com/block/buzz/90dd8f7e1a7a344dc9bad75cf3275d31976d3071/pr-7192--after-fresh-add.png)

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-09-30 11:23:06 -04:00
committed by GitHub
parent 7f8c2e5c30
commit 1a02b3a01d
11 changed files with 1212 additions and 49 deletions

View File

@@ -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<Channel[]>(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);
},

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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("<!doctype html><html><body></body></html>", {
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,
);
});

View File

@@ -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<ChannelMember[]>(
channelMembersQueryKey(channelId),
),
members,
);
}
return members;
},
staleTime: CHANNEL_MEMBERS_STALE_TIME_MS,
});
}

View File

@@ -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("<!doctype html><html><body></body></html>", {
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);
}
});

View File

@@ -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 }),

View File

@@ -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<WelcomeTeamAgents> {
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<WelcomeTeamAgents> {
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;
}

View File

@@ -578,6 +578,7 @@ export function useWelcomeKickoff(
const welcomeTeam = await ensureWelcomeTeam(
channelId,
activeCommunity?.relayUrl,
queryClient,
);
await queryClient.invalidateQueries({
queryKey: managedAgentsQueryKey,

View File

@@ -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");
});

View File

@@ -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();