feat(session-limits): LS7 — câblage backend app-tauri (taps niveaux 1&2 + reprise annulable)
Branche le SessionLimitService dans l'application Tauri et expose la surface de reprise/annulation au front. - application/agent/lifecycle.rs : LaunchAgentOutput.profile exposé (None sur réattache/idempotent, Some sur lancement effectif). - application/terminal/registry.rs : StructuredSessions::meta_for_session() (lookup agent/node par SessionId pour le tap niveau 1). - app-tauri/state.rs : ResumeContext(s), AppAgentResumer (impl du port AgentResumer au-dessus de LaunchAgent), instanciation + câblage du SessionLimitService (TokioScheduler + drain des réveils) dans AppState::build. - app-tauri/commands.rs : taps niveau 1 (agent_send) et niveau 2 (launch_agent, parser regex confiné), alimentation de resume_contexts, commande cancel_resume. - app-tauri/lib.rs : enregistrement de cancel_resume dans le handler. - app-tauri/Cargo.toml : dépendance async-trait. Tests : session_limit_wiring.rs (2 tests de composition) + meta_for_session dans structured_registry_d1.rs ; fixtures dto_agents/dto_chat ajustées (profile: None). Tout vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
98
crates/app-tauri/tests/session_limit_wiring.rs
Normal file
98
crates/app-tauri/tests/session_limit_wiring.rs
Normal file
@ -0,0 +1,98 @@
|
||||
//! 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");
|
||||
}
|
||||
Reference in New Issue
Block a user