fix: prevent duplicate message in chat when tool is executed

- Track tool_was_executed flag in stream_response
- Send empty content in final is_complete message when tool already sent results
- Prevents the LLM's pre-tool text from appearing twice in the chat UI
- DB message saving is unaffected (uses full_response_clone before the check)
This commit is contained in:
Rodrigo Rodriguez 2026-02-18 20:31:34 +00:00
parent b1118f977d
commit 3b21ab5ef9
2 changed files with 36 additions and 7 deletions

View file

@ -1410,6 +1410,9 @@ impl ScriptService {
/// Transforms: SELECT var ... CASE "value" ... END SELECT /// Transforms: SELECT var ... CASE "value" ... END SELECT
/// Into: if var == "value" { ... } else if var == "value2" { ... } /// Into: if var == "value" { ... } else if var == "value2" { ... }
/// Note: We use if-else instead of match because 'match' is a reserved keyword in Rhai /// Note: We use if-else instead of match because 'match' is a reserved keyword in Rhai
///
/// IMPORTANT: This function strips 'let ' keywords from assignment statements inside CASE blocks
/// to avoid creating local variables that shadow outer scope variables.
pub fn convert_select_case_syntax(script: &str) -> String { pub fn convert_select_case_syntax(script: &str) -> String {
let mut result = String::new(); let mut result = String::new();
let mut lines: Vec<&str> = script.lines().collect(); let mut lines: Vec<&str> = script.lines().collect();
@ -1417,6 +1420,20 @@ impl ScriptService {
log::info!("[TOOL] Converting SELECT/CASE syntax to if-else chains"); log::info!("[TOOL] Converting SELECT/CASE syntax to if-else chains");
// Helper function to strip 'let ' from the beginning of a line
// This is needed because convert_if_then_syntax adds 'let' to all assignments,
// but inside CASE blocks we want to modify outer variables, not create new ones
fn strip_let_from_assignment(line: &str) -> String {
let trimmed = line.trim();
let trimmed_lower = trimmed.to_lowercase();
if trimmed_lower.starts_with("let ") && trimmed.contains('=') {
// This is a 'let' assignment - strip the 'let ' keyword
trimmed[4..].trim().to_string()
} else {
trimmed.to_string()
}
}
while i < lines.len() { while i < lines.len() {
let trimmed = lines[i].trim(); let trimmed = lines[i].trim();
let upper = trimmed.to_uppercase(); let upper = trimmed.to_uppercase();
@ -1450,9 +1467,11 @@ impl ScriptService {
if in_case { if in_case {
for body_line in &current_case_body { for body_line in &current_case_body {
result.push_str(" "); result.push_str(" ");
result.push_str(body_line); // Strip 'let ' from assignments to avoid creating local variables
let processed_line = strip_let_from_assignment(body_line);
result.push_str(&processed_line);
// Add semicolon if line doesn't have one // Add semicolon if line doesn't have one
if !body_line.ends_with(';') && !body_line.ends_with('{') && !body_line.ends_with('}') { if !processed_line.ends_with(';') && !processed_line.ends_with('{') && !processed_line.ends_with('}') {
result.push(';'); result.push(';');
} }
result.push('\n'); result.push('\n');
@ -1471,9 +1490,11 @@ impl ScriptService {
if in_case { if in_case {
for body_line in &current_case_body { for body_line in &current_case_body {
result.push_str(" "); result.push_str(" ");
result.push_str(body_line); // Strip 'let ' from assignments to avoid creating local variables
let processed_line = strip_let_from_assignment(body_line);
result.push_str(&processed_line);
// Add semicolon if line doesn't have one // Add semicolon if line doesn't have one
if !body_line.ends_with(';') && !body_line.ends_with('{') && !body_line.ends_with('}') { if !processed_line.ends_with(';') && !processed_line.ends_with('{') && !processed_line.ends_with('}') {
result.push(';'); result.push(';');
} }
result.push('\n'); result.push('\n');
@ -1490,9 +1511,11 @@ impl ScriptService {
if in_case { if in_case {
for body_line in &current_case_body { for body_line in &current_case_body {
result.push_str(" "); result.push_str(" ");
result.push_str(body_line); // Strip 'let ' from assignments to avoid creating local variables
let processed_line = strip_let_from_assignment(body_line);
result.push_str(&processed_line);
// Add semicolon if line doesn't have one // Add semicolon if line doesn't have one
if !body_line.ends_with(';') && !body_line.ends_with('{') && !body_line.ends_with('}') { if !processed_line.ends_with(';') && !processed_line.ends_with('{') && !processed_line.ends_with('}') {
result.push(';'); result.push(';');
} }
result.push('\n'); result.push('\n');

View file

@ -654,6 +654,7 @@ impl BotOrchestrator {
let mut in_analysis = false; let mut in_analysis = false;
let mut tool_call_buffer = String::new(); // Accumulate potential tool call JSON chunks let mut tool_call_buffer = String::new(); // Accumulate potential tool call JSON chunks
let mut accumulating_tool_call = false; // Track if we're currently accumulating a tool call let mut accumulating_tool_call = false; // Track if we're currently accumulating a tool call
let mut tool_was_executed = false; // Track if a tool was executed to avoid duplicate final message
let handler = llm_models::get_handler(&model); let handler = llm_models::get_handler(&model);
info!("[STREAM_START] Entering stream processing loop for model: {}", model); info!("[STREAM_START] Entering stream processing loop for model: {}", model);
@ -835,6 +836,7 @@ impl BotOrchestrator {
// Clear the tool_call_buffer since we found and executed a tool call // Clear the tool_call_buffer since we found and executed a tool call
tool_call_buffer.clear(); tool_call_buffer.clear();
accumulating_tool_call = false; // Reset accumulation flag accumulating_tool_call = false; // Reset accumulation flag
tool_was_executed = true; // Mark that a tool was executed
// Continue to next chunk // Continue to next chunk
continue; continue;
} }
@ -1004,12 +1006,16 @@ impl BotOrchestrator {
#[cfg(not(feature = "chat"))] #[cfg(not(feature = "chat"))]
let suggestions: Vec<crate::core::shared::models::Suggestion> = Vec::new(); let suggestions: Vec<crate::core::shared::models::Suggestion> = Vec::new();
// When a tool was executed, the content was already sent as streaming chunks
// (pre-tool text + tool result). Sending full_response again would duplicate it.
let final_content = if tool_was_executed { String::new() } else { full_response };
let final_response = BotResponse { let final_response = BotResponse {
bot_id: message.bot_id, bot_id: message.bot_id,
user_id: message.user_id, user_id: message.user_id,
session_id: message.session_id, session_id: message.session_id,
channel: message.channel, channel: message.channel,
content: full_response, content: final_content,
message_type: MessageType::BOT_RESPONSE, message_type: MessageType::BOT_RESPONSE,
stream_token: None, stream_token: None,
is_complete: true, is_complete: true,