From ebec2e03626e89ede8badc3e21ccdae515253582 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 19 Jun 2026 13:31:13 +0000 Subject: [PATCH 1/4] fix(adapter): propagate mentions to all split chunks (#1151) 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. --- src/adapter.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/adapter.rs b/src/adapter.rs index 32764a87c..7e1847cb5 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -923,7 +923,9 @@ impl AdapterRouter { }; let final_content = markdown::convert_tables(&final_content, table_mode); + let mentions = extract_mentions(&final_content); let chunks = format::split_message(&final_content, message_limit); + let chunks = propagate_mentions_to_chunks(chunks, &mentions); // Track delivery health across all final write paths. Any failure // here means the user's view is incomplete; we propagate Err at the // end of the closure so dispatch surfaces set_error (❌) instead of @@ -1139,6 +1141,71 @@ impl AdapterRouter { } } +/// Extract all Discord mentions (`<@123>`, `<@!123>`, `<@&123>`) from content. +/// Returns deduplicated list in appearance order. +fn extract_mentions(content: &str) -> Vec { + let mut mentions = Vec::new(); + let mut i = 0; + let bytes = content.as_bytes(); + while i + 2 < bytes.len() { + if bytes[i] == b'<' && bytes[i + 1] == b'@' { + let prefix_end = if i + 2 < bytes.len() + && (bytes[i + 2] == b'!' || bytes[i + 2] == b'&') + { + i + 3 + } else { + i + 2 + }; + if prefix_end < bytes.len() && bytes[prefix_end].is_ascii_digit() { + if let Some(end) = content[prefix_end..].find('>') { + if content[prefix_end..prefix_end + end] + .chars() + .all(|c| c.is_ascii_digit()) + { + let mention = &content[i..prefix_end + end + 1]; + if !mentions.contains(&mention.to_string()) { + mentions.push(mention.to_string()); + } + } + i = prefix_end + end + 1; + continue; + } + } + i = prefix_end; + } else { + i += 1; + } + } + mentions +} + +/// Append mentions to split chunks that don't already contain them. +/// Ensures every chunk carries all mentions from the original content so +/// receiving bots under `allow_bot_messages = "mentions"` gate accept all pieces. +fn propagate_mentions_to_chunks(chunks: Vec, mentions: &[String]) -> Vec { + if mentions.is_empty() || chunks.len() <= 1 { + return chunks; + } + chunks + .into_iter() + .enumerate() + .map(|(i, chunk)| { + if i == 0 { + return chunk; + } + let missing: Vec<&String> = mentions + .iter() + .filter(|m| !chunk.contains(m.as_str())) + .collect(); + if missing.is_empty() { + chunk + } else { + format!("{}\n{}", chunk, missing.iter().map(|m| m.as_str()).collect::>().join(" ")) + } + }) + .collect() +} + /// Returns true if `content` contains a Discord user/bot mention (`<@123>`, `<@!123>`) /// or a role mention (`<@&123>`). /// Used to detect cross-bot mentions so the streaming path can switch from @@ -1505,6 +1572,78 @@ mod tests { fn contains_bot_mention_embedded() { assert!(contains_bot_mention("請問 <@1501788608439386172> 1+1=?")); } + + #[test] + fn extract_mentions_basic() { + let mentions = extract_mentions("hello <@123> and <@&456> world"); + assert_eq!(mentions, vec!["<@123>", "<@&456>"]); + } + + #[test] + fn extract_mentions_dedup() { + let mentions = extract_mentions("<@123> foo <@123> bar"); + assert_eq!(mentions, vec!["<@123>"]); + } + + #[test] + fn extract_mentions_nickname() { + let mentions = extract_mentions("hey <@!789>"); + assert_eq!(mentions, vec!["<@!789>"]); + } + + #[test] + fn extract_mentions_none() { + let mentions = extract_mentions("no mentions here"); + assert!(mentions.is_empty()); + } + + #[test] + fn propagate_mentions_single_chunk() { + let chunks = vec!["hello <@123>".to_string()]; + let result = propagate_mentions_to_chunks(chunks.clone(), &["<@123>".to_string()]); + assert_eq!(result, chunks); + } + + #[test] + fn propagate_mentions_appends_missing() { + let chunks = vec![ + "hello <@123> table".to_string(), + "more rows".to_string(), + "end".to_string(), + ]; + let result = propagate_mentions_to_chunks(chunks, &["<@123>".to_string()]); + assert_eq!(result[0], "hello <@123> table"); + assert_eq!(result[1], "more rows\n<@123>"); + assert_eq!(result[2], "end\n<@123>"); + } + + #[test] + fn propagate_mentions_skips_already_present() { + let chunks = vec![ + "hello <@123>".to_string(), + "world <@123>".to_string(), + ]; + let result = propagate_mentions_to_chunks(chunks.clone(), &["<@123>".to_string()]); + assert_eq!(result, chunks); + } + + #[test] + fn propagate_mentions_multiple() { + let chunks = vec![ + "<@111> and <@222> start".to_string(), + "middle".to_string(), + ]; + let mentions = vec!["<@111>".to_string(), "<@222>".to_string()]; + let result = propagate_mentions_to_chunks(chunks, &mentions); + assert_eq!(result[1], "middle\n<@111> <@222>"); + } + + #[test] + fn propagate_mentions_empty() { + let chunks = vec!["hello".to_string(), "world".to_string()]; + let result = propagate_mentions_to_chunks(chunks.clone(), &[]); + assert_eq!(result, chunks); + } } #[cfg(test)] From 5d0c67ec0c48271e2460df34abc50ce5940ce3a8 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 19 Jun 2026 14:21:29 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20budget=20reserve,=20code=20block=20skip,=20normaliz?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/adapter.rs | 217 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 166 insertions(+), 51 deletions(-) diff --git a/src/adapter.rs b/src/adapter.rs index 7e1847cb5..7a466e645 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -923,9 +923,17 @@ impl AdapterRouter { }; let final_content = markdown::convert_tables(&final_content, table_mode); - let mentions = extract_mentions(&final_content); - let chunks = format::split_message(&final_content, message_limit); - let chunks = propagate_mentions_to_chunks(chunks, &mentions); + let chunks = if adapter.platform() == "discord" { + let mentions = extract_mentions(&final_content); + let mention_reserve = mention_footer_len(&mentions); + let chunks = format::split_message( + &final_content, + message_limit.saturating_sub(mention_reserve), + ); + propagate_mentions_to_chunks(chunks, &mentions, message_limit) + } else { + format::split_message(&final_content, message_limit) + }; // Track delivery health across all final write paths. Any failure // here means the user's view is incomplete; we propagate Err at the // end of the closure so dispatch surfaces set_error (❌) instead of @@ -1141,71 +1149,122 @@ impl AdapterRouter { } } -/// Extract all Discord mentions (`<@123>`, `<@!123>`, `<@&123>`) from content. +/// Extract all Discord mentions (`<@123>`, `<@!123>`, `<@&123>`) from content, +/// skipping mentions inside fenced code blocks (``` ... ```). +/// Normalizes `<@!UID>` to `<@UID>` for deduplication (same user). /// Returns deduplicated list in appearance order. fn extract_mentions(content: &str) -> Vec { let mut mentions = Vec::new(); - let mut i = 0; - let bytes = content.as_bytes(); - while i + 2 < bytes.len() { - if bytes[i] == b'<' && bytes[i + 1] == b'@' { - let prefix_end = if i + 2 < bytes.len() - && (bytes[i + 2] == b'!' || bytes[i + 2] == b'&') - { - i + 3 - } else { - i + 2 - }; - if prefix_end < bytes.len() && bytes[prefix_end].is_ascii_digit() { - if let Some(end) = content[prefix_end..].find('>') { - if content[prefix_end..prefix_end + end] - .chars() - .all(|c| c.is_ascii_digit()) - { - let mention = &content[i..prefix_end + end + 1]; - if !mentions.contains(&mention.to_string()) { - mentions.push(mention.to_string()); + let mut in_fence = false; + + for line in content.split('\n') { + if line.starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + + let bytes = line.as_bytes(); + let mut i = 0; + while i + 2 < bytes.len() { + if bytes[i] == b'<' && bytes[i + 1] == b'@' { + let (prefix_end, is_role) = if i + 2 < bytes.len() && bytes[i + 2] == b'&' { + (i + 3, true) + } else if i + 2 < bytes.len() && bytes[i + 2] == b'!' { + (i + 3, false) + } else { + (i + 2, false) + }; + if prefix_end < bytes.len() && bytes[prefix_end].is_ascii_digit() { + if let Some(end) = line[prefix_end..].find('>') { + if line[prefix_end..prefix_end + end] + .chars() + .all(|c| c.is_ascii_digit()) + { + // Normalize: <@!UID> → <@UID>, keep <@&RoleID> as-is + let uid = &line[prefix_end..prefix_end + end]; + let normalized = if is_role { + format!("<@&{uid}>") + } else { + format!("<@{uid}>") + }; + if !mentions.contains(&normalized) { + mentions.push(normalized); + } + i = prefix_end + end + 1; + continue; } } - i = prefix_end + end + 1; - continue; } + i = prefix_end; + } else { + i += 1; } - i = prefix_end; - } else { - i += 1; } } mentions } +/// Compute the char length of the mention footer that will be appended. +/// Returns 0 if no mentions or only 1 chunk would be produced. +fn mention_footer_len(mentions: &[String]) -> usize { + if mentions.is_empty() { + return 0; + } + // "\n" + mentions joined by " " + 1 + mentions.iter().map(|m| m.len()).sum::() + mentions.len().saturating_sub(1) +} + /// Append mentions to split chunks that don't already contain them. /// Ensures every chunk carries all mentions from the original content so /// receiving bots under `allow_bot_messages = "mentions"` gate accept all pieces. -fn propagate_mentions_to_chunks(chunks: Vec, mentions: &[String]) -> Vec { +/// `limit` is the hard message limit (e.g. 2000) — chunks that would exceed it +/// after appending are left unchanged (they already fit within split_message's +/// reduced limit, so the mention_reserve guarantees space in normal cases). +fn propagate_mentions_to_chunks( + chunks: Vec, + mentions: &[String], + limit: usize, +) -> Vec { if mentions.is_empty() || chunks.len() <= 1 { return chunks; } chunks .into_iter() - .enumerate() - .map(|(i, chunk)| { - if i == 0 { - return chunk; - } + .map(|chunk| { let missing: Vec<&String> = mentions .iter() - .filter(|m| !chunk.contains(m.as_str())) + .filter(|m| !chunk_contains_mention(&chunk, m)) .collect(); if missing.is_empty() { chunk } else { - format!("{}\n{}", chunk, missing.iter().map(|m| m.as_str()).collect::>().join(" ")) + let footer = format!( + "\n{}", + missing.iter().map(|m| m.as_str()).collect::>().join(" ") + ); + if chunk.chars().count() + footer.chars().count() <= limit { + format!("{chunk}{footer}") + } else { + // Safety: don't exceed limit; chunk already passes gate + // if it contained the mention from the original content. + chunk + } } }) .collect() } +/// Check if a chunk contains an exact mention. +/// Since mentions are formatted as `<@DIGITS>` (terminated by `>`), a simple +/// substring search is sufficient — `<@123>` cannot match inside `<@1234>` +/// because the `>` acts as an exact boundary delimiter. +fn chunk_contains_mention(chunk: &str, mention: &str) -> bool { + chunk.contains(mention) +} + /// Returns true if `content` contains a Discord user/bot mention (`<@123>`, `<@!123>`) /// or a role mention (`<@&123>`). /// Used to detect cross-bot mentions so the streaming path can switch from @@ -1586,9 +1645,24 @@ mod tests { } #[test] - fn extract_mentions_nickname() { + fn extract_mentions_normalizes_nickname() { + // <@!789> should be normalized to <@789> let mentions = extract_mentions("hey <@!789>"); - assert_eq!(mentions, vec!["<@!789>"]); + assert_eq!(mentions, vec!["<@789>"]); + } + + #[test] + fn extract_mentions_dedup_after_normalize() { + // <@123> and <@!123> are the same user + let mentions = extract_mentions("<@123> and <@!123>"); + assert_eq!(mentions, vec!["<@123>"]); + } + + #[test] + fn extract_mentions_skips_code_blocks() { + let content = "hello <@111>\n```\n<@222>\n```\nworld <@333>"; + let mentions = extract_mentions(content); + assert_eq!(mentions, vec!["<@111>", "<@333>"]); } #[test] @@ -1597,24 +1671,42 @@ mod tests { assert!(mentions.is_empty()); } + #[test] + fn mention_footer_len_empty() { + assert_eq!(mention_footer_len(&[]), 0); + } + + #[test] + fn mention_footer_len_single() { + // "\n<@123>" = 1 + 6 = 7 + assert_eq!(mention_footer_len(&["<@123>".to_string()]), 7); + } + + #[test] + fn mention_footer_len_multiple() { + // "\n<@123> <@456>" = 1 + 6 + 1 + 6 = 14 + let mentions = vec!["<@123>".to_string(), "<@456>".to_string()]; + assert_eq!(mention_footer_len(&mentions), 14); + } + #[test] fn propagate_mentions_single_chunk() { let chunks = vec!["hello <@123>".to_string()]; - let result = propagate_mentions_to_chunks(chunks.clone(), &["<@123>".to_string()]); + let result = propagate_mentions_to_chunks(chunks.clone(), &["<@123>".to_string()], 2000); assert_eq!(result, chunks); } #[test] - fn propagate_mentions_appends_missing() { + fn propagate_mentions_appends_to_all_chunks() { let chunks = vec![ - "hello <@123> table".to_string(), - "more rows".to_string(), - "end".to_string(), + "first chunk no mention".to_string(), + "second chunk".to_string(), + "third chunk".to_string(), ]; - let result = propagate_mentions_to_chunks(chunks, &["<@123>".to_string()]); - assert_eq!(result[0], "hello <@123> table"); - assert_eq!(result[1], "more rows\n<@123>"); - assert_eq!(result[2], "end\n<@123>"); + let result = propagate_mentions_to_chunks(chunks, &["<@123>".to_string()], 2000); + assert!(result[0].ends_with("\n<@123>")); + assert!(result[1].ends_with("\n<@123>")); + assert!(result[2].ends_with("\n<@123>")); } #[test] @@ -1623,10 +1715,20 @@ mod tests { "hello <@123>".to_string(), "world <@123>".to_string(), ]; - let result = propagate_mentions_to_chunks(chunks.clone(), &["<@123>".to_string()]); + let result = propagate_mentions_to_chunks(chunks.clone(), &["<@123>".to_string()], 2000); assert_eq!(result, chunks); } + #[test] + fn propagate_mentions_respects_limit() { + // Chunk at exactly limit - no room to append + let chunk = "x".repeat(2000); + let chunks = vec!["short <@123>".to_string(), chunk.clone()]; + let result = propagate_mentions_to_chunks(chunks, &["<@123>".to_string()], 2000); + // Second chunk unchanged (would exceed limit) + assert_eq!(result[1], chunk); + } + #[test] fn propagate_mentions_multiple() { let chunks = vec![ @@ -1634,16 +1736,29 @@ mod tests { "middle".to_string(), ]; let mentions = vec!["<@111>".to_string(), "<@222>".to_string()]; - let result = propagate_mentions_to_chunks(chunks, &mentions); + let result = propagate_mentions_to_chunks(chunks, &mentions, 2000); assert_eq!(result[1], "middle\n<@111> <@222>"); } #[test] fn propagate_mentions_empty() { let chunks = vec!["hello".to_string(), "world".to_string()]; - let result = propagate_mentions_to_chunks(chunks.clone(), &[]); + let result = propagate_mentions_to_chunks(chunks.clone(), &[], 2000); assert_eq!(result, chunks); } + + #[test] + fn chunk_contains_mention_exact() { + assert!(chunk_contains_mention("hello <@123> world", "<@123>")); + assert!(chunk_contains_mention("<@123>", "<@123>")); + } + + #[test] + fn chunk_contains_mention_not_substring() { + // <@123> ends with > so it won't match inside <@1234> + // because <@1234> is "<@1234>" not "<@123>4" + assert!(!chunk_contains_mention("hello <@1234> world", "<@123>")); + } } #[cfg(test)] From 9a815b5691800c050de5f573e2524c2f25544861 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 19 Jun 2026 14:32:39 +0000 Subject: [PATCH 3/4] test(adapter): add edge-case tests for mention propagation - 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 --- src/adapter.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/adapter.rs b/src/adapter.rs index 7a466e645..b66eb67e1 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -1759,6 +1759,58 @@ mod tests { // because <@1234> is "<@1234>" not "<@123>4" assert!(!chunk_contains_mention("hello <@1234> world", "<@123>")); } + + #[test] + fn pipeline_split_then_propagate() { + // End-to-end: split a message that exceeds limit, then propagate mentions. + use crate::format::split_message; + let mention = "<@99999>"; + let body = "x".repeat(80); + let content = format!("{mention} {body}"); + let limit = 50; + let mentions = extract_mentions(&content); + let reserve = mention_footer_len(&mentions); + let chunks = split_message(&content, limit.saturating_sub(reserve)); + let result = propagate_mentions_to_chunks(chunks, &mentions, limit); + // Every chunk must carry the mention and fit within limit. + for chunk in &result { + assert!(chunk.contains(mention), "chunk missing mention: {chunk}"); + assert!(chunk.chars().count() <= limit, "chunk exceeds limit"); + } + } + + #[test] + fn extract_mentions_unclosed_fence() { + // Unclosed code fence — everything after it is "inside" fence, so no mentions extracted. + let content = "hello <@111>\n```\n<@222>\n<@333>"; + let mentions = extract_mentions(content); + assert_eq!(mentions, vec!["<@111>"]); + } + + #[test] + fn saturating_sub_large_reserve() { + // When mention_reserve exceeds the limit, saturating_sub yields 0. + // split_message with limit=0 puts nothing in first chunk but must not panic. + use crate::format::split_message; + let mentions = vec!["<@111111111111111111>".to_string(); 200]; + let reserve = mention_footer_len(&mentions); + let limit: usize = 100; + // saturating_sub → 0 + let effective = limit.saturating_sub(reserve); + assert_eq!(effective, 0); + let chunks = split_message("short", effective); + // Should not panic; propagation returns chunks unchanged when they'd exceed limit. + let result = propagate_mentions_to_chunks(chunks, &mentions, limit); + assert!(!result.is_empty()); + } + + #[test] + fn role_vs_user_mention_distinction() { + // <@&123> (role) and <@123> (user) are distinct mentions. + let content = "<@123> hello <@&123>"; + let mentions = extract_mentions(content); + assert_eq!(mentions, vec!["<@123>", "<@&123>"]); + } } #[cfg(test)] From e89f70e6520806f9e3b22c9ca3d38e2ac5f6aaaf Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Fri, 19 Jun 2026 14:34:13 +0000 Subject: [PATCH 4/4] fix: specify usize type for limit in test --- src/adapter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapter.rs b/src/adapter.rs index b66eb67e1..9342b1934 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -1767,7 +1767,7 @@ mod tests { let mention = "<@99999>"; let body = "x".repeat(80); let content = format!("{mention} {body}"); - let limit = 50; + let limit: usize = 50; let mentions = extract_mentions(&content); let reserve = mention_footer_len(&mentions); let chunks = split_message(&content, limit.saturating_sub(reserve));