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")); } diff --git a/frontend/src/features/tickets/useTicketAssistant.test.tsx b/frontend/src/features/tickets/useTicketAssistant.test.tsx index 04ef025b..4f014363 100644 --- a/frontend/src/features/tickets/useTicketAssistant.test.tsx +++ b/frontend/src/features/tickets/useTicketAssistant.test.tsx @@ -94,6 +94,32 @@ describe("useTicketAssistant — ticket #60 (no-reply terminal)", () => { expect(last.role).toBe("assistant"); expect(last.pending).toBeFalsy(); expect(last.text).toContain("Le modèle n'a renvoyé aucune réponse."); + expect(last.text).toContain("Échec sans contenu"); + }); + + it("preserves streamed partial content when the terminal error says finalization failed", async () => { + const gw = new FakeTicketGateway(); + const { result } = await openAndSend(gw); + + act(() => { + gw.captured!({ kind: "textDelta", text: "Réponse partielle utile." }); + }); + act(() => { + gw.captured!({ + kind: "error", + message: "La réponse du modèle n'a pas pu être finalisée.", + }); + }); + + await waitFor(() => expect(result.current.busy).toBe(false)); + const last = result.current.turns.at(-1)!; + expect(last.role).toBe("assistant"); + expect(last.pending).toBeFalsy(); + expect(last.text).toContain("Réponse partielle utile."); + expect(last.text).toContain("Réponse partielle — fin non reçue."); + expect(last.text).toContain( + "demandez à l'assistant de continuer à partir de la réponse partielle", + ); }); it("a non-empty final renders the answer and clears busy (nominal, no regression)", async () => { diff --git a/frontend/src/features/tickets/useTicketAssistant.ts b/frontend/src/features/tickets/useTicketAssistant.ts index 029cbb97..16c5852a 100644 --- a/frontend/src/features/tickets/useTicketAssistant.ts +++ b/frontend/src/features/tickets/useTicketAssistant.ts @@ -52,6 +52,31 @@ function describe(e: unknown): string { return String(e); } +function assistantFailureText(message: string, partialText: string): string { + const partial = partialText.trim(); + const normalized = message.toLowerCase(); + if (!partial) { + return [ + `Échec sans contenu — ${message}`, + "Reprise: renvoyez le message ou reformulez-le en plusieurs parties.", + ].join("\n"); + } + + const label = + normalized.includes("tronqu") || normalized.includes("truncat") + ? "Réponse tronquée." + : normalized.includes("finalis") || normalized.includes("fin ") + ? "Réponse partielle — fin non reçue." + : "Réponse partielle — échec transitoire possible."; + return [ + partialText, + "", + `Échec — ${label}`, + message, + "Reprise: demandez à l'assistant de continuer à partir de la réponse partielle ci-dessus.", + ].join("\n"); +} + export function useTicketAssistant( projectId: string, issueRef: string, @@ -136,12 +161,11 @@ export function useTicketAssistant( // Best-effort inline activity badge. next[i] = { ...next[i], text: `${next[i].text}\n· ${chunk.label}` }; } else if (chunk.kind === "error") { - // Terminal fallback (ticket #60): the model returned no usable answer. - // Freeze the turn with the readable explanation so the bubble is never - // left empty and pending — the assistant no longer hangs silently. + // Terminal fallback: preserve any streamed content, then classify the + // failure so a partial answer is not hidden by the terminal error. next[i] = { role: "assistant", - text: `⚠️ ${chunk.message}`, + text: assistantFailureText(chunk.message, next[i].text), pending: false, }; } else if (chunk.kind === "final") {