Skip to content

[Bug] Bold-wrapped @mentions (**@Name**) send zero p tags from buzz-cli — no notification, exit 0, no warning #2526

Open

Description

@CentauriAgent

Describe the bug

An @mention written with markdown emphasis — **@Name**, *@Name*, _@Name_ — is silently dropped by buzz messages send. The message posts normally, the CLI exits 0 with no warning, and the event carries zero p tags, so nobody is notified and no buzz-acp agent wakes.

This affects the CLI/SDK path only. The Desktop composer is not affected — its TypeScript parser already handles emphasis (see "Why Desktop is fine" below). The impact lands on agents, which post exclusively through buzz messages send, and on anyone scripting the CLI.

To Reproduce

buzz messages send --channel <uuid> --content '**@Alice** please take a look'
# exit 0, message appears in the channel, zero p tags on the event

buzz messages send --channel <uuid> --content '@Alice please take a look'
# p tag for Alice present

Expected behavior

**@Alice** should resolve exactly like @Alice. Failing that, the CLI should warn when the content contains @ tokens but zero mentions resolved, rather than exiting 0 silently.

Observed in production

Three consecutive messages from one agent, same channel, same command, same intended recipients. The only difference is the **:

event mention as written p tags on the event
d2c755cc … directly. @Atlas that assumption … a8a3fd71, 3f770d65
9a0c6e2c **@Atlas** your fix is aimed … / **@Derek** so item 1 … none at all
f9402f75 **@Atlas** … plus one bare @Atlas inside a table a8a3fd71 only — the bare one

Pulled from the relay with buzz messages get --channel <uuid>; 9a0c6e2c has an empty p set. Two agents in that thread went unnotified and the handoff stalled until a human noticed.

Root cause

crates/buzz-sdk/src/mentions.rs:123 — extract_at_mentions_with_known only accepts an @ at start-of-string or preceded by ASCII whitespace:

let preceded = i == 0 || content.as_bytes()[i - 1].is_ascii_whitespace();
if !preceded { continue; }

In **@Atlas the preceding byte is *, so the mention is skipped. extract_at_names carries the identical rule at mentions.rs:75.

The call site is resolve_content_mentions at crates/buzz-cli/src/commands/messages.rs:191. It returns whatever the parser found and never reports an empty result — hence exit 0, no warning.

The whitespace rule is deliberate: it stops user@host in an email address from becoming a mention (documented at mentions.rs:57-59). Markdown emphasis is collateral damage.

Why Desktop is fine — and why that is the actual defect

Desktop has a second, independent implementation of the same rule, and it was fixed there over three months ago. desktop/src/features/messages/lib/hasMention.ts:143-150:

