Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
isAgentIdentityReachableForMentions,
relayAgentIsSharedWithUser,
shouldHideAgentFromMentions,
shouldOfferAgentIdentityForMentions,
} from "./agentAutocompleteEligibility.ts";

const CURRENT_PUBKEY = "a".repeat(64);
Expand Down Expand Up @@ -162,6 +164,224 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent
);
});

test("isAgentIdentityReachableForMentions: keeps channel members the viewer does not manage", () => {
const managedAgentPubkeys = new Set([PUB_A]);

assert.equal(
isAgentIdentityReachableForMentions(
{ isAgent: true, isMember: true, pubkey: PUB_B },
managedAgentPubkeys,
true,
),
true,
);
assert.equal(
isAgentIdentityReachableForMentions(
{ isAgent: true, isMember: false, pubkey: PUB_B },
managedAgentPubkeys,
true,
),
false,
);
assert.equal(
isAgentIdentityReachableForMentions(
{ isAgent: true, isMember: false, pubkey: PUB_A.toUpperCase() },
managedAgentPubkeys,
true,
),
true,
);
assert.equal(
isAgentIdentityReachableForMentions(
{ isAgent: false, isMember: false, pubkey: PUB_B },
managedAgentPubkeys,
true,
),
true,
);
});

test("isAgentIdentityReachableForMentions: holds the member pass-through until the relay directory is ready", () => {
const managedAgentPubkeys = new Set([PUB_A]);

assert.equal(
isAgentIdentityReachableForMentions(
{ isAgent: true, isMember: true, pubkey: PUB_B },
managedAgentPubkeys,
false,
),
false,
);
// A managed agent and a human are unaffected by the directory load state.
assert.equal(
isAgentIdentityReachableForMentions(
{ isAgent: true, isMember: true, pubkey: PUB_A },
managedAgentPubkeys,
false,
),
true,
);
assert.equal(
isAgentIdentityReachableForMentions(
{ isAgent: false, isMember: true, pubkey: PUB_B },
managedAgentPubkeys,
false,
),
true,
);
});

/**
* Drives the real admission check `useMentions`' `addCandidate` calls, building
* its pubkey sets the same way the hook does from `relayAgentsQuery.data`.
* `tests/e2e/mentions.spec.ts` covers the same scenarios through the composer.
*/
function mentionCandidateIsOffered(
candidate,
{
managedAgentPubkeys,
relayAgents,
sharedChannelIds,
relayAgentDirectoryReady = true,
},
) {
return shouldOfferAgentIdentityForMentions({
candidate,
managedAgentPubkeys,
mentionableAgentPubkeys: getMentionableAgentPubkeys({
currentPubkey: CURRENT_PUBKEY,
managedAgentPubkeys,
relayAgents,
sharedChannelIds,
}),
directoryAgentPubkeys: new Set(
relayAgents.map((agent) => agent.pubkey.toLowerCase()),
),
relayAgentDirectoryReady,
});
}

test("mention path offers an invocable channel bot from another install", () => {
const crossOwnerBot = { isAgent: true, isMember: true, pubkey: PUB_B };

assert.equal(
mentionCandidateIsOffered(crossOwnerBot, {
managedAgentPubkeys: new Set([PUB_A]),
relayAgents: [
{
pubkey: PUB_B,
respondTo: "allowlist",
respondToAllowlist: [CURRENT_PUBKEY],
channelIds: ["general"],
},
],
sharedChannelIds: new Set(["general"]),
}),
true,
);
assert.equal(
mentionCandidateIsOffered(crossOwnerBot, {
managedAgentPubkeys: new Set([PUB_A]),
relayAgents: [
{
pubkey: PUB_B,
respondTo: "anyone",
respondToAllowlist: [],
channelIds: ["general"],
},
],
sharedChannelIds: new Set(["general"]),
}),
true,
);
});

