fix(ticket-assistant): rendre visible l'échec « aucune réponse » de l'assistant de ticket (#60)

Introduit un variant terminal `ReplyEvent::Error { message }` (domain/ports)
propagé jusqu'au chat : l'adapter OpenAI-compat récupère `reasoning_content`
quand `content` est vide, et un `Final` vide est converti en `Error` visible
plutôt qu'un tour silencieux qui pend. Le front (`ReplyChunk` + useTicketAssistant)
rend ce message d'erreur dans la cellule.

- domain: variant `ReplyEvent::Error`, bras non terminal (readiness, structured drain)
- infra/session: parse `reasoning_content` (delta/completion), fallback visible
- app-tauri: mapping ReplyEvent::Error -> ReplyChunk::Error, Final vide -> Error
- frontend: ReplyChunk `error`, rendu dans useTicketAssistant (+ test .test.tsx)

NB: commit sur la feature branch, PAS un merge. #60 reste inProgress.
Les 10 tests `cargo test -p infrastructure --lib session` échouent SOUS SANDBOX
uniquement (bind loopback 127.0.0.1:0 interdit -> Os PermissionDenied), pas une
régression : revérification hors-sandbox requise avant tout merge vers develop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:32:59 +02:00
parent ce5aa2811c
commit 2f7f111545
14 changed files with 571 additions and 25 deletions

View File

@ -51,11 +51,18 @@ fn final_chunk(s: &str) -> ReplyChunk {
}
}
fn error_chunk(s: &str) -> ReplyChunk {
ReplyChunk::Error {
message: s.to_owned(),
}
}
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
match chunk {
ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(),
ReplyChunk::Final { content } => content.len(),
ReplyChunk::Error { message } => message.len(),
}
}
@ -83,6 +90,16 @@ fn chunk_from_event_maps_tool_activity() {
);
}
#[test]
fn chunk_from_event_maps_error() {
assert_eq!(
chunk_from_event(ReplyEvent::Error {
message: "auth failed".into()
}),
Some(error_chunk("auth failed"))
);
}
#[test]
fn chunk_from_event_maps_final() {
assert_eq!(
@ -95,6 +112,16 @@ fn chunk_from_event_maps_final() {
);
}
#[test]
fn chunk_from_event_maps_blank_final_to_visible_error() {
assert_eq!(
chunk_from_event(ReplyEvent::Final {
content: " \n\t ".into()
}),
Some(error_chunk("Réponse vide du modèle."))
);
}
#[test]
fn chunk_from_event_drops_heartbeat() {
// A heartbeat is a non-terminal liveness proof with no chat content (lot 1) ⇒ no
@ -110,12 +137,22 @@ fn chunk_from_event_drops_heartbeat() {
/// does (`chunk_from_event` + `send_output`), then `detach_if(gen)` at stream
/// 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;
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 matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
terminal_visible = true;
}
let _ = bridge.send_output(session, chunk);
}
}
if !terminal_visible {
let _ = bridge.send_output(
session,
error_chunk("Le modèle n'a renvoyé aucune réponse."),
);
}
bridge.detach_if(session, gen);
}
@ -174,6 +211,65 @@ fn pump_with_only_a_final_delivers_just_the_final() {
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("ok")]);
}
#[test]
fn pump_with_heartbeat_then_eof_emits_visible_error() {
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::Heartbeat]);
assert_eq!(
sink.lock().unwrap().as_slice(),
&[error_chunk("Le modèle n'a renvoyé aucune réponse.")]
);
}
#[test]
fn pump_with_blank_final_emits_visible_error_without_silent_detach() {
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::Final {
content: String::new(),
}],
);
assert_eq!(
sink.lock().unwrap().as_slice(),
&[error_chunk("Réponse vide du modèle.")]
);
}
#[test]
fn pump_with_existing_error_does_not_emit_generic_error() {
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::Error {
message: "auth failed".into(),
}],
);
assert_eq!(
sink.lock().unwrap().as_slice(),
&[error_chunk("auth failed")]
);
}
// ---------------------------------------------------------------------------
// Generation supersede — the critical invariant (zone 1)
// ---------------------------------------------------------------------------

View File

@ -45,6 +45,18 @@ fn reply_chunk_final_serialises_exact_camel_case() {
assert_eq!(v, json!({ "kind": "final", "content": "done" }));
}
#[test]
fn reply_chunk_error_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::Error {
message: "Réponse vide du modèle.".into(),
})
.unwrap();
assert_eq!(
v,
json!({ "kind": "error", "message": "Réponse vide du modèle." })
);
}
#[test]
fn reply_chunk_round_trips_through_json_for_every_variant() {
for chunk in [
@ -55,6 +67,9 @@ fn reply_chunk_round_trips_through_json_for_every_variant() {
ReplyChunk::Final {
content: "y".into(),
},
ReplyChunk::Error {
message: "empty".into(),
},
] {
let v = serde_json::to_value(&chunk).unwrap();
let back: ReplyChunk = serde_json::from_value(v).unwrap();