Skip to content

fix(clients): p-tag the author a reply answers, and say it is not a mention - #5806

Open
cyberzero000 wants to merge 3 commits into
block:mainfrom
cyberzero000:fix/agent-mention-delivery
Open

fix(clients): p-tag the author a reply answers, and say it is not a mention#5806
cyberzero000 wants to merge 3 commits into
block:mainfrom
cyberzero000:fix/agent-mention-delivery

Conversation

@cyberzero000

@cyberzero000 cyberzero000 commented Aug 13, 2026

Copy link
Copy Markdown

A NIP-10 reply should p-tag the author it answers. Buzz's clients never did,
and require_mention subscriptions add #p to the relay-side REQ filter
(crates/buzz-acp/src/relay.rs), so the relay never transmitted the event and an
agent replied to in a thread heard nothing. The agent cannot compensate — it does
not know the message exists.

Observed on a live relay: a reply to an agent's message stored with tags
[["h","<channel>"],["e","<parent>","","reply"]] and no answer, while
@mentions in the same channel minutes earlier were answered in 23 seconds. The
comment above buildReplyTags already stated the intent — p-tags are there "so
mention-filtered subscriptions (e.g. ACP agent harness) receive the reply event."

Adding that tag exposes a problem the protocol has, not this deployment, and
that problem is most of this PR.

The addressing tag is indistinguishable from a mention

As a bare tag, ["p", <pubkey>] added because a reply addresses you is
byte-identical to one added because someone typed @you. The two must behave
differently: a mention pierces a channel or thread mute and raises dock-badge
priority, being replied to does not.

Nothing distinguished them, so a receiver had to fetch the parent message and
check who wrote it — a relay round trip to recover something the sender knew for
free, plus the caching, chunking, retry and fail-open handling that round trip
needs.

Replies now mark each p tag with the role it plays, in the fourth position,
the way e tags already carry root and reply:

["p", <typed pubkey>,  "", "mention"]   someone typed as @name
["p", <parent author>, "", "reply"]     the author being answered
["p", <dm participant>]                 addressed by the channel

The third shape is the one a DM needs. A DM tags every other participant whether
or not anyone typed their names, so neither marker is true of those tags — and
claiming mention would let a DM thread reply pierce a mute and take a slot in
the mention feed ahead of a real @you. They stay bare, which under the read
rule below means "ask the parent" — exactly the answer they already got. The
ordinary DM reply is unchanged in shape, because the counterpart is also the
parent's author and so gets one tag, marked reply.

Four properties make this safe to land without coordinating clients:

  • Read one-way. A marker that is present is authoritative. An absent marker
    means "ask the parent", never "this is a mention". Senders that predate the
    markers keep working exactly as before, and there is no flag day.
  • #p delivery is untouched. Relay tag filters compare only a tag's second
    element (crates/buzz-core/src/filter.rs, t.content()), so a marker cannot
    affect which agents receive the event.
  • Top-level messages stay bare. Without a parent there is nothing to
    disambiguate, and a p tag there can only be a mention.
  • The undecidable case is settled. When you are both the author being
    answered and typed in the body, one tag cannot be marked and unmarked at once.
    The sender emits the mention marker and mention wins — the answer that
    preserves the stronger signal, and one no amount of parent-fetching could have
    reached.

Emitted by all four senders: the Tauri backend, buzz-sdk for the CLI and the
ACP harness, and mobile's channel and forum providers. Typed mentions and
channel-addressed recipients travel as a named pair (Recipients { typed, addressed } in Rust, { mentions, addressed } in TypeScript) rather than two
adjacent same-typed lists — conflating them is the exact mistake the markers
exist to prevent. Emission order is mentions, then bare, then addressing, in all
three languages, so a reply's tags are byte-identical whichever client sent it.

Two contracts became load-bearing. ThreadRef.parent_author must be the author
of parent_event_id and not of the thread root, because receivers read the
addressing marker as "this answers a message you wrote". And the self-null that
suppresses the addressing tag on a self-reply is judged against the signing
key: a managed agent posts under its own key, so comparing against the desktop
owner's would strip the tag off a reply to the owner.