test("mention path still hides a channel bot whose directory entry excludes the viewer", () => {
assert.equal(
mentionCandidateIsOffered(
{ isAgent: true, isMember: true, pubkey: PUB_B },
{
managedAgentPubkeys: new Set([PUB_A]),
relayAgents: [
{
pubkey: PUB_B,
respondTo: "owner-only",
respondToAllowlist: [],
channelIds: ["general"],
},
],
sharedChannelIds: new Set(["general"]),
},
),
false,
);
assert.equal(
mentionCandidateIsOffered(
{ isAgent: true, isMember: true, pubkey: PUB_B },
{
managedAgentPubkeys: new Set([PUB_A]),
relayAgents: [
{
pubkey: PUB_B,
respondTo: "allowlist",
respondToAllowlist: [OTHER_OWNER_PUBKEY],
channelIds: ["general"],
},
],
sharedChannelIds: new Set(["general"]),
},
),
false,
);
});

test("mention path does not offer a channel bot while the relay directory is still loading", () => {
// In flight, `relayAgents` is empty: the bot is neither invocable nor
// directory-present, so `shouldHideAgentFromMentions` would read it as
// unknown-invocability and show it. Readiness is what keeps it hidden until
// the directory can answer.
assert.equal(
mentionCandidateIsOffered(
{ isAgent: true, isMember: true, pubkey: PUB_B },
{
managedAgentPubkeys: new Set([PUB_A]),
relayAgents: [],
sharedChannelIds: new Set(["general"]),
relayAgentDirectoryReady: false,
},
),
false,
);
// Once ready, an empty or errored directory falls back to Option B (unknown
// invocability => show) rather than hiding members indefinitely.
assert.equal(
mentionCandidateIsOffered(
{ isAgent: true, isMember: true, pubkey: PUB_B },
{
managedAgentPubkeys: new Set([PUB_A]),
relayAgents: [],
sharedChannelIds: new Set(["general"]),
relayAgentDirectoryReady: true,
},
),
true,
);
});

test("mention path keeps unreachable non-member agents out of the composer", () => {
assert.equal(
mentionCandidateIsOffered(
{ isAgent: true, isMember: false, pubkey: PUB_B },
{
managedAgentPubkeys: new Set([PUB_A]),
relayAgents: [],
sharedChannelIds: new Set(["general"]),
},
),
false,
);
});

test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
Expand Down
63 changes: 63 additions & 0 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,32 @@ export function isAgentIdentityInManagedList(
);
}

export function isAgentIdentityReachableForMentions(
candidate: { isAgent?: boolean; isMember?: boolean; pubkey: string },
managedAgentPubkeys: ReadonlySet<string>,
relayAgentDirectoryReady: boolean,
) {
// `managedAgentPubkeys` only ever lists agents this Desktop install runs, so
// it cannot decide whether a channel member is invocable — an agent owned by
// a teammate is absent from it no matter how its `respond_to` is configured.
// Members are therefore left to `shouldHideAgentFromMentions`, which reads
// the relay directory. Non-members keep the managed-list gate so the
// composer still does not offer unreachable identities.
//
// The member pass-through waits for the relay directory because
// `shouldHideAgentFromMentions` reads an absent directory entry as "unknown
// invocability => show". Without this guard, an agent whose directory entry
// excludes the viewer would be offered for as long as that query is in
// flight. While the query is still loading, readiness stays false and
// members stay hidden (same as pre-fix). An errored or empty directory
// still counts as ready, so a finished-but-empty/failed fetch degrades to
// Option B (show) rather than hiding members indefinitely.
return (
(candidate.isMember === true && relayAgentDirectoryReady) ||
isAgentIdentityInManagedList(candidate, managedAgentPubkeys)
);
}

