mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
🤖 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:  After — Add succeeds and the first `@Fresh` search shows Fresh Scout as a channel member:  Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
370
desktop/src/features/channels/membershipMentionJourney.test.mjs
Normal file
370
desktop/src/features/channels/membershipMentionJourney.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
38
desktop/src/features/channels/useChannelMembersQuery.ts
Normal file
38
desktop/src/features/channels/useChannelMembersQuery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -578,6 +578,7 @@ export function useWelcomeKickoff(
|
||||
const welcomeTeam = await ensureWelcomeTeam(
|
||||
channelId,
|
||||
activeCommunity?.relayUrl,
|
||||
queryClient,
|
||||
);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: managedAgentsQueryKey,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user