From dd200bd3a589fb54d9743246bc621f6156044ecf Mon Sep 17 00:00:00 2001 From: wangyuyan-agent Date: Wed, 8 Apr 2026 11:30:37 +0800 Subject: [PATCH 1/3] fix: prevent incomplete Discord messages during streaming Two issues caused fragmented/incomplete messages in Discord: 1. The streaming edit task would call channel.say() to send new messages when content exceeded 1900 chars. These intermediate messages were never updated again, leaving stale incomplete fragments. Now the streaming phase only edits the single thinking message, truncating long content with an ellipsis until streaming completes. 2. split_message() used byte-level chunking (as_bytes().chunks()) which could split in the middle of multi-byte UTF-8 sequences (CJK, emoji), producing garbled text via from_utf8_lossy. Now splits on char boundaries. Also adds truncate_utf8() helper for safe byte-limit truncation. --- src/discord.rs | 22 ++++++++-------------- src/format.rs | 23 ++++++++++++++++++----- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index da52c691..4852b491 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -217,31 +217,25 @@ async fn stream_prompt( text_buf.push_str("⚠️ _Session expired, starting fresh..._\n\n"); } - // Spawn edit-streaming task + // Spawn edit-streaming task — only edits the single message, never sends new ones. + // Long content is truncated during streaming; final multi-message split happens after. let edit_handle = { let ctx = ctx.clone(); let mut buf_rx = buf_rx.clone(); tokio::spawn(async move { let mut last_content = String::new(); - let mut current_edit_msg = msg_id; loop { tokio::time::sleep(std::time::Duration::from_millis(1500)).await; if buf_rx.has_changed().unwrap_or(false) { let content = buf_rx.borrow_and_update().clone(); if content != last_content { - if content.len() > 1900 { - let chunks = format::split_message(&content, 1900); - if let Some(first) = chunks.first() { - let _ = edit(&ctx, channel, current_edit_msg, first).await; - } - for chunk in chunks.iter().skip(1) { - if let Ok(new_msg) = channel.say(&ctx.http, chunk).await { - current_edit_msg = new_msg.id; - } - } + let display = if content.len() > 1900 { + let truncated = format::truncate_utf8(&content, 1900); + format!("{truncated}…") } else { - let _ = edit(&ctx, channel, current_edit_msg, &content).await; - } + content.clone() + }; + let _ = edit(&ctx, channel, msg_id, &display).await; last_content = content; } } diff --git a/src/format.rs b/src/format.rs index a0026ebb..abecb28d 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,4 +1,4 @@ -/// Split text into chunks at line boundaries, each <= limit chars. +/// Split text into chunks at line boundaries, each <= limit bytes (UTF-8 safe). pub fn split_message(text: &str, limit: usize) -> Vec { if text.len() <= limit { return vec![text.to_string()]; @@ -16,13 +16,14 @@ pub fn split_message(text: &str, limit: usize) -> Vec { if !current.is_empty() { current.push('\n'); } - // If a single line exceeds limit, hard-split it + // If a single line exceeds limit, hard-split on char boundaries if line.len() > limit { - for chunk in line.as_bytes().chunks(limit) { - if !current.is_empty() { + for ch in line.chars() { + if current.len() + ch.len_utf8() > limit { chunks.push(current); + current = String::new(); } - current = String::from_utf8_lossy(chunk).to_string(); + current.push(ch); } } else { current.push_str(line); @@ -33,3 +34,15 @@ pub fn split_message(text: &str, limit: usize) -> Vec { } chunks } + +/// Truncate a string to at most `limit` bytes on a char boundary. +pub fn truncate_utf8(s: &str, limit: usize) -> &str { + if s.len() <= limit { + return s; + } + let mut end = limit; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} \ No newline at end of file From d457f018af0addc283bf80cdbaed9db29946427d Mon Sep 17 00:00:00 2001 From: wangyuyan-agent <265828726+wangyuyan-agent@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:36:58 +0800 Subject: [PATCH 2/3] fix: use char count instead of byte length for Discord message limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord's 2000-character limit counts Unicode characters, not bytes. CJK characters (3 bytes each) caused overly conservative splitting at ~667 chars instead of the full 2000 allowed. - split_message(): switch threshold to chars().count() - truncate_utf8() → truncate_chars(): use char_indices().nth(limit) - Streaming check: content.chars().count() > 1900 Suggested by masami-agent in PR review. --- src/discord.rs | 4 ++-- src/format.rs | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index 4852b491..7af168d7 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -229,8 +229,8 @@ async fn stream_prompt( if buf_rx.has_changed().unwrap_or(false) { let content = buf_rx.borrow_and_update().clone(); if content != last_content { - let display = if content.len() > 1900 { - let truncated = format::truncate_utf8(&content, 1900); + let display = if content.chars().count() > 1900 { + let truncated = format::truncate_chars(&content, 1900); format!("{truncated}…") } else { content.clone() diff --git a/src/format.rs b/src/format.rs index abecb28d..c7533460 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,6 +1,7 @@ -/// Split text into chunks at line boundaries, each <= limit bytes (UTF-8 safe). +/// Split text into chunks at line boundaries, each <= limit Unicode characters (UTF-8 safe). +/// Discord's message limit counts Unicode characters, not bytes. pub fn split_message(text: &str, limit: usize) -> Vec { - if text.len() <= limit { + if text.chars().count() <= limit { return vec![text.to_string()]; } @@ -8,8 +9,10 @@ pub fn split_message(text: &str, limit: usize) -> Vec { let mut current = String::new(); for line in text.split('\n') { + let line_chars = line.chars().count(); + let current_chars = current.chars().count(); // +1 for the newline - if !current.is_empty() && current.len() + line.len() + 1 > limit { + if !current.is_empty() && current_chars + line_chars + 1 > limit { chunks.push(current); current = String::new(); } @@ -17,9 +20,9 @@ pub fn split_message(text: &str, limit: usize) -> Vec { current.push('\n'); } // If a single line exceeds limit, hard-split on char boundaries - if line.len() > limit { + if line_chars > limit { for ch in line.chars() { - if current.len() + ch.len_utf8() > limit { + if current.chars().count() + 1 > limit { chunks.push(current); current = String::new(); } @@ -35,14 +38,11 @@ pub fn split_message(text: &str, limit: usize) -> Vec { chunks } -/// Truncate a string to at most `limit` bytes on a char boundary. -pub fn truncate_utf8(s: &str, limit: usize) -> &str { - if s.len() <= limit { - return s; +/// Truncate a string to at most `limit` Unicode characters. +/// Discord's message limit counts Unicode characters, not bytes. +pub fn truncate_chars(s: &str, limit: usize) -> &str { + match s.char_indices().nth(limit) { + Some((idx, _)) => &s[..idx], + None => s, } - let mut end = limit; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } - &s[..end] } \ No newline at end of file From 58f91a568237894f8ca164e6dc80464ee8dbee25 Mon Sep 17 00:00:00 2001 From: thepagent Date: Sat, 11 Apr 2026 19:49:54 +0000 Subject: [PATCH 3/3] =?UTF-8?q?perf:=20replace=20O(n=C2=B2)=20chars().coun?= =?UTF-8?q?t()=20with=20incremental=20counter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit split_message() called chars().count() on every loop iteration — O(n²) for long single lines (base64, minified JSON). Use an incremental usize counter instead. Also adds missing trailing newline. Addresses chaodu-agent review feedback. --- src/format.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/format.rs b/src/format.rs index c7533460..841cf559 100644 --- a/src/format.rs +++ b/src/format.rs @@ -7,29 +7,34 @@ pub fn split_message(text: &str, limit: usize) -> Vec { let mut chunks = Vec::new(); let mut current = String::new(); + let mut current_len: usize = 0; for line in text.split('\n') { let line_chars = line.chars().count(); - let current_chars = current.chars().count(); // +1 for the newline - if !current.is_empty() && current_chars + line_chars + 1 > limit { + if !current.is_empty() && current_len + line_chars + 1 > limit { chunks.push(current); current = String::new(); + current_len = 0; } if !current.is_empty() { current.push('\n'); + current_len += 1; } // If a single line exceeds limit, hard-split on char boundaries if line_chars > limit { for ch in line.chars() { - if current.chars().count() + 1 > limit { + if current_len + 1 > limit { chunks.push(current); current = String::new(); + current_len = 0; } current.push(ch); + current_len += 1; } } else { current.push_str(line); + current_len += line_chars; } } if !current.is_empty() { @@ -45,4 +50,4 @@ pub fn truncate_chars(s: &str, limit: usize) -> &str { Some((idx, _)) => &s[..idx], None => s, } -} \ No newline at end of file +}