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

@ -30,7 +30,9 @@ use backend::stream::ReplayOutputBridge;
use tauri::ipc::Channel; use tauri::ipc::Channel;
use domain::ids::SessionId; use domain::ids::SessionId;
use domain::ports::ReplyEvent; use domain::ports::{
ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
};
use crate::dto::ReplyChunk; use crate::dto::ReplyChunk;
use crate::stream::TauriChannelSink; use crate::stream::TauriChannelSink;
@ -136,6 +138,13 @@ fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
ReplyChunk::UserPrompt { text } => text.len(), ReplyChunk::UserPrompt { text } => text.len(),
ReplyChunk::TextDelta { text } => text.len(), ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(), ReplyChunk::ToolActivity { label } => label.len(),
ReplyChunk::Progress { progress } => {
progress.label.len()
+ progress.text.as_ref().map_or(0, String::len)
+ progress.provider.as_ref().map_or(0, String::len)
+ progress.native_event.as_ref().map_or(0, String::len)
+ progress.tool_name.as_ref().map_or(0, String::len)
}
ReplyChunk::Final { content } => content.len(), ReplyChunk::Final { content } => content.len(),
ReplyChunk::Error { message } => message.len(), ReplyChunk::Error { message } => message.len(),
} }
@ -154,8 +163,20 @@ fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
#[must_use] #[must_use]
pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> { pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
match event { match event {
ReplyEvent::Progress { progress } => Some(ReplyChunk::Progress {
progress: progress.into(),
}),
ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }), ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }),
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }), ReplyEvent::ToolActivity { label } => Some(ReplyChunk::Progress {
progress: ReplyProgress::new(
ReplyProgressSource::ProviderNative,
ReplyProgressKind::Tool,
ReplyProgressStage::Info,
label.clone(),
)
.with_tool_name(label)
.into(),
}),
ReplyEvent::Error { message } => Some(ReplyChunk::Error { message }), ReplyEvent::Error { message } => Some(ReplyChunk::Error { message }),
ReplyEvent::Final { content } => { ReplyEvent::Final { content } => {
if content.trim().is_empty() { if content.trim().is_empty() {
@ -166,7 +187,16 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
Some(ReplyChunk::Final { content }) Some(ReplyChunk::Final { content })
} }
} }
ReplyEvent::Announcement { .. } => None, ReplyEvent::Announcement { text } => Some(ReplyChunk::Progress {
progress: ReplyProgress::new(
ReplyProgressSource::ProviderNative,
ReplyProgressKind::Message,
ReplyProgressStage::Delta,
"message intermédiaire",
)
.with_text(text)
.into(),
}),
ReplyEvent::Heartbeat => None, ReplyEvent::Heartbeat => None,
ReplyEvent::RateLimited { .. } => None, ReplyEvent::RateLimited { .. } => None,
} }

View File

@ -2195,14 +2195,32 @@ pub async fn agent_send(
}, },
); );
// Open the turn stream with a live progress tap. The returned `ReplyStream`
// remains the authoritative drain to `Final`; the tap is best-effort
// observability for provider-native/local progress produced while `send` is
// still running.
let (tap_tx, tap_rx) = std::sync::mpsc::channel();
let tap_bridge = std::sync::Arc::clone(&state.chat_bridge);
let tap_sid = sid;
let tap_pump = std::thread::spawn(move || {
for event in tap_rx {
let Some(chunk) = crate::chat::chunk_from_event(event) else {
continue;
};
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
continue;
}
let _ = tap_bridge.send_output(&tap_sid, chunk);
}
});
// Open the turn stream. A start failure leaves the just-registered channel in // Open the turn stream. A start failure leaves the just-registered channel in
// place (the cell stays attached, ready for a retry) — mirrors the PTY pump, // place (the cell stays attached, ready for a retry) — mirrors the PTY pump,
// which only unregisters on a hard subscribe failure; here the session is // which only unregisters on a hard subscribe failure; here the session is
// still live, so we keep the attach and surface the error. // still live, so we keep the attach and surface the error.
let stream = session let stream_result = session.send_with_tap(&prompt_for_model, tap_tx).await;
.send(&prompt_for_model) let _ = tap_pump.join();
.await let stream = stream_result.map_err(|e| ErrorDto::from(AppError::from(e)))?;
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
// Drain the blocking reply iterator on a dedicated OS thread (the stream is a // Drain the blocking reply iterator on a dedicated OS thread (the stream is a
// synchronous `Iterator`, exactly like the PTY byte stream). It runs to the // synchronous `Iterator`, exactly like the PTY byte stream). It runs to the

View File