The backend is the source of the addressing tag rather than the frontend, which
also closes a gap in the delivery fix itself. resolve_thread_ref already
fetches the parent event on its way to the thread root, so parent.pubkey costs
no extra query and is strictly more reliable than the frontend cache the tag was
previously read from — that cache silently missed for any channel not opened in
the current session, which would have shipped the reply with no addressing tag
at all and reproduced the original bug.

NOSTR.md's NIP-10 row and its nak reply example now describe the markers,
since that document is what third-party clients read and it previously taught
them to emit an unmarked reply.

Notification correctness

Teaching the senders to emit an addressing p tag means teaching every path
that reads a p tag to tell it apart from a mention. There are seven — the
live notify path, the dock badge, the home feed, the channel unread scan, the
live channel updates, the community observer, and the app-shell notification
effect. Miss one and that path treats every reply as a mention. That fan-out is
where most of this branch went.

Grouped by what was wrong:

  • Double-notify. A reply could be counted by both the backend feed poll and
    the frontend live path. Exactly one owner notifies per event now.
  • Mutes. A muted channel or thread leaked replies; a muted DM inverted; a
    real @mention inside a muted thread was reported as notifying but did not.
  • Priority. High-priority classification failed open on an unresolved
    parent, so an addressing tag could raise the dock badge as if it were a
    mention. It fails closed, and the addressing tag is out of both priority and
    badge math.
  • Dock badge. A DM thread reply counted twice.
  • Deferral. Handing an unresolvable reply to the live path lost the
    notification instead of deferring it. collectHomeAlertItems returns the
    whole mention list unfiltered and the notification effect added every item
    it saw — including ones just declined as replyToSelf — to the persisted seen
    set, so the declining poll consumed the slot and the next poll dropped the
    item as already-seen. A genuine typed @mention inside a thread was lost for
    good, across restarts, because its parent belongs to a third party and so
    looks unresolved.
  • Catch-up. The unread scan judged a mention against the wrong message, kept
    the oldest replies instead of the newest when trimming its window, stranded
    participation claims on failure, and retried unscoped — re-sweeping every
    channel rather than the failed one, indefinitely on a fixed 5s cadence behind
    the same rate-limit gate as foreground channel history.
  • Robustness. Event ids are validated and case-normalized at the filter
    boundary. Any member can publish an event whose e tag is not a 64-char hex
    id — the relay's NIP-10 resolver ignores such a tag rather than rejecting the
    event. Put into an ids REQ filter the relay answers a bare NOTICE, and
    this client only resolves rate-limited: notices, so the request hangs the
    full 25s history timeout. One such event in a channel made every parent lookup
    there fail, leaving the channel with no badge, no unread events and no thread
    activity for the whole session, and it did not clear on restart because the
    event stays inside the read-marker window.

Since #6024 moved the unread scan into unread_catch_up.rs, the gates live in
unread_notify.rs beside it, ported from shouldNotify.ts function for
function. The two decide notification ownership independently from the same
question, so a divergence makes them disagree and notify twice, or not at all.
unread_parent_authors.rs resolves a parent only when no marker answers the
question, so a marked reply costs no round trip.

Why this isn't split further

Two of the three defects originally on this branch have been split out and stand
alone:

What remains is one causal knot and cuts badly:

  • The senders cannot land before the readers. An addressing p tag with nothing
    reading the marker turns every reply into a mention that pierces mutes and
    raises the dock badge — worse than the bug being fixed.
  • The readers are the seven paths listed above. Landing a subset leaves the
    others treating every reply as a mention.
  • Four files crossed the 1000-line ratchet, so per AGENTS.md each was split
    rather than raised. commands/feed.rs is the clearest case: main leaves
    commands/messages.rs at 988 lines against the 1000 limit, so adding the
    three-line role check to get_feed trips the guard. That extraction is ~400
    lines of moved code for a three-line behaviour change, and no scoping decision
    avoids it. Same story for events/message_tags.rs and
    events/identity_archive.rs out of events.rs, unread_notify.rs out of
    unread_catch_up.rs, and unreadReadMarker.ts out of useUnreadChannels.ts.

Roughly half the remaining diff is tests.

