Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 123 additions & 12 deletions desktop/src/features/channels/membershipMentionJourney.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,7 @@ for (const [owner, role] of [
]) {
test(`create/add refreshes discovery for the next open, not the displayed rows (${owner === VIEWER ? "owned" : "nonowned"}, ${role})`, async () => {
await setup({ owner, role });
assert.equal(
rows().filter((row) => row.isAgent && !row.notInChannel).length,
0,
);
assert.equal(rows().filter((row) => row.action === "mention").length, 0);
assert.equal(
state.directoryCalls,
1,
Expand All @@ -300,7 +297,7 @@ for (const [owner, role] of [
"accepted add refreshes even while the roster lags",
);
assert.equal(
rows().filter((row) => row.isAgent && !row.notInChannel).length,
rows().filter((row) => row.action === "mention").length,
0,
"acceptance does not fabricate membership (owned preparation may offer Invite)",
);
Expand Down Expand Up @@ -348,6 +345,37 @@ for (const [owner, role] of [
});
}

for (const condition of ["denied", "missing", "failed"]) {
test(`membership alone never admits a ${condition} agent`, async () => {
await setup({ owner: OTHER });
state.visible = true;
state.directoryVisible = true;
state.policy = condition === "denied" ? "owner-only" : "anyone";
state.missingDirectory = condition === "missing";
state.failDirectory = condition === "failed";
await act(async () =>
mutations.add.mutateAsync({
pubkeys: [AGENT],
role: state.role,
channelId: CHANNEL,
}),
);
await settle();
assert.equal(mention.memberPubkeys.has(AGENT), true);
await act(async () => mention.openMentionPicker(1));
assert.equal(
rows().length,
1,
"an already visible member explains its disabled action",
);
assert.equal(
rows()[0].action,
condition === "missing" ? "checking" : "unavailable",
);
assert.equal(rows()[0].notInChannel, false);
});
}

test("an all-rejected add does not trigger a directory rebuild", async () => {
await setup({
addResult: { added: [], errors: [{ pubkey: AGENT, error: "denied" }] },
Expand Down Expand Up @@ -384,10 +412,7 @@ test("a cancelled late roster cannot trigger discovery or resurrect its member",
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,
);
assert.equal(rows().filter((row) => row.action === "mention").length, 0);
});

for (const change of [
Expand Down Expand Up @@ -427,6 +452,11 @@ for (const change of [
"old actionable row must not establish intent",
);
assert.deepEqual(mention.knownNames, []);
assert.equal(
mention.isAgentPubkey(AGENT),
true,
"directory removal never turns a known agent into a human",
);
});
}

Expand Down Expand Up @@ -713,12 +743,12 @@ test("background membership/search updates leave visible same-name rows and Tab
client.invalidateQueries({ queryKey: ["user-search"] }),
);
await settle();
assert.equal(mention.suggestions, displayed);
assert.deepEqual(mention.suggestions, displayed);
let outcome, edit;
await act(async () => {
outcome = mention.handleMentionKeyDown(keyboard("Tab"));
});
assert.equal(outcome.suggestion, selected);
assert.deepEqual(outcome.suggestion, selected);
await act(async () => {
edit = mention.insertMention(outcome.suggestion, 6);
});
Expand Down Expand Up @@ -764,7 +794,7 @@ test("text changes load a new request; superseded and closed responses cannot in
releaseOld({ users: [person(VIEWER, "Old")], next_cursor: null }),
);
await settle();
assert.equal(mention.suggestions, displayed);
assert.deepEqual(mention.suggestions, displayed);
await act(async () => mention.updateMentionQuery("@closed", 7));
await settle();
await act(async () => mention.cancelMentionAutocomplete());
Expand Down Expand Up @@ -814,3 +844,84 @@ test("Space completes an exact name but remains literal for partial and same-nam
mention.suggestions[0].pubkey,
);
});

for (const condition of ["denied", "missing", "failed"]) {
test(`disabled ${condition} member rejects pointer, keyboard and new pin intent`, async () => {
await setup({
owner: OTHER,
visible: true,
directoryVisible: true,
policy: condition === "denied" ? "owner-only" : "anyone",
missingDirectory: condition === "missing",
failDirectory: condition === "failed",
});
const row = rows()[0];
assert.equal(mention.canSelectMention(row), false);
await act(async () => {
picker.selectMentionSuggestion(row);
picker.toggleAlwaysAddressAgent(row);
mention.handleMentionKeyDown(keyboard("ArrowDown"));
});
for (const key of ["Tab", "Enter", " "]) {
let outcome;
await act(async () => {
outcome = mention.handleMentionKeyDown(keyboard(key));
});
assert.equal(outcome.suggestion, undefined);
}
assert.deepEqual(effects, []);
assert.deepEqual(mention.knownNames, []);
});
}

test("checking resolves and retry refreshes without moving the selected identity", async () => {
await setup({
owner: OTHER,
visible: true,
directoryVisible: true,
missingDirectory: true,
});
const identity = rows()[0].pubkey;
assert.equal(rows()[0].action, "checking");
state.missingDirectory = false;
await act(async () =>
client.invalidateQueries({ queryKey: ["relay-agents"] }),
);
await settle();
assert.equal(rows()[0].pubkey, identity);
assert.equal(mention.mentionSelectedIndex, 0);
assert.equal(rows()[0].action, "mention");
assert.equal(mention.canSelectMention(rows()[0]), true);
state.failDirectory = true;
await act(async () =>
client.invalidateQueries({ queryKey: ["relay-agents"] }),
);
await settle();
assert.equal(rows()[0].action, "unavailable");
assert.equal(mention.canSelectMention(rows()[0]), false);
state.failDirectory = false;
await act(async () => rows()[0].onRetry());
await settle();
assert.equal(rows()[0].pubkey, identity);
assert.equal(rows()[0].action, "mention");
});

test("verification expiry never installs an unfinished people search", async () => {
await setup();
let release;
state.pendingSearch = {
slow: new Promise((resolve) => {
release = resolve;
}),
};
await act(async () => mention.updateMentionQuery("@slow", 5));
await act(async () => new Promise((resolve) => setTimeout(resolve, 5100)));
assert.equal(mention.isMentionLoading, true);
assert.deepEqual(mention.suggestions, []);
await act(async () =>
release({ users: [person(OTHER, "Slow")], next_cursor: null }),
);
await settle();
assert.equal(mention.isMentionLoading, false);
assert.equal(mention.suggestions[0].pubkey, OTHER);
});
128 changes: 94 additions & 34 deletions desktop/src/features/messages/lib/buildMentionCandidates.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { relayAgentIsSharedWithUser } from "@/features/agents/lib/agentAutocompleteEligibility";
import { markMentionCollisions } from "./mentionPresentation";
import {
coalesceAgentAutocompleteCandidates,
coalesceAutocompleteCandidatesByKey,
shouldHideAgentFromMentions,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type {
AgentPersona,
Expand All @@ -16,14 +12,15 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
import {
formatSearchUserDisplayName,
formatSearchUserSecondaryLabel,
globalSearchIdentityKey,
type MentionCandidate,
mentionCandidateLabel,
} from "./mentionCandidates";

/** Directories and rosters the mention picker merges into one candidate list. */
export type BuildMentionCandidatesInput = {
activeAgentPubkeys: ReadonlySet<string>;
knownAgentPubkeys?: ReadonlySet<string>;
verificationFailed?: boolean;
presenceFresh?: boolean;
activePersonaById: ReadonlyMap<string, AgentPersona>;
/** Already narrowed to `isActive` personas. */
activePersonas: readonly AgentPersona[];
Expand Down Expand Up @@ -56,6 +53,9 @@ export type BuildMentionCandidatesInput = {
*/
export function buildMentionCandidates({
activeAgentPubkeys,
knownAgentPubkeys = new Set(),
verificationFailed = false,
presenceFresh = true,
activePersonaById,
activePersonas,
canSearchGlobalUsers,
Expand Down Expand Up @@ -83,19 +83,6 @@ export function buildMentionCandidates({
if (isArchived(pubkey)) {
return;
}
if (
shouldHideAgentFromMentions({
isAgent: candidate.isAgent === true,
pubkey,
mentionableAgentPubkeys,
directoryReady:
candidate.isManagedAgent === true
? managedAgentDirectoryReady
: relayAgentDirectoryReady,
})
) {
return;
}
const current = candidatesByPubkey.get(pubkey);
if (!current) {
candidatesByPubkey.set(pubkey, { ...candidate, pubkey });
Expand Down Expand Up @@ -174,9 +161,9 @@ export function buildMentionCandidates({
// is filtered by access policy, so its channel ids can legitimately omit
// a room where this identity is already a member.
isMember:
memberPubkeys.has(pubkey) ||
(mentionChannelId !== null &&
agent.channelIds.includes(mentionChannelId)),
members !== undefined
? members.some((member) => normalizePubkey(member.pubkey) === pubkey)
: memberPubkeys.has(pubkey),
personaId:
managedAgentPersonaIdsByPubkey.get(pubkey) ??
(activePersonaById.has(pubkey) ? pubkey : undefined),
Expand Down Expand Up @@ -236,17 +223,90 @@ export function buildMentionCandidates({
isAgent: true,
}))
.filter((candidate) => candidate.displayName.trim().length > 0);
return markMentionCollisions(
coalesceAgentAutocompleteCandidates(
coalesceAutocompleteCandidatesByKey(
[...candidatesByPubkey.values(), ...personaCandidates],
globalSearchIdentityKey,
),
{
// Classify the exact-key union BEFORE admission. A known agent returned by
// people search must never bypass policy as a human.
const relayByKey = new Map(
(relayAgents ?? []).map((a) => [normalizePubkey(a.pubkey), a]),
);
const managedByKey = new Map(
(managedAgents ?? []).map((a) => [normalizePubkey(a.pubkey), a]),
);
const roster =
members === undefined
? memberPubkeys
: new Set(members.map((m) => normalizePubkey(m.pubkey)));
const union = [...candidatesByPubkey.values()].map((candidate) => {
const key = candidate.pubkey ?? "";
const relay = relayByKey.get(key);
const managed = managedByKey.get(key);
const isAgent =
candidate.isAgent || !!relay || !!managed || knownAgentPubkeys.has(key);
// Profiles/search may classify an agent, but do not verify ownership.
const ownerPubkey = relay?.ownerPubkey ?? (managed ? currentPubkey : null);
const ready = managed
? managedAgentDirectoryReady
: relayAgentDirectoryReady;
const hasEvidence = !!managed || !!relay;
const isMember = roster.has(key);
const memberPolicyAllows =
relay &&
isMember &&
mentionChannelId &&
relayAgentIsSharedWithUser(
{ ...relay, channelIds: [mentionChannelId] },
new Set([mentionChannelId]),
currentPubkey,
getLabel: mentionCandidateLabel,
preferredPubkeys: memberPubkeys,
},
),
);
const allowed =
ready &&
hasEvidence &&
(mentionableAgentPubkeys.has(key) || memberPolicyAllows);
const action =
!isAgent || allowed
? isMember
? "mention"
: "invite"
: ready && hasEvidence
? "unavailable"
: verificationFailed
? "unavailable"
: "checking";
return {
...candidate,
isAgent,
isMember,
ownerPubkey,
isOwned:
!!ownerPubkey &&
!!currentPubkey &&
normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey),
action,
unavailableReason:
action === "unavailable"
? ready && hasEvidence
? "This agent does not permit you to mention it here."
: "Could not verify access. Retry to check again."
: action === "checking"
? "Checking access…"
: undefined,
presence:
isAgent && relay && presenceFresh && relayAgentDirectoryReady
? relay.status
: "unknown",
localLifecycle: managed?.status,
localError: Boolean(managed?.lastError),
} satisfies MentionCandidate;
});
// No new disclosure: unverified/denied directory-only nonmembers remain
// hidden. Current roster identities and local managed identities are already
// visible and can explain an unavailable action without granting one.
const marked = markMentionCollisions([...union, ...personaCandidates]);
return marked.filter(
(candidate) =>
!candidate.isAgent ||
candidate.kind !== "identity" ||
candidate.isMember ||
candidate.action === "invite" ||
(candidate.isManagedAgent && candidate.action === "checking"),
);
}
11 changes: 8 additions & 3 deletions desktop/src/features/messages/lib/mentionCandidates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MentionAction } from "./mentionPresentation";
import type { MentionAction, MentionPresence } from "./mentionPresentation";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import type {
AgentPersona,
Expand Down Expand Up @@ -34,8 +34,6 @@ export type TeamMentionMember = {
};

export type MentionCandidate = {
action?: MentionAction;
hasNameCollision?: boolean;
kind: "identity" | "persona" | "team";
pubkey?: string;
personaId?: string;
Expand All @@ -52,6 +50,13 @@ export type MentionCandidate = {
isActiveAgent?: boolean;
isManagedAgent?: boolean;
isGlobalSearchResult?: boolean;
action?: MentionAction;
unavailableReason?: string;
presence?: MentionPresence;
localLifecycle?: string;
localError?: boolean;
isOwned?: boolean;
hasNameCollision?: boolean;
};

export function mentionCandidateLabel(candidate: MentionCandidate) {
Expand Down
Loading
Loading