fix(custom-cli): le bouton Stop annule le tour courant sans fermer la session et cascade l'annulation aux agents délégués — stop_agent suit l'arbre des dépendances actives (cancel_current_turn + préemption runtime + enfants d'exécution), TurnCancel tue le process du tour en cours, autorisation requester/sous-arbre sur idea_stop_agent, fixtures DTO runtime_state/node_id alignées — #257 (QA verte)

This commit is contained in:
2026-08-28 22:57:32 +02:00
parent 9505a3305d
commit 1aabc3d702
12 changed files with 534 additions and 110 deletions

View File

@ -98,10 +98,11 @@ impl PtyPort for FakePty {
}
}
/// A fake structured [`AgentSession`] recording whether `shutdown` was called.
/// A fake structured [`AgentSession`] recording stop/cancel calls.
struct FakeSession {
id: SessionId,
shutdown_called: Arc<AtomicBool>,
cancel_called: Arc<AtomicBool>,
}
#[async_trait]
@ -115,6 +116,10 @@ impl AgentSession for FakeSession {
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
Ok(Box::new(std::iter::empty()))
}
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
self.cancel_called.store(true, Ordering::SeqCst);
Ok(())
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
self.shutdown_called.store(true, Ordering::SeqCst);
Ok(())
@ -187,18 +192,20 @@ fn insert_structured(
session_id: SessionId,
agent_id: AgentId,
node_id: NodeId,
) -> Arc<AtomicBool> {
let flag = Arc::new(AtomicBool::new(false));
) -> (Arc<AtomicBool>, Arc<AtomicBool>) {
let shutdown_called = Arc::new(AtomicBool::new(false));
let cancel_called = Arc::new(AtomicBool::new(false));
sessions.insert_in_project(
project().id,
Arc::new(FakeSession {
id: session_id,
shutdown_called: Arc::clone(&flag),
shutdown_called: Arc::clone(&shutdown_called),
cancel_called: Arc::clone(&cancel_called),
}),
agent_id,
node_id,
);
flag
(shutdown_called, cancel_called)
}
// ---------------------------------------------------------------------------
@ -239,7 +246,7 @@ fn attach_pty_rebinds_node_without_changing_session() {
fn attach_structured_rebinds_node() {
let f = fixture();
let a = aid(10);
let _flag = insert_structured(&f.structured, sid(2), a, nid(100));
let _flags = insert_structured(&f.structured, sid(2), a, nid(100));
let out = f
.attach
@ -339,10 +346,10 @@ async fn stop_pty_kills_and_removes_session() {
}
#[tokio::test]
async fn stop_structured_shuts_down_and_removes_session() {
async fn stop_structured_cancels_turn_and_keeps_session() {
let f = fixture();
let a = aid(10);
let flag = insert_structured(&f.structured, sid(2), a, nid(100));
let (shutdown_called, cancel_called) = insert_structured(&f.structured, sid(2), a, nid(100));
let out = f
.stop
@ -356,12 +363,19 @@ async fn stop_structured_shuts_down_and_removes_session() {
assert_eq!(out.session_id, sid(2));
assert_eq!(out.kind, LiveSessionKind::Structured);
assert_eq!(out.runtime_state, LiveRuntimeState::Stopped);
assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called");
assert!(f.structured.is_empty(), "live session removed");
assert!(
cancel_called.load(Ordering::SeqCst),
"session.cancel_current_turn() was called"
);
assert!(
!shutdown_called.load(Ordering::SeqCst),
"session.shutdown() must not be called"
);
assert!(!f.structured.is_empty(), "live session is preserved");
assert_eq!(
f.structured
.session_id_for_agent_in_project(project().id, &a),
None
Some(sid(2))
);
// No PTY was touched.
assert!(f.pty_port.kills().is_empty());
@ -371,7 +385,7 @@ async fn stop_structured_shuts_down_and_removes_session() {
async fn stop_structured_runs_execution_cascade_best_effort() {
let f = fixture();
let a = aid(10);
let flag = insert_structured(&f.structured, sid(2), a, nid(100));
let (shutdown_called, cancel_called) = insert_structured(&f.structured, sid(2), a, nid(100));
let cascade = Arc::new(FakeCascade::default());
let stop = f
.stop
@ -387,8 +401,15 @@ async fn stop_structured_runs_execution_cascade_best_effort() {
assert_eq!(out.session_id, sid(2));
assert_eq!(*cascade.calls.lock().unwrap(), vec![(project().id, a)]);
assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called");
assert!(f.structured.is_empty(), "live session removed");
assert!(
cancel_called.load(Ordering::SeqCst),
"session.cancel_current_turn() was called"
);
assert!(
!shutdown_called.load(Ordering::SeqCst),
"session.shutdown() must not be called"
);
assert!(!f.structured.is_empty(), "live session is preserved");
}
#[tokio::test]