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>
189 lines
8.1 KiB
Rust
189 lines
8.1 KiB
Rust
//! Politique de **readiness** (« fin-de-tour ») model-agnostique (chantier
|
|
//! readiness/heartbeat, lot 1).
|
|
//!
|
|
//! Objet **pur** (aucune I/O, aucune dépendance externe) qui classe un signal
|
|
//! observable d'un tour d'agent en un [`ReadinessSignal`] normalisé. Le but : que
|
|
//! l'application puisse décider de marquer un agent `Idle`
|
|
//! ([`crate::input::InputMediator::mark_idle`]) sur un **signal déterministe**
|
|
//! (`Final` du flux structuré) plutôt que de dépendre uniquement d'un `idea_reply`
|
|
//! explicite ou d'un sniff littéral de prompt PTY.
|
|
//!
|
|
//! # Hiérarchie des signaux de fin-de-tour (rappel cadrage)
|
|
//!
|
|
//! 1. **Signal n°1 — fin de tour structurée** : [`ReplyEvent::Final`] émis par
|
|
//! l'adapter (Claude `type:"result"`, Codex `agent_message`/`item.completed`).
|
|
//! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`].
|
|
//! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP
|
|
//! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1.
|
|
//! 3. **Signal n°3 — fin de tour transcript** : l'agent a écrit une fin de tour dans
|
|
//! son transcript on-disk (Claude `turn_duration`), observée par le
|
|
//! [`crate::ports::TurnWatcher`] qui appelle [`crate::input::InputMediator::turn_ended`].
|
|
//! Backstop no-reply : remplace l'ancien sniff littéral du sigil de prompt PTY
|
|
//! (`prompt_ready_pattern`), supprimé car inopérant par tour.
|
|
//!
|
|
//! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la
|
|
//! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) :
|
|
//! elles existent dans le vocabulaire mais ne sont **pas** produites par
|
|
//! [`ReadinessPolicy::classify`] dans ce lot.
|
|
|
|
use crate::ports::ReplyEvent;
|
|
|
|
/// Signal de readiness normalisé, model-agnostique, qu'une [`ReadinessPolicy`]
|
|
/// déduit d'un événement observable du tour.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ReadinessSignal {
|
|
/// Le tour est **déterministiquement terminé** : l'agent a rendu son `Final`.
|
|
/// C'est le signal n°1, model-agnostique — il doit réveiller le `pending` et
|
|
/// marquer l'agent `Idle`.
|
|
TurnEnded,
|
|
/// Un `idea_reply` explicite a été observé (signal n°2). N'est **pas** produit
|
|
/// par [`ReadinessPolicy::classify`] (qui ne voit que des [`ReplyEvent`]) : il
|
|
/// est porté par le chemin de délégation, présent ici pour compléter le
|
|
/// vocabulaire et le rendre explicite.
|
|
ExplicitReply,
|
|
/// Une fin de tour transcript (repli n°3, [`crate::ports::TurnWatcher`]) est
|
|
/// apparue. Non produit par `classify` (qui ne voit que des [`ReplyEvent`]) :
|
|
/// présent pour nommer le signal de fin-de-tour observé hors flux structuré.
|
|
PromptReady,
|
|
/// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place
|
|
/// réservée au **lot 2** — non produit dans ce lot.
|
|
Stalled,
|
|
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
|
|
/// produit dans ce lot.
|
|
TimedOut,
|
|
/// L'agent est en **limite de session/débit** (ARCHITECTURE §21) : le moteur l'a
|
|
/// suspendu pour cause de quota. Déduit de [`ReplyEvent::RateLimited`] par
|
|
/// [`ReadinessPolicy::classify`]. **Pas terminal** au sens « fin de tour » : il ne
|
|
/// marque pas le tour comme `TurnEnded` (l'agent n'a pas rendu son `Final`) — il
|
|
/// signale un 3ᵉ axe d'état orthogonal (« limité jusqu'à T »), que l'application
|
|
/// traduit en planification de reprise.
|
|
///
|
|
/// `Copy` préservé : `Option<i64>` est `Copy`, donc l'enum le reste (cf. §21.3).
|
|
RateLimited {
|
|
/// Instant de reset en **époche-millisecondes** (cf.
|
|
/// [`ReplyEvent::RateLimited::resets_at_ms`]). `None` ⇒ pas d'heure connue
|
|
/// (filet humain, pas de reprise auto).
|
|
resets_at_ms: Option<i64>,
|
|
},
|
|
}
|
|
|
|
/// Politique **pure** de classification d'un événement de tour en
|
|
/// [`ReadinessSignal`]. Sans état, sans I/O : un simple `match` sur le contrat de
|
|
/// port universel [`ReplyEvent`], pour que la décision « ce tour est-il fini ? »
|
|
/// vive dans le **domaine** et reste testable sans process ni réseau.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub struct ReadinessPolicy;
|
|
|
|
impl ReadinessPolicy {
|
|
/// Classe un [`ReplyEvent`] en signal de readiness.
|
|
///
|
|
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
|
|
/// événement terminal, il signe la fin de tour déterministe.
|
|
/// - [`ReplyEvent::RateLimited`] ⇒
|
|
/// `Some(`[`ReadinessSignal::RateLimited`]`{..})` : **non terminal** (l'agent
|
|
/// n'a pas rendu son `Final`), mais porteur d'un signal exploitable par
|
|
/// l'application (planifier la reprise à `resets_at_ms`). L'heure de reset est
|
|
/// propagée telle quelle.
|
|
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::Announcement`] /
|
|
/// [`ReplyEvent::Error`] /
|
|
/// [`ReplyEvent::ToolActivity`] /
|
|
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
|
|
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
|
|
#[must_use]
|
|
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
|
match event {
|
|
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
|
ReplyEvent::RateLimited { resets_at_ms } => Some(ReadinessSignal::RateLimited {
|
|
resets_at_ms: *resets_at_ms,
|
|
}),
|
|
ReplyEvent::TextDelta { .. }
|
|
| ReplyEvent::Announcement { .. }
|
|
| ReplyEvent::Error { .. }
|
|
| ReplyEvent::ToolActivity { .. }
|
|
| ReplyEvent::Heartbeat => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn final_classifies_as_turn_ended() {
|
|
let ev = ReplyEvent::Final {
|
|
content: "fini".to_owned(),
|
|
};
|
|
assert_eq!(
|
|
ReadinessPolicy::classify(&ev),
|
|
Some(ReadinessSignal::TurnEnded)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deltas_activities_and_heartbeats_are_non_terminal() {
|
|
assert_eq!(
|
|
ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
ReadinessPolicy::classify(&ReplyEvent::ToolActivity {
|
|
label: "lit".into()
|
|
}),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
ReadinessPolicy::classify(&ReplyEvent::Heartbeat),
|
|
None,
|
|
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
|
|
);
|
|
}
|
|
|
|
// -- §21 : RateLimited ⇒ ReadinessSignal::RateLimited (heure propagée) -------
|
|
|
|
#[test]
|
|
fn rate_limited_with_known_reset_classifies_and_propagates_time() {
|
|
let ev = ReplyEvent::RateLimited {
|
|
resets_at_ms: Some(1_700_000_000_000),
|
|
};
|
|
assert_eq!(
|
|
ReadinessPolicy::classify(&ev),
|
|
Some(ReadinessSignal::RateLimited {
|
|
resets_at_ms: Some(1_700_000_000_000),
|
|
}),
|
|
"l'heure de reset doit être propagée telle quelle"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rate_limited_without_reset_classifies_with_none() {
|
|
let ev = ReplyEvent::RateLimited { resets_at_ms: None };
|
|
assert_eq!(
|
|
ReadinessPolicy::classify(&ev),
|
|
Some(ReadinessSignal::RateLimited { resets_at_ms: None }),
|
|
"heure inconnue ⇒ None propagé (filet humain en aval)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rate_limited_is_not_classified_as_turn_ended() {
|
|
// Non terminal : un RateLimited ne marque JAMAIS le tour comme fini.
|
|
let signal = ReadinessPolicy::classify(&ReplyEvent::RateLimited {
|
|
resets_at_ms: Some(42),
|
|
});
|
|
assert_ne!(signal, Some(ReadinessSignal::TurnEnded));
|
|
}
|
|
|
|
#[test]
|
|
fn readiness_signal_is_copy() {
|
|
// Test de compilation : `ReadinessSignal` reste `Copy` (cf. §21.3). Si le
|
|
// type cessait d'être `Copy`, l'usage après le `copy` ci-dessous (move
|
|
// implicite) ne compilerait plus.
|
|
let sig = ReadinessSignal::RateLimited {
|
|
resets_at_ms: Some(7),
|
|
};
|
|
let copy = sig; // copie implicite, pas un move
|
|
assert_eq!(sig, copy);
|
|
}
|
|
}
|