feat(session-limits): LS6 — câblage des événements de limite vers le front

Relaie les 5 nouvelles variantes DomainEvent (AgentRateLimited, ResumeScheduled,
ResumeCancelled, Resumed, RateLimitSuspected) via leurs DTO miroirs et bras From
dans events.rs, et propage ReplyEvent::RateLimited en chunk côté chat.rs pour que
le front soit informé des suspensions/reprises de session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 20:02:29 +02:00
parent 98bfcf4f22
commit ea94e756e2
2 changed files with 93 additions and 2 deletions

View File

@ -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<ReplyChunk> {
match event {
@ -187,5 +189,6 @@ pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
ReplyEvent::Heartbeat => None,
ReplyEvent::RateLimited { .. } => None,
}
}

View File

@ -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<i64>,
},
/// 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<i64>,
},
/// 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);
}
}