@ -18,7 +18,9 @@ use app_tauri_lib::chat::{
}; };
use app_tauri_lib::dto::ReplyChunk; use app_tauri_lib::dto::ReplyChunk;
use domain::ids::SessionId; use domain::ids::SessionId;
use domain::ports::ReplyEvent; use domain::ports::{
ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
};
use uuid::Uuid; use uuid::Uuid;
/// Builds a `Channel<ReplyChunk>` whose sent chunks are recorded into `sink`. /// Builds a `Channel<ReplyChunk>` whose sent chunks are recorded into `sink`.
@ -62,6 +64,9 @@ fn chunk_bytes(chunk: &ReplyChunk) -> usize {
ReplyChunk::UserPrompt { text } => text.len(), ReplyChunk::UserPrompt { text } => text.len(),
ReplyChunk::TextDelta { text } => text.len(), ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(), ReplyChunk::ToolActivity { label } => label.len(),
ReplyChunk::Progress { progress } => {
progress.label.len() + progress.text.as_ref().map_or(0, String::len)
}
ReplyChunk::Final { content } => content.len(), ReplyChunk::Final { content } => content.len(),
ReplyChunk::Error { message } => message.len(), ReplyChunk::Error { message } => message.len(),
} }
@ -81,13 +86,41 @@ fn chunk_from_event_maps_text_delta() {
#[test] #[test]
fn chunk_from_event_maps_tool_activity() { fn chunk_from_event_maps_tool_activity() {
let Some(ReplyChunk::Progress { progress }) = chunk_from_event(ReplyEvent::ToolActivity {
label: "reads file".into(),
}) else {
panic!("tool activity must map to canonical progress")
};
assert_eq!(progress.label, "reads file");
assert_eq!( assert_eq!(
chunk_from_event(ReplyEvent::ToolActivity { progress.kind,
label: "reads file".into() app_tauri_lib::dto::ReplyProgressKindDto::Tool
}), );
Some(ReplyChunk::ToolActivity { assert_eq!(
label: "reads file".into() progress.source,
}) app_tauri_lib::dto::ReplyProgressSourceDto::ProviderNative
);
}
#[test]
fn chunk_from_event_maps_canonical_progress() {
let event = ReplyEvent::Progress {
progress: ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
ReplyProgressStage::Started,
"idea_ask_agent",
)
.with_tool_name("idea_ask_agent"),
};
let Some(ReplyChunk::Progress { progress }) = chunk_from_event(event) else {
panic!("progress event must map to progress chunk")
};
assert_eq!(progress.label, "idea_ask_agent");
assert_eq!(progress.kind, app_tauri_lib::dto::ReplyProgressKindDto::Mcp);
assert_eq!(
progress.source,
app_tauri_lib::dto::ReplyProgressSourceDto::IdeaLocal
); );
} }
@ -357,7 +390,18 @@ fn scrollback_accumulates_every_routed_chunk_in_order() {
); );
assert_eq!( assert_eq!(
bridge.scrollback(&session), bridge
.scrollback(&session)
.into_iter()
.map(|chunk| match chunk {
ReplyChunk::Progress { progress } => {
ReplyChunk::ToolActivity {
label: progress.label,
}
}
other => other,
})
.collect::<Vec<_>>(),
vec![ vec![
delta("x"), delta("x"),
ReplyChunk::ToolActivity { ReplyChunk::ToolActivity {

View File

@ -8,7 +8,8 @@
use app_tauri_lib::dto::{ use app_tauri_lib::dto::{
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ImportChatAttachmentsRequestDto, CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ImportChatAttachmentsRequestDto,
ImportChatAttachmentsResponseDto, ReattachChatDto, ReplyChunk, TerminalSessionDto, ImportChatAttachmentsResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto,
ReplyProgressKindDto, ReplyProgressSourceDto, ReplyProgressStageDto, TerminalSessionDto,
}; };
use application::{LaunchAgentOutput, StructuredSessionDescriptor}; use application::{LaunchAgentOutput, StructuredSessionDescriptor};
use domain::project::ProjectPath; use domain::project::ProjectPath;
@ -48,6 +49,37 @@ fn reply_chunk_tool_activity_serialises_exact_camel_case() {
assert_eq!(v, json!({ "kind": "toolActivity", "label": "reads file" })); assert_eq!(v, json!({ "kind": "toolActivity", "label": "reads file" }));
} }
#[test]
fn reply_chunk_progress_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::Progress {
progress: ReplyProgressDto {
source: ReplyProgressSourceDto::IdeaLocal,
kind: ReplyProgressKindDto::Mcp,
stage: ReplyProgressStageDto::Started,
label: "idea_ask_agent".into(),
text: Some("vers QA".into()),
provider: None,
native_event: None,
tool_name: Some("idea_ask_agent".into()),
},
})
.unwrap();
assert_eq!(
v,
json!({
"kind": "progress",
"progress": {
"source": "ideaLocal",
"kind": "mcp",
"stage": "started",
"label": "idea_ask_agent",
"text": "vers QA",
"toolName": "idea_ask_agent"
}
})
);
}
#[test] #[test]
fn reply_chunk_final_serialises_exact_camel_case() { fn reply_chunk_final_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::Final { let v = serde_json::to_value(ReplyChunk::Final {
@ -77,6 +109,18 @@ fn reply_chunk_round_trips_through_json_for_every_variant() {
ReplyChunk::ToolActivity { ReplyChunk::ToolActivity {
label: "runs".into(), label: "runs".into(),
}, },
ReplyChunk::Progress {
progress: ReplyProgressDto {
source: ReplyProgressSourceDto::ProviderNative,
kind: ReplyProgressKindDto::Turn,
stage: ReplyProgressStageDto::Started,
label: "tour démarré".into(),
text: None,
provider: Some("codex".into()),
native_event: Some("turn.started".into()),
tool_name: None,
},
},
ReplyChunk::Final { ReplyChunk::Final {
content: "y".into(), content: "y".into(),
}, },

View File

@ -66,16 +66,25 @@ pub struct AnnouncementPublisher {
impl AnnouncementPublisher { impl AnnouncementPublisher {
fn publish_event(&self, event: &ReplyEvent) { fn publish_event(&self, event: &ReplyEvent) {
if let ReplyEvent::Announcement { text } = event { let text = match event {
self.bus.publish(DomainEvent::AgentAnnouncement { ReplyEvent::Announcement { text } => Some(text.clone()),
project_id: self.project_id, ReplyEvent::Progress { progress } => progress
requester: self.requester, .text
target: self.target, .clone()
ticket: self.ticket, .or_else(|| (!progress.label.trim().is_empty()).then(|| progress.label.clone())),
text: text.clone(), _ => None,
at_ms: now_epoch_ms(), };
}); let Some(text) = text else {
} return;
};
self.bus.publish(DomainEvent::AgentAnnouncement {
project_id: self.project_id,
requester: self.requester,
target: self.target,
ticket: self.ticket,
text,
at_ms: now_epoch_ms(),
});
} }
} }
@ -383,9 +392,11 @@ fn drain_stream_to_final(
match event { match event {
ReplyEvent::Final { content } => return Ok(TurnOutcome::Completed(content)), ReplyEvent::Final { content } => return Ok(TurnOutcome::Completed(content)),
ReplyEvent::RateLimited { resets_at_ms } => last_rate_limit = Some(resets_at_ms), ReplyEvent::RateLimited { resets_at_ms } => last_rate_limit = Some(resets_at_ms),
// TextDelta / Announcement / ToolActivity / Error / Heartbeat : non terminaux // Progress / TextDelta / Announcement / ToolActivity / Error / Heartbeat :
// non terminaux
// pour ce drain synchrone ; seul Final est une réponse réussie. // pour ce drain synchrone ; seul Final est une réponse réussie.
ReplyEvent::TextDelta { .. } ReplyEvent::Progress { .. }
| ReplyEvent::TextDelta { .. }
| ReplyEvent::Announcement { .. } | ReplyEvent::Announcement { .. }
| ReplyEvent::Error { .. } | ReplyEvent::Error { .. }
| ReplyEvent::ToolActivity { .. } | ReplyEvent::ToolActivity { .. }

View File

@ -17,6 +17,7 @@ use application::{
ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
}; };
use domain::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage};
use domain::{ use domain::{
AgentBusyState, PageCursor, PageDirection, Project, ProjectId, ProjectSystemPermissions, AgentBusyState, PageCursor, PageDirection, Project, ProjectId, ProjectSystemPermissions,
ResolvedAgentSystemPermissions, SystemPermissionSet, TurnRole, ResolvedAgentSystemPermissions, SystemPermissionSet, TurnRole,
@ -3046,6 +3047,119 @@ impl From<domain::ChatAttachment> for ChatAttachmentDto {
} }
} }
/// DTO source of a canonical non-terminal reply progress event.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ReplyProgressSourceDto {
/// Native provider event.
ProviderNative,
/// Local IdeA observability event.
IdeaLocal,
}
impl From<ReplyProgressSource> for ReplyProgressSourceDto {
fn from(value: ReplyProgressSource) -> Self {
match value {
ReplyProgressSource::ProviderNative => Self::ProviderNative,
ReplyProgressSource::IdeaLocal => Self::IdeaLocal,
}
}
}
/// DTO kind of a canonical non-terminal reply progress event.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ReplyProgressKindDto {
/// Turn lifecycle/progress.
Turn,
/// Assistant message/progress.
Message,
/// Provider tool activity.
Tool,
/// IdeA MCP/tool activity.
Mcp,
/// Unclassified event.
Other,
}
impl From<ReplyProgressKind> for ReplyProgressKindDto {
fn from(value: ReplyProgressKind) -> Self {
match value {
ReplyProgressKind::Turn => Self::Turn,
ReplyProgressKind::Message => Self::Message,
ReplyProgressKind::Tool => Self::Tool,
ReplyProgressKind::Mcp => Self::Mcp,
ReplyProgressKind::Other => Self::Other,
}
}
}
/// DTO stage of a canonical non-terminal reply progress event.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ReplyProgressStageDto {
/// Activity started.
Started,
/// Incremental update.
Delta,
/// Activity completed.
Completed,
/// Informational point event.
Info,
}
impl From<ReplyProgressStage> for ReplyProgressStageDto {
fn from(value: ReplyProgressStage) -> Self {
match value {
ReplyProgressStage::Started => Self::Started,
ReplyProgressStage::Delta => Self::Delta,
ReplyProgressStage::Completed => Self::Completed,
ReplyProgressStage::Info => Self::Info,
}
}
}
/// Canonical, provider-agnostic non-terminal progress/event DTO.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReplyProgressDto {
/// Event source.
pub source: ReplyProgressSourceDto,
/// Canonical event family.
pub kind: ReplyProgressKindDto,
/// Canonical stage.
pub stage: ReplyProgressStageDto,
/// Short display label.
pub label: String,
/// Optional display excerpt.
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
/// Optional provider identity.
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Optional native provider event name.
#[serde(skip_serializing_if = "Option::is_none")]
pub native_event: Option<String>,
/// Optional tool/MCP name.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
}
impl From<ReplyProgress> for ReplyProgressDto {
fn from(value: ReplyProgress) -> Self {
Self {
source: value.source.into(),
kind: value.kind.into(),
stage: value.stage.into(),
label: value.label,
text: value.text,
provider: value.provider,
native_event: value.native_event,
tool_name: value.tool_name,
}
}
}
/// One incremental chunk of a structured agent reply, streamed over the chat /// One incremental chunk of a structured agent reply, streamed over the chat
/// session's adapter-owned channel. The serialised wire twin of a /// session's adapter-owned channel. The serialised wire twin of a
/// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to /// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to
@ -3077,6 +3191,12 @@ pub enum ReplyChunk {
/// The human-readable activity label. /// The human-readable activity label.
label: String, label: String,
}, },
/// Canonical non-terminal progress/event emitted before `Final`.
#[serde(rename_all = "camelCase")]
Progress {
/// Canonical event payload.
progress: ReplyProgressDto,
},
/// The deterministic end-of-turn chunk carrying the aggregated final content. /// The deterministic end-of-turn chunk carrying the aggregated final content.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
Final { Final {

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). /// 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é /// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté
@ -604,6 +719,13 @@ pub enum WakeReason {
/// frontière domaine. /// frontière domaine.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplyEvent { 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). /// Un fragment de texte assistant (rendu incrémental côté UI chat).
TextDelta { TextDelta {
/// Le fragment de texte. /// 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 /// 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 /// l'application (planifier la reprise à `resets_at_ms`). L'heure de reset est
/// propagée telle quelle. /// propagée telle quelle.
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::Announcement`] / /// - [`ReplyEvent::Progress`] /
/// [`ReplyEvent::TextDelta`] / [`ReplyEvent::Announcement`] /
/// [`ReplyEvent::Error`] / /// [`ReplyEvent::Error`] /
/// [`ReplyEvent::ToolActivity`] / /// [`ReplyEvent::ToolActivity`] /
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux /// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
@ -96,7 +97,8 @@ impl ReadinessPolicy {
ReplyEvent::RateLimited { resets_at_ms } => Some(ReadinessSignal::RateLimited { ReplyEvent::RateLimited { resets_at_ms } => Some(ReadinessSignal::RateLimited {
resets_at_ms: *resets_at_ms, resets_at_ms: *resets_at_ms,
}), }),
ReplyEvent::TextDelta { .. } ReplyEvent::Progress { .. }
| ReplyEvent::TextDelta { .. }
| ReplyEvent::Announcement { .. } | ReplyEvent::Announcement { .. }
| ReplyEvent::Error { .. } | ReplyEvent::Error { .. }
| ReplyEvent::ToolActivity { .. } | ReplyEvent::ToolActivity { .. }
@ -108,6 +110,7 @@ impl ReadinessPolicy {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::ports::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage};
#[test] #[test]
fn final_classifies_as_turn_ended() { fn final_classifies_as_turn_ended() {
@ -122,6 +125,18 @@ mod tests {
#[test] #[test]
fn deltas_activities_and_heartbeats_are_non_terminal() { 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!( assert_eq!(
ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }), ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }),
None None

View File

@ -18,7 +18,10 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; use domain::ports::{
AgentSession, AgentSessionError, ReplyEvent, ReplyProgress, ReplyProgressKind,
ReplyProgressSource, ReplyProgressStage, ReplyStream,
};
use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId; use domain::SessionId;
@ -88,7 +91,17 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
let events = match value.get("type").and_then(Value::as_str) { let events = match value.get("type").and_then(Value::as_str) {
// init/handshake : on capte le session_id ET on émet un battement de cœur // init/handshake : on capte le session_id ET on émet un battement de cœur
// (preuve de vivacité non terminale : la CLI a démarré et répond). // (preuve de vivacité non terminale : la CLI a démarré et répond).
Some("system") => vec![ReplyEvent::Heartbeat], Some("system") => vec![
ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Turn,
ReplyProgressStage::Started,
"session initialisée",
"system",
),
},
ReplyEvent::Heartbeat,
],
// Limite de session/débit (ARCHITECTURE §21, niveau 1) : on lit l'heure de // Limite de session/débit (ARCHITECTURE §21, niveau 1) : on lit l'heure de
// reset dans `rate_limit_info` (au lieu de la jeter) et on émet un // reset dans `rate_limit_info` (au lieu de la jeter) et on émet un
// `RateLimited{resets_at_ms}` **non terminal**. Robuste : absence/illisibilité // `RateLimited{resets_at_ms}` **non terminal**. Robuste : absence/illisibilité
@ -140,6 +153,15 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("outil") .unwrap_or("outil")
.to_owned(); .to_owned();
events.push(ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Tool,
ReplyProgressStage::Started,
label.clone(),
"tool_use",
)
.with_tool_name(label.clone()),
});
events.push(ReplyEvent::ToolActivity { label }); events.push(ReplyEvent::ToolActivity { label });
} }
_ => {} _ => {}
@ -148,6 +170,16 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
events events
} }
fn provider_progress(
kind: ReplyProgressKind,
stage: ReplyProgressStage,
label: impl Into<String>,
native_event: impl Into<String>,
) -> ReplyProgress {
ReplyProgress::new(ReplyProgressSource::ProviderNative, kind, stage, label)
.with_provider_event("claude", native_event)
}
/// **Extrait l'heure de reset d'une limite de débit** depuis l'objet /// **Extrait l'heure de reset d'une limite de débit** depuis l'objet
/// `rate_limit_info` d'un `rate_limit_event` Claude, **normalisée en époche-ms** /// `rate_limit_info` d'un `rate_limit_event` Claude, **normalisée en époche-ms**
/// (ARCHITECTURE §21, niveau 1 structuré). Fonction **pure** (aucune I/O, aucun /// (ARCHITECTURE §21, niveau 1 structuré). Fonction **pure** (aucune I/O, aucun

View File

@ -15,7 +15,10 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; use domain::ports::{
AgentSession, AgentSessionError, ReplyEvent, ReplyProgress, ReplyProgressKind,
ReplyProgressSource, ReplyProgressStage, ReplyStream,
};
use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId; use domain::SessionId;
@ -81,7 +84,28 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
// Début/fin de tour côté moteur : pas de contenu, mais preuve de vivacité ⇒ // Début/fin de tour côté moteur : pas de contenu, mais preuve de vivacité ⇒
// battement de cœur non terminal (readiness/heartbeat lot 1). Le `Final` vient // battement de cœur non terminal (readiness/heartbeat lot 1). Le `Final` vient
// toujours de l'`agent_message`, jamais de `turn.completed`. // toujours de l'`agent_message`, jamais de `turn.completed`.
Some("turn.started") | Some("turn.completed") => events.push(ReplyEvent::Heartbeat), Some("turn.started") => {
events.push(ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Turn,
ReplyProgressStage::Started,
"tour démarré",
"turn.started",
),
});
events.push(ReplyEvent::Heartbeat);
}
Some("turn.completed") => {
events.push(ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Turn,
ReplyProgressStage::Completed,
"tour terminé côté provider",
"turn.completed",
),
});
events.push(ReplyEvent::Heartbeat);
}
Some("item.completed") => { Some("item.completed") => {
if let Some(item) = value.get("item") { if let Some(item) = value.get("item") {
match item.get("type").and_then(Value::as_str) { match item.get("type").and_then(Value::as_str) {
@ -99,9 +123,20 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
}); });
} }
// reasoning / command / tout autre item ⇒ activité (label = type). // reasoning / command / tout autre item ⇒ activité (label = type).
Some(kind) => events.push(ReplyEvent::ToolActivity { Some(kind) => {
label: kind.to_owned(), events.push(ReplyEvent::Progress {
}), progress: provider_progress(
ReplyProgressKind::Tool,
ReplyProgressStage::Completed,
kind,
"item.completed",
)
.with_tool_name(kind.to_owned()),
});
events.push(ReplyEvent::ToolActivity {
label: kind.to_owned(),
});
}
None => {} None => {}
} }
} }
@ -116,6 +151,16 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
}) })
} }
fn provider_progress(
kind: ReplyProgressKind,
stage: ReplyProgressStage,
label: impl Into<String>,
native_event: impl Into<String>,
) -> ReplyProgress {
ReplyProgress::new(ReplyProgressSource::ProviderNative, kind, stage, label)
.with_provider_event("codex", native_event)
}
const CODEX_ERROR_FALLBACK: &str = "Codex a renvoyé une erreur."; const CODEX_ERROR_FALLBACK: &str = "Codex a renvoyé une erreur.";
fn non_blank_string(value: Option<&Value>) -> Option<String> { fn non_blank_string(value: Option<&Value>) -> Option<String> {

View File

@ -216,7 +216,8 @@ pub(crate) mod harness {
assert!( assert!(
matches!( matches!(
e, e,
ReplyEvent::TextDelta { .. } ReplyEvent::Progress { .. }
| ReplyEvent::TextDelta { .. }
| ReplyEvent::ToolActivity { .. } | ReplyEvent::ToolActivity { .. }
| ReplyEvent::Announcement { .. } | ReplyEvent::Announcement { .. }
| ReplyEvent::Heartbeat | ReplyEvent::Heartbeat

View File

@ -65,6 +65,13 @@ mod tests {
// -- Helpers ---------------------------------------------------------- // -- Helpers ----------------------------------------------------------
fn without_progress(events: Vec<ReplyEvent>) -> Vec<ReplyEvent> {
events
.into_iter()
.filter(|event| !matches!(event, ReplyEvent::Progress { .. }))
.collect()
}
fn prepared_ctx() -> PreparedContext { fn prepared_ctx() -> PreparedContext {
PreparedContext { PreparedContext {
content: MarkdownDoc::new("# ctx"), content: MarkdownDoc::new("# ctx"),
@ -188,7 +195,7 @@ mod tests {
.expect("parse ok"); .expect("parse ok");
assert_eq!(parsed.session_id.as_deref(), Some("conv-123")); assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
// L'init capte le session_id ET émet un heartbeat (vivacité non terminale, lot 1). // L'init capte le session_id ET émet un heartbeat (vivacité non terminale, lot 1).
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]); assert_eq!(without_progress(parsed.events), vec![ReplyEvent::Heartbeat]);
} }
/// §21 (LS2) : un `rate_limit_event` n'est PLUS un heartbeat — il porte désormais /// §21 (LS2) : un `rate_limit_event` n'est PLUS un heartbeat — il porte désormais
@ -226,7 +233,7 @@ mod tests {
) )
.expect("parse ok"); .expect("parse ok");
assert_eq!( assert_eq!(
tool.events, without_progress(tool.events),
vec![ReplyEvent::ToolActivity { vec![ReplyEvent::ToolActivity {
label: "Read".to_owned() label: "Read".to_owned()
}] }]
@ -245,7 +252,7 @@ mod tests {
) )
.expect("parse ok"); .expect("parse ok");
assert_eq!( assert_eq!(
parsed.events, without_progress(parsed.events),
vec![ vec![
ReplyEvent::TextDelta { ReplyEvent::TextDelta {
text: "un".to_owned() text: "un".to_owned()
@ -308,9 +315,15 @@ mod tests {
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1). // turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok"); let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]); assert_eq!(
without_progress(started.events),
vec![ReplyEvent::Heartbeat]
);
let completed = codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok"); let completed = codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]); assert_eq!(
without_progress(completed.events),
vec![ReplyEvent::Heartbeat]
);
let msg = codex::parse_event( let msg = codex::parse_event(
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#, r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#,
@ -857,7 +870,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(t1.events, vec![ReplyEvent::TextDelta { text: "a".into() }]); assert_eq!(t1.events, vec![ReplyEvent::TextDelta { text: "a".into() }]);
assert_eq!( assert_eq!(
t2.events, without_progress(t2.events),
vec![ReplyEvent::ToolActivity { vec![ReplyEvent::ToolActivity {
label: "Bash".into() label: "Bash".into()
}] }]
@ -876,7 +889,7 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
parsed.events, without_progress(parsed.events),
vec![ vec![
ReplyEvent::TextDelta { text: "un".into() }, ReplyEvent::TextDelta { text: "un".into() },
ReplyEvent::ToolActivity { ReplyEvent::ToolActivity {
@ -897,7 +910,7 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
parsed.events, without_progress(parsed.events),
vec![ReplyEvent::ToolActivity { vec![ReplyEvent::ToolActivity {
label: "outil".into() label: "outil".into()
}] }]
@ -936,7 +949,7 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
r.events, without_progress(r.events),
vec![ReplyEvent::ToolActivity { vec![ReplyEvent::ToolActivity {
label: "reasoning".into() label: "reasoning".into()
}] }]
@ -945,7 +958,7 @@ mod tests {
codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#) codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
c.events, without_progress(c.events),
vec![ReplyEvent::ToolActivity { vec![ReplyEvent::ToolActivity {
label: "command".into() label: "command".into()
}] }]
@ -1138,7 +1151,7 @@ mod tests {
let events: Vec<_> = s.send("x").await.expect("send").collect(); let events: Vec<_> = s.send("x").await.expect("send").collect();
assert_eq!( assert_eq!(
events, without_progress(events.clone()),
vec![ vec![
ReplyEvent::Announcement { ReplyEvent::Announcement {
text: "je regarde".into() text: "je regarde".into()
@ -1169,7 +1182,7 @@ mod tests {
let events: Vec<_> = s.send("x").await.expect("send").collect(); let events: Vec<_> = s.send("x").await.expect("send").collect();
assert_eq!( assert_eq!(
events, without_progress(events.clone()),
vec![ReplyEvent::Final { vec![ReplyEvent::Final {
content: "résultat".into() content: "résultat".into()
}] }]
@ -1237,7 +1250,7 @@ mod tests {
"le tap live publie chaque agent_message, y compris celui qui deviendra Final" "le tap live publie chaque agent_message, y compris celui qui deviendra Final"
); );
assert_eq!( assert_eq!(
events, without_progress(events.clone()),
vec![ReplyEvent::Final { vec![ReplyEvent::Final {
content: "résultat".into() content: "résultat".into()
}], }],
@ -1630,7 +1643,7 @@ mod tests {
); );
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect(); let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
assert_eq!( assert_eq!(
events, without_progress(events.clone()),
vec![ vec![
// L'init `system` émet un heartbeat (vivacité non terminale, lot 1). // L'init `system` émet un heartbeat (vivacité non terminale, lot 1).
ReplyEvent::Heartbeat, ReplyEvent::Heartbeat,
@ -2449,7 +2462,7 @@ mod tests {
); );
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect(); let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
assert_eq!( assert_eq!(
events, without_progress(events.clone()),
vec![ vec![
ReplyEvent::Heartbeat, ReplyEvent::Heartbeat,
ReplyEvent::RateLimited { ReplyEvent::RateLimited {

View File

@ -15,7 +15,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use domain::ports::{ use domain::ports::{
AgentSession, AgentSessionError, ReplyEvent, ReplyStream, ToolInvoker, ToolSpec, AgentSession, AgentSessionError, ReplyEvent, ReplyProgress, ReplyProgressKind,
ReplyProgressSource, ReplyProgressStage, ReplyStream, ToolInvoker, ToolSpec,
}; };
use domain::profile::{HttpChatConfig, StructuredAdapter}; use domain::profile::{HttpChatConfig, StructuredAdapter};
use domain::SessionId; use domain::SessionId;
@ -461,6 +462,13 @@ impl OpenAiCompatibleSession {
})); }));
for call in tool_calls { for call in tool_calls {
let started = local_tool_progress(
ReplyProgressStage::Started,
format!("appel MCP {}", call.name),
&call.name,
);
send_tap(tap, &started);
events.push(started);
let event = ReplyEvent::ToolActivity { let event = ReplyEvent::ToolActivity {
label: call.name.clone(), label: call.name.clone(),
}; };
@ -473,6 +481,13 @@ impl OpenAiCompatibleSession {
.unwrap_or_else(|e| format!("Tool invocation failed: {e}")), .unwrap_or_else(|e| format!("Tool invocation failed: {e}")),
None => "Tool invocation unavailable".to_owned(), None => "Tool invocation unavailable".to_owned(),
}; };
let completed = local_tool_progress(
ReplyProgressStage::Completed,
format!("MCP {} terminé", call.name),
&call.name,
);
send_tap(tap, &completed);
events.push(completed);
self.transcript.lock().expect("mutex sain").push(json!({ self.transcript.lock().expect("mutex sain").push(json!({
"role": "tool", "role": "tool",
"tool_call_id": call.id, "tool_call_id": call.id,
@ -639,6 +654,22 @@ fn send_tap(tap: &Option<std::sync::mpsc::Sender<ReplyEvent>>, event: &ReplyEven
} }
} }
fn local_tool_progress(
stage: ReplyProgressStage,
label: impl Into<String>,
tool_name: &str,
) -> ReplyEvent {
ReplyEvent::Progress {
progress: ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
stage,
label,
)
.with_tool_name(tool_name.to_owned()),
}
}
fn should_retry_without_tools(err: &AgentSessionError, using_tools: bool) -> bool { fn should_retry_without_tools(err: &AgentSessionError, using_tools: bool) -> bool {
using_tools && matches!(err, AgentSessionError::Start(_)) using_tools && matches!(err, AgentSessionError::Start(_))
} }

View File

@ -16,7 +16,10 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::Mutex as AsyncMutex; use tokio::sync::Mutex as AsyncMutex;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; use domain::ports::{
AgentSession, AgentSessionError, ReplyEvent, ReplyProgress, ReplyProgressKind,
ReplyProgressSource, ReplyProgressStage, ReplyStream,
};
use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId; use domain::SessionId;
@ -187,12 +190,37 @@ fn parse_jsonl_turn_scoped(
for record in records { for record in records {
match record.event { match record.event {
ParsedEvent::StepStart => { ParsedEvent::StepStart => {
events.push(ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Turn,
ReplyProgressStage::Started,
"étape démarrée",
"step_start",
),
});
events.push(ReplyEvent::Heartbeat); events.push(ReplyEvent::Heartbeat);
} }
ParsedEvent::StepFinish => { ParsedEvent::StepFinish => {
events.push(ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Turn,
ReplyProgressStage::Completed,
"étape terminée",
"step_finish",
),
});
events.push(ReplyEvent::Heartbeat); events.push(ReplyEvent::Heartbeat);
} }
ParsedEvent::ToolActivity(label) => { ParsedEvent::ToolActivity(label) => {
events.push(ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Tool,
ReplyProgressStage::Started,
label.clone(),
"tool_use",
)
.with_tool_name(label.clone()),
});
events.push(ReplyEvent::ToolActivity { label }); events.push(ReplyEvent::ToolActivity { label });
} }
ParsedEvent::Text(text) => { ParsedEvent::Text(text) => {
@ -222,6 +250,16 @@ fn parse_jsonl_turn_scoped(
Ok(events) Ok(events)
} }
fn provider_progress(
kind: ReplyProgressKind,
stage: ReplyProgressStage,
label: impl Into<String>,
native_event: impl Into<String>,
) -> ReplyProgress {
ReplyProgress::new(ReplyProgressSource::ProviderNative, kind, stage, label)
.with_provider_event("opencode", native_event)
}
fn current_turn_records( fn current_turn_records(
lines: &[String], lines: &[String],
expected_session_id: Option<&str>, expected_session_id: Option<&str>,
@ -539,6 +577,13 @@ mod tests {
use super::*; use super::*;
fn without_progress(events: Vec<ReplyEvent>) -> Vec<ReplyEvent> {
events
.into_iter()
.filter(|event| !matches!(event, ReplyEvent::Progress { .. }))
.collect()
}
#[test] #[test]
fn split_command_prefix_handles_quotes_without_shell() { fn split_command_prefix_handles_quotes_without_shell() {
assert_eq!( assert_eq!(
@ -557,7 +602,7 @@ mod tests {
]) ])
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
events, without_progress(events),
vec![ vec![
ReplyEvent::Heartbeat, ReplyEvent::Heartbeat,
ReplyEvent::TextDelta { ReplyEvent::TextDelta {
@ -624,7 +669,7 @@ mod tests {
let lines: Vec<String> = opencode_script().into_iter().map(str::to_owned).collect(); let lines: Vec<String> = opencode_script().into_iter().map(str::to_owned).collect();
let events = parse_jsonl_turn(&lines).unwrap(); let events = parse_jsonl_turn(&lines).unwrap();
assert_eq!( assert_eq!(
events, without_progress(events),
vec![ vec![
ReplyEvent::Heartbeat, ReplyEvent::Heartbeat,
ReplyEvent::TextDelta { ReplyEvent::TextDelta {
@ -647,7 +692,7 @@ mod tests {
]) ])
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
events, without_progress(events),
vec![ vec![
ReplyEvent::Heartbeat, ReplyEvent::Heartbeat,
ReplyEvent::Error { ReplyEvent::Error {
@ -779,7 +824,7 @@ exit 1
let events = session.send("prompt").await.unwrap().collect::<Vec<_>>(); let events = session.send("prompt").await.unwrap().collect::<Vec<_>>();
assert_eq!( assert_eq!(
events, without_progress(events),
vec![ vec![
ReplyEvent::Heartbeat, ReplyEvent::Heartbeat,
ReplyEvent::TextDelta { ReplyEvent::TextDelta {