Skip to content

fix(desktop): let channel-member agents be mentioned - #5

Merged
yjc801 merged 2 commits into
mainfrom
claude/agent-mention-member-gate
Aug 9, 2026
Merged

yjc801 merged 2 commits into
mainfrom
claude/agent-mention-member-gate

Conversation

@yjc801

@yjc801 yjc801 commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

Summary

An agent owned by someone else could never be mentioned, even when its owner had explicitly allowlisted the mentioning user. Found while investigating why an agent that was a bot-role member of a channel, with permission granted, simply did not appear in the @ picker.

The mention picker ran two agent gates in sequence, and the first made the second's intent unreachable:

if (!isAgentIdentityInAllowedList(candidate, mentionableAgentPubkeys)) return;
if (shouldHideAgentFromMentions({ ... })) return;

isAgentIdentityInAllowedList rejects every candidate flagged isAgent that isn't already in mentionableAgentPubkeys. shouldHideAgentFromMentions is a strict superset of that check except for exactly one case, which it handles deliberately and documents at length:

// 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.

That branch never ran. The first gate dropped the candidate before it could apply.

Why the effect is total, not marginal. A relay agent enters mentionableAgentPubkeys only via channel_ids / respond_to / respond_to_allowlist, read from its kind:10100 profile. Nothing in this repo publishes that event. The only writer is buzz channels set-add-policy, whose content is just {"channel_add_policy": …} — and since kind 10100 is replaceable (asserted at compile time in buzz-core/src/kind.rs), that write also erases any profile that did exist. So the directory is empty in practice, no other-owner agent can enter the allowed set, and all of them were hidden.

The fix: gate on shouldHideAgentFromMentions alone.

Reviewer notes

  • Nothing else loosens. Non-member agents are still hidden (if (!isMember) return true), and a directory entry that excludes the viewer still hides (return directoryAgentPubkeys.has(...)). The only behavior change is the case the hide rule always intended to allow.
  • MembersSidebar deliberately keeps the stricter predicate. Deciding whether to add an agent to a channel is a different question from whether to mention one already in it.
  • The new test pins the disagreement between the two predicates on that single input, so the gate isn't reinstated by someone reading isAgentIdentityInAllowedList as the obvious guard.
  • Honest limitation: I could not write a test that fails if the gate is re-added at the call site. useMentions is a hook with a dozen queries and the repo has no hook-testing harness, so the protection here is a pinned-contract test plus a comment at the call site — documentation-grade, not enforcement.
  • Out of scope, filed separately: that set-add-policy erases agent profiles, and that kind:10100 has no producer at all, are both upstream (block/buzz) problems. This PR only stops the desktop from hiding agents because of them.

Related issue

None found (fork).

Testing

  • pnpm test: 4553 passed (1 new — asserts shouldHideAgentFromMentions shows a channel-member agent with no directory entry, while isAgentIdentityInAllowedList rejects the same input).
  • pnpm exec tsc --noEmit: clean.
  • pnpm check (biome + file-size + px-text + pubkey-truncation): clean, only the two known pre-existing infos.
  • Verified live. Built a release bundle from this branch (--no-sign) and ran it against a real relay: an agent owned by another user, holding bot role in the channel and permitted by its owner, now appears in the @ picker. It was absent on main under identical conditions.

No screenshots — the change is the presence of a row in an autocomplete that requires a specific multi-user setup to reproduce.

🤖 Generated with Claude Code

yjc801 and others added 2 commits August 8, 2026 10:24
The mention picker ran two agent gates in sequence, and the first made
the second's intent unreachable:

    if (!isAgentIdentityInAllowedList(candidate, mentionable)) return;
    if (shouldHideAgentFromMentions({...})) return;

`isAgentIdentityInAllowedList` rejects every candidate flagged `isAgent`
that is not already in `mentionableAgentPubkeys`. `shouldHideAgentFromMentions`
is a strict superset of that check except for exactly one case, which it
handles deliberately and documents at length: a channel-member agent with
no kind:10100 directory entry is *shown*, because unknown invocability is
not evidence of exclusion.

