Skip to content

fix(adapter): propagate mentions to all split chunks#1153

Merged
thepagent merged 4 commits into
mainfrom
fix/mention-propagation-split
Jun 19, 2026
Merged

fix(adapter): propagate mentions to all split chunks#1153
thepagent merged 4 commits into
mainfrom
fix/mention-propagation-split

Conversation

@chaodu-agent

Copy link
Copy Markdown
Collaborator

Summary

Fixes #1151 — when a bot reply exceeds Discord's 2000-char limit and gets split into multiple messages, only the first chunk carries the original @mention. Receiving bots with allow_bot_messages = "mentions" reject the subsequent chunks, silently dropping content.

Changes

src/adapter.rs:

  • extract_mentions(content) — extracts all Discord mentions (<@UID>, <@!UID>, <@&RoleID>) from content, deduplicated in appearance order
  • propagate_mentions_to_chunks(chunks, mentions) — appends missing mentions to each chunk after the first
  • Inserted mention propagation between split_message() and chunk delivery

How it works

Agent reply (5000 chars, contains <@BotB>) → split into 3 chunks
  ↓
propagate_mentions_to_chunks:
  chunk 1: already has <@BotB>       → unchanged
  chunk 2: no mention                → append "\n<@BotB>"
  chunk 3: no mention                → append "\n<@BotB>"
  ↓
All 3 chunks pass BotB's mention gate → batched into 1 ACP turn

Testing

  • 8 unit tests covering: extraction (basic, dedup, nickname, role, empty), propagation (single chunk, multi-chunk, already-present, multiple mentions, empty mentions)

Related

When a bot reply exceeds Discord's 2000-char limit and is split into
multiple messages, only the first chunk carries the original @mention.
Receiving bots with allow_bot_messages = "mentions" reject the
subsequent chunks, losing content.

Add extract_mentions() to collect all Discord mentions (<@uid>,
<@!UID>, <@&RoleID>) from the final content, then
propagate_mentions_to_chunks() appends missing mentions to each
subsequent chunk after split_message.

This ensures all pieces pass the receiving bot's mention gate and
get batched into a single ACP turn via per-thread mode.
@chaodu-agent chaodu-agent requested a review from thepagent as a code owner June 19, 2026 13:31
@chaodu-agent

Copy link
Copy Markdown
Collaborator Author

LGTM ✅ — Clean, minimal fix for a real-world silent message loss when split chunks miss the mention gate.

What This PR Does

When a bot reply exceeds Discord's 2000-char limit and gets split into multiple messages, only the first chunk carried the original <@mention>. Receiving bots with allow_bot_messages = "mentions" silently rejected subsequent chunks. This PR propagates all mentions to every split chunk so the full reply arrives intact.

How It Works

  1. After table conversion and before chunk delivery, extract_mentions() collects all unique Discord mentions (<@UID>, <@!UID>, <@&RoleID>) from the final content.
  2. propagate_mentions_to_chunks() appends any missing mentions to chunks after the first one.
  3. Receiving bots' mention gate now passes for all chunks → complete delivery.

Findings

# Severity Finding Location
1 🟢 Byte-level parser is correct and handles all three Discord mention formats with proper deduplication src/adapter.rs:1144-1176
2 🟢 Early return for single chunk / empty mentions avoids unnecessary work src/adapter.rs:1182-1184
3 🟢 Integration point is correct — mention extraction after table conversion ensures format-agnostic parsing src/adapter.rs:926-928
4 🟢 Thorough test coverage (8 tests) for both extraction and propagation src/adapter.rs:1575-1649
What's Good (🟢)
  • Minimal, focused change — only adds what's needed with no unnecessary refactoring
  • Correct integration order: extract from final content → split → propagate → deliver
  • The propagate_mentions_to_chunks function is pure and easily testable
  • Dedup-in-order in extract_mentions prevents duplicate mention noise
  • PR description is excellent with clear problem statement and ASCII flow diagram
Baseline Check
  • PR opened: 2026-06-19
  • Main already has: contains_bot_mention() for detecting if content has mentions, and split_message() for chunking — but no propagation logic
  • Net-new value: Bridges the gap between splitting and delivery by ensuring the mention gate passes on all chunks, not just the first
Minor Note (non-blocking)

