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:
2026-09-13 10:03:36 +02:00
parent a1e8a6466f
commit c37638ff24
26 changed files with 1210 additions and 232 deletions

View File

@ -48,26 +48,27 @@ use crate::dto::{
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
AllocateManagedTempRequestDto, AppExitWorkGuardStateDto, AssignSkillRequestDto,
AttachBackgroundTaskResultDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
BackgroundTaskDto, CellKind, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto, ChatPromptAttachmentDto,
CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto,
ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto,
MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
BackgroundTaskDto, CancelAgentChatResponseDto, CellKind, ChangeAgentProfileDto,
ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
ChatDraftScopeRequestDto, ChatPromptAttachmentDto, CloneOpenCodeProfileFromSeedRequestDto,
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto,
ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto,
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto,
ImportChatAttachmentsResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
ManagedTempGcResponseDto, ManagedTempHandleRequestDto, ManagedTempLeaseDto,
ManagedTempLeaseResponseDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
OpenProjectRestoreStateDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto,
RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto,
@ -2855,18 +2856,29 @@ pub async fn agent_send(
.structured_sessions
.session(&sid)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;
let turn_generation = state
.structured_sessions
.begin_turn(&sid)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;
// (Re-)register the reply channel; bumps the generation so an earlier attach's
// pump (if any) is superseded and stops delivering to its stale channel.
let gen = state.chat_bridge.register(sid, on_reply);
let imported_attachments = import_chat_attachments_for_session(
let imported_attachments = match import_chat_attachments_for_session(
&state,
&sid,
attachment_paths.unwrap_or_default(),
attachments.unwrap_or_default(),
)
.await?;
.await
{
Ok(attachments) => attachments,
Err(error) => {
state.structured_sessions.finish_turn(&sid, turn_generation);
return Err(error);
}
};
let attachment_refs = chat_prompt_attachment_refs(&imported_attachments);
let prompt_for_model =
prompt_with_imported_attachments(&prompt, &imported_attachments, &attachment_refs);
@ -2910,10 +2922,14 @@ pub async fn agent_send(
let tap_bridge = std::sync::Arc::clone(&state.chat_bridge);
let tap_clock = std::sync::Arc::clone(&state.clock);
let tap_sid = sid;
let tap_structured = std::sync::Arc::clone(&state.structured_sessions);
let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle());
let tap_pump = std::thread::spawn(move || {
let mut settler = crate::chat::ProgressSettler::default();
for event in tap_rx {
if !tap_structured.accepts_turn_output(&tap_sid, turn_generation) {
break;
}
if !matches!(event, domain::ports::ReplyEvent::Final { .. }) {
if let Some(guard) = &tap_turn_guard {
guard.mark_alive();
@ -2929,11 +2945,25 @@ pub async fn agent_send(
}
let _ = tap_bridge.send_output_settled(&tap_sid, &mut settler, chunk);
}
if let Some(completed) = settler.finish() {
let _ = tap_bridge.send_output(&tap_sid, completed);
if tap_structured.accepts_turn_output(&tap_sid, turn_generation) {
if let Some(completed) = settler.finish() {
let _ = tap_bridge.send_output(&tap_sid, completed);
}
}
});
if !state
.structured_sessions
.accepts_turn_output(&sid, turn_generation)
{
drop(tap_tx);
let _ = tap_pump.join();
if let Some(guard) = &turn_guard {
guard.finish();
}
return Ok(());
}
// Open the turn stream. A start failure leaves the just-registered channel in
// place (the cell stays attached, ready for a retry) — mirrors the PTY pump,
// which only unregisters on a hard subscribe failure; here the session is
@ -2941,6 +2971,16 @@ pub async fn agent_send(
let stream_result = session.send_with_tap(&prompt_for_model, tap_tx).await;
let _ = tap_pump.join();
let mut turn_guard = turn_guard;
if !state
.structured_sessions
.accepts_turn_output(&sid, turn_generation)
{
if let Some(guard) = &turn_guard {
guard.finish();
}
state.chat_bridge.detach_if(&sid, gen);
return Ok(());
}
let stream = match stream_result {
Ok(stream) => stream,
Err(_error)
@ -2952,7 +2992,10 @@ pub async fn agent_send(
// logical turn. The detached drain below waits for the replacement.
Box::new(std::iter::empty()) as domain::ports::ReplyStream
}
Err(error) => return Err(ErrorDto::from(AppError::from(error))),
Err(error) => {
state.structured_sessions.finish_turn(&sid, turn_generation);
return Err(ErrorDto::from(AppError::from(error)));
}
};
// Drain the blocking reply iterator on a dedicated OS thread (the stream is a
@ -2978,6 +3021,10 @@ pub async fn agent_send(
let mut resume = false;
let mut resume_prompt = None;
for event in current_stream {
if !structured.accepts_turn_output(&sid, turn_generation) {
terminal_visible = true;
break 'logical_turn;
}
let provider_terminal = matches!(
event,
domain::ports::ReplyEvent::Final { .. }
@ -3126,7 +3173,7 @@ pub async fn agent_send(
}
}
}
if !terminal_visible {
if !terminal_visible && structured.accepts_turn_output(&sid, turn_generation) {
let chunk = crate::chat::with_chunk_timestamp(
crate::chat::unfinished_turn_chunk(),
u64::try_from(clock.now_millis()).ok(),
@ -3136,6 +3183,7 @@ pub async fn agent_send(
if let Some(guard) = &turn_guard {
guard.finish();
}
structured.finish_turn(&sid, turn_generation);
bridge.detach_if(&sid, gen);
});
@ -3512,6 +3560,7 @@ pub async fn interrupt_agent(
return session
.cancel_current_turn()
.await
.map(|_| ())
.map_err(|e| ErrorDto::from(AppError::from(e)));
}
}
@ -3524,11 +3573,10 @@ pub async fn interrupt_agent(
.map_err(ErrorDto::from)
}
/// `cancel_agent_chat` — stop a live structured chat session and cascade its
/// running execution children best-effort.
/// `cancel_agent_chat` — cancel only the current request of a live structured chat.
///
/// This is the session-id based twin of [`stop_live_agent`] for a custom chat
/// surface that already owns the structured `sessionId`.
/// The reusable session, conversation bridge and runtime state remain intact;
/// agent-level teardown stays exclusively on [`stop_live_agent`].
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
@ -3537,30 +3585,26 @@ pub async fn interrupt_agent(
pub async fn cancel_agent_chat(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
) -> Result<CancelAgentChatResponseDto, ErrorDto> {
let sid = parse_session_id(&session_id)?;
let (project_id, agent_id, _, _) = state
.structured_sessions
.meta_for_session(&sid)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;
let project = resolve_project(&project_id.to_string(), &state).await?;
let dependencies = state
.orchestrator_service
.active_wait_dependencies(agent_id);
state.stop_turn_watch(project.id, agent_id);
for dependency in dependencies {
state.stop_turn_watch(project.id, dependency);
}
state
.stop_live_agent
.execute(StopLiveAgentInput {
project,
agent_id,
reason: Some("fermeture de la session chat".to_owned()),
})
let output = state
.cancel_agent_chat
.execute(sid)
.await
.map(|_| ())
.map_err(ErrorDto::from)
.map_err(ErrorDto::from)?;
if output.outcome == domain::ports::CancelCurrentTurnOutcome::Cancelled {
let project = resolve_project(&project_id.to_string(), &state).await?;
state
.orchestrator_service
.cancel_agent_turn_execution_children(&project, agent_id)
.await
.map_err(ErrorDto::from)?;
}
Ok(CancelAgentChatResponseDto::from(output))
}
async fn preferred_view_for_agent(