Testing

  • Reply p-tag on all four senders, self-reply adds no tag, mention dedup, the
    addressing tag keeping its slot under the mention cap, a typed parent author
    tagged once as a mention, top-level messages staying bare, and a mention
    piercing a mute on a thread you started.
  • Rust: buzz-sdk 803, buzz-cli 363, buzz-acp 271, and
    desktop-tauri-test 2794 passed / 0 failed / 18 ignored. The earlier
    revision of this PR could not run the Tauri suite locally (a
    sherpa-onnx-c-api link failure unrelated to this branch); it runs now, so
    resolve_parent_authors and the unread_notify gates are covered by their
    own tests rather than by extracted standalone programs.
  • Desktop JS: 5484 passed / 0 failed. Mobile: 1673 passed / 0 failed.
  • tsc --noEmit, biome check, cargo clippy --workspace --all-targets -D warnings, cargo clippy (Tauri), cargo fmt --check, flutter analyze,
    dart format --set-exit-if-changed, check-px-text and check-file-sizes
    all clean.
  • resolve_parent_authors's REQ was also exercised against a running relay
    directly. The exact {kinds, ids, limit} filter it builds is accepted and
    returns the parent authors, and a full 200-id PARENT_LOOKUP_CHUNK is
    accepted in one request.

History

Rewritten from 36 commits into 3, by client surface. The original sequence was
17 review cycles in which several commits fixed regressions introduced two
commits earlier, which made reading it commit-by-commit actively misleading. The
tree is unchanged; only the history was re-cut. Rebased onto current main.

@cyberzero000

Copy link
Copy Markdown
Author

Merged with main

main moved 85 commits while this was in review, overlapping 23 of these files. Merged rather than rebased so the review history stays addressable. Five conflicts, all resolved keeping both sides — the notable ones:

  • resolve_thread_ref moved upstream into commands/messages/thread_ref.rs and gained a pinned keys snapshot for the read's NIP-98 auth. That snapshot and the self-reply check are deliberately separate arguments: a managed agent reads as the active identity but signs as itself, so one key authenticates the read and the other decides whether this reply is answering ourselves.
  • Main grew mobile/.../message_mention_pubkeys.dart, which folds DM recipients into the mention list — the conflation section 4 describes. The conflict could not be resolved without splitting it, so mobile now has message_recipients.dart returning {mentions, addressed} to match desktop, with tests for both DM reply shapes.
  • relayAgentCanRespondInChannel keeps main's ownerPubkey owner-only gating alongside this branch's relay-membership eligibility.

@cyberzero000

Copy link
Copy Markdown
Author

Merged with main again — the p-tag fix moved into native catch-up

