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:
@ -133,6 +133,7 @@ impl ChatBridge {
|
||||
|
||||
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||
match chunk {
|
||||
ReplyChunk::UserPrompt { text } => text.len(),
|
||||
ReplyChunk::TextDelta { text } => text.len(),
|
||||
ReplyChunk::ToolActivity { label } => label.len(),
|
||||
ReplyChunk::Final { content } => content.len(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -344,6 +344,7 @@ pub fn run() {
|
||||
commands::cancel_resume,
|
||||
commands::set_resume_at,
|
||||
commands::interrupt_agent,
|
||||
commands::cancel_agent_chat,
|
||||
commands::delegation_delivered,
|
||||
commands::set_front_attached,
|
||||
commands::reattach_agent_chat,
|
||||
|
||||
@ -59,6 +59,7 @@ fn error_chunk(s: &str) -> ReplyChunk {
|
||||
|
||||
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||
match chunk {
|
||||
ReplyChunk::UserPrompt { text } => text.len(),
|
||||
ReplyChunk::TextDelta { text } => text.len(),
|
||||
ReplyChunk::ToolActivity { label } => label.len(),
|
||||
ReplyChunk::Final { content } => content.len(),
|
||||
|
||||
@ -9,7 +9,7 @@ use app_tauri_lib::dto::{
|
||||
};
|
||||
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
|
||||
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
||||
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
|
||||
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId, PreferredView};
|
||||
|
||||
use application::{AppError, HealthInput};
|
||||
use domain::events::DomainEvent;
|
||||
@ -311,6 +311,7 @@ fn layout_dto_serialises_camelcase_tagged_tree() {
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
preferred_view: domain::PreferredView::Tui,
|
||||
});
|
||||
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||
layout_id: domain::LayoutId::new_random(),
|
||||
@ -367,6 +368,26 @@ fn layout_operation_dto_set_session_accepts_null_session() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_operation_dto_set_cell_preferred_view_deserialises() {
|
||||
let json = json!({
|
||||
"type": "setCellPreferredView",
|
||||
"target": nid(1).to_string(),
|
||||
"preferredView": "chat",
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
|
||||
match dto.into_operation().unwrap() {
|
||||
LayoutOperation::SetCellPreferredView {
|
||||
target,
|
||||
preferred_view,
|
||||
} => {
|
||||
assert_eq!(target, nid(1));
|
||||
assert_eq!(preferred_view, PreferredView::Chat);
|
||||
}
|
||||
other => panic!("expected SetCellPreferredView, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_operation_dto_resize_carries_weights() {
|
||||
let json = json!({
|
||||
@ -413,6 +434,7 @@ fn layout_dto_round_trips_a_split_tree_shape() {
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
preferred_view: domain::PreferredView::Tui,
|
||||
})
|
||||
.split(
|
||||
nid(1),
|
||||
@ -424,6 +446,7 @@ fn layout_dto_round_trips_a_split_tree_shape() {
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
preferred_view: domain::PreferredView::Tui,
|
||||
},
|
||||
nid(9),
|
||||
)
|
||||
|
||||
@ -18,6 +18,15 @@ use uuid::Uuid;
|
||||
// ReplyChunk — tagged camelCase, exact wire shape + round-trip (zone 5/6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn reply_chunk_user_prompt_serialises_exact_camel_case() {
|
||||
let v = serde_json::to_value(ReplyChunk::UserPrompt {
|
||||
text: "run tests".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_chunk_text_delta_serialises_exact_camel_case() {
|
||||
let v = serde_json::to_value(ReplyChunk::TextDelta {
|
||||
@ -60,6 +69,7 @@ fn reply_chunk_error_serialises_exact_camel_case() {
|
||||
#[test]
|
||||
fn reply_chunk_round_trips_through_json_for_every_variant() {
|
||||
for chunk in [
|
||||
ReplyChunk::UserPrompt { text: "u".into() },
|
||||
ReplyChunk::TextDelta { text: "x".into() },
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "runs".into(),
|
||||
@ -81,8 +91,8 @@ fn reply_chunk_round_trips_through_json_for_every_variant() {
|
||||
fn reply_chunk_deserialises_from_camel_case_wire_payload() {
|
||||
// The shape the frontend (or a mock gateway) emits.
|
||||
let back: ReplyChunk =
|
||||
serde_json::from_value(json!({ "kind": "textDelta", "text": "hi" })).unwrap();
|
||||
assert_eq!(back, ReplyChunk::TextDelta { text: "hi".into() });
|
||||
serde_json::from_value(json!({ "kind": "userPrompt", "text": "hi" })).unwrap();
|
||||
assert_eq!(back, ReplyChunk::UserPrompt { text: "hi".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -102,6 +112,7 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
||||
let dto = ReattachChatDto {
|
||||
session_id: "sess-1".into(),
|
||||
scrollback: vec![
|
||||
ReplyChunk::UserPrompt { text: "Hi".into() },
|
||||
ReplyChunk::TextDelta { text: "Hi".into() },
|
||||
ReplyChunk::Final {
|
||||
content: "Hi".into(),
|
||||
@ -114,6 +125,7 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
||||
json!({
|
||||
"sessionId": "sess-1",
|
||||
"scrollback": [
|
||||
{ "kind": "userPrompt", "text": "Hi" },
|
||||
{ "kind": "textDelta", "text": "Hi" },
|
||||
{ "kind": "final", "content": "Hi" },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user