merge(chat): fins de tour « Le modèle n'a renvoyé aucune réponse. » — #231 (QA verte)
This commit is contained in:
@ -35,6 +35,11 @@ use domain::ports::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::dto::{ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressStageDto};
|
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;
|
use crate::stream::TauriChannelSink;
|
||||||
|
|
||||||
/// Maximum number of recent chat chunks retained for transport reattach replay.
|
/// Maximum number of recent chat chunks retained for transport reattach replay.
|
||||||
@ -338,3 +343,12 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
|||||||
ReplyEvent::RateLimited { .. } => None,
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2512,7 +2512,6 @@ pub async fn agent_send(
|
|||||||
let meta = state.structured_sessions.meta_for_session(&sid);
|
let meta = state.structured_sessions.meta_for_session(&sid);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut terminal_visible = false;
|
let mut terminal_visible = false;
|
||||||
let mut streamed_text = String::new();
|
|
||||||
let mut settler = crate::chat::ProgressSettler::default();
|
let mut settler = crate::chat::ProgressSettler::default();
|
||||||
for event in stream {
|
for event in stream {
|
||||||
// Non-terminal, sans contenu chat : un signal de limite alimente le service
|
// 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 {
|
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let ReplyChunk::TextDelta { text } = &chunk {
|
|
||||||
streamed_text.push_str(text);
|
|
||||||
}
|
|
||||||
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
||||||
terminal_visible = true;
|
terminal_visible = true;
|
||||||
}
|
}
|
||||||
@ -2556,15 +2552,7 @@ pub async fn agent_send(
|
|||||||
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
||||||
}
|
}
|
||||||
if !terminal_visible {
|
if !terminal_visible {
|
||||||
let chunk = if streamed_text.trim().is_empty() {
|
let chunk = crate::chat::unfinished_turn_chunk();
|
||||||
ReplyChunk::Error {
|
|
||||||
message: "Le modèle n'a renvoyé aucune réponse.".to_owned(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ReplyChunk::Final {
|
|
||||||
content: streamed_text,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
||||||
}
|
}
|
||||||
bridge.detach_if(&sid, gen);
|
bridge.detach_if(&sid, gen);
|
||||||
|
|||||||
@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex};
|
|||||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||||
|
|
||||||
use app_tauri_lib::chat::{
|
use app_tauri_lib::chat::{
|
||||||
chunk_from_event, ChatBridge, ProgressSettler, MAX_CHAT_SCROLLBACK_BYTES,
|
chunk_from_event, unfinished_turn_chunk, ChatBridge, ProgressSettler,
|
||||||
MAX_CHAT_SCROLLBACK_CHUNKS,
|
MAX_CHAT_SCROLLBACK_BYTES, MAX_CHAT_SCROLLBACK_CHUNKS, MODEL_RESPONSE_NOT_FINALIZED_MESSAGE,
|
||||||
};
|
};
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto,
|
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.
|
/// end. This is the body of the spawned pump thread, run inline.
|
||||||
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
||||||
let mut terminal_visible = false;
|
let mut terminal_visible = false;
|
||||||
let mut streamed_text = String::new();
|
|
||||||
for event in events {
|
for event in events {
|
||||||
// Mirror the real pump: heartbeats map to no chunk and are skipped.
|
// Mirror the real pump: heartbeats map to no chunk and are skipped.
|
||||||
if let Some(chunk) = chunk_from_event(event) {
|
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 { .. }) {
|
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
||||||
terminal_visible = true;
|
terminal_visible = true;
|
||||||
}
|
}
|
||||||
@ -247,12 +243,7 @@ fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<Rep
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !terminal_visible {
|
if !terminal_visible {
|
||||||
let chunk = if streamed_text.trim().is_empty() {
|
let _ = bridge.send_output(session, unfinished_turn_chunk());
|
||||||
error_chunk("Le modèle n'a renvoyé aucune réponse.")
|
|
||||||
} else {
|
|
||||||
final_chunk(&streamed_text)
|
|
||||||
};
|
|
||||||
let _ = bridge.send_output(session, chunk);
|
|
||||||
}
|
}
|
||||||
bridge.detach_if(session, gen);
|
bridge.detach_if(session, gen);
|
||||||
}
|
}
|
||||||
@ -313,7 +304,7 @@ fn pump_with_only_a_final_delivers_just_the_final() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pump_with_text_deltas_then_eof_synthesizes_final_not_no_reply() {
|
fn pump_with_text_deltas_then_eof_emits_not_finalized_error() {
|
||||||
let bridge = ChatBridge::new();
|
let bridge = ChatBridge::new();
|
||||||
let session = sid();
|
let session = sid();
|
||||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||||
@ -331,12 +322,20 @@ fn pump_with_text_deltas_then_eof_synthesizes_final_not_no_reply() {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sink.lock().unwrap().as_slice(),
|
sink.lock().unwrap().as_slice(),
|
||||||
&[delta("Hel"), delta("lo"), final_chunk("Hello")]
|
&[
|
||||||
|
delta("Hel"),
|
||||||
|
delta("lo"),
|
||||||
|
error_chunk(MODEL_RESPONSE_NOT_FINALIZED_MESSAGE)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bridge.scrollback(&session),
|
bridge.scrollback(&session),
|
||||||
vec![delta("Hel"), delta("lo"), final_chunk("Hello")],
|
vec![
|
||||||
"reattach replays the synthesized terminal chunk instead of no-reply"
|
delta("Hel"),
|
||||||
|
delta("lo"),
|
||||||
|
error_chunk(MODEL_RESPONSE_NOT_FINALIZED_MESSAGE)
|
||||||
|
],
|
||||||
|
"reattach replays the explicit not-finalized terminal chunk"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -351,7 +350,7 @@ fn pump_with_heartbeat_then_eof_emits_visible_error() {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sink.lock().unwrap().as_slice(),
|
sink.lock().unwrap().as_slice(),
|
||||||
&[error_chunk("Le modèle n'a renvoyé aucune réponse.")]
|
&[error_chunk(MODEL_RESPONSE_NOT_FINALIZED_MESSAGE)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -593,7 +592,7 @@ fn detach_view_keeps_session_scrollback_and_later_reattach_replays_absent_chunks
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detached_turn_with_text_eof_replays_final_without_no_reply_on_reattach() {
|
fn detached_turn_with_text_eof_replays_not_finalized_error_on_reattach() {
|
||||||
let bridge = ChatBridge::new();
|
let bridge = ChatBridge::new();
|
||||||
let session = sid();
|
let session = sid();
|
||||||
let first_sink = Arc::new(Mutex::new(Vec::new()));
|
let first_sink = Arc::new(Mutex::new(Vec::new()));
|
||||||
@ -618,13 +617,11 @@ fn detached_turn_with_text_eof_replays_final_without_no_reply_on_reattach() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bridge.scrollback(&session),
|
bridge.scrollback(&session),
|
||||||
vec![delta("off"), delta("screen"), final_chunk("offscreen")]
|
vec![
|
||||||
);
|
delta("off"),
|
||||||
assert!(
|
delta("screen"),
|
||||||
!bridge
|
error_chunk(MODEL_RESPONSE_NOT_FINALIZED_MESSAGE)
|
||||||
.scrollback(&session)
|
]
|
||||||
.iter()
|
|
||||||
.any(|chunk| matches!(chunk, ReplyChunk::Error { message } if message == "Le modèle n'a renvoyé aucune réponse."))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -183,6 +183,13 @@ fn error_dto_carries_stable_code_and_message() {
|
|||||||
assert!(v.get("message").is_some());
|
assert!(v.get("message").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_dto_preserves_structured_no_final_code() {
|
||||||
|
let dto = ErrorDto::from(AppError::TargetReturnedNoReply("architect".into()));
|
||||||
|
assert_eq!(dto.code, "TARGET_RETURNED_NO_REPLY");
|
||||||
|
assert!(dto.message.contains("structured Final response"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn domain_event_relay_name_is_frozen() {
|
fn domain_event_relay_name_is_frozen() {
|
||||||
assert_eq!(DOMAIN_EVENT, "domain://event");
|
assert_eq!(DOMAIN_EVENT, "domain://event");
|
||||||
|
|||||||
Reference in New Issue
Block a user