From 8a6970734e82fc7b77bc33abcbd53f9ea4739a53 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Tue, 14 Apr 2026 18:56:24 -0300 Subject: [PATCH] fix: Extract thinking signals from anywhere in chunk to prevent leakage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thinking signals ({"type":"thinking"} and {"type":"thinking_clear"}) were leaking into the final HTML response when they appeared in the middle or end of chunks, concatenated with regular content. The previous check only looked at the start of chunks with chunk.trim().starts_with('{'), which missed embedded signals. Solution: - Use regex to find ALL thinking signal JSON objects anywhere in the chunk - Send each thinking signal separately to the frontend - Remove thinking signals from the chunk before content processing - Skip to next iteration if chunk contained only thinking signals This prevents thinking signals from appearing in the final HTML output and ensures they're properly handled by the frontend thinking indicator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/core/bot/mod.rs | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/core/bot/mod.rs b/src/core/bot/mod.rs index 8db873c5..b16fde3f 100644 --- a/src/core/bot/mod.rs +++ b/src/core/bot/mod.rs @@ -40,6 +40,7 @@ use serde_json; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::mpsc; +use regex; #[cfg(feature = "drive")] #[cfg(feature = "drive")] use tokio::sync::Mutex as AsyncMutex; @@ -881,21 +882,23 @@ impl BotOrchestrator { // Add chunk to tool_call_buffer and try to parse // Tool calls arrive as JSON that can span multiple chunks - // Check if this chunk is an internal event (thinking/thinking_clear) - let is_internal_signal = if chunk.trim().starts_with('{') { - if let Ok(v) = serde_json::from_str::(&chunk) { - let t = v.get("type").and_then(|t| t.as_str()).unwrap_or_default(); - t == "thinking" || t == "thinking_clear" - } else { false } - } else { false }; + // Extract and send any thinking signals embedded in the chunk + // Thinking signals can appear anywhere in the chunk (start, middle, or end) + let thinking_regex = regex::Regex::new(r#"\{"content":"[^"]*","type":"thinking"\}|\{"type":"thinking_clear"\}"#).unwrap(); + let mut cleaned_chunk = chunk.clone(); + let mut found_thinking = false; - if is_internal_signal { + for mat in thinking_regex.find_iter(&chunk) { + found_thinking = true; + let thinking_signal = mat.as_str(); + + // Send the thinking signal to the frontend let response = BotResponse { bot_id: message.bot_id.clone(), user_id: message.user_id.clone(), session_id: message.session_id.clone(), channel: message.channel.clone(), - content: chunk.clone(), + content: thinking_signal.to_string(), message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: false, @@ -909,9 +912,19 @@ impl BotOrchestrator { warn!("Response channel closed during thinking event"); break; } - continue; // Important: do not append to full_response or tool_buffer + + // Remove the thinking signal from the cleaned chunk + cleaned_chunk = cleaned_chunk.replace(thinking_signal, ""); } + // If the chunk contained only thinking signals, skip to next iteration + if found_thinking && cleaned_chunk.trim().is_empty() { + continue; + } + + // Use the cleaned chunk for further processing + let chunk = cleaned_chunk; + // Check if this chunk contains a tool call start // We only accumulate if it strongly resembles a tool call to avoid swallowing regular JSON/text let looks_like_tool_start = (chunk.trim().starts_with('{') || chunk.trim().starts_with('[')) &&