That branch never ran. Any agent owned by someone else was dropped by the
first gate before it could apply.

The practical effect is total: `mentionableAgentPubkeys` admits a relay
agent only via `channel_ids` / `respond_to` / `respond_to_allowlist` read
from its kind:10100 profile — and nothing in this repo publishes that
event. The only writer is `buzz channels set-add-policy`, whose content is
just `{"channel_add_policy": …}`; since kind 10100 is replaceable, that
write also erases any profile that did exist. So the directory is empty in
practice, no other-owner agent can enter the allowed set, and every one of
them was hidden — even when its owner had explicitly allowlisted the
mentioning user.

Gate on `shouldHideAgentFromMentions` alone. Non-member agents are still
hidden, and a directory entry that excludes the viewer still hides
(`return directoryAgentPubkeys.has(...)`), so the only behavior change is
the case the hide rule always meant to allow.

MembersSidebar keeps the stricter predicate — deciding whether to ADD an
agent to a channel is a different question from whether to mention one
already in it.

Verified: desktop tests 4553 passed (1 new, pinning the disagreement
between the two predicates so the gate is not reinstated), tsc clean,
pnpm check clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Junchao Yan <[email protected]>
Review finding (P2, useMentions.ts:257): this PR admits a channel-member
agent with no kind:10100 directory entry into the mention picker, but
`knownAgentPubkeys` stayed exactly `mentionableAgentPubkeys`, so
`isAgentPubkey` returned false for that same identity. After selection
the send flow therefore dropped it from `explicitAgentPubkeys` and
`preparedAgentPubkeys` — with "Keep addressed agents active" on, the
agent vanished from the composer after the first thread send and was
omitted from `sync_agents_to_active_huddle`.

The picker's member branch and the send path's classification are now
one gate: `getAdmittedMemberAgentPubkeys` lives beside
`shouldHideAgentFromMentions`, so the two cannot drift apart.

`useMentionAgentPubkeys` derives the three sets the mention surface
needs — invocable, member-agent, and known (invocable plus admitted
member agents) — and `memberAgentPubkeys` becomes the single source for
the candidate loop's `isAgent` instead of a repeated inline expression.
It is a separate module because `useMentions.ts` already sat at the
1000-line ratchet; the guard says split, not bump.

Replaces the smoke test that asserted this agent stays hidden. It passed
only by checking for zero rows before member and profile data settled,
so it could not see the regression. The new test drives picker selection
through a thread send and asserts the mention is retained, promoted into
the thread's stored audience, and enrolled in the Huddle sync; it fails
on the pre-fix classification with an empty composer.

Signed-off-by: Junchao Yan <[email protected]>
@yjc801
yjc801 merged commit 50dce49 into main Aug 9, 2026
41 of 47 checks passed
@yjc801
yjc801 deleted the claude/agent-mention-member-gate branch August 9, 2026 02:58
yjc801 added a commit that referenced this pull request Aug 21, 2026
Resolves the desktop agent-mention conflicts from upstream block#6338
("Fix cross-owner relay agent mentions in owner-only builds"), which
removed the owner-only gate from mention admission that this fork had
also been carrying.

