Skip to content
Merged
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 @@ -336,26 +336,26 @@ test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => {
);
});

test("coalesceAgentAutocompleteCandidates: merges agents with the same persona id", () => {
test("coalesceAgentAutocompleteCandidates: keeps agents with the same persona id distinct", () => {
const first = makeAgent({ pubkey: PUB_A, personaId: "pinky" });
const second = makeAgent({
pubkey: PUB_B,
personaId: "pinky",
isMember: true,
});

assert.deepEqual(coalesce([first, second]), [second]);
assert.deepEqual(coalesce([first, second]), [first, second]);
});

test("coalesceAgentAutocompleteCandidates: merges agents with the same owner and name", () => {
test("coalesceAgentAutocompleteCandidates: keeps agents with the same owner and name distinct", () => {
const first = makeAgent({ pubkey: PUB_A, ownerPubkey: OWNER_PUBKEY });
const second = makeAgent({
pubkey: PUB_B,
ownerPubkey: OWNER_PUBKEY,
isMember: true,
});

assert.deepEqual(coalesce([first, second]), [second]);
assert.deepEqual(coalesce([first, second]), [first, second]);
});

test("coalesceAgentAutocompleteCandidates: keeps same-name agents with different owners distinct", () => {
Expand All @@ -382,14 +382,24 @@ test("coalesceAgentAutocompleteCandidates: keeps owner-less managed same-name ag
assert.deepEqual(coalesce([first, second]), [first, second]);
});

test("coalesceAgentAutocompleteCandidates: merges current-owner same-name agents", () => {
test("coalesceAgentAutocompleteCandidates: keeps current-owner same-name agents distinct", () => {
const first = makeAgent({ pubkey: PUB_A, ownerPubkey: CURRENT_PUBKEY });
const second = makeAgent({
pubkey: PUB_B,
ownerPubkey: CURRENT_PUBKEY,
isManagedAgent: true,
});

assert.deepEqual(coalesce([first, second]), [first, second]);
});

test("coalesceAgentAutocompleteCandidates: coalesces repeated source rows for the same pubkey", () => {
const first = makeAgent({ pubkey: PUB_A });
const second = makeAgent({
pubkey: PUB_A.toUpperCase(),
isMember: true,
});

assert.deepEqual(coalesce([first, second]), [second]);
});

Expand Down
61 changes: 12 additions & 49 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,75 +184,39 @@ type AgentAutocompleteCandidate = {
personaId?: string | null;
};

function normalizeLabel(label: string | null | undefined) {
return label?.trim().toLowerCase() || null;
}

function agentIdentityKey<T extends AgentAutocompleteCandidate>(
candidate: T,
currentPubkey: string | null | undefined,
getLabel: (candidate: T) => string | null | undefined,
) {
if (candidate.isAgent !== true) {
return null;
}

if (candidate.personaId) {
return `persona:${candidate.personaId}`;
}

const label = normalizeLabel(getLabel(candidate));
if (!label) {
function agentIdentityKey<T extends AgentAutocompleteCandidate>(candidate: T) {
if (candidate.isAgent !== true || !candidate.pubkey) {
return null;
}

const ownerPubkey = candidate.ownerPubkey
? normalizePubkey(candidate.ownerPubkey)
: null;
if (ownerPubkey) {
if (currentPubkey && ownerPubkey === normalizePubkey(currentPubkey)) {
return `local:name:${label}`;
}
return `owner:${ownerPubkey}:name:${label}`;
}

return null;
// Pubkeys—not persona metadata or a display name—are agent identities.
// A persona may be installed more than once, and an owner may intentionally
// create multiple same-named agents. Collapsing either case makes one agent
// impossible to choose from autocomplete.
return `pubkey:${normalizePubkey(candidate.pubkey)}`;
}

function agentCandidateRank<T extends AgentAutocompleteCandidate>(
candidate: T,
currentPubkey: string | null | undefined,
preferredPubkeys: ReadonlySet<string>,
) {
const pubkey = candidate.pubkey ? normalizePubkey(candidate.pubkey) : null;
const ownerPubkey = candidate.ownerPubkey
? normalizePubkey(candidate.ownerPubkey)
: null;
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;

return [
candidate.isMember === true ? 0 : 1,
pubkey && preferredPubkeys.has(pubkey) ? 0 : 1,
candidate.isManagedAgent === true ? 0 : 1,
candidate.personaId ? 0 : 1,
ownerPubkey && ownerPubkey === normalizedCurrentPubkey ? 0 : 1,
];
}

function isPreferredAgentCandidate<T extends AgentAutocompleteCandidate>(
next: T,
current: T,
currentPubkey: string | null | undefined,
preferredPubkeys: ReadonlySet<string>,
) {
const nextRank = agentCandidateRank(next, currentPubkey, preferredPubkeys);
const currentRank = agentCandidateRank(
current,
currentPubkey,
preferredPubkeys,
);
const nextRank = agentCandidateRank(next, preferredPubkeys);
const currentRank = agentCandidateRank(current, preferredPubkeys);

for (let index = 0; index < nextRank.length; index++) {
if (nextRank[index] !== currentRank[index]) {
Expand Down Expand Up @@ -291,8 +255,8 @@ export function coalesceAgentAutocompleteCandidates<
>(
candidates: readonly T[],
{
currentPubkey,
getLabel,
currentPubkey: _currentPubkey,
getLabel: _getLabel,
preferredPubkeys = new Set(),
}: {
currentPubkey?: string | null;
Expand All @@ -304,7 +268,7 @@ export function coalesceAgentAutocompleteCandidates<
const indexesByKey = new Map<string, number>();

for (const candidate of candidates) {
const key = agentIdentityKey(candidate, currentPubkey, getLabel);
const key = agentIdentityKey(candidate);
if (!key) {
output.push(candidate);
continue;
Expand All @@ -321,7 +285,6 @@ export function coalesceAgentAutocompleteCandidates<
isPreferredAgentCandidate(
candidate,
output[currentIndex],
currentPubkey,
preferredPubkeys,
)
) {
Expand Down
14 changes: 3 additions & 11 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,12 +280,6 @@ export function MembersSidebar({
agent,
]),
);
const memberAgentLabels = new Set(
rawMembers
.filter((member) => member.isAgent === true || member.role === "bot")
.map((member) => member.displayName?.trim().toLowerCase())
.filter((label): label is string => Boolean(label)),
);
const sharedChannelIds = getSharedChannelIds(channelsQuery.data);
const allowedAgentPubkeys = getMentionableAgentPubkeys({
currentPubkey,
Expand All @@ -298,10 +292,6 @@ export function MembersSidebar({
const addCandidate = (candidate: AddMemberSearchCandidate) => {
const pubkey = normalizePubkey(candidate.pubkey);
if (
(candidate.isAgent &&
memberAgentLabels.has(
formatAddCandidateName(candidate).toLowerCase(),
)) ||
memberPubkeys.has(pubkey) ||
isArchivedDiscovery(pubkey) ||
!isAgentIdentityInAllowedList(candidate, allowedAgentPubkeys)
Expand Down Expand Up @@ -391,7 +381,6 @@ export function MembersSidebar({
normalizedDeferredSearchQuery,
relayAgentsQuery.data,
userSearchResults,
rawMembers,
]);
const isAddSearchLoading =
userSearchQuery.isLoading ||
Expand Down Expand Up @@ -969,6 +958,9 @@ function AddMemberSearchResultRow({
agent
</span>
</div>
<span className="block truncate font-mono text-2xs text-muted-foreground">
{truncatePubkey(user.pubkey)}
</span>
{ownerLabel ? (
<span className="block truncate text-xs text-muted-foreground">
managed by {ownerLabel}
Expand Down
6 changes: 3 additions & 3 deletions desktop/tests/e2e/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3774,7 +3774,7 @@ test("channel header actions show tooltips", async ({ page }) => {
}
});

test("members sidebar collapses same-persona managed agents", async ({
test("members sidebar retains distinct same-persona managed agents", async ({
page,
}) => {
const inChannelAgentPubkey =
Expand Down Expand Up @@ -3824,8 +3824,8 @@ test("members sidebar collapses same-persona managed agents", async ({
).toHaveCount(0);
await expect(
page.getByTestId(`channel-user-search-result-${outOfChannelAgentPubkey}`),
).toHaveCount(0);
await expect(page.getByText("Pinky", { exact: true })).toHaveCount(1);
).toBeVisible();
await expect(page.getByText("Pinky", { exact: true })).toHaveCount(2);
});

test("private-channel members cannot add people without owner/admin", async ({
Expand Down
Loading