From ade1908c7609241ee2aa6c1e188d1b7d261e1eae Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 22 Aug 2026 16:38:47 +0200 Subject: [PATCH] =?UTF-8?q?fix(session):=20final=20de=20secours=20fabriqu?= =?UTF-8?q?=C3=A9=20depuis=20les=20deltas=20quand=20Claude=20ferme=20sans?= =?UTF-8?q?=20ligne=20result=20=E2=80=94=20#239?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit append_text_fallback_final accumule les TextDelta et produit un Final uniqne en fin de flux si aucun Final/Error/RateLimited n'a été émis et que le texte est non vide. Un long message utilisateur sur un autre projet (GameTime) ne conclut plus a tort « La réponse du modèle n'a pas pu être finalisée. ». Le test qui pinnait l'absence de Final devient la régression #239. --- crates/infrastructure/src/session/claude.rs | 91 +++++++++++++++++++++ crates/infrastructure/src/session/mod.rs | 24 ++++-- 2 files changed, 106 insertions(+), 9 deletions(-) diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 0220b520..ddca8bba 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -172,6 +172,28 @@ fn assistant_events(value: &Value) -> Vec { events } +fn append_text_fallback_final(events: &mut Vec) { + let mut text = String::new(); + let mut has_final = false; + let mut has_error = false; + let mut has_rate_limit = false; + for event in events.iter() { + match event { + ReplyEvent::TextDelta { text: delta } => text.push_str(delta), + ReplyEvent::Final { .. } => has_final = true, + ReplyEvent::Error { .. } => has_error = true, + ReplyEvent::RateLimited { .. } => has_rate_limit = true, + ReplyEvent::Progress { .. } + | ReplyEvent::ToolActivity { .. } + | ReplyEvent::Heartbeat + | ReplyEvent::Announcement { .. } => {} + } + } + if !has_final && !has_error && !has_rate_limit && !text.trim().is_empty() { + events.push(ReplyEvent::Final { content: text }); + } +} + fn tool_use_preview(block: &Value) -> Option { block .get("input") @@ -424,7 +446,76 @@ impl ClaudeSdkSession { if let Some(id) = captured_id { *self.conversation_id.lock().expect("mutex sain") = Some(id); } + append_text_fallback_final(&mut events); Ok(Box::new(events.into_iter())) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn assistant_line(text: &str) -> String { + serde_json::json!({ + "type": "assistant", + "message": { + "role": "assistant", + "content": [{ "type": "text", "text": text }] + }, + "session_id": "session-1" + }) + .to_string() + } + + #[test] + fn text_only_success_stream_gets_fallback_final() { + let mut events = Vec::new(); + for line in [assistant_line("longue "), assistant_line("réponse")] { + events.extend(parse_event(&line).expect("valid assistant line").events); + } + + append_text_fallback_final(&mut events); + + assert_eq!( + events.last(), + Some(&ReplyEvent::Final { + content: "longue réponse".to_owned() + }), + "assistant text must remain usable when Claude omits a result line" + ); + } + + #[test] + fn fallback_final_does_not_mask_rate_limit_or_error() { + let mut limited = vec![ + ReplyEvent::TextDelta { + text: "partiel".to_owned(), + }, + ReplyEvent::RateLimited { resets_at_ms: None }, + ]; + append_text_fallback_final(&mut limited); + assert!( + limited + .iter() + .all(|event| !matches!(event, ReplyEvent::Final { .. })), + "rate-limited streams must remain non-final" + ); + + let mut failed = vec![ + ReplyEvent::TextDelta { + text: "partiel".to_owned(), + }, + ReplyEvent::Error { + message: "provider error".to_owned(), + }, + ]; + append_text_fallback_final(&mut failed); + assert!( + failed + .iter() + .all(|event| !matches!(event, ReplyEvent::Final { .. })), + "provider errors must not be converted to successful finals" + ); + } +} diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 4c915812..30af9f35 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -1513,11 +1513,10 @@ mod tests { ); } - /// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au - /// niveau de l'adapter — `send()` renvoie Ok avec uniquement des deltas et AUCUN - /// `Final`. Le §17.9 D2 mentionne « flux sans Final ⇒ Io » ; l'adapter actuel ne - /// l'applique pas (c'est `send_blocking` côté application, lot D1, qui transforme - /// l'absence de Final en Timeout). Ce test PINNE le comportement réel observé. + /// Régression #239 : Claude peut fermer proprement après des blocs `assistant` + /// textuels sans ligne `result`. Ces deltas sont une réponse modèle exploitable : + /// l'adapter doit donc fabriquer un `Final` de secours, au lieu de laisser la + /// surface chat conclure à tort « réponse non finalisée ». #[tokio::test] async fn stream_without_final_is_silently_ok_at_adapter_level() { let fake = FakeCli::printing(&[ @@ -1533,11 +1532,18 @@ mod tests { None, ); let events: Vec<_> = s.send("x").await.expect("send ok").collect(); - let finals = events + let finals: Vec<_> = events .iter() - .filter(|e| matches!(e, ReplyEvent::Final { .. })) - .count(); - assert_eq!(finals, 0, "comportement actuel: aucun Final fabriqué"); + .filter_map(|e| match e { + ReplyEvent::Final { content } => Some(content.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + finals, + vec!["a"], + "Claude text-only streams must yield one fallback Final" + ); // L'id de conversation est tout de même capté (init lu). assert_eq!(s.conversation_id().as_deref(), Some("c")); }