- agentAutocompleteEligibility: take upstream's removal of the
  ownerOnly/isManagedAgent/ownerPubkey gate; keep the fork's
  lenient channel-member branch (#5) and its directoryAgentPubkeys
  input, which upstream's change does not cover.
- agentMentionRevalidation: same — drop the owner-profile proof fetch,
  keep the roster fetch that the member branch depends on.
- useMentions: keep the fork's per-community relay URL (#4), drop the
  owner-only query.
- MembersSidebar: keep the fork's MembersSidebarAddMemberRows split and
  delete upstream's duplicate AddMemberSearchResultRow.tsx.
- relayReconnectReplay.test.mjs: upstream's new coupling guard reads the
  drift literal from ingest.rs; this fork hoists it into buzz-core for
  buzz-waker, so point the guard at the real definition.

Validated: desktop tsc, 5438 desktop tests, biome, px/file-size guards,
cargo fmt, cargo check (workspace + Tauri, all targets), 2781 Tauri lib
tests.

Signed-off-by: Junchao Yan <[email protected]>
yjc801 pushed a commit that referenced this pull request Oct 28, 2026
…ction (block#8606)

Follow-up to block#8575.

## Problem

Today a kind:9000 is stored first and the membership write runs
afterwards, on its own. If that write fails, the relay logs it and still
returns an accepted OK. A resend of the same event then returns
`duplicate:`, so the person is never added. This is the
catch-log-and-accept failure that Review-Proven Rules #1 and #5
describe.

## Change

kind:9000 now uses the same atomic path as kind:9002
(`persisted_atomically` in ingest).

- **`buzz-db`:** new `put_members_with_event`, which runs one
transaction:
1. Take the channel's membership lock, before the event insert, the TTL
lock and the channel row. This matches the order the other membership
writers use, so it adds no new lock cycle.
2. Store the event. If it's already stored, roll back and return
`Duplicate`.
3. Run the existing `put_members` decisions and upsert. These now live
in a shared `put_members_on` step on the transaction.
  4. Commit.
- **Failure:** nothing is stored and the client gets an `error:`
rejection, so it can resend the same event and have it applied.
- **Everyone refused:** if the decisions under the lock refuse every
person, nothing is stored and the event is rejected with the same text
validation gives. Before, a single-person add refused at that point was
accepted with an empty message.
- **After commit:** the live effects stay as they were. They're
best-effort and read the committed state: cache invalidation,
`member_joined` notices, member-added notifications and 39002 discovery.
- **`NOSTR.md`:** now says a failed kind:9000 can be resent as is.

## What got simpler

- kind:9000 no longer has its own store-then-apply path in ingest. It
shares the atomic branch with 9002 and workflow deletion.
- `apply_put_user`'s "write failed, report it as a per-person rejection
inside an accepted OK" branch is gone.
- The `Db::put_members` wrapper is deleted. Its only caller was the
kind:9000 path.

## Identified debt (not in this PR)

kind:9001 (remove), 9021 (join) and 9022 (leave) have the same gap. Each
stores the event, then runs the membership write in
`handle_side_effects`, and a failure is only logged. kind:9008 and 9005
follow the same store-then-apply pattern. These can move onto the same
atomic shape in a follow-up.

## Tests

- `failed_put_user_stores_nothing` (relay, Postgres): a test trigger
fails the second person's row in a two-person add, after the event row
and the first person were already written in the same transaction. It
checks:
  - Both the send and a resend are rejected with `error:`.
- No kind:9000 is stored, nobody is added, and no `member_joined` notice
is posted.
  - With the trigger dropped, the same signed event adds both people.
  - One more resend returns `duplicate:` and posts no second notice.
- `put_members_with_event_stores_the_event_with_its_writes` (buzz-db):
checks the three outcomes:
  - Refused: nothing is stored.
  - Applied: the event is stored and the person is written.
  - Duplicate: the roster is left alone.
- `put_user_refused_under_the_lock_stores_nothing` (relay, Postgres): a
test trigger removes the sender from a private channel inside the write
transaction, so validation passes and the decision under the lock
refuses. For a single add and a batch, it checks the `invalid:`
rejection, that nothing is stored and no notice is posted, and that the
same event applies once the trigger is gone.
- Mutation checks, run in Blox gates:
- At `d18e88fd`: committing the refused event fails the buzz-db test;
applying a duplicate fails the first two tests; storing the event in its
own transaction fails the first two tests.
- At `3b106623`: turning the refusal under the lock into `error:` fails
the new refusal test, and so does committing the refused event.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Luke Tornquist <[email protected]>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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