feat(backend): modèle unifié streaming/progress/events provider-agnostic + pont app-tauri — foundation #156 (QA verte)

This commit is contained in:
2026-08-06 13:13:17 +02:00
parent 40fa551f32
commit 918116664c
14 changed files with 630 additions and 59 deletions

View File

@ -596,6 +596,121 @@ pub enum WakeReason {
},
}
/// Origine canonique d'un événement de progression intermédiaire.
///
/// La séparation est volontairement métier-neutre : un adapter peut relayer un fait
/// natif du provider, tandis qu'IdeA peut produire sa propre observabilité locale
/// (MCP/outils orchestrés) sans prétendre que le provider l'a streamée.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplyProgressSource {
/// Événement observé dans le flux/documentation du provider.
ProviderNative,
/// Événement produit par IdeA autour d'un outil, d'un appel MCP ou d'une
/// orchestration locale.
IdeaLocal,
}
/// Taxonomie stable des progress/events non terminaux projetables avant `Final`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplyProgressKind {
/// Début/fin/étape de tour sans contenu assistant exploitable.
Turn,
/// Message/progression textuelle intermédiaire.
Message,
/// Appel ou activité d'outil du provider.
Tool,
/// Appel MCP ou outil orchestré par IdeA.
Mcp,
/// Événement conservé sans spécialisation plus fine.
Other,
}
/// Étape canonique d'un événement intermédiaire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplyProgressStage {
/// Démarrage d'une activité.
Started,
/// Fragment ou mise à jour en cours.
Delta,
/// Fin d'une activité.
Completed,
/// Information ponctuelle.
Info,
}
/// Événement de progression canonique, provider-agnostic et best-effort.
///
/// Aucun champ n'a autorité sur la fin de tour : seul [`ReplyEvent::Final`] reste
/// terminal. `provider` / `native_event` sont des métadonnées d'observabilité, pas
/// un contrat métier à parser côté application.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplyProgress {
/// Origine de l'événement (provider natif ou observabilité locale IdeA).
pub source: ReplyProgressSource,
/// Famille canonique.
pub kind: ReplyProgressKind,
/// Étape canonique.
pub stage: ReplyProgressStage,
/// Libellé court affichable.
pub label: String,
/// Texte/extrait optionnel affichable, déjà nettoyé par l'adapter.
pub text: Option<String>,
/// Identité provider optionnelle (`codex`, `claude`, `openai-compatible`, ...).
pub provider: Option<String>,
/// Nom du type natif observé, quand disponible (`turn.started`, `tool_use`, ...).
pub native_event: Option<String>,
/// Nom de l'outil/MCP, quand applicable.
pub tool_name: Option<String>,
}
impl ReplyProgress {
/// Construit un événement de progression avec les champs obligatoires.
#[must_use]
pub fn new(
source: ReplyProgressSource,
kind: ReplyProgressKind,
stage: ReplyProgressStage,
label: impl Into<String>,
) -> Self {
Self {
source,
kind,
stage,
label: label.into(),
text: None,
provider: None,
native_event: None,
tool_name: None,
}
}
/// Ajoute un extrait textuel.
#[must_use]
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = Some(text.into());
self
}
/// Ajoute les métadonnées provider natives.
#[must_use]
pub fn with_provider_event(
mut self,
provider: impl Into<String>,
native_event: impl Into<String>,
) -> Self {
self.provider = Some(provider.into());
self.native_event = Some(native_event.into());
self
}
/// Ajoute le nom d'outil/MCP associé.
#[must_use]
pub fn with_tool_name(mut self, tool_name: impl Into<String>) -> Self {
self.tool_name = Some(tool_name.into());
self
}
}
/// Un événement incrémental d'un tour de réponse d'un agent IA (ARCHITECTURE §17.1).
///
/// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté
@ -604,6 +719,13 @@ pub enum WakeReason {
/// frontière domaine.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplyEvent {
/// Progress/event intermédiaire canonique et non terminal, utilisable pour
/// relayer les événements provider natifs et l'observabilité locale IdeA avant
/// le `Final`.
Progress {
/// Événement de progression normalisé.
progress: ReplyProgress,
},
/// Un fragment de texte assistant (rendu incrémental côté UI chat).
TextDelta {
/// Le fragment de texte.

View File

@ -84,7 +84,8 @@ impl ReadinessPolicy {
/// 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::Progress`] /
/// [`ReplyEvent::TextDelta`] / [`ReplyEvent::Announcement`] /
/// [`ReplyEvent::Error`] /
/// [`ReplyEvent::ToolActivity`] /
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
@ -96,7 +97,8 @@ impl ReadinessPolicy {
ReplyEvent::RateLimited { resets_at_ms } => Some(ReadinessSignal::RateLimited {
resets_at_ms: *resets_at_ms,
}),
ReplyEvent::TextDelta { .. }
ReplyEvent::Progress { .. }
| ReplyEvent::TextDelta { .. }
| ReplyEvent::Announcement { .. }
| ReplyEvent::Error { .. }
| ReplyEvent::ToolActivity { .. }
@ -108,6 +110,7 @@ impl ReadinessPolicy {
#[cfg(test)]
mod tests {
use super::*;
use crate::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage};
#[test]
fn final_classifies_as_turn_ended() {
@ -122,6 +125,18 @@ mod tests {
#[test]
fn deltas_activities_and_heartbeats_are_non_terminal() {
assert_eq!(
ReadinessPolicy::classify(&ReplyEvent::Progress {
progress: ReplyProgress::new(
ReplyProgressSource::ProviderNative,
ReplyProgressKind::Turn,
ReplyProgressStage::Started,
"tour démarré",
)
}),
None,
"un progress provider est projetable avant Final mais ne termine JAMAIS le tour"
);
assert_eq!(
ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }),
None