fix(session): final de secours fabriqué depuis les deltas quand Claude ferme sans ligne result — #239

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.
This commit is contained in:
2026-08-22 16:38:47 +02:00
parent ce03471caf
commit ade1908c76
2 changed files with 106 additions and 9 deletions

View File

@ -172,6 +172,28 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
events
}
fn append_text_fallback_final(events: &mut Vec<ReplyEvent>) {
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<String> {
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"
);
}
}