fix(domain,application,infrastructure,backend,app-tauri,frontend): Stop demande agent préserve la conversation ouverte — arrêt sans fermeture/clôture du chat, états cohérents après cancel sur les 3 adapters de session, cellule et workstate synchronisés — #343 (QA verte)
This commit is contained in:
142
crates/application/tests/cancel_agent_chat.rs
Normal file
142
crates/application/tests/cancel_agent_chat.rs
Normal file
@ -0,0 +1,142 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
CancelAgentChat, LiveRuntimeState, LiveSessions, StructuredSessions, TerminalSessions,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{AgentSession, AgentSessionError, CancelCurrentTurnOutcome, ReplyStream};
|
||||
use domain::{AgentId, NodeId, ProjectId, SessionId};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct FakeSession {
|
||||
id: SessionId,
|
||||
cancellations: Mutex<VecDeque<CancelCurrentTurnOutcome>>,
|
||||
sends: Mutex<Vec<String>>,
|
||||
shutdowns: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl FakeSession {
|
||||
fn new(id: SessionId, outcomes: impl IntoIterator<Item = CancelCurrentTurnOutcome>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
cancellations: Mutex::new(outcomes.into_iter().collect()),
|
||||
sends: Mutex::new(Vec::new()),
|
||||
shutdowns: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
Some("conversation-kept".to_owned())
|
||||
}
|
||||
|
||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
self.sends.lock().unwrap().push(prompt.to_owned());
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
|
||||
async fn cancel_current_turn(&self) -> Result<CancelCurrentTurnOutcome, AgentSessionError> {
|
||||
Ok(self
|
||||
.cancellations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or(CancelCurrentTurnOutcome::AlreadyCompleted))
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
*self.shutdowns.lock().unwrap() += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn id<T>(value: u128, make: impl FnOnce(Uuid) -> T) -> T {
|
||||
make(Uuid::from_u128(value))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_keeps_session_and_accepts_a_second_prompt_without_shutdown() {
|
||||
let session_id = id(1, SessionId::from_uuid);
|
||||
let project_id = id(2, ProjectId::from_uuid);
|
||||
let agent_id = id(3, AgentId::from_uuid);
|
||||
let node_id = id(4, NodeId::from_uuid);
|
||||
let session = Arc::new(FakeSession::new(
|
||||
session_id,
|
||||
[CancelCurrentTurnOutcome::Cancelled],
|
||||
));
|
||||
let sessions = Arc::new(StructuredSessions::new());
|
||||
sessions.insert_in_project(project_id, session.clone(), agent_id, node_id);
|
||||
let live = LiveSessions::new(Arc::new(TerminalSessions::new()), Arc::clone(&sessions));
|
||||
let cancel = CancelAgentChat::new(Arc::clone(&sessions));
|
||||
let first_generation = sessions.begin_turn(&session_id).unwrap();
|
||||
assert!(sessions.accepts_turn_output(&session_id, first_generation));
|
||||
|
||||
let output = cancel.execute(session_id).await.unwrap();
|
||||
|
||||
assert_eq!(output.outcome, CancelCurrentTurnOutcome::Cancelled);
|
||||
assert!(!sessions.accepts_turn_output(&session_id, first_generation));
|
||||
let retained = sessions.session(&session_id).expect("session is retained");
|
||||
let second_generation = sessions.begin_turn(&session_id).unwrap();
|
||||
assert!(sessions.accepts_turn_output(&session_id, second_generation));
|
||||
sessions.finish_turn(&session_id, first_generation);
|
||||
assert!(
|
||||
sessions.accepts_turn_output(&session_id, second_generation),
|
||||
"an old turn cannot retire or fence its successor"
|
||||
);
|
||||
let _second_reply = retained.send("second prompt").await.unwrap();
|
||||
assert_eq!(*session.sends.lock().unwrap(), ["second prompt"]);
|
||||
assert_eq!(*session.shutdowns.lock().unwrap(), 0);
|
||||
assert_eq!(
|
||||
live.runtime_status_for(project_id, &agent_id)
|
||||
.unwrap()
|
||||
.state,
|
||||
LiveRuntimeState::Running
|
||||
);
|
||||
assert_eq!(
|
||||
sessions.meta_for_session(&session_id).unwrap().3.as_deref(),
|
||||
Some("conversation-kept")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn double_cancel_is_typed_and_missing_session_remains_distinct() {
|
||||
let session_id = id(10, SessionId::from_uuid);
|
||||
let session = Arc::new(FakeSession::new(
|
||||
session_id,
|
||||
[
|
||||
CancelCurrentTurnOutcome::Cancelled,
|
||||
CancelCurrentTurnOutcome::AlreadyCancelled,
|
||||
],
|
||||
));
|
||||
let sessions = Arc::new(StructuredSessions::new());
|
||||
sessions.insert_in_project(
|
||||
id(11, ProjectId::from_uuid),
|
||||
session,
|
||||
id(12, AgentId::from_uuid),
|
||||
id(13, NodeId::from_uuid),
|
||||
);
|
||||
let cancel = CancelAgentChat::new(Arc::clone(&sessions));
|
||||
sessions.begin_turn(&session_id).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cancel.execute(session_id).await.unwrap().outcome,
|
||||
CancelCurrentTurnOutcome::Cancelled
|
||||
);
|
||||
assert_eq!(
|
||||
cancel.execute(session_id).await.unwrap().outcome,
|
||||
CancelCurrentTurnOutcome::AlreadyCancelled
|
||||
);
|
||||
let error = cancel
|
||||
.execute(id(99, SessionId::from_uuid))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code(), "NOT_FOUND");
|
||||
assert!(sessions.session(&session_id).is_some());
|
||||
}
|
||||
@ -44,10 +44,10 @@ use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn,
|
||||
LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService,
|
||||
SessionLimitService, SpawnBackgroundCommand, TerminalSessions, UpdateAgentContext,
|
||||
UpdateLiveState,
|
||||
CancelAgentChat, CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean,
|
||||
HarvestMemoryFromTurn, LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider,
|
||||
OrchestratorService, SessionLimitService, SpawnBackgroundCommand, TerminalSessions,
|
||||
UpdateAgentContext, UpdateLiveState,
|
||||
};
|
||||
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
|
||||
use domain::ports::LiveStateStore;
|
||||
@ -1428,7 +1428,11 @@ async fn stop_agent_closes_structured_runtime_and_is_idempotent() {
|
||||
fx.pty.kills().is_empty(),
|
||||
"structured stop must not kill PTY"
|
||||
);
|
||||
assert_eq!(*cancel_called.lock().unwrap(), 0);
|
||||
assert_eq!(
|
||||
*cancel_called.lock().unwrap(),
|
||||
1,
|
||||
"structured hard stop must cancel the live turn before shutdown"
|
||||
);
|
||||
assert_eq!(
|
||||
*shutdown_called.lock().unwrap(),
|
||||
1,
|
||||
@ -1453,6 +1457,46 @@ async fn stop_agent_closes_structured_runtime_and_is_idempotent() {
|
||||
assert_eq!(*shutdown_called.lock().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_chat_keeps_structured_runtime_and_execution_admission_open() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
let contexts = FakeContexts::with_agent(&agent, "# persona");
|
||||
let fx = ask_fixture(contexts);
|
||||
let cancel_called = Arc::new(Mutex::new(0_u32));
|
||||
let shutdown_called = Arc::new(Mutex::new(0_u32));
|
||||
let session_id = sid(9343);
|
||||
let session = Arc::new(CompletionSession {
|
||||
id: session_id,
|
||||
agent: agent.id,
|
||||
completions: fx.mailbox.completions(),
|
||||
cancel_called: Some(Arc::clone(&cancel_called)),
|
||||
shutdown_called: Some(Arc::clone(&shutdown_called)),
|
||||
}) as Arc<dyn AgentSession>;
|
||||
fx.structured
|
||||
.insert_in_project(project_id(), session, agent.id, nid(9343));
|
||||
fx.structured.begin_turn(&session_id).unwrap();
|
||||
|
||||
let output = CancelAgentChat::new(Arc::clone(&fx.structured))
|
||||
.execute(session_id)
|
||||
.await
|
||||
.expect("turn cancel succeeds");
|
||||
fx.service
|
||||
.cancel_agent_turn_execution_children(&project(), agent.id)
|
||||
.await
|
||||
.expect("child cancellation succeeds");
|
||||
|
||||
assert_eq!(
|
||||
output.outcome,
|
||||
domain::ports::CancelCurrentTurnOutcome::Cancelled
|
||||
);
|
||||
assert_eq!(*cancel_called.lock().unwrap(), 1);
|
||||
assert_eq!(*shutdown_called.lock().unwrap(), 0);
|
||||
assert!(fx.structured.session(&session_id).is_some());
|
||||
fx.service
|
||||
.ensure_agent_execution_active(&project(), agent.id)
|
||||
.expect("turn cancellation must not close #320 admission");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_from_unrelated_agent_is_rejected() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
@ -2117,11 +2161,13 @@ impl AgentSession for CompletionSession {
|
||||
)),
|
||||
}
|
||||
}
|
||||
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
|
||||
async fn cancel_current_turn(
|
||||
&self,
|
||||
) -> Result<domain::ports::CancelCurrentTurnOutcome, AgentSessionError> {
|
||||
if let Some(cancel_called) = &self.cancel_called {
|
||||
*cancel_called.lock().unwrap() += 1;
|
||||
}
|
||||
Ok(())
|
||||
Ok(domain::ports::CancelCurrentTurnOutcome::Cancelled)
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
if let Some(shutdown_called) = &self.shutdown_called {
|
||||
@ -4999,8 +5045,10 @@ impl AgentSession for RestartingStructuredSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
async fn cancel_current_turn(
|
||||
&self,
|
||||
) -> Result<domain::ports::CancelCurrentTurnOutcome, AgentSessionError> {
|
||||
Ok(domain::ports::CancelCurrentTurnOutcome::AlreadyCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -117,11 +117,17 @@ 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> {
|
||||
async fn cancel_current_turn(
|
||||
&self,
|
||||
) -> Result<domain::ports::CancelCurrentTurnOutcome, AgentSessionError> {
|
||||
self.cancel_called.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
Ok(domain::ports::CancelCurrentTurnOutcome::Cancelled)
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
assert!(
|
||||
self.cancel_called.load(Ordering::SeqCst),
|
||||
"shutdown must be preceded by cancel_current_turn"
|
||||
);
|
||||
self.shutdown_called.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
@ -530,7 +536,7 @@ async fn stop_structured_shuts_down_and_removes_session() {
|
||||
assert_eq!(out.stopped[0].session_id, sid(2));
|
||||
assert_eq!(out.stopped[0].kind, LiveSessionKind::Structured);
|
||||
assert_eq!(out.stopped[0].state, LiveRuntimeState::Stopped);
|
||||
assert!(!cancel_called.load(Ordering::SeqCst));
|
||||
assert!(cancel_called.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
shutdown_called.load(Ordering::SeqCst),
|
||||
"session.shutdown() must be called"
|
||||
@ -567,8 +573,8 @@ async fn stop_structured_runs_execution_cascade_before_shutdown() {
|
||||
assert_eq!(out.stopped[0].session_id, sid(2));
|
||||
assert_eq!(*cascade.calls.lock().unwrap(), vec![(project().id, a)]);
|
||||
assert!(
|
||||
!cancel_called.load(Ordering::SeqCst),
|
||||
"session.cancel_current_turn() is not the runtime stop primitive"
|
||||
cancel_called.load(Ordering::SeqCst),
|
||||
"hard stop must cancel the current turn before shutdown"
|
||||
);
|
||||
assert!(
|
||||
shutdown_called.load(Ordering::SeqCst),
|
||||
|
||||
Reference in New Issue
Block a user