main moved 7 more commits, and one of them (#6024, "move five hot renderer paths from JS into Rust") lands directly on this branch's territory: it moved the unread catch-up scan into desktop/src-tauri/src/unread_catch_up.rs, replacing the JS modules this branch had split useUnreadChannels into.

The native version computed mentioned from a bare has_tag_value(tags, "p", self) and set high_priority the same way — the conflation this PR exists to remove, reimplemented in Rust. Neither side could be taken alone, so the gates moved to where the decision now lives.

Ported into unread_notify.rs, mirroring shouldNotify.ts:

  • has_authored_mention — a p tag is a mention only if the sender marked it one, or the parent's author is somebody else. Feeds the discovered.mentioned roots the renderer persists.
  • is_high_priority — same read, failing closed on an unresolved parent. The flag is persisted and drops the channel's top-level items from the dock badge, so guessing "high" after a relay flap would hide an approval request until the channel is read.
  • should_notify — a reply answering you no longer pierces a mute on the strength of its addressing tag, and is re-admitted after the mute gates so "someone replied to you" still reports. Also gains the mentioned term the JS gate already had.
  • resolve_parent_authors — one chunked ids REQ over the session the command already holds, for the replies that tag you in non-DM channels; parents already in the batch are answered locally. A failed lookup errors those channels so the renderer releases the claim and retries, rather than persisting a guess.

p_tag_role.rs is now the single reader of the marker on the Tauri side, over an iterator of tag slices so nostr::Event and the native commands' Vec<Vec<String>> share it. commands/feed.rs delegates instead of holding a second copy.

Deleted as superseded: catchUpClaims, catchUpMembership, catchUpParentAuthors, catchUpScan, unreadChannelStores, useCatchUpRetrySignal and their tests.

Only the catch-up half went native — the live path still runs in JS — so recordMentionedRoot, handleChannelMessage and handleThreadReplyNotification keep taking the parent author.

cyberzero000 added 3 commits August 22, 2026 11:48
A NIP-10 reply should `p`-tag the author it answers — `require_mention`
subscriptions add `#p` to the relay-side REQ filter
(`crates/buzz-acp/src/relay.rs`), so without that tag the relay never transmits
the event and an agent replied to in a thread hears nothing. But as a bare tag,
`["p", <pubkey>]` added because a reply addresses you is byte-identical to one
added because someone typed `@you`, and the two must behave differently: a
mention pierces a channel or thread mute and raises dock-badge priority, being
replied to does not.

Without a marker a receiver had to fetch the parent message and check who wrote
it — a relay round trip to recover something the sender knew for free, plus the
caching, chunking, retry and fail-open handling that round trip needs.

Replies now mark each `p` tag with the role it plays, in the fourth position,
the way `e` tags already carry `root` and `reply`:

    ["p", <typed pubkey>,  "", "mention"]   someone typed as @name
    ["p", <parent author>, "", "reply"]     the author being answered
    ["p", <dm participant>]                 addressed by the channel

The third shape is what a DM needs: a DM tags every other participant whether or
not anyone typed their names, so neither marker is true of those tags, and
claiming `mention` would let a DM thread reply pierce a mute and take a slot in
the mention feed ahead of a real `@you`. They stay bare, which under the read
rule below means "ask the parent" — the answer they already got.

Four properties make this safe to land without coordinating clients:

- Read one-way. A marker that is present is authoritative. An absent marker
  means "ask the parent", never "this is a mention". Senders that predate the
  markers keep working exactly as before, and there is no flag day.
- `#p` delivery is untouched. Relay tag filters compare only a tag's second
  element (`crates/buzz-core/src/filter.rs`), so a marker cannot affect which
  agents receive the event.
- Top-level messages stay bare. Without a parent there is nothing to
  disambiguate, and a `p` tag there can only be a mention.
- The undecidable case is settled. When you are both the author being answered
  and typed in the body, one tag cannot be marked and unmarked at once. The
  sender emits the mention marker and mention wins — the answer that preserves
  the stronger signal, and one no amount of parent-fetching could reach.

Emitted by the Tauri backend, and by `buzz-sdk` for the CLI and the ACP harness.
Typed mentions and channel-addressed recipients travel as a named pair
(`Recipients { typed, addressed }`) rather than two adjacent same-typed lists —
conflating them is the exact mistake the markers exist to prevent.

Read by the native catch-up scan and the home feed. `p_tag_role` is the shared
vocabulary; `unread_notify` holds the gates, ported from `shouldNotify.ts`
function for function, and `unread_parent_authors` resolves a parent only when
no marker answers the question. A marked reply costs no round trip.

Two contracts became load-bearing. `ThreadRef.parent_author` must be the author
of `parent_event_id`, not of the thread root, because receivers read the
addressing marker as "this answers a message you wrote". And the self-null that
suppresses the addressing tag on a self-reply is judged against the *signing*
key: a managed agent posts under its own key, so comparing against the desktop
owner's would strip the tag off a reply to the owner.

The backend is the source of the addressing tag rather than the frontend, which
closes a gap in the delivery fix itself. `resolve_thread_ref` already fetches the
parent event on its way to the thread root, so `parent.pubkey` costs no extra
query and is strictly more reliable than the frontend cache the tag was
previously read from — that cache silently missed for any channel not opened in
the current session, which would have shipped the reply with no addressing tag
at all.

High-priority classification fails closed on an unresolved parent. The flag is
persisted and drops the channel's top-level items from the dock badge, so
guessing "high" after a relay flap would silently hide an approval request until
the channel is read.

Event ids are validated and case-normalized at the filter boundary. Any member
can publish an event whose `e` tag is not a 64-char hex id — the relay's NIP-10
resolver ignores such a tag rather than rejecting the event. Put into an `ids`
REQ filter the relay answers a bare NOTICE, and this client only resolves
`rate-limited:` notices, so the request hangs the full 25s history timeout. One
such event in a channel made every parent lookup there fail, leaving the channel
with no badge, no unread events and no thread activity for the session, and it
did not clear on restart.

`commands/feed.rs` is `get_feed` moved out of `commands/messages.rs`, which main
leaves at 988 lines against the 1000-line ratchet — adding the role check trips
the guard, and AGENTS.md says split rather than raise. `events/message_tags.rs`
and `events/identity_archive.rs` are the same story for `events.rs`, and
`unread_notify.rs` for `unread_catch_up.rs`.

`NOSTR.md`'s NIP-10 row and its `nak` reply example now describe the markers,
since that document is what third-party clients read and it previously taught
them to emit an unmarked reply.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
`_buildReplyTags` emitted `e` tags for the thread and `p` tags for explicit
mentions, but never a `p` tag for the author being replied to, so a reply to an
agent never reached it — `require_mention` subscriptions filter on `#p`
relay-side, and the agent cannot compensate for an event it never receives.

Adding that tag makes it indistinguishable from a typed `@mention`, so both
channel and forum providers now mark each `p` tag with its role, matching the
backend and `buzz-sdk`:

    ["p", <typed pubkey>,  "", "mention"]
    ["p", <parent author>, "", "reply"]
    ["p", <dm participant>]

`message_recipients.dart` replaces `message_mention_pubkeys.dart`: it returns
the two groups separately rather than one merged list, because merging them is
the mistake the markers exist to prevent. Emission order — mentions, then bare,
then addressing — matches the Rust and TypeScript senders, so a reply's tags are
byte-identical whichever client sent it.

Mobile never had the eligibility half of this bug: it already resolves `@name`
against relay membership rather than the agent's self-declared `channel_ids`.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Teaching the senders to emit an addressing `p` tag means teaching every path
that reads a `p` tag to tell it apart from a mention. There are seven of them —
the live notify path, the dock badge, the home feed, the channel unread scan,
the live channel updates, the community observer, and the app-shell notification
effect. Miss one and that path treats every reply as a mention: it pierces
mutes, takes a slot in the mention feed ahead of a real `@you`, and raises the
dock badge.

`shouldNotify.ts` reads the sender's marker when there is one and falls back to
the parent's author when there is not, matching `unread_notify.rs` function for
function. The two decide notification ownership independently from the same
question, so a divergence makes them disagree and notify twice, or not at all.

Grouped by what was wrong:

- Double-notify. A reply could be counted by both the backend feed poll and the
  frontend live path. Exactly one owner notifies per event now.
- Mutes. A muted channel or thread leaked replies; a muted DM inverted; a real
  `@mention` inside a muted thread was reported as notifying but did not.
- Priority. High-priority classification failed open on an unresolved parent, so
  an addressing tag could raise the dock badge as if it were a mention. It fails
  closed, and the addressing tag is out of both priority and badge math.
- Dock badge. A DM thread reply counted twice.
- Deferral. Handing an unresolvable reply to the live path lost the notification
  instead of deferring it: `collectHomeAlertItems` returns the whole mention
  list unfiltered and the notification effect added every item it saw —
  including ones just declined as `replyToSelf` — to the persisted seen set, so
  the declining poll consumed the slot and the next poll dropped the item as
  already-seen. A genuine typed `@mention` inside a thread was lost for good,
  across restarts, because its parent belongs to a third party and so looks
  unresolved.
- Robustness. Event ids are validated and case-normalized at the filter
  boundary, matching the Rust side. A single channel's failed parent lookup no
  longer aborts the whole community poll and drops the community to
  `state: "error"`, which would clear its dot and badge outright —
  undercounting one channel for 30 seconds is the smaller wrong answer.

`messageRecipients.ts` replaces `messageMentionPubkeys.ts` and returns typed
mentions and channel-addressed recipients as a named pair rather than one merged
list. `replyContextEvents.ts` resolves a reply's parent from every cache that
can hold it — the channel timeline is not the only one, the thread panel keeps
its replies under a separate key and the Inbox can answer a message in a channel
the user never opened this session, and looking only at the channel cache
silently missed both.

`unreadReadMarker.ts` is a split of `useUnreadChannels.ts`, which crossed the
1000-line ratchet.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
@cyberzero000
cyberzero000 force-pushed the fix/agent-mention-delivery branch from c2b7809 to 0dc8641 Compare August 22, 2026 18:55
@cyberzero000 cyberzero000 changed the title fix: agent mentions and thread replies that never reached the agent fix(clients): p-tag the author a reply answers, and say it is not a mention Aug 22, 2026
@cyberzero000
cyberzero000 marked this pull request as ready for review August 22, 2026 18:56
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