export function shouldHideAgentFromMentions({
isAgent,
isMember,
Expand Down Expand Up @@ -97,6 +123,43 @@ export function shouldHideAgentFromMentions({
return directoryAgentPubkeys.has(normalized);
}

/**
* The full mention-autocomplete admission check: reachability first, then the
* invocability gate. Kept here as one exported step so the composer has a
* single call and tests exercise the real chain instead of a copy of it.
*/
export function shouldOfferAgentIdentityForMentions({
candidate,
managedAgentPubkeys,
mentionableAgentPubkeys,
directoryAgentPubkeys,
relayAgentDirectoryReady,
}: {
candidate: { isAgent?: boolean; isMember?: boolean; pubkey: string };
managedAgentPubkeys: ReadonlySet<string>;
mentionableAgentPubkeys: ReadonlySet<string>;
directoryAgentPubkeys: ReadonlySet<string>;
relayAgentDirectoryReady: boolean;
}) {
if (
!isAgentIdentityReachableForMentions(
candidate,
managedAgentPubkeys,
relayAgentDirectoryReady,
)
) {
return false;
}

return !shouldHideAgentFromMentions({
isAgent: candidate.isAgent === true,
isMember: candidate.isMember === true,
pubkey: candidate.pubkey,
mentionableAgentPubkeys,
directoryAgentPubkeys,
});
}

type AgentAutocompleteCandidate = {
pubkey?: string;
displayName?: string | null;
Expand Down
15 changes: 6 additions & 9 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import {
coalesceAutocompleteCandidatesByKey,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInManagedList,
shouldHideAgentFromMentions,
shouldOfferAgentIdentityForMentions,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import {
useInfiniteUserSearchQuery,
Expand Down Expand Up @@ -246,16 +245,13 @@ export function useMentions(
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) {
return;
}
if (
shouldHideAgentFromMentions({
isAgent: candidate.isAgent === true,
isMember: candidate.isMember === true,
pubkey,
!shouldOfferAgentIdentityForMentions({
candidate: { ...candidate, pubkey },
managedAgentPubkeys,
mentionableAgentPubkeys,
directoryAgentPubkeys,
relayAgentDirectoryReady,
})
) {
return;
Expand Down Expand Up @@ -427,6 +423,7 @@ export function useMentions(
mentionableAgentPubkeys,
personaNameByPubkey,
profiles,
relayAgentDirectoryReady,
relayAgentNamesByPubkey,
relayAgentsQuery.data,
]);
Expand Down
15 changes: 9 additions & 6 deletions desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,22 +209,23 @@ test("@ trigger prioritizes channel members before runnable personas and other m

const dropdown = autocomplete(page);
await expect(dropdown).toBeVisible();
await expect(dropdown.getByText("alice")).toHaveCount(0);
// alice is a channel member and a relay agent that responds to anyone, so she
// is invocable here even though this install does not manage her (#3809).
await expect(dropdown.getByText("alice")).toBeVisible();
await expect(dropdown.getByText("bob")).toBeVisible();
await expect(dropdown.getByText("Fizz")).toBeVisible();
await expect(dropdown.getByText("charlie")).toBeVisible();
await expect(dropdown.getByText("outsider")).toHaveCount(0);
const charlieRow = dropdown.locator("button", { hasText: "charlie" });
await expect(charlieRow.getByTestId("mention-agent-icon")).toBeVisible();
await expect(charlieRow.getByText("not in channel")).toBeVisible();
await expect(
dropdown
.locator("button", { hasText: "alice" })
.getByText("not in channel"),
).not.toBeVisible();
const aliceRow = dropdown.locator("button", { hasText: "alice" });
await expect(aliceRow.getByTestId("mention-agent-icon")).toBeVisible();
await expect(aliceRow.getByText("not in channel")).not.toBeVisible();

const suggestions = dropdown.locator("button");
const suggestionText = await suggestions.allInnerTexts();
const aliceIndex = suggestionText.findIndex((text) => text.includes("alice"));
const fizzIndex = suggestionText.findIndex((text) => text.includes("Fizz"));
const bobIndex = suggestionText.findIndex((text) => text.includes("bob"));
const charlieIndex = suggestionText.findIndex((text) =>
Expand All @@ -233,10 +234,12 @@ test("@ trigger prioritizes channel members before runnable personas and other m
const outsiderIndex = suggestionText.findIndex((text) =>
text.includes("outsider"),
);
expect(aliceIndex).toBeGreaterThanOrEqual(0);
expect(fizzIndex).toBeGreaterThanOrEqual(0);
expect(bobIndex).toBeGreaterThanOrEqual(0);
expect(charlieIndex).toBeGreaterThanOrEqual(0);
expect(outsiderIndex).toEqual(-1);
expect(aliceIndex).toBeLessThan(fizzIndex);
expect(bobIndex).toBeLessThan(fizzIndex);
expect(fizzIndex).toBeLessThan(charlieIndex);
});
Expand Down