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

@ -887,6 +887,46 @@ mod tests {
);
}
/// `item.type == error` ⇒ Error terminal visible, avec message réel.
#[test]
fn codex_error_item_is_error_event_with_real_message() {
let r = codex::parse_event(
r#"{"type":"item.completed","item":{"id":"i0","type":"error","text":"auth failed"}}"#,
)
.unwrap();
assert_eq!(
r.events,
vec![ReplyEvent::Error {
message: "auth failed".into()
}]
);
let nested = codex::parse_event(
r#"{"type":"item.completed","item":{"id":"i0","type":"error","error":{"message":"quota exceeded"}}}"#,
)
.unwrap();
assert_eq!(
nested.events,
vec![ReplyEvent::Error {
message: "quota exceeded".into()
}]
);
}
/// `item.type == error` sans champ exploitable ⇒ fallback stable.
#[test]
fn codex_error_item_without_message_uses_fallback() {
let r =
codex::parse_event(r#"{"type":"item.completed","item":{"id":"i0","type":"error"}}"#)
.unwrap();
assert_eq!(
r.events,
vec![ReplyEvent::Error {
message: "Codex a renvoyé une erreur.".into()
}]
);
}
/// `agent_message` ⇒ Final (porte le texte de réponse).
#[test]
fn codex_agent_message_is_final() {
@ -982,6 +1022,37 @@ mod tests {
);
}
#[tokio::test]
async fn codex_error_item_surfaces_error_without_final() {
let fake = FakeCli::printing(&[
r#"{"type":"thread.started","thread_id":"c"}"#,
r#"{"type":"turn.started"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"error","text":"auth failed"}}"#,
r#"{"type":"turn.completed","usage":{}}"#,
]);
let s = CodexExecSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
Vec::new(),
Vec::new(),
None,
None,
);
let events: Vec<_> = s.send("x").await.expect("send").collect();
assert!(events.iter().any(|event| matches!(
event,
ReplyEvent::Error { message } if message.contains("auth failed")
)));
assert!(
events
.iter()
.all(|event| !matches!(event, ReplyEvent::Final { .. })),
"Codex error must not be fabricated as a successful Final"
);
}
#[tokio::test]
async fn codex_two_agent_messages_preserve_first_as_announcement_and_last_as_final() {
let fake = FakeCli::printing(&[