From 0684e6dfa8811264842fac0d1011b1d56fcc74e3 Mon Sep 17 00:00:00 2001 From: ruandan Date: Sun, 5 Apr 2026 14:17:20 +0800 Subject: [PATCH 1/2] fix: use toolCallId for tool status tracking, prevent duplicate messages on long responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs in the Discord display pipeline, both caused by the same architectural gap: tool call events lack stable identity tracking, and the streaming/final-edit lifecycle creates orphaned messages. --- Bug 1: tool status lines show wrong or empty titles --- Root cause: tool_call (start) and tool_call_update (done) events from the ACP protocol frequently carry different `title` values for the same tool invocation. For example, a Read tool starts with title "Read CLAUDE.md" but completes with "Read File" โ€” or an empty string for subagent child tools. The current title-based string matching (`tool_lines.find(|l| l.contains(&title))`) fails to correlate these, producing empty `โœ… ` lines or stale `๐Ÿ”ง` lines that never resolve. Fix: extract `toolCallId` from ACP notifications (every tool_call and tool_call_update carries this unique identifier) and use it as the primary matching key. On ToolDone, locate the corresponding ToolStart line by ID and swap only the icon (๐Ÿ”งโ†’โœ…/โŒ), preserving the original title from the start event โ€” which always has the most descriptive text. --- Bug 2: long responses appear duplicated in Discord --- Root cause: when streamed content exceeds 1900 characters, the edit-streaming task (running every 1.5s) calls `channel.say()` to create overflow messages. These message IDs are tracked only inside the spawned streaming task. When streaming completes, the final edit uses the *original* message ID and calls `channel.say()` again for its own overflow โ€” producing a second copy of the same content. The streaming overflow messages are never cleaned up. Considered alternatives: - Share message IDs between streaming task and final edit via Arc>> โ€” adds complexity and race conditions - Delete streaming overflow messages before final edit โ€” fragile, depends on Discord API timing - Don't create overflow during streaming; truncate to single message โ† chosen approach Fix: during streaming, only edit the single placeholder message. If content exceeds 1900 chars, truncate with "โ€ฆ" โ€” this is a live preview, not the final output. The final edit (after streaming completes) handles proper multi-message splitting. This cleanly separates responsibilities: streaming = live preview in one message, final edit = authoritative multi-message delivery. Changes: protocol.rs (+7/-5): add `id` field to ToolStart/ToolDone variants, extract toolCallId from ACP update notifications discord.rs (+24/-16): track tool_ids vec parallel to tool_lines, match ToolDone by ID preserving original title; simplify streaming edit to single-message truncation --- src/acp/protocol.rs | 12 +++++++----- src/discord.rs | 40 ++++++++++++++++++++++++---------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index d3e96ed5..9f728ba9 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -60,8 +60,8 @@ impl std::fmt::Display for JsonRpcError { pub enum AcpEvent { Text(String), Thinking, - ToolStart { title: String }, - ToolDone { title: String, status: String }, + ToolStart { id: String, title: String }, + ToolDone { id: String, title: String, status: String }, Status, } @@ -79,16 +79,18 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option { Some(AcpEvent::Thinking) } "tool_call" => { + let id = update.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("").to_string(); let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); - Some(AcpEvent::ToolStart { title }) + Some(AcpEvent::ToolStart { id, title }) } "tool_call_update" => { + let id = update.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("").to_string(); let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("").to_string(); if status == "completed" || status == "failed" { - Some(AcpEvent::ToolDone { title, status }) + Some(AcpEvent::ToolDone { id, title, status }) } else { - Some(AcpEvent::ToolStart { title }) + Some(AcpEvent::ToolStart { id, title }) } } "plan" => Some(AcpEvent::Status), diff --git a/src/discord.rs b/src/discord.rs index 32c495ab..b59b1061 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -192,6 +192,7 @@ async fn stream_prompt( let mut text_buf = String::new(); let mut tool_lines: Vec = Vec::new(); + let mut tool_ids: Vec = Vec::new(); let current_msg_id = msg_id; if reset { @@ -210,19 +211,15 @@ async fn stream_prompt( 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; - } - } + // During streaming, only edit the single placeholder message. + // Truncate if over Discord's limit โ€” the final edit handles + // proper multi-message splitting after streaming completes. + let display = if content.len() > 1900 { + format!("{}โ€ฆ", &content[..1900]) } else { - let _ = edit(&ctx, channel, current_edit_msg, &content).await; - } + content.clone() + }; + let _ = edit(&ctx, channel, current_edit_msg, &display).await; last_content = content; } } @@ -253,16 +250,27 @@ async fn stream_prompt( AcpEvent::Thinking => { reactions.set_thinking().await; } - AcpEvent::ToolStart { title, .. } if !title.is_empty() => { + AcpEvent::ToolStart { id, title, .. } if !title.is_empty() => { reactions.set_tool(&title).await; + tool_ids.push(id); tool_lines.push(format!("๐Ÿ”ง `{title}`...")); let _ = buf_tx.send(compose_display(&tool_lines, &text_buf)); } - AcpEvent::ToolDone { title, status, .. } => { + AcpEvent::ToolDone { id, title, status, .. } => { reactions.set_thinking().await; let icon = if status == "completed" { "โœ…" } else { "โŒ" }; - if let Some(line) = tool_lines.iter_mut().rev().find(|l| l.contains(&title)) { - *line = format!("{icon} `{title}`"); + // Match by toolCallId instead of title text. + // On match, swap the icon but keep the original ToolStart title, + // since ToolDone often returns a different or empty title. + let idx = tool_ids.iter().rposition(|tid| !tid.is_empty() && tid == &id); + if let Some(i) = idx { + let kept_title = tool_lines[i] + .split('`').nth(1) + .unwrap_or(&title); + tool_lines[i] = format!("{icon} `{kept_title}`"); + } else if !title.is_empty() { + tool_ids.push(id); + tool_lines.push(format!("{icon} `{title}`")); } let _ = buf_tx.send(compose_display(&tool_lines, &text_buf)); } From 3cbc865a54eceea6bd6ab6b7280572d1096a45af Mon Sep 17 00:00:00 2001 From: ruandan Date: Fri, 10 Apr 2026 15:17:55 +0800 Subject: [PATCH 2/2] fix: use floor_char_boundary for UTF-8 safe truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming truncation (`&content[..1900]`) panics when byte 1900 falls inside a multi-byte UTF-8 character (CJK, emoji). Use `floor_char_boundary()` (stable since Rust 1.80) to find the nearest valid boundary. Also fix `split_message()` hard-split path which used `as_bytes().chunks()` + `from_utf8_lossy`, silently corrupting multi-byte characters with replacement chars (๏ฟฝ). Co-Authored-By: Claude Opus 4.6 --- src/discord.rs | 3 ++- src/format.rs | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index b59b1061..c374504c 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -215,7 +215,8 @@ async fn stream_prompt( // Truncate if over Discord's limit โ€” the final edit handles // proper multi-message splitting after streaming completes. let display = if content.len() > 1900 { - format!("{}โ€ฆ", &content[..1900]) + let end = content.floor_char_boundary(1900); + format!("{}โ€ฆ", &content[..end]) } else { content.clone() }; diff --git a/src/format.rs b/src/format.rs index a0026ebb..495e37e3 100644 --- a/src/format.rs +++ b/src/format.rs @@ -18,11 +18,14 @@ pub fn split_message(text: &str, limit: usize) -> Vec { } // If a single line exceeds limit, hard-split it if line.len() > limit { - for chunk in line.as_bytes().chunks(limit) { + let mut pos = 0; + while pos < line.len() { + let end = line.floor_char_boundary((pos + limit).min(line.len())); if !current.is_empty() { chunks.push(current); } - current = String::from_utf8_lossy(chunk).to_string(); + current = line[pos..end].to_string(); + pos = end; } } else { current.push_str(line);