diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index dfcdecd..daa7977 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -178,8 +178,10 @@ impl ChatBridge { /// /// Returns `None` for [`ReplyEvent::Heartbeat`]: a heartbeat is a non-terminal /// liveness proof (readiness/heartbeat lot 1) with **no chat content**, so it maps -/// to no wire chunk — the pump simply skips it. Every content-bearing event still -/// maps to exactly one chunk. +/// to no wire chunk — the pump simply skips it. Likewise [`ReplyEvent::RateLimited`] +/// is non-terminal and content-free (ports §21.2-T4): the UI badge comes from the +/// `DomainEvent::AgentRateLimited` bus, not the chat stream, so it maps to `None` +/// too. Every content-bearing event still maps to exactly one chunk. #[must_use] pub fn chunk_from_event(event: ReplyEvent) -> Option { match event { @@ -187,5 +189,6 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option { ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }), ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }), ReplyEvent::Heartbeat => None, + ReplyEvent::RateLimited { .. } => None, } } diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 26c512d..1dbce94 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -205,6 +205,52 @@ pub enum DomainEventDto { /// Whether the in-process ONNX capability is compiled in. vector_onnx_enabled: bool, }, + /// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency, + /// model-agnostic badge: carries only the neutral "limited, maybe resets at T" + /// fact. The frontend badges "limité jusqu'à HH:MM". + #[serde(rename_all = "camelCase")] + AgentRateLimited { + /// Agent id (UUID string). + agent_id: String, + /// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume). + #[serde(skip_serializing_if = "Option::is_none")] + resets_at_ms: Option, + }, + /// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The + /// frontend shows the countdown + the "Annuler la reprise" button (cancellable + /// window). + #[serde(rename_all = "camelCase")] + AgentResumeScheduled { + /// Agent id (UUID string). + agent_id: String, + /// Wake-up deadline in **epoch-milliseconds**. + fire_at_ms: i64, + }, + /// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user + /// clicked "Annuler la reprise". The frontend removes the countdown. + #[serde(rename_all = "camelCase")] + AgentResumeCancelled { + /// Agent id (UUID string). + agent_id: String, + }, + /// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the + /// wake-up fired (or immediate resume). The frontend clears the "limited" state. + #[serde(rename_all = "camelCase")] + AgentResumed { + /// Agent id (UUID string). + agent_id: String, + }, + /// **Human net (level 3)**: a session limit is **suspected** without any reliable + /// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front + /// to **prompt the user** ("limit detected but time unknown — resume at?"). + #[serde(rename_all = "camelCase")] + AgentRateLimitSuspected { + /// Agent id (UUID string). + agent_id: String, + /// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`. + #[serde(skip_serializing_if = "Option::is_none")] + resets_at_ms: Option, + }, /// Raw PTY output (normally routed to a per-session channel, not here). #[serde(rename_all = "camelCase")] PtyOutput { @@ -377,6 +423,33 @@ impl From<&DomainEvent> for DomainEventDto { vector_http_enabled: *vector_http_enabled, vector_onnx_enabled: *vector_onnx_enabled, }, + DomainEvent::AgentRateLimited { + agent_id, + resets_at_ms, + } => Self::AgentRateLimited { + agent_id: agent_id.to_string(), + resets_at_ms: *resets_at_ms, + }, + DomainEvent::AgentResumeScheduled { + agent_id, + fire_at_ms, + } => Self::AgentResumeScheduled { + agent_id: agent_id.to_string(), + fire_at_ms: *fire_at_ms, + }, + DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled { + agent_id: agent_id.to_string(), + }, + DomainEvent::AgentResumed { agent_id } => Self::AgentResumed { + agent_id: agent_id.to_string(), + }, + DomainEvent::AgentRateLimitSuspected { + agent_id, + resets_at_ms, + } => Self::AgentRateLimitSuspected { + agent_id: agent_id.to_string(), + resets_at_ms: *resets_at_ms, + }, DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput { session_id: session_id.to_string(), bytes: bytes.clone(), @@ -450,4 +523,19 @@ mod tests { assert_eq!(json["type"], "agentLivenessChanged"); assert_eq!(json["liveness"], "alive"); } + + /// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent + /// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec + /// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ». + #[test] + fn rate_limited_relays_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited { + agent_id: agent(11), + resets_at_ms: Some(1_700_000_000_000), + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentRateLimited"); + assert_eq!(json["agentId"], agent(11).to_string()); + assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64); + } }