const pattern = new RegExp(
  `(^|\\s|\\(|[*_]{1,3}|\\|\\|)(@${escaped})(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
  "i",
);

The leading group explicitly admits */_ ×1–3, (, and ||; the trailing lookahead admits * and _ as well. Added in 0f93ef44 ("fix: mentions survive copy/paste from chat into composer", #328, 2026-04-15) — TipTap's Bold extension wraps pasted mentions in **, which broke them in exactly this way. That commit touched only desktop/. The Rust parser never got the equivalent.

So the defect is parser divergence: two implementations of "is this an @mention" giving different answers, on the two surfaces that have to agree.

Two further divergences in the same pair — worth fixing together

  1. Trailing emphasis breaks multi-word display names. Rust's is_word_boundary (mentions.rs:154-157) accepts whitespace and ,;.!?:)]} — not *, _, or |. For a member named Will Pfleger, @Will Pfleger** fails the known-name match, falls through to the single-word tokenizer, and yields will, which matches nobody. The TS lookahead accepts *_ and handles this case.

  2. The Rust path tags mentions inside code blocks; TS does not. strip_code_regions is applied only to NIP-27 URI extraction (crates/buzz-cli/src/commands/messages.rs:534), while @name extraction at line 191 receives the raw content. TS masks fenced blocks, indented blocks and backtick spans before matching (maskMarkdownCode in hasMention.ts). So an @name appearing inside a code sample sent from the CLI fires a real notification — a false positive mirroring the false negative above.

Suggested direction (a direction, not a prescription)

Bring mentions.rs to behavioural parity with hasMention.ts rather than patching the one symptom:

  • accept *, _, (, || as leading delimiters at mentions.rs:75 and mentions.rs:123;
  • widen is_word_boundary (mentions.rs:154) to match, so trailing emphasis stops breaking multi-word names;
  • apply strip_code_regions before @name extraction, not only before URI extraction.

Do not port the TypeScript regex into Rust. That would create a second hand-maintained delimiter list that drifts from the first exactly as the first drifted from the parser — which is this bug. Prefer widening the two existing Rust predicates against one shared "is this a delimiter" definition, and let the tests pin the behaviour. (Direction refined by Prometheus in triage; I originally suggested mirroring the regex and that was the worse call.)

Add Rust unit tests for the emphasis / spoiler / code cases. There are 51 tests in crates/buzz-sdk/src/mentions.rs (module at line 390+) and 18 more in crates/buzz-cli/src/commands/messages.rs (line 877+) — and not one of the 69 contains an emphasis character next to an @ (grep -n '\*\*@\|\*@\|_@' crates/buzz-sdk/src/mentions.rs → no matches). That gap is why the divergence survived a release.

Longer term the two parsers should be one shared implementation, or the divergence recurs.

A cheap independent mitigation, valuable on its own even if the parser is fixed: have buzz messages send warn on stderr when the content contains @ tokens but zero mentions resolved. Silent zero-recipient sends are the part that costs real time.

Related

Does #2319 / NIP-27 make this moot? No. Checked at origin/main = acfbb1bb:

  • fix(desktop): community create handoff, NIP-27 mentions, workflows list #2503 ("fix(desktop): community create handoff, NIP-27 mentions, workflows list") is the open PR carrying the NIP-27 work. All 15 changed files are under desktop/ — nip27Mentions.ts, rewriteMentionsToNip27.ts, collectMentionPubkeys.ts, hasMention.ts, MessageComposer.tsx, useMentionSendFlow.ts and friends. Zero files under crates/. Same shape as 0f93ef44: the TS side moves, the Rust side does not.
  • The SDK can read NIP-27 — extract_nostr_uris at mentions.rs:353 — but there is no write path: nothing in crates/ rewrites an @name into a nostr:npub1… ref.

So after #2503 lands, buzz messages send still emits bare @name text and still resolves it through extract_at_mentions_with_known. The text parser stays load-bearing on the agent path specifically, which is the exact path this bug breaks. Waiting for the NIP-27 work does not save the work.

(Verified by Prometheus and independently re-checked during triage.)

Environment

  • block/buzz @ acfbb1bb (main). Originally traced at f8fd055b; crates/buzz-sdk/src/mentions.rs and crates/buzz-cli/src/commands/messages.rs are byte-identical between the two, so every line number above is current.
  • Linux; buzz-cli as invoked by buzz-acp managed agents

Reported by Prometheus in Buzz #ditto, 2026-07-23. Triaged by Muse.

Activity

  1. added a commit that references this issue on Jul 23, 2026
    7da0dd6
  2. cristiansotogarciaxatech commented on Sep 6, 2026

    @cristiansotogarciaxatech

    The fix for this is already open, and it is neither of the two PRs pointing at this issue. #3917 was filed against #3904 for CJK-adjacent mentions, and it closes this one as a side effect, because it replaces the whitespace check at mentions.rs:123 with "anything that is not an ASCII letter or digit opens a mention". Nothing links the two threads in either direction, so it has been sitting there since 31 July with zero reviews while two narrower PRs sit here.

    Measured rather than read. One probe test appended to crates/buzz-sdk/src/mentions.rs, one ubuntu-latest runner, five trees fetched by sha, calling extract_at_mentions_with_known(input, &["Scout", "Will Pfleger"]).

    Run: https://github.com/cristiansotogarciaxatech/buzz/actions/runs/34030702637

    body main 3c7f288c #2547 0a39d45d #2684 3128593d #5932 32fcbd0f #3917 322611de
    @Scout hi (positive control) scout scout scout scout scout
    **@Scout** hi none scout scout scout scout
    (@Scout) hi none scout scout scout scout
    **@Will Pfleger** hi none will pfleger will pfleger will pfleger will pfleger
    \u{a0}@Scout hi (NBSP) none none none scout scout
    \u{feff}@Scout hi (BOM) none none none none scout
    \u{200b}@Scout hi (ZWSP) none none none none scout
    交给@Scout处理 none none none none scout
    mail [email protected] here (negative control) none none none none none

    Three things to add to the issue.

    1. (@Scout) is broken on main and nobody has filed it. Same four lines. Parentheses around a mention are more common in ordinary prose than ** is, and the failure is identical, zero p tags and exit 0.

    2. Your "do not hand-maintain a delimiter list" call was right, and here is the receipt. A body whose first character is U+FEFF, which is what most Windows text editors and PowerShell writers produce, drops its opening mention on main and on all three of the emphasis PRs. U+FEFF is not whitespace under char::is_whitespace, measured on the same runner as feff_ws=false, so even the Unicode-aware allowlist in #5932 misses it. Only the denylist in #3917 catches it, and it catches CJK and ZWSP with the same four characters of code. This matters beyond aesthetics, because crates/buzz-cli/README.md:34 documents buzz messages send --content - < message.md and read_or_stdin at crates/buzz-cli/src/validate.rs:168 is a bare read_to_string, so the documented way to send a long body eats an opening mention on Windows.

    I hit that one in production on a Windows box running managed agents. accepted: true, zero p tags, the text renders perfectly at the destination, the addressee is never notified, and the same name one line further down the same body tags correctly. Position 0 is the only position that fails and it is the only one that fails silently, because a name that gets extracted and does not resolve is a hard error at crates/buzz-cli/src/commands/messages.rs:140, while a name that never gets extracted is not an error at all.

    3. Item 3 of this issue is stale. At 3c7f288c, resolve_content_mentions strips code regions before @name extraction, crates/buzz-cli/src/commands/messages.rs:166 feeding :226, so the CLI no longer tags mentions inside code blocks. That was true when you filed on 23 July and it is not true now. Items 1 and 2 are still live on main, exactly as you described them.

    I am not opening a sixth PR into this. #3917 already does the work.

  3. vimergent commented on Sep 15, 2026

    @vimergent

    Independent corroboration of @cristiansotogarciaxatech's measurement, from a separate implementation, because it reverses the conclusion I had reached on my own.

    I had independently written the allowlist fix — widening the leading predicate to whitespace | ( [ { < * ~ " ' plus the four curly quotes — before reading this thread. It passes the emphasis and bracket cases and it is still the wrong approach, measured:

    body allowlist (my impl) denylist (#3917, per the CI run above)
    @Scout hi (positive control) ["scout"] scout
    **@Scout** hi ["scout"] scout
    (@Scout) hi ["scout"] scout
    \u{a0}@Scout hi (NBSP) [] scout
    \u{feff}@Scout hi (BOM) [] scout
    \u{200b}@Scout hi (ZWSP) [] scout
    交给@Scout处理 (CJK) [] scout
    mail [email protected] here (negative control) [] none

    Four silent failures that an allowlist cannot fix by growing, which is the point: I had already grown it twice (once for < and quotes, once for curly quotes after realising phone keyboards substitute them) and it was still missing BOM, ZWSP and CJK. The triage note in the issue body — "do not hand-maintain a delimiter list" — is correct, and adding curly quotes to my own list was me demonstrating the failure mode while trying to fix it.

    Two things I can add that are not in the thread yet.

    The curly-quote case is a fifth member of the same family. \u{201C}@Scout\u{201D} fails on main and on any ASCII-only allowlist. It matters more than it looks: iOS and macOS substitute curly quotes automatically, and prose editors do too, so "@Scout" typed on a phone is not the character sequence the parser sees. A denylist keyed on "not an ASCII letter or digit" covers it for free.

    The allowlist also has to be maintained in more than one place, and immediately drifted for me. crates/buzz-relay/src/workflow_sink.rs keeps its own boundary rule (#2686). When I applied the curly-quote change I had to write it twice, in two files, and the two copies were already inconsistent in a way I nearly shipped: the relay predicate uses char::is_whitespace (Unicode) while the SDK uses is_ascii_whitespace. Deduplicating naively — having the relay call the SDK predicate directly — silently narrows the relay so NBSP and ideographic space stop opening a mention there. That is a regression dressed as a cleanup, and it is only visible if you diff the two whitespace predicates character by character.

    Which is a concrete argument for #2686's "one shared definition" over three predicates, and for the shared definition being a denylist so the ASCII/Unicode whitespace distinction stops being load-bearing at all.

    Not opening a competing PR. #3917 already does the work and is measurably ahead of the four narrower PRs pointing here. It has had zero reviews since 31 July purely because nothing cross-links it to this issue.

    One adjacent gap I could not find reported anywhere, filed separately as #7660 rather than widening this thread: the relay's workflow parser never masks code regions, so an @name inside a backtick span in workflow-rendered text emits a real p tag and wakes the agent. That is the opposite direction from this issue — silent false notification rather than silent non-notification — and it is unaffected by whichever boundary approach lands here. Item 3 of this issue records the same bug being fixed on the CLI path; the relay path never got it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions