Merge feature/ticket60-ticket-assistant-no-reply into develop (#60)
Rendre visible l'échec « aucune réponse » de l'assistant de ticket : stream sans Final ou Final vide → ReplyChunk::Error terminal visible, plus jamais de tour muet. Backend + frontend, tests verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -196,6 +196,7 @@ fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||
ReplyChunk::TextDelta { text } => text.len(),
|
||||
ReplyChunk::ToolActivity { label } => label.len(),
|
||||
ReplyChunk::Final { content } => content.len(),
|
||||
ReplyChunk::Error { message } => message.len(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,7 +215,16 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
||||
match event {
|
||||
ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }),
|
||||
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
|
||||
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
|
||||
ReplyEvent::Error { message } => Some(ReplyChunk::Error { message }),
|
||||
ReplyEvent::Final { content } => {
|
||||
if content.trim().is_empty() {
|
||||
Some(ReplyChunk::Error {
|
||||
message: "Réponse vide du modèle.".to_owned(),
|
||||
})
|
||||
} else {
|
||||
Some(ReplyChunk::Final { content })
|
||||
}
|
||||
}
|
||||
ReplyEvent::Announcement { .. } => None,
|
||||
ReplyEvent::Heartbeat => None,
|
||||
ReplyEvent::RateLimited { .. } => None,
|
||||
|
||||
@ -1659,6 +1659,7 @@ pub async fn agent_send(
|
||||
let service = std::sync::Arc::clone(&state.session_limit_service);
|
||||
let meta = state.structured_sessions.meta_for_session(&sid);
|
||||
std::thread::spawn(move || {
|
||||
let mut terminal_visible = false;
|
||||
for event in stream {
|
||||
// Non-terminal, sans contenu chat : un signal de limite alimente le service
|
||||
// (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
|
||||
@ -1682,12 +1683,23 @@ pub async fn agent_send(
|
||||
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
||||
terminal_visible = true;
|
||||
}
|
||||
// `send_output` always records into the conversation scrollback; the
|
||||
// boolean only reflects live delivery. If the view navigated away
|
||||
// (no channel at this generation) we keep draining so the turn still
|
||||
// completes and the scrollback stays whole for the next re-attach.
|
||||
let _ = bridge.send_output(&sid, chunk);
|
||||
}
|
||||
if !terminal_visible {
|
||||
let _ = bridge.send_output(
|
||||
&sid,
|
||||
ReplyChunk::Error {
|
||||
message: "Le modèle n'a renvoyé aucune réponse.".to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
bridge.detach_if(&sid, gen);
|
||||
});
|
||||
|
||||
|
||||
@ -1750,10 +1750,11 @@ impl From<TerminalSession> for TerminalSessionDto {
|
||||
/// twin of a [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn
|
||||
/// event to one of these and pushes it to the frontend `AgentChatView`.
|
||||
///
|
||||
/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"`), so
|
||||
/// the front branches without positional parsing. `Final` is the **deterministic
|
||||
/// terminal** chunk of a turn — after it the turn is frozen and the stream ends.
|
||||
/// `Deserialize` is derived too so tests (and a mock gateway) can round-trip it.
|
||||
/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"` |
|
||||
/// `"error"`), so the front branches without positional parsing. `Final` is the
|
||||
/// normal deterministic terminal chunk of a turn; `Error` is a visible terminal
|
||||
/// fallback for an otherwise silent/empty turn. `Deserialize` is derived too so
|
||||
/// tests (and a mock gateway) can round-trip it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ReplyChunk {
|
||||
@ -1775,6 +1776,12 @@ pub enum ReplyChunk {
|
||||
/// The aggregated final content of the turn.
|
||||
content: String,
|
||||
},
|
||||
/// A visible terminal fallback when the model stream produced no usable answer.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Error {
|
||||
/// Human-readable explanation to render in the chat cell.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Response DTO for `reattach_agent_chat`: the retained **conversation
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user