fix(chat): reprise robuste au retour d'un layout non conversationnel (composer verrouillé selon l'état réel, replay complet du scrollback, plus de fausse terminaison 'no reply') (#190)
This commit is contained in:
@ -2415,6 +2415,7 @@ 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
|
||||
@ -2445,6 +2446,9 @@ 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;
|
||||
}
|
||||
@ -2455,13 +2459,16 @@ pub async fn agent_send(
|
||||
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
||||
}
|
||||
if !terminal_visible {
|
||||
let _ = bridge.send_output_settled(
|
||||
&sid,
|
||||
&mut settler,
|
||||
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 _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
||||
}
|
||||
bridge.detach_if(&sid, gen);
|
||||
});
|
||||
|
||||
@ -197,9 +197,13 @@ 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<ReplyEvent>) {
|
||||
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;
|
||||
}
|
||||
@ -207,10 +211,12 @@ fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<Rep
|
||||
}
|
||||
}
|
||||
if !terminal_visible {
|
||||
let _ = bridge.send_output(
|
||||
session,
|
||||
error_chunk("Le modèle n'a renvoyé aucune réponse."),
|
||||
);
|
||||
let chunk = if streamed_text.trim().is_empty() {
|
||||
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);
|
||||
}
|
||||
@ -270,6 +276,34 @@ fn pump_with_only_a_final_delivers_just_the_final() {
|
||||
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("ok")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pump_with_text_deltas_then_eof_synthesizes_final_not_no_reply() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
|
||||
pump_turn(
|
||||
&bridge,
|
||||
&session,
|
||||
gen,
|
||||
vec![
|
||||
ReplyEvent::TextDelta { text: "Hel".into() },
|
||||
ReplyEvent::TextDelta { text: "lo".into() },
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sink.lock().unwrap().as_slice(),
|
||||
&[delta("Hel"), delta("lo"), final_chunk("Hello")]
|
||||
);
|
||||
assert_eq!(
|
||||
bridge.scrollback(&session),
|
||||
vec![delta("Hel"), delta("lo"), final_chunk("Hello")],
|
||||
"reattach replays the synthesized terminal chunk instead of no-reply"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pump_with_heartbeat_then_eof_emits_visible_error() {
|
||||
let bridge = ChatBridge::new();
|
||||
@ -522,6 +556,42 @@ fn detach_view_keeps_session_scrollback_and_later_reattach_replays_absent_chunks
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_turn_with_text_eof_replays_final_without_no_reply_on_reattach() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let first_sink = Arc::new(Mutex::new(Vec::new()));
|
||||
let gen = bridge.register(session, capturing_channel(Arc::clone(&first_sink)));
|
||||
|
||||
bridge.detach(&session);
|
||||
pump_turn(
|
||||
&bridge,
|
||||
&session,
|
||||
gen,
|
||||
vec![
|
||||
ReplyEvent::TextDelta { text: "off".into() },
|
||||
ReplyEvent::TextDelta {
|
||||
text: "screen".into(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
first_sink.lock().unwrap().is_empty(),
|
||||
"detached view receives no live chunks"
|
||||
);
|
||||
assert_eq!(
|
||||
bridge.scrollback(&session),
|
||||
vec![delta("off"), delta("screen"), final_chunk("offscreen")]
|
||||
);
|
||||
assert!(
|
||||
!bridge
|
||||
.scrollback(&session)
|
||||
.iter()
|
||||
.any(|chunk| matches!(chunk, ReplyChunk::Error { message } if message == "Le modèle n'a renvoyé aucune réponse."))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollback_of_unknown_session_is_empty() {
|
||||
let bridge = ChatBridge::new();
|
||||
|
||||
Reference in New Issue
Block a user