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
191 changes: 143 additions & 48 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,84 +162,179 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent
);
});

function hideArgs(overrides = {}) {
const {
candidate = {},
currentPubkey = CURRENT_PUBKEY,
managedAgentPubkeys = new Set(),
relayAgentPolicies = new Map(),
} = overrides;
return {
candidate: {
isAgent: true,
isMember: true,
ownerPubkey: null,
pubkey: PUB_A,
...candidate,
},
currentPubkey,
managedAgentPubkeys,
relayAgentPolicies,
};
}

function policy(respondTo, respondToAllowlist = []) {
return new Map([[PUB_A, { respondTo, respondToAllowlist }]]);
}

test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
isAgent: false,
isMember: false,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set(),
directoryAgentPubkeys: new Set([PUB_A]),
}),
shouldHideAgentFromMentions(
hideArgs({ candidate: { isAgent: false, isMember: false } }),
),
false,
);
});

test("shouldHideAgentFromMentions: shows invocable agents even when non-member", () => {
test("shouldHideAgentFromMentions: always offers managed agents, member or not", () => {
// The local record is the desktop's own proof the agent will answer; it
// also covers the non-member auto-add flow for managed agents.
for (const isMember of [true, false]) {
assert.equal(
shouldHideAgentFromMentions(
hideArgs({
candidate: { isMember },
managedAgentPubkeys: new Set([PUB_A]),
}),
),
false,
);
}
});

test("shouldHideAgentFromMentions: offers external member agents declaring respond-to anyone", () => {
assert.equal(
shouldHideAgentFromMentions({
isAgent: true,
isMember: false,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set([PUB_A]),
directoryAgentPubkeys: new Set([PUB_A]),
}),
shouldHideAgentFromMentions(
hideArgs({ relayAgentPolicies: policy("anyone") }),
),
false,
);
});

test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () => {
test("shouldHideAgentFromMentions: allowlist admits only listed users", () => {
assert.equal(
shouldHideAgentFromMentions({
isAgent: true,
isMember: false,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set(),
directoryAgentPubkeys: new Set(),
}),
shouldHideAgentFromMentions(
hideArgs({ relayAgentPolicies: policy("allowlist", [CURRENT_PUBKEY]) }),
),
false,
);
assert.equal(
shouldHideAgentFromMentions(
hideArgs({ relayAgentPolicies: policy("allowlist", [OWNER_PUBKEY]) }),
),
true,
);
});

test("shouldHideAgentFromMentions: hides member agents with an explicit not-invocable directory entry (Fizz)", () => {
test("shouldHideAgentFromMentions: owner-only admits the verified owner", () => {
// ownerPubkey is the NIP-OA-verified value the "managed by" surface
// renders — external agents owned by the current user mention like
// managed ones.
assert.equal(
shouldHideAgentFromMentions({
isAgent: true,
isMember: true,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set(),
directoryAgentPubkeys: new Set([PUB_A]),
}),
shouldHideAgentFromMentions(
hideArgs({
candidate: { ownerPubkey: CURRENT_PUBKEY },
relayAgentPolicies: policy("owner-only"),
}),
),
false,
);
assert.equal(
shouldHideAgentFromMentions(
hideArgs({
candidate: { ownerPubkey: OTHER_OWNER_PUBKEY },
relayAgentPolicies: policy("owner-only"),
}),
),
true,
);
// Unverified ownership is no proof at all.
assert.equal(
shouldHideAgentFromMentions(
hideArgs({ relayAgentPolicies: policy("owner-only") }),
),
true,
);
});

test("shouldHideAgentFromMentions: shows member agents with unknown invocability (not in directory)", () => {
test("shouldHideAgentFromMentions: declaration-less agents are offered only to their verified owner", () => {
// No entry at all (stranger's view) → hidden.
assert.equal(shouldHideAgentFromMentions(hideArgs()), true);
// Same agent, viewed by its NIP-OA-verified owner → offered. The harness
// author gate admits the owner under every respond-to mode, so this is
// never a void chip; it covers owner-on-another-device (#2349, #3277).
assert.equal(
shouldHideAgentFromMentions({
isAgent: true,
isMember: true,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set(),
directoryAgentPubkeys: new Set(),
}),
shouldHideAgentFromMentions(
hideArgs({ candidate: { ownerPubkey: CURRENT_PUBKEY } }),
),
false,
);
// Unverified ownership stays hidden — attribution without proof is not
// enough.
assert.equal(
shouldHideAgentFromMentions(
hideArgs({ candidate: { ownerPubkey: OTHER_OWNER_PUBKEY } }),
),
true,
);
});

test("shouldHideAgentFromMentions: hides non-member external agents regardless of policy", () => {
// Offering these would need the add-via-mention flow to honor the
// directory's channel_add_policy — an explicit follow-up.
assert.equal(
shouldHideAgentFromMentions(
hideArgs({
candidate: { isMember: false },
relayAgentPolicies: policy("anyone"),
}),
),
true,
);
});

test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => {
test("shouldHideAgentFromMentions: hides agents when the viewer is unknown", () => {
for (const respondTo of ["allowlist", "owner-only"]) {
assert.equal(
shouldHideAgentFromMentions(
hideArgs({
candidate: { ownerPubkey: OWNER_PUBKEY },
currentPubkey: null,
relayAgentPolicies: policy(respondTo, [CURRENT_PUBKEY]),
}),
),
true,
);
}
});

test("shouldHideAgentFromMentions: normalizes pubkeys before every comparison", () => {
const mixedCase = "Ab".repeat(32);
const normalized = mixedCase.toLowerCase();

assert.equal(
shouldHideAgentFromMentions({
isAgent: true,
isMember: true,
pubkey: mixedCase,
mentionableAgentPubkeys: new Set(),
directoryAgentPubkeys: new Set([normalized]),
}),
true,
shouldHideAgentFromMentions(
hideArgs({
candidate: {
ownerPubkey: CURRENT_PUBKEY.toUpperCase(),
pubkey: mixedCase,
},
relayAgentPolicies: new Map([
[normalized, { respondTo: "owner-only", respondToAllowlist: [] }],
]),
}),
),
false,
);
});

Expand Down
120 changes: 92 additions & 28 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,37 +64,101 @@ export function isAgentIdentityInManagedList(
);
}

/** The respond-to declaration from an agent's kind:10100 directory entry. */
export type RelayAgentMentionPolicy = Pick<
RelayAgent,
"respondTo" | "respondToAllowlist"
>;

/**
* Hide an agent-classified mention candidate unless there is evidence the
* mention will be received and answered.
*
* Offering a mention asserts "this agent will receive the message and its
* policy admits responding to you". That is decided from the same proofs
* other agent surfaces already trust, never from the local store alone:
*
* 1. A local managed record — this desktop runs the agent, so it can vouch
* directly (the native path, unchanged).
* 2. Channel membership (relay-authoritative — candidates originate from the
* member list) plus the agent's own kind:10100 respond-to declaration:
* `anyone`, an allowlist naming the current user, or `owner-only` when
* the current user IS the agent's owner. Ownership uses
* `candidate.ownerPubkey` — the NIP-OA-verified value the "managed by"
* surface renders — so external agents owned by the current user are
* mentionable exactly like managed ones.
*
* Liveness is deliberately not required: buzz-acp replays missed mentions on
* reconnect (`since` filter), and stopped managed agents are offered today.
* An agent-classified candidate with no local record and no directory
* declaration stays hidden — with no proof it will answer, a mention chip
* would fire into the void.
*/
export function shouldHideAgentFromMentions({
isAgent,
isMember,
pubkey,
mentionableAgentPubkeys,
directoryAgentPubkeys,
candidate,
currentPubkey,
managedAgentPubkeys,
relayAgentPolicies,
}: {
isAgent: boolean;
isMember: boolean;
pubkey: string;
mentionableAgentPubkeys: ReadonlySet<string>;
directoryAgentPubkeys: ReadonlySet<string>;
candidate: {
isAgent?: boolean;
isMember?: boolean;
ownerPubkey?: string | null;
pubkey: string;
};
currentPubkey: string | null | undefined;
managedAgentPubkeys: ReadonlySet<string>;
relayAgentPolicies: ReadonlyMap<string, RelayAgentMentionPolicy>;
}) {
if (!isAgent) return false;
const normalized = normalizePubkey(pubkey);
// Invocable => always show.
if (mentionableAgentPubkeys.has(normalized)) return false;
// Non-member, non-invocable => hide (preserves prior behavior).
if (!isMember) return true;
// Member (Option B): hide only when we have an explicit not-invocable
// signal — a relay directory (kind:10100) entry that excludes us.
// Unknown invocability (not in directory) => show.
//
// NOTE: this assumes `directoryAgentPubkeys` and `mentionableAgentPubkeys`
// share the same source query (`relayAgentsQuery.data`), so directory
// presence without membership in `mentionableAgentPubkeys` is a real
// explicit-exclusion signal. If a future change sources the directory set
// from a different query, an agent that's directory-present but whose
// mentionability is still loading could be hidden prematurely — keep the
// two sets derived from the same query.
return directoryAgentPubkeys.has(normalized);
if (candidate.isAgent !== true) return false;
const pubkey = normalizePubkey(candidate.pubkey);
if (managedAgentPubkeys.has(pubkey)) return false;
// Offering a non-member external agent would need the add-via-mention flow
// to honor the agent's `channel_add_policy` declaration — a follow-up, not
// this change. Members only for now.
if (candidate.isMember !== true) return true;
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
const ownerPubkey = candidate.ownerPubkey
? normalizePubkey(candidate.ownerPubkey)
: null;
const policy = relayAgentPolicies.get(pubkey);
if (!policy) {
// No declaration: offer only to the verified owner. Sound because the
// harness's inbound author gate admits the owner under every respond-to
// mode, so an owner's mention is never a void chip — and it covers the
// owner-on-another-device reports (#2349, #3277) without requiring a
// directory entry that nothing publishes for native agents yet. For
// anyone else there is still no proof the agent will answer. Stale
// same-name identities an owner might now see fold into the live one
// via the same-label/same-owner coalescing (and NIP-IA archived
// identities are peeled before candidates are built).
return (
normalizedCurrentPubkey === null ||
ownerPubkey === null ||
ownerPubkey !== normalizedCurrentPubkey
);
}
switch (policy.respondTo) {
case "anyone":
return false;
case "allowlist":
return (
normalizedCurrentPubkey === null ||
!policy.respondToAllowlist
.map((entry) => normalizePubkey(entry))
.includes(normalizedCurrentPubkey)
);
case "owner-only":
return (
normalizedCurrentPubkey === null ||
ownerPubkey === null ||
ownerPubkey !== normalizedCurrentPubkey
);
default:
return true;
}
}

type AgentAutocompleteCandidate = {
Expand Down
Loading