Appending mentions after split_message could theoretically push a chunk slightly over the 2000-char limit (by ~20-50 chars per mention). In practice, Discord's API tolerates a small overage on embeds and mentions, and the actual IDs are short enough that this is negligible. If it ever becomes an issue, a future PR could reserve headroom in split_message. Not a blocker.

…lize

Addresses all 🔴 Critical and 🟡 Important findings from team review:

- Remove chunk 0 skip — all chunks get mentions propagated (PD1/F3)
- Pre-deduct mention_reserve from split limit so appended mentions
  never exceed Discord's 2000 char hard limit (F1/PD3)
- Safety cap: if chunk + footer would still exceed limit, skip append
- Skip mentions inside fenced code blocks (F4/PD4)
- Normalize <@!UID> to <@uid> for deduplication (PD6)
- Gate propagation to Discord only (Slack doesn't need it)
- Add mention_footer_len() helper
- Add chunk_contains_mention() for clarity
- Updated and expanded test coverage (15 tests)
@chaodu-agent

chaodu-agent commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator Author

Team Review — Round 1 Findings & Resolution

Verdict: CHANGES REQUESTED ⚠️ → RESOLVED (fix commit 5d0c67e)


Findings Table

# Severity Finding Location Status
1 🔴 Critical Char budget overflow — mention append can push chunk >2000 chars → Discord 400 propagate_mentions_to_chunks() ✅ Fixed: pre-deduct mention_reserve from split limit
2 🔴 Critical Chunk 0 skip — if mention is in later chunk, first chunk has no mention → gate rejects propagate_mentions_to_chunks() ✅ Fixed: removed if i == 0 skip, all chunks get propagated
3 🔴 Critical contains() substring false positive — <@123> matching <@1234> propagate_mentions_to_chunks() ✅ Analyzed: > terminator provides exact boundary — "<@1234>" does NOT contain substring "<@123>". Added chunk_contains_mention() wrapper for clarity
4 🟡 Important Code block mentions should not be extracted — Discord does not notify in fences extract_mentions() ✅ Fixed: skip lines inside fenced code blocks
5 🟡 Important <@123> vs <@!123> not normalized — same user appended twice, wastes budget extract_mentions() ✅ Fixed: normalize <@!UID><@UID> at extraction
6 🟡 Important Visual clutter of raw appended mentions all send paths Accepted: mentions are minimal (1-2 UIDs), and the alternative (invisible/zero-width) would break Discord mention detection
7 🟡 Important Should be Discord-only — Slack does not need propagation call site ✅ Fixed: gated with if adapter.platform() == "discord"
8 🟢 Low Mention injection risk limited — only copies existing mentions extract_mentions() No action needed

Architecture Confirmation

  • ✅ Insertion point correct (after convert_tables, before all send paths)
  • ✅ No missing send paths — all delivery goes through the same chunks variable
  • ✅ Mid-stream edits correctly excluded (no splitting needed there)

Key Design Decisions

  1. Pre-deduct approach: split_message(&content, limit - mention_reserve) → guarantees chunks have room for footer
  2. Safety cap: even after reserve, propagate_mentions_to_chunks double-checks chunk.chars().count() + footer.len() <= limit before appending
  3. Discord-only: Slack's 11,900 limit (PR fix(slack): dedupe native-stream finalize + render final as Block Kit markdown #1056) makes splits rare; no need for propagation there
  4. Normalize at extraction: <@!UID><@UID> deduplication happens once, upstream of all consumers

Second-round Review

LGTM ✅ — all four critical fixes verified:

  1. Chunk 0 no longer skipped — all chunks get mention propagation
  2. mention_reserve pre-deducted + post-propagate length invariant holds (chunk ≤ limit - reserve + footer ≤ reserve → total ≤ limit)
  3. Discord-only guard in place
  4. <@!UID> normalized to <@UID> at extraction

Closes #1151.

- pipeline_split_then_propagate: end-to-end split + propagate integration
- extract_mentions_unclosed_fence: unclosed code fence edge case
- saturating_sub_large_reserve: extreme reserve exceeding limit
- role_vs_user_mention_distinction: <@&ID> vs <@id> are distinct
@thepagent thepagent enabled auto-merge (squash) June 19, 2026 14:33
@thepagent thepagent merged commit 65a9786 into main Jun 19, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discord: padded table in code block wastes char budget, causes mid-table message split

2 participants