Skip to content

fix(desktop): let anyone mention an agent that is already in the channel - #5

Merged
mattbalza merged 2 commits into
mainfrom
fix/agent-mentions-from-any-relay-user
Aug 1, 2026
Merged

fix(desktop): let anyone mention an agent that is already in the channel#5
mattbalza merged 2 commits into
mainfrom
fix/agent-mentions-from-any-relay-user

Conversation

@mattbalza

Copy link
Copy Markdown
Owner

Problem

An agent in a channel is addressable only by its operator. On every other
device mentionableAgentPubkeys is empty for that agent while the relay
directory (kind:10100) lists it — and shouldHideAgentFromMentions read
exactly that combination (member, directory-present, not locally mentionable)
as an explicit not-invocable signal, and hid it.

The result is a participant nobody in the room can talk to: it posts messages,
it shows in the member sidebar, and typing @ does not offer it.

This is what our @erp and @codex agents look like to every user on the
relay except the one machine that manages them.

Fix

Channel membership already authorises every other participant; make it
sufficient here too.

// Channel membership is sufficient for people and agents alike. Whether an
// agent is managed on this device only governs local lifecycle controls.
if (!isAgent || isMember) return false;
return !mentionableAgentPubkeys.has(normalizePubkey(pubkey));

Whether an agent is managed on this device governs local lifecycle controls
(start, stop, attach) — not whether a peer may speak to it. With the
membership clause isAgentIdentityInManagedList no longer answers "is this
managed here" but "may this be surfaced at all", so it is renamed
isAgentIdentityDiscoverable.

The directoryAgentPubkeys memo in useMentions had no other consumer and is
removed.

Why this is safe

DMs are unaffected. isMember: true is set in exactly one place — the
members loop in useMentions, from the channel's own member list. Every
other candidate source hard-codes isMember: false, and the coalescer only
ORs. An agent that is not party to a conversation is still gated by the
managed-list branch.

Nothing is newly disclosed. Channel members are already listed in the
sidebar; this only makes one of them typeable.

Tests

The existing tests stopped at the predicate, which is how it stayed possible
to relax it without knowing whether a mention actually reaches the agent.
The new agentMentionComposedPath.test.mjs walks the whole path a picked
mention travels — eligibility → mentionCandidateLabel
mapMentionCandidateToSuggestionsnapshotDraftMentionRefs
messageMentionPubkeys — and asserts at the only stage the agent can observe,
the emitted p tags:

  • a channel-member agent managed on another device reaches the p tag, and is
    not flagged notInChannel
  • an agent in nobody's channel and nobody's managed list produces no suggestion
    and no p tag
  • a locally managed non-member agent is still suggested and still routes
    (prior behaviour preserved)
  • a DM addresses its own participants; an uninvolved agent never appears

Test plan

cd desktop
pnpm test        # 3876 pass, 0 fail
npx tsc --noEmit # clean
npx biome check src/

An agent in a channel is invisible to everyone but its operator. On any
other device `mentionableAgentPubkeys` is empty for that agent, and the
relay directory (kind:10100) lists it, so the member branch of
`shouldHideAgentFromMentions` read directory-presence-without-local-
mentionability as an explicit not-invocable signal and hid it. The result
is a participant nobody in the room can address: it posts, it is in the
member sidebar, and typing `@` does not offer it.

Channel membership is the authorisation that already governs every other
participant, so make it sufficient here too. Whether an agent is managed
on *this* device decides local lifecycle controls — start, stop, attach —
not whether a peer may speak to it.

DMs are unaffected. `isMember: true` is set in exactly one place, the
`members` loop in `useMentions`, from the channel's own member list; every
other candidate source hard-codes `isMember: false` and the coalescer only
ORs. An agent that is not party to a conversation is still gated by the
managed-list branch. Nor is anything newly disclosed: channel members are
already listed in the sidebar.

`isAgentIdentityInManagedList` is renamed `isAgentIdentityDiscoverable`,
since with the membership clause its answer is no longer "is this managed
here" but "may this be surfaced at all".

The existing tests stopped at the predicate, which is how it stayed
possible to relax it without knowing whether a mention actually reaches
the agent. `agentMentionComposedPath.test.mjs` walks the whole path a
picked mention travels — eligibility, label, suggestion, draft ref, `p`
tag — and pins the member, non-member, locally-managed and DM cases at the
only stage the agent can observe: the emitted `p` tags.

Signed-off-by: mattbalza <mattbalza@users.noreply.github.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

`prefer live agent mentions` (block#2149) flipped this assertion to `toHaveCount(0)`
when it started hiding relay-classified agents the local device does not manage.
alice is exactly that: an agent by the relay's classification and an owner-member
of #general — the fixture stand-in for an agent someone else runs.

Restoring her here is the point of the change, so the sibling assertion becomes
load-bearing rather than vacuous: `.not.toBeVisible()` passed while the row was
absent, and only `toHaveCount(0)` on a row that now exists proves a member agent
carries no "not in channel" caveat.

Signed-off-by: mattbalza <mattbalza@users.noreply.github.com>
@mattbalza
mattbalza merged commit 4ae33be into main Aug 1, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant