feat(chat): livre la CLI custom de chat agent (#147) et corrige Cancel

Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom,
préférence persistée `preferred_view`, reattach live, composer + pièces
jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt,
cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat).

Corrige le bug bloquant relevé par QA : le bouton Cancel de
CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu
de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la
session contrairement au contrat produit validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 11:59:39 +02:00
parent efbd56a149
commit dcba76b871
33 changed files with 1681 additions and 144 deletions

View File

@ -7,6 +7,7 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::ipc::Channel;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
@ -30,12 +31,12 @@ use application::{
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, TICKET_ATTACHMENT_MAX_BYTES,
};
use backend::stream::OutputSink;
use domain::ports::ModelServerRuntime;
use domain::ports::PtyHandle;
use domain::{PersistedPluginLayoutWindow, PluginId, PluginLayoutType};
use domain::{LayoutNode, PersistedPluginLayoutWindow, PluginId, PluginLayoutType, PreferredView};
use crate::dto::{
model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile,
@ -2156,6 +2157,7 @@ pub async fn change_agent_profile(
pub async fn agent_send(
session_id: String,
prompt: String,
attachment_paths: Option<Vec<String>>,
on_reply: Channel<ReplyChunk>,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
@ -2172,12 +2174,25 @@ pub async fn agent_send(
// pump (if any) is superseded and stops delivering to its stale channel.
let gen = state.chat_bridge.register(sid, on_reply);
let staged_attachments =
stage_chat_attachments(&state, &sid, attachment_paths.unwrap_or_default()).await?;
let prompt_for_model = prompt_with_staged_attachments(&prompt, &staged_attachments);
// Retain the human submit in the same live scrollback as model chunks so
// `reattach_agent_chat` can repaint the whole in-flight conversation.
let _ = state.chat_bridge.send_output(
&sid,
ReplyChunk::UserPrompt {
text: prompt.clone(),
},
);
// 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
// still live, so we keep the attach and surface the error.
let stream = session
.send(&prompt)
.send(&prompt_for_model)
.await
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
@ -2246,6 +2261,112 @@ pub async fn agent_send(
Ok(())
}
async fn stage_chat_attachments(
state: &AppState,
session_id: &domain::SessionId,
paths: Vec<String>,
) -> Result<Vec<PathBuf>, ErrorDto> {
if paths.is_empty() {
return Ok(Vec::new());
}
let (project_id, agent_id, _, _) = state
.structured_sessions
.meta_for_session(session_id)
.ok_or_else(|| {
ErrorDto::from(AppError::NotFound(format!(
"structured session {session_id}"
)))
})?;
let project = state
.project_store
.list_projects()
.await
.map_err(|err| ErrorDto::from(AppError::Store(err.to_string())))?
.into_iter()
.find(|project| project.id == project_id)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("project {project_id}"))))?;
let stage_dir = Path::new(project.root.as_str())
.join(".ideai")
.join("run")
.join(agent_id.to_string())
.join("attachments")
.join(session_id.to_string());
if tokio::fs::metadata(&stage_dir).await.is_ok() {
tokio::fs::remove_dir_all(&stage_dir)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
}
tokio::fs::create_dir_all(&stage_dir)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
let mut staged = Vec::with_capacity(paths.len());
for (index, raw_path) in paths.iter().enumerate() {
let source = PathBuf::from(raw_path);
let meta = tokio::fs::metadata(&source)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
if !meta.is_file() {
return Err(ErrorDto::from(AppError::Invalid(
"chat attachment source must be a file".to_owned(),
)));
}
if meta.len() > TICKET_ATTACHMENT_MAX_BYTES {
return Err(ErrorDto::from(AppError::Invalid(format!(
"chat attachment exceeds {} bytes",
TICKET_ATTACHMENT_MAX_BYTES
))));
}
let filename = source
.file_name()
.and_then(|name| name.to_str())
.filter(|name| valid_chat_attachment_filename(name))
.ok_or_else(|| {
ErrorDto::from(AppError::Invalid(
"invalid chat attachment filename".to_owned(),
))
})?;
let dest = stage_dir.join(format!("{index}-{filename}"));
tokio::fs::copy(&source, &dest)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
staged.push(dest);
}
Ok(staged)
}
fn valid_chat_attachment_filename(filename: &str) -> bool {
let lowered = filename.to_ascii_lowercase();
let blocked = [
"exe", "bat", "cmd", "com", "scr", "msi", "dll", "so", "dylib", "sh", "ps1", "jar", "app",
"deb", "rpm",
];
!lowered.trim().is_empty()
&& !lowered.contains('/')
&& !lowered.contains('\\')
&& lowered != "."
&& lowered != ".."
&& !lowered
.rsplit_once('.')
.is_some_and(|(_, ext)| blocked.contains(&ext))
}
fn prompt_with_staged_attachments(prompt: &str, staged: &[PathBuf]) -> String {
if staged.is_empty() {
return prompt.to_owned();
}
let mut out = String::with_capacity(prompt.len() + staged.len() * 96);
out.push_str(prompt);
out.push_str("\n\nPièces jointes copiées dans le run dir de cette session :\n");
for path in staged {
out.push_str("- ");
out.push_str(&path.to_string_lossy());
out.push('\n');
}
out
}
/// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité
/// (ARCHITECTURE §21.1-4, fenêtre annulable).
///
@ -2351,6 +2472,19 @@ pub async fn interrupt_agent(
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
if preferred_view_for_agent(&state, project.id, agent_id).await == PreferredView::Chat {
if let Some(session) = state
.structured_sessions
.session_for_agent_in_project(project.id, &agent_id)
{
return session
.cancel_current_turn()
.await
.map_err(|e| ErrorDto::from(AppError::from(e)));
}
}
state
.orchestrator_service
.interrupt_agent(&project, agent_id)
@ -2359,6 +2493,64 @@ pub async fn interrupt_agent(
.map_err(ErrorDto::from)
}
/// `cancel_agent_chat` — interrupt the current turn of a live structured chat
/// session without shutting the session down.
///
/// This is the session-id based twin of [`interrupt_agent`] for a custom chat
/// surface that already owns the structured `sessionId`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id, `PROCESS` if a concrete cancel fails).
#[tauri::command]
pub async fn cancel_agent_chat(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&session_id)?;
let session = state
.structured_sessions
.session(&sid)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;
session
.cancel_current_turn()
.await
.map_err(|e| ErrorDto::from(AppError::from(e)))
}
async fn preferred_view_for_agent(
state: &AppState,
project_id: domain::ProjectId,
agent_id: domain::AgentId,
) -> PreferredView {
let Ok(out) = state
.load_layout
.execute(LoadLayoutInput {
project_id,
layout_id: None,
})
.await
else {
return PreferredView::Tui;
};
preferred_view_in_node(&out.layout.root, agent_id).unwrap_or_default()
}
fn preferred_view_in_node(node: &LayoutNode, agent_id: domain::AgentId) -> Option<PreferredView> {
match node {
LayoutNode::Leaf(leaf) if leaf.agent == Some(agent_id) => Some(leaf.preferred_view),
LayoutNode::Leaf(_) | LayoutNode::CustomPluginLayout(_) => None,
LayoutNode::Split(split) => split
.children
.iter()
.find_map(|child| preferred_view_in_node(&child.node, agent_id)),
LayoutNode::Grid(grid) => grid
.cells
.iter()
.find_map(|cell| preferred_view_in_node(&cell.node, agent_id)),
}
}
/// `delegation_delivered` — the frontend write-portal's **ack** (ARCHITECTURE §20.3).
///
/// Called once the cell has physically written a delegation `ticket` into the agent's