Files
IdeA/crates/app-tauri/tests/session_limit_wiring.rs
Blomios 9430c65050 fix(session-limit): brancher le handle de limite sur le chemin direct (#30)
Le handle de limite de session ne se déclenchait pas quand un agent
directement adressé (dont Main) touchait sa propre limite : le ReplyEvent
::RateLimited du chemin structuré direct n'était pas relayé au service de
limite.

On tap désormais ReplyEvent::RateLimited dans le registre terminal vers
SessionLimitService::on_rate_limited, qui émet AgentRateLimited puis
AgentResumeScheduled et arme la reprise auto annulable, exactement comme le
chemin délégué.

Couverture QA (sortie réelle) : structured_registry_d1 12 passed,
session_limit_wiring 6 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:19:11 +02:00

335 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Integration test for the **session-limit** wiring in the composition root
//! (ARCHITECTURE §21, LS7).
//!
//! Proves that [`AppState`] actually *constructs and wires* the
//! `SessionLimitService` (the gap LS7 closes: the LS1LS4 domain/application
//! machinery existed but was never instantiated at runtime). The detect→plan→
//! resume→cancel logic itself is covered by the application/infrastructure unit
//! tests (LS3/LS4); here we assert the **composition** the commands rely on:
//! the service is present, `on_rate_limited` arms a cancellable resume that emits
//! the domain events on the real bus, and `cancel_resume` is a clean no-op for an
//! unknown agent (never a panic).
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use app_tauri_lib::state::AppState;
use async_trait::async_trait;
use domain::events::DomainEvent;
use domain::ids::{NodeId, SessionId};
use domain::ports::{AgentSession, AgentSessionError, IdGenerator, ReplyStream};
use domain::AgentId;
use infrastructure::UuidGenerator;
fn temp_path(tag: &str) -> PathBuf {
let ids = UuidGenerator::new();
std::env::temp_dir().join(format!("idea-sl-test-{tag}-{}", ids.new_uuid()))
}
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn node(n: u128) -> NodeId {
NodeId::from_uuid(uuid::Uuid::from_u128(n))
}
fn session(n: u128) -> SessionId {
SessionId::from_uuid(uuid::Uuid::from_u128(n))
}
struct LiveStructuredSession {
id: SessionId,
conversation_id: Option<String>,
}
#[async_trait]
impl AgentSession for LiveStructuredSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
self.conversation_id.clone()
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
Ok(Box::new(std::iter::empty()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
/// `cancel_resume` on an agent with no armed resume returns `false` (and never
/// panics): the service is wired and behaves as a clean no-op.
#[tokio::test]
async fn cancel_resume_is_a_clean_noop_for_unknown_agent() {
let state = AppState::build(temp_path("appdata"));
assert!(
!state.session_limit_service.cancel_resume(agent(1)),
"no armed resume ⇒ cancel returns false"
);
}
/// A structured rate-limit signal with a **future** reset arms a cancellable
/// resume: the wired service publishes `AgentRateLimited` then
/// `AgentResumeScheduled` on the **real** event bus, and `cancel_resume` then
/// publishes `AgentResumeCancelled` and returns `true`.
#[tokio::test]
async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() {
let state = AppState::build(temp_path("appdata"));
let mut rx = state.event_bus.raw_receiver();
// Reset far in the future so the scheduler does NOT fire before we cancel.
let resets_at_ms = i64::MAX;
state
.session_limit_service
.on_rate_limited(agent(7), node(70), None, Some(resets_at_ms));
// The two arming events, in order, on the bus.
let mut saw_rate_limited = false;
let mut saw_scheduled = false;
for _ in 0..32 {
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(7) => {
saw_rate_limited = true;
}
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
saw_scheduled = true;
break;
}
Ok(Ok(_)) => continue,
_ => break,
}
}
assert!(saw_rate_limited, "AgentRateLimited relayed on the bus");
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus");
// The window is cancellable: cancel succeeds and emits AgentResumeCancelled.
assert!(
state.session_limit_service.cancel_resume(agent(7)),
"an armed resume is cancellable"
);
let mut saw_cancelled = false;
for _ in 0..32 {
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
Ok(Ok(DomainEvent::AgentResumeCancelled { agent_id })) if agent_id == agent(7) => {
saw_cancelled = true;
break;
}
Ok(Ok(_)) => continue,
_ => break,
}
}
assert!(saw_cancelled, "AgentResumeCancelled relayed on the bus");
}
/// B1/B2 direct : le tap structuré doit résoudre `agent_id` + `node_id` +
/// `conversation_id` depuis le registre vivant avant d'appeler `on_rate_limited`.
/// Avec un reset connu et une conversation moteur connue, le service publie
/// `AgentRateLimited` puis `AgentResumeScheduled`.
#[tokio::test]
async fn direct_structured_rate_limit_uses_live_meta_and_arms_resume() {
let state = AppState::build(temp_path("appdata"));
let mut rx = state.event_bus.raw_receiver();
let sid = session(707);
state.structured_sessions.insert(
Arc::new(LiveStructuredSession {
id: sid,
conversation_id: Some("engine-main".to_owned()),
}),
agent(7),
node(70),
);
let (agent_id, node_id, conversation_id) = state
.structured_sessions
.meta_for_session(&sid)
.expect("live structured session metadata");
assert_eq!(agent_id, agent(7));
assert_eq!(node_id, node(70));
assert_eq!(conversation_id.as_deref(), Some("engine-main"));
let resets_at_ms = i64::MAX;
state.session_limit_service.on_rate_limited(
agent_id,
node_id,
conversation_id,
Some(resets_at_ms),
);
let mut saw_rate_limited = false;
let mut saw_scheduled = false;
for _ in 0..32 {
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(7) => {
saw_rate_limited = true;
}
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
saw_scheduled = true;
break;
}
Ok(Ok(_)) => continue,
_ => break,
}
}
assert!(
saw_rate_limited,
"AgentRateLimited emitted for direct agent"
);
assert!(
saw_scheduled,
"AgentResumeScheduled emitted for direct agent"
);
assert!(state.session_limit_service.cancel_resume(agent(7)));
}
/// B1/B2 direct : si le moteur donne une heure de reset mais que la session vivante
/// ne porte pas de `conversation_id`, la commande doit normaliser vers le fallback
/// humain plutôt que d'armer une reprise impossible à reprendre.
#[tokio::test]
async fn direct_structured_rate_limit_without_conversation_uses_human_fallback() {
let state = AppState::build(temp_path("appdata"));
let mut rx = state.event_bus.raw_receiver();
let sid = session(708);
state.structured_sessions.insert(
Arc::new(LiveStructuredSession {
id: sid,
conversation_id: None,
}),
agent(7),
node(70),
);
let (agent_id, node_id, conversation_id) = state
.structured_sessions
.meta_for_session(&sid)
.expect("live structured session metadata");
assert_eq!(conversation_id, None);
let resets_at_ms = Some(i64::MAX);
let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) {
(Some(conversation_id), resets_at_ms) => (Some(conversation_id), resets_at_ms),
(None, None) => (None, None),
(None, Some(_)) => (None, None),
};
state
.session_limit_service
.on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms);
let mut saw_rate_limited_none = false;
let mut saw_suspected = false;
let mut saw_scheduled = false;
for _ in 0..32 {
match tokio::time::timeout(Duration::from_millis(50), rx.recv()).await {
Ok(Ok(DomainEvent::AgentRateLimited {
agent_id,
resets_at_ms: None,
})) if agent_id == agent(7) => saw_rate_limited_none = true,
Ok(Ok(DomainEvent::AgentRateLimitSuspected { agent_id, .. }))
if agent_id == agent(7) =>
{
saw_suspected = true;
}
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
saw_scheduled = true;
}
Ok(Ok(_)) => continue,
_ => break,
}
}
assert!(
saw_rate_limited_none,
"fallback exposes AgentRateLimited(None)"
);
assert!(saw_suspected, "fallback exposes human input state");
assert!(
!saw_scheduled,
"no AgentResumeScheduled without engine conversation_id"
);
assert!(!state.session_limit_service.cancel_resume(agent(7)));
}
/// `set_resume_at` (filet humain niveau 3) résout l'agent→cellule dans les registries
/// des sessions vivantes ; **sans cellule vivante**, la résolution échoue ⇒ la commande
/// renvoie `NOT_FOUND` sans armer quoi que ce soit (pas d'armement orphelin).
///
/// La commande `#[tauri::command]` exige une `State<AppState>` non constructible hors
/// runtime Tauri ; on couvre donc ici sa **précondition exacte** : sur un `AppState`
/// neuf (aucune session vivante), aucune registry ne résout l'agent — c'est précisément
/// le `None` qui produit le `NOT_FOUND` dans `set_resume_at` (cf. commands.rs:1420-1428).
#[tokio::test]
async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() {
let state = AppState::build(temp_path("appdata"));
let unknown = agent(99);
assert!(
state.structured_sessions.node_for_agent(&unknown).is_none(),
"aucune session structurée vivante ⇒ pas de cellule"
);
assert!(
state.terminal_sessions.node_for_agent(&unknown).is_none(),
"aucune session terminal vivante ⇒ pas de cellule"
);
// Conjonction = la branche `ok_or_else(NotFound)` de set_resume_at est prise :
// la saisie d'heure n'a pas de cible ⇒ aucun armement orphelin.
let resolved = state
.structured_sessions
.node_for_agent(&unknown)
.or_else(|| state.terminal_sessions.node_for_agent(&unknown));
assert!(
resolved.is_none(),
"set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)"
);
}
/// Parité runtime du filet humain : `confirm_human_resume` (la délégation de
/// `set_resume_at`) arme une reprise annulable sur le **vrai** bus — `AgentRateLimited`
/// puis `AgentResumeScheduled` — exactement comme la branche auto `on_rate_limited`.
#[tokio::test]
async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() {
let state = AppState::build(temp_path("appdata"));
let mut rx = state.event_bus.raw_receiver();
// Reset très loin dans le futur : le scheduler ne tire pas avant l'annulation.
let resets_at_ms = i64::MAX;
state
.session_limit_service
.confirm_human_resume(agent(8), node(80), None, resets_at_ms);
let mut saw_rate_limited = false;
let mut saw_scheduled = false;
for _ in 0..32 {
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(8) => {
saw_rate_limited = true;
}
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(8) => {
saw_scheduled = true;
break;
}
Ok(Ok(_)) => continue,
_ => break,
}
}
assert!(
saw_rate_limited,
"AgentRateLimited relayed on the bus (humain)"
);
assert!(
saw_scheduled,
"AgentResumeScheduled relayed on the bus (humain)"
);
// Annulable par la même voie que l'auto.
assert!(
state.session_limit_service.cancel_resume(agent(8)),
"an armed human resume is cancellable"
);
}