diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index a0ae1675..6170e86d 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -35,6 +35,11 @@ use domain::ports::{ }; use crate::dto::{ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressStageDto}; + +/// Visible terminal message when a structured stream ends without the authoritative +/// `ReplyEvent::Final`. +pub const MODEL_RESPONSE_NOT_FINALIZED_MESSAGE: &str = + "La réponse du modèle n'a pas pu être finalisée."; use crate::stream::TauriChannelSink; /// Maximum number of recent chat chunks retained for transport reattach replay. @@ -338,3 +343,12 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option { ReplyEvent::RateLimited { .. } => None, } } + +/// Terminal wire chunk for an exhausted structured stream that never produced the +/// authoritative `ReplyEvent::Final`. +#[must_use] +pub fn unfinished_turn_chunk() -> ReplyChunk { + ReplyChunk::Error { + message: MODEL_RESPONSE_NOT_FINALIZED_MESSAGE.to_owned(), + } +} diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 80b258ca..73dc7d26 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -2512,7 +2512,6 @@ pub async fn agent_send( let meta = state.structured_sessions.meta_for_session(&sid); std::thread::spawn(move || { let mut terminal_visible = false; - let mut streamed_text = String::new(); let mut settler = crate::chat::ProgressSettler::default(); for event in stream { // Non-terminal, sans contenu chat : un signal de limite alimente le service @@ -2543,9 +2542,6 @@ pub async fn agent_send( let Some(chunk) = crate::chat::chunk_from_event(event) else { continue; }; - if let ReplyChunk::TextDelta { text } = &chunk { - streamed_text.push_str(text); - } if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) { terminal_visible = true; } @@ -2556,15 +2552,7 @@ pub async fn agent_send( let _ = bridge.send_output_settled(&sid, &mut settler, chunk); } if !terminal_visible { - let chunk = if streamed_text.trim().is_empty() { - ReplyChunk::Error { - message: "Le modèle n'a renvoyé aucune réponse.".to_owned(), - } - } else { - ReplyChunk::Final { - content: streamed_text, - } - }; + let chunk = crate::chat::unfinished_turn_chunk(); let _ = bridge.send_output_settled(&sid, &mut settler, chunk); } bridge.detach_if(&sid, gen); diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index 23b70dcb..35ee19d9 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex}; use tauri::ipc::{Channel, InvokeResponseBody}; use app_tauri_lib::chat::{ - chunk_from_event, ChatBridge, ProgressSettler, MAX_CHAT_SCROLLBACK_BYTES, - MAX_CHAT_SCROLLBACK_CHUNKS, + chunk_from_event, unfinished_turn_chunk, ChatBridge, ProgressSettler, + MAX_CHAT_SCROLLBACK_BYTES, MAX_CHAT_SCROLLBACK_CHUNKS, MODEL_RESPONSE_NOT_FINALIZED_MESSAGE, }; use app_tauri_lib::dto::{ ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto, @@ -233,13 +233,9 @@ fn chunk_from_event_drops_heartbeat() { /// end. This is the body of the spawned pump thread, run inline. fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec) { let mut terminal_visible = false; - let mut streamed_text = String::new(); for event in events { // Mirror the real pump: heartbeats map to no chunk and are skipped. if let Some(chunk) = chunk_from_event(event) { - if let ReplyChunk::TextDelta { text } = &chunk { - streamed_text.push_str(text); - } if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) { terminal_visible = true; } @@ -247,12 +243,7 @@ fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec