Permet à l'humain de confirmer/forcer l'heure de reprise quand le niveau 2 a détecté une limite sans heure exploitable. - application/agent/session_limit.rs : refactor privé arm_scheduled (param resets_at_ms brut) partagé par on_rate_limited + nouvelle confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms) (source Human, réutilise la branche Scheduled, annulable). - app-tauri/commands.rs : commande set_resume_at(agent_id, resets_at_ms) (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante). - app-tauri/lib.rs : set_resume_at enregistrée après cancel_resume. Réutilise les événements existants (AgentRateLimited + AgentResumeScheduled), aucun nouvel événement. Tests : +6 session_limit_service, +2 wiring, verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
6.7 KiB
Rust
168 lines
6.7 KiB
Rust
//! 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 LS1–LS4 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::time::Duration;
|
||
|
||
use app_tauri_lib::state::AppState;
|
||
use domain::events::DomainEvent;
|
||
use domain::ids::NodeId;
|
||
use domain::AgentId;
|
||
use infrastructure::UuidGenerator;
|
||
use domain::ports::IdGenerator;
|
||
|
||
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))
|
||
}
|
||
|
||
/// `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");
|
||
}
|
||
|
||
/// `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"
|
||
);
|
||
}
|