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:
2026-06-17 07:56:08 +02:00
parent ea94e756e2
commit 9df592389c
10 changed files with 406 additions and 3 deletions

View File

@ -309,6 +309,7 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
profile: None,
};
let dto = TerminalSessionDto::from(out);
@ -354,6 +355,7 @@ fn launch_agent_output_propagates_assigned_conversation_id() {
assigned_conversation_id: Some("conv-xyz".to_owned()),
engine_session_id: None,
structured: None,
profile: None,
};
let dto = TerminalSessionDto::from(out);

View File

@ -157,6 +157,7 @@ fn launch_output_with_structured_descriptor_derives_chat_cell_kind() {
assigned_conversation_id: None,
engine_session_id: None,
structured: Some(descriptor),
profile: None,
};
let dto = TerminalSessionDto::from(out);
assert_eq!(dto.cell_kind, CellKind::Chat);
@ -173,6 +174,7 @@ fn launch_output_without_structured_descriptor_derives_pty_cell_kind() {
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
profile: None,
};
let dto = TerminalSessionDto::from(out);
assert_eq!(dto.cell_kind, CellKind::Pty, "no descriptor ⇒ pty");
@ -207,6 +209,7 @@ fn pty_launch_output_serialises_cellkind_pty_without_breaking_existing_keys() {
assigned_conversation_id: None,
engine_session_id: None,
structured: None,
profile: None,
};
let v = serde_json::to_value(TerminalSessionDto::from(out)).unwrap();
assert_eq!(v["sessionId"], sid.to_string());

View 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 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::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");
}