From dcba76b87129c41176a146d85859249c58d60118 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 5 Aug 2026 11:59:39 +0200 Subject: [PATCH] feat(chat): livre la CLI custom de chat agent (#147) et corrige Cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .ideai/tickets/147/carnet.md | 53 ++- .ideai/tickets/147/issue.md | 4 +- crates/app-tauri/src/chat.rs | 1 + crates/app-tauri/src/commands.rs | 198 ++++++++- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/tests/chat_bridge.rs | 1 + crates/app-tauri/tests/dto.rs | 25 +- crates/app-tauri/tests/dto_chat.rs | 16 +- crates/application/src/layout/usecases.rs | 13 +- .../application/tests/change_agent_profile.rs | 1 + crates/application/tests/layout_usecases.rs | 48 ++- .../tests/list_resumable_agents.rs | 1 + crates/application/tests/reconcile_layouts.rs | 1 + .../tests/snapshot_running_agents.rs | 1 + crates/application/tests/window_usecases.rs | 1 + crates/backend/src/dto.rs | 25 +- crates/domain/src/layout.rs | 60 +++ crates/domain/src/lib.rs | 5 +- crates/domain/src/ports.rs | 13 + crates/domain/tests/agent_profile_a0.rs | 1 + crates/domain/tests/layout.rs | 12 + crates/domain/tests/serde_roundtrip.rs | 4 + crates/domain/tests/window.rs | 1 + crates/infrastructure/src/session/opencode.rs | 122 ++++-- frontend/src/adapters/agent.test.ts | 9 + frontend/src/adapters/agent.ts | 66 +++ frontend/src/adapters/mock/index.ts | 124 +++++- .../agents/CustomAgentChatView.test.tsx | 97 +++++ .../features/agents/CustomAgentChatView.tsx | 388 ++++++++++++++++++ frontend/src/features/agents/index.ts | 2 + .../features/layout/LayoutGrid.chat.test.tsx | 187 +++++---- frontend/src/features/layout/LayoutGrid.tsx | 300 +++++++++++++- frontend/src/ports/index.ts | 44 ++ 33 files changed, 1681 insertions(+), 144 deletions(-) create mode 100644 frontend/src/features/agents/CustomAgentChatView.test.tsx create mode 100644 frontend/src/features/agents/CustomAgentChatView.tsx diff --git a/.ideai/tickets/147/carnet.md b/.ideai/tickets/147/carnet.md index c6f86b7..874d551 100644 --- a/.ideai/tickets/147/carnet.md +++ b/.ideai/tickets/147/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#147" -version: 6 +version: 7 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1785880207496 +updatedAt: 1785882543204 --- ## Cadrage consolidé @@ -43,3 +43,52 @@ Ce ticket ne doit pas être traité comme un simple ticket UI : il implique un v - Comportement exact du staging temporaire des fichiers joints : durée de vie, nettoyage, taille max, comportement si le fichier source change. - UX précise du warning de bascule de mode et du redémarrage de session. - Stratégie de dégradation contrôlée selon la richesse réellement exposée par chaque agent headless. + +## Exécution du cycle au 4 août 2026 + +### Architect +- Architect a revu le code réel et a conclu qu'il existe déjà un socle backend de chat structuré (`AgentSession`, `ChatBridge`, `ReplyChunk`, `reattach_agent_chat`) ; le ticket est donc une réintégration de la vue chat avec quelques compléments ciblés, pas une reconstruction complète. +- Contrats proposés : `preferred_view` persistant par cellule, `cancel_current_turn()` côté `AgentSession`, `ReplyChunk::UserPrompt`, toggle par cellule agent, vue live seulement, pas de transcript persistant secondaire. + +### Git +- Branche de travail locale décidée par Git : `feature/ticket147-custom-chat-cli`. +- Base choisie : `develop`. +- Commit de bookkeeping déjà posé par Git : `efbd56a1 chore(tickets): sync carnets/issues #102/#141-#147 + agent glmopencode`. + +### DevBackend — état réel livré +- Livré : + - `LeafCell.preferred_view` avec migration douce (`Tui` par défaut). + - mutation layout pour persister cette préférence. + - `ReplyChunk::UserPrompt` + ajout du prompt user dans le scrollback live. + - `AgentSession::cancel_current_turn()` avec défaut no-op. + - implémentation concrète best-effort du cancel pour `OpenCodeSession`. + - routage `interrupt_agent` selon `preferred_view`. + - commande Tauri `cancel_agent_chat(session_id)`. +- Limites explicitement laissées : + - pas de staging backend structuré des pièces jointes ; le frontend injecte actuellement le chemin dans le prompt. + - cancel concret non généralisé à tous les adapters. + - pas de nouvelle persistance de transcript hors session vivante. +- Tests annoncés verts par DevBackend : `cargo test -p domain`, tests `application` ciblés layout, tests `infrastructure opencode`, tests `app-tauri` ciblés `dto_chat` / `chat_bridge`, `cargo fmt`. + +### DevFrontend — état réel livré +- Livré : + - toggle `TUI native` / `CLI custom` par cellule agent seulement, selon compatibilité structured/headless. + - modale de confirmation de switch avec arrêt + relance et wording dynamique. + - vue `CustomAgentChatView` live en bulles user/agent, rendu défensif, mise en avant du `Final` via `Task Complete`. + - composer texte + pièce jointe via `pickFile()` + bouton `Cancel`. + - reattach live de session structurée si elle est encore vivante. + - câblage TS des méthodes `launchAgentChat`, `reattachAgentChat`, `sendAgentChat`, `closeAgentChat` côté ports/adapters/mock. +- Limites explicitement laissées : + - pas de payload structuré pour les attachments ; chemin injecté dans le prompt. + - rendu limité aux chunks réellement exposés (`textDelta`, `toolActivity`, `final`, `error`, `userPrompt`). + +### QA — verdict actuel +- Verdict QA au 4 août 2026 : **ROUGE** pour le MVP réellement livré. +- Finding bloquant principal : dans `CustomAgentChatView`, le bouton `Cancel` ferme la session structurée via `closeAgentChat` au lieu d'interrompre seulement le tour courant. Cela viole explicitement le contrat produit validé avec l'utilisateur. +- Finding secondaire : absence de test de non-régression couvrant ce comportement `Cancel`. +- Côté frontend, `npx vitest run` a été exécuté par QA et est vert (`115` fichiers / `1083` tests). +- Côté Rust, QA n'a pas obtenu de preuve globale exploitable dans le sandbox à cause de contraintes d'environnement (`Read-only file system`, verrous `cargo`, saturation temporaire `/tmp`). + +### État d'avancement +- Le cycle a été lancé et exécuté jusqu'à QA. +- Le ticket n'est pas encore validé parce qu'il reste un correctif frontend ciblé à livrer sur `Cancel`, suivi d'une revalidation QA. diff --git a/.ideai/tickets/147/issue.md b/.ideai/tickets/147/issue.md index 7176a44..a9e2df0 100644 --- a/.ideai/tickets/147/issue.md +++ b/.ideai/tickets/147/issue.md @@ -11,8 +11,8 @@ attachments: [{"id":"06f4e827-d693-4190-9c53-186585602006","filename":"task_comp createdBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1785877963139 -updatedAt: 1785880207496 -version: 6 +updatedAt: 1785882543204 +version: 7 --- J'aimerais une CLI alternative pour mes agents. Cette CLI doit contenir bine entendu la barre de chat, ainsi que pour le reste de l'écran les bulle de conversation de l'agent et dde l'utilisateur. Cette CLI doit etre de la meme forme que ce qu'on peut voir dans les CLI des agents IA dans les IDE de code. Je veux voir s'afficher les reflexions de l'IA etc. La CLI communiquera en headless avec l'agent. diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index 1cf82da..070b3fa 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -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(), diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index a122ca8..b11518f 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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>, on_reply: Channel, 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, +) -> Result, 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 { + 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 diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index b26786f..edc3b43 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index fe59cce..a680d87 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -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(), diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index d925037..237b94a 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -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), ) diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index c802275..4b78923 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -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" }, ], diff --git a/crates/application/src/layout/usecases.rs b/crates/application/src/layout/usecases.rs index 2f86768..4540291 100644 --- a/crates/application/src/layout/usecases.rs +++ b/crates/application/src/layout/usecases.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use domain::ports::{EventBus, FileSystem, ProjectStore}; use domain::{ AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, - ProjectId, SessionId, + PreferredView, ProjectId, SessionId, }; use crate::error::AppError; @@ -98,6 +98,13 @@ pub enum LayoutOperation { /// Conversation id to record, or `None` to clear. conversation_id: Option, }, + /// Persist the preferred live view for an agent leaf. + SetCellPreferredView { + /// The hosting leaf. + target: NodeId, + /// Preferred live view. + preferred_view: PreferredView, + }, /// Persist opaque state for a plugin-provided custom layout leaf. SetPluginLayoutState { /// The custom plugin layout node. @@ -130,6 +137,10 @@ impl LayoutOperation { target, conversation_id, } => tree.set_cell_conversation(*target, conversation_id.clone()), + Self::SetCellPreferredView { + target, + preferred_view, + } => tree.set_cell_preferred_view(*target, *preferred_view), Self::SetPluginLayoutState { target, state } => { tree.set_plugin_layout_state(*target, state.clone()) } diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 06857af..ab0b056 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -590,6 +590,7 @@ fn agent_leaf( conversation_id: conversation_id.map(str::to_owned), engine_session_id: None, agent_was_running, + preferred_view: domain::PreferredView::Tui, } } diff --git a/crates/application/tests/layout_usecases.rs b/crates/application/tests/layout_usecases.rs index 5c4f0cc..7e4bc7a 100644 --- a/crates/application/tests/layout_usecases.rs +++ b/crates/application/tests/layout_usecases.rs @@ -20,8 +20,8 @@ use domain::ports::{ use domain::{ AgentId, ContentHash, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, LocalPath, NodeId, PluginBundleUrl, PluginId, PluginInstallSource, PluginLifecycleState, PluginPackageRef, - PluginRegistry, PluginRegistryEntry, Project, ProjectId, ProjectPath, RelativePath, RemoteRef, - RemovalOutcome, SessionId, StagedPluginPackage, + PluginRegistry, PluginRegistryEntry, PreferredView, Project, ProjectId, ProjectPath, + RelativePath, RemoteRef, RemovalOutcome, SessionId, StagedPluginPackage, }; use uuid::Uuid; @@ -350,6 +350,7 @@ fn single_leaf(node_id: NodeId) -> LayoutTree { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, }) } @@ -936,6 +937,49 @@ async fn mutate_set_cell_conversation_missing_leaf_is_not_found() { assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } +#[tokio::test] +async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() { + let env = mut_env(pid(54)).await; + + env.mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::SetCellPreferredView { + target: nid(1), + preferred_view: PreferredView::Chat, + }, + }) + .await + .expect("set_cell_preferred_view records chat"); + + let tree_json = active_tree_json(&env.fs); + assert_eq!(tree_json["root"]["node"]["preferredView"], "chat"); + + let out = env + .mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::SetCellPreferredView { + target: nid(1), + preferred_view: PreferredView::Tui, + }, + }) + .await + .expect("set_cell_preferred_view restores tui"); + + match &out.layout.root { + LayoutNode::Leaf(l) => assert_eq!(l.preferred_view, PreferredView::Tui), + _ => panic!("expected leaf root"), + } + let tree_json = active_tree_json(&env.fs); + assert!( + tree_json["root"]["node"].get("preferredView").is_none(), + "default TUI view is omitted from persisted JSON" + ); +} + // --------------------------------------------------------------------------- // Named-layout management (#4) // --------------------------------------------------------------------------- diff --git a/crates/application/tests/list_resumable_agents.rs b/crates/application/tests/list_resumable_agents.rs index 49fd731..9738553 100644 --- a/crates/application/tests/list_resumable_agents.rs +++ b/crates/application/tests/list_resumable_agents.rs @@ -332,6 +332,7 @@ fn agent_leaf( conversation_id: conversation_id.map(str::to_owned), engine_session_id: None, agent_was_running, + preferred_view: domain::PreferredView::Tui, } } diff --git a/crates/application/tests/reconcile_layouts.rs b/crates/application/tests/reconcile_layouts.rs index 2b916ef..e9f84bf 100644 --- a/crates/application/tests/reconcile_layouts.rs +++ b/crates/application/tests/reconcile_layouts.rs @@ -163,6 +163,7 @@ fn agent_leaf(node: NodeId, agent: Option, conv: Option<&str>, running: conversation_id: conv.map(str::to_string), engine_session_id: None, agent_was_running: running, + preferred_view: domain::PreferredView::Tui, } } diff --git a/crates/application/tests/snapshot_running_agents.rs b/crates/application/tests/snapshot_running_agents.rs index 19eb826..6918dfc 100644 --- a/crates/application/tests/snapshot_running_agents.rs +++ b/crates/application/tests/snapshot_running_agents.rs @@ -183,6 +183,7 @@ fn agent_leaf(node: NodeId, agent: Option) -> LeafCell { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, } } diff --git a/crates/application/tests/window_usecases.rs b/crates/application/tests/window_usecases.rs index 631e7c0..ab17aa8 100644 --- a/crates/application/tests/window_usecases.rs +++ b/crates/application/tests/window_usecases.rs @@ -267,6 +267,7 @@ fn tab(n: u128) -> Tab { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, })), } } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 84265cb..cab00aa 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1120,7 +1120,9 @@ use application::{ CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput, LoadLayoutOutput, MutateLayoutOutput, PluginLayoutOrigin, SetActiveLayoutOutput, }; -use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId, PluginId, PluginLayoutType}; +use domain::{ + AgentId, Direction, LayoutId, LayoutTree, NodeId, PluginId, PluginLayoutType, PreferredView, +}; /// Response DTO carrying a layout tree. /// @@ -1214,6 +1216,14 @@ pub enum LayoutOperationDto { #[serde(default)] conversation_id: Option, }, + /// Persist the preferred live view for an agent leaf. + #[serde(rename_all = "camelCase")] + SetCellPreferredView { + /// Hosting leaf. + target: String, + /// Preferred live view. + preferred_view: PreferredView, + }, /// Persist opaque plugin layout state. #[serde(rename_all = "camelCase")] SetPluginLayoutState { @@ -1273,6 +1283,13 @@ impl LayoutOperationDto { target: parse_node_id(&target)?, conversation_id, }, + Self::SetCellPreferredView { + target, + preferred_view, + } => LayoutOperation::SetCellPreferredView { + target: parse_node_id(&target)?, + preferred_view, + }, Self::SetPluginLayoutState { target, state } => LayoutOperation::SetPluginLayoutState { target: parse_node_id(&target)?, state, @@ -2957,6 +2974,12 @@ impl From for TerminalSessionDto { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum ReplyChunk { + /// The user's submitted prompt, retained in live scrollback for reattach. + #[serde(rename_all = "camelCase")] + UserPrompt { + /// Submitted prompt text. + text: String, + }, /// An assistant text fragment (incremental chat rendering). #[serde(rename_all = "camelCase")] TextDelta { diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs index 13bf8fe..e07f4a5 100644 --- a/crates/domain/src/layout.rs +++ b/crates/domain/src/layout.rs @@ -22,6 +22,23 @@ pub enum Direction { Column, } +/// Preferred live view for an agent leaf. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreferredView { + /// Native terminal/TUI view. + #[default] + Tui, + /// Structured chat view. + Chat, +} + +/// Returns `true` when the preferred view is the default `Tui`. +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_default_preferred_view(view: &PreferredView) -> bool { + *view == PreferredView::Tui +} + /// Returns `true` when a boolean is `false`. Used as a `skip_serializing_if` /// predicate so that default (`false`) flags are omitted from the serialized /// form, preserving backward/forward compatibility with leaves that predate the @@ -64,6 +81,10 @@ pub struct LeafCell { /// last closed. Used to decide whether to auto-resume the agent on reopen. #[serde(default, skip_serializing_if = "is_false")] pub agent_was_running: bool, + /// Preferred live view for this cell. Additive and defaulted to TUI for old + /// layouts; meaningful only when [`Self::agent`] is set. + #[serde(default, skip_serializing_if = "is_default_preferred_view")] + pub preferred_view: PreferredView, } impl LeafCell { @@ -81,6 +102,7 @@ impl LeafCell { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: PreferredView::Tui, } } @@ -105,6 +127,13 @@ impl LeafCell { self.engine_session_id = engine_session_id; self } + + /// Wither additif : pose la vue live préférée de la cellule. + #[must_use] + pub fn with_preferred_view(mut self, preferred_view: PreferredView) -> Self { + self.preferred_view = preferred_view; + self + } } /// A weighted child within a [`SplitContainer`]. The `weight` is a *relative* @@ -567,6 +596,37 @@ impl LayoutTree { Ok(tree) } + /// Sets the preferred live view on the leaf `target`. + /// + /// Pure: returns a new validated tree. + /// + /// # Errors + /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree. + pub fn set_cell_preferred_view( + &self, + target: NodeId, + preferred_view: PreferredView, + ) -> Result { + let mut found = false; + let root = map_node(&self.root, &mut |node| { + if let LayoutNode::Leaf(leaf) = node { + if leaf.id == target { + found = true; + let mut leaf = leaf.clone(); + leaf.preferred_view = preferred_view; + return LayoutNode::Leaf(leaf); + } + } + node.clone() + }); + if !found { + return Err(LayoutError::NodeNotFound(target)); + } + let tree = Self { root }; + tree.validate()?; + Ok(tree) + } + /// Sets (or, with `None`, clears) the persistent CLI `conversation_id` on /// the leaf `target`. /// diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 84453ed..490add4 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -195,8 +195,9 @@ pub use git::GitRepository; pub use layout::{ CustomPluginLayoutCell, Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell, PersistedMonitorState, PersistedPluginLayoutWindow, PersistedWindowKind, - PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, SplitContainer, Tab, - WeightedChild, Window, WindowStateSnapshot, Workspace, WINDOW_STATE_SNAPSHOT_VERSION, + PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, PreferredView, + SplitContainer, Tab, WeightedChild, Window, WindowStateSnapshot, Workspace, + WINDOW_STATE_SNAPSHOT_VERSION, }; pub use events::{DomainEvent, OrchestrationSource}; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 9b0ffc4..231b577 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1200,6 +1200,19 @@ pub trait AgentSession: Send + Sync { self.send(prompt).await } + /// Interrupts the currently running turn without shutting down the session. + /// + /// Default is a no-op so existing structured adapters/fakes remain valid. + /// Process-backed adapters that retain a live child during `send` should + /// override it and kill only that child. + /// + /// # Errors + /// [`AgentSessionError::Io`] when the adapter attempted a concrete cancel and + /// it failed. + async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> { + Ok(()) + } + /// Termine proprement la session (tue le process/SDK sous-jacent). Idempotent. /// /// # Errors diff --git a/crates/domain/tests/agent_profile_a0.rs b/crates/domain/tests/agent_profile_a0.rs index 4593a08..d6f53cb 100644 --- a/crates/domain/tests/agent_profile_a0.rs +++ b/crates/domain/tests/agent_profile_a0.rs @@ -124,6 +124,7 @@ fn leaf_cell(id: u128, sess: Option) -> LeafCell { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, } } diff --git a/crates/domain/tests/layout.rs b/crates/domain/tests/layout.rs index 74b1fe3..69025d4 100644 --- a/crates/domain/tests/layout.rs +++ b/crates/domain/tests/layout.rs @@ -22,6 +22,7 @@ fn leaf(id: u128, sess: Option) -> LeafCell { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, } } @@ -35,6 +36,7 @@ fn leaf_with_resume(id: u128, sess: Option) -> LeafCell { conversation_id: Some("conv-1".to_string()), engine_session_id: None, agent_was_running: true, + preferred_view: domain::PreferredView::Tui, } } @@ -680,6 +682,7 @@ fn agent_leaves_collects_only_agent_bearing_leaves() { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, }), weight: 1.0, }, @@ -695,6 +698,7 @@ fn agent_leaves_collects_only_agent_bearing_leaves() { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, }), weight: 1.0, }, @@ -791,6 +795,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() { conversation_id: Some("conv-from".to_string()), engine_session_id: None, agent_was_running: true, + preferred_view: domain::PreferredView::Tui, }), weight: 1.0, }, @@ -802,6 +807,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() { conversation_id: Some("conv-to".to_string()), engine_session_id: None, agent_was_running: true, + preferred_view: domain::PreferredView::Tui, }), weight: 1.0, }, @@ -847,6 +853,7 @@ fn agent_leaf_full(id: u128, agent: u128, conv: Option<&str>, running: bool) -> conversation_id: conv.map(str::to_string), engine_session_id: None, agent_was_running: running, + preferred_view: domain::PreferredView::Tui, } } @@ -1053,6 +1060,7 @@ fn leaf_serde_all_four_combinations_roundtrip() { conversation_id: conv.clone(), engine_session_id: None, agent_was_running: running, + preferred_view: domain::PreferredView::Tui, }; let json = serde_json::to_string(&cell).unwrap(); let back: LeafCell = serde_json::from_str(&json).unwrap(); @@ -1069,6 +1077,7 @@ fn leaf_serde_omits_defaults() { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, }; let json = serde_json::to_string(&cell).unwrap(); assert!( @@ -1090,6 +1099,7 @@ fn leaf_serde_field_names_are_camel_case_when_present() { conversation_id: Some("c".to_string()), engine_session_id: None, agent_was_running: true, + preferred_view: domain::PreferredView::Tui, }; let json = serde_json::to_string(&cell).unwrap(); assert!(json.contains("conversationId"), "json was {json}"); @@ -1117,6 +1127,7 @@ fn leaf_can_carry_conversation_without_session_and_inversely() { conversation_id: Some("c".to_string()), engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, }; let a_back: LeafCell = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); assert_eq!(a_back, a); @@ -1129,6 +1140,7 @@ fn leaf_can_carry_conversation_without_session_and_inversely() { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, }; let b_back: LeafCell = serde_json::from_str(&serde_json::to_string(&b).unwrap()).unwrap(); assert_eq!(b_back, b); diff --git a/crates/domain/tests/serde_roundtrip.rs b/crates/domain/tests/serde_roundtrip.rs index 9addd53..24e2baf 100644 --- a/crates/domain/tests/serde_roundtrip.rs +++ b/crates/domain/tests/serde_roundtrip.rs @@ -408,6 +408,7 @@ fn layout_roundtrip() { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, }), weight: 1.5, }, @@ -419,6 +420,7 @@ fn layout_roundtrip() { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, }), weight: 2.5, }, @@ -445,6 +447,7 @@ fn leaf_with_agent_roundtrip_and_omits_null() { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, })); let rt = roundtrip(&tree); match rt.root { @@ -465,6 +468,7 @@ fn leaf_with_agent_roundtrip_and_omits_null() { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, })); let json2 = serde_json::to_string(&tree_no_agent).unwrap(); assert!( diff --git a/crates/domain/tests/window.rs b/crates/domain/tests/window.rs index 0a408c8..7abedbf 100644 --- a/crates/domain/tests/window.rs +++ b/crates/domain/tests/window.rs @@ -23,6 +23,7 @@ fn leaf_tree() -> LayoutTree { conversation_id: None, engine_session_id: None, agent_was_running: false, + preferred_view: domain::PreferredView::Tui, })) } fn tab(n: u128) -> Tab { diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index bdc3502..a7d7e2a 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -14,6 +14,7 @@ use async_trait::async_trait; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use tokio::process::Command; +use tokio::sync::Mutex as AsyncMutex; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; use domain::sandbox::{SandboxEnforcer, SandboxPlan}; @@ -322,11 +323,25 @@ pub struct OpenCodeSession { cwd: String, env: Vec<(String, String)>, engine_session_id: Mutex>, + current_child: Mutex>>>, sandbox: Option, sandbox_enforcer: Option>, } impl OpenCodeSession { + fn clear_current_child(&self, child: &Arc>) { + let mut current = self + .current_child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if current + .as_ref() + .is_some_and(|stored| Arc::ptr_eq(stored, child)) + { + *current = None; + } + } + /// Construit l'adapter. `command_prefix` peut être `opencode`, un chemin absolu, /// ou un wrapper avec arguments; IdeA ajoute ensuite `run --format json`. pub fn new( @@ -349,6 +364,7 @@ impl OpenCodeSession { cwd: cwd.into(), env, engine_session_id: Mutex::new(seed), + current_child: Mutex::new(None), sandbox, sandbox_enforcer, }) @@ -401,46 +417,65 @@ impl AgentSession for OpenCodeSession { // structuré reste réservé aux chemins process génériques existants. let _ = (&self.sandbox, &self.sandbox_enforcer); - let mut child = cmd + let child = cmd .spawn() .map_err(|e| AgentSessionError::Start(format!("{}: {e}", self.command)))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?; - let mut stderr_pipe = child - .stderr - .take() - .ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?; - - let expected_session = self.conversation_id(); - let mut lines = BufReader::new(stdout).lines(); - let mut collected = Vec::new(); - while let Some(line) = lines - .next_line() - .await - .map_err(|e| AgentSessionError::Io(e.to_string()))? + let child = Arc::new(AsyncMutex::new(child)); { - if let Some(engine_id) = extract_session_id(&line)? { - if expected_session - .as_deref() - .is_none_or(|expected| expected == engine_id) - { - self.capture_session_id(engine_id); - } - } - collected.push(line); + let mut current = self + .current_child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *current = Some(Arc::clone(&child)); } + let outcome = async { + let (stdout, mut stderr_pipe) = + { + let mut locked = child.lock().await; + let stdout = locked.stdout.take().ok_or_else(|| { + AgentSessionError::Io("stdout pipe indisponible".to_owned()) + })?; + let stderr = locked.stderr.take().ok_or_else(|| { + AgentSessionError::Io("stderr pipe indisponible".to_owned()) + })?; + (stdout, stderr) + }; - let mut stderr_bytes = Vec::new(); - stderr_pipe - .read_to_end(&mut stderr_bytes) - .await - .map_err(|e| AgentSessionError::Io(e.to_string()))?; - let status = child - .wait() - .await - .map_err(|e| AgentSessionError::Io(e.to_string()))?; + let expected_session = self.conversation_id(); + let mut lines = BufReader::new(stdout).lines(); + let mut collected = Vec::new(); + while let Some(line) = lines + .next_line() + .await + .map_err(|e| AgentSessionError::Io(e.to_string()))? + { + if let Some(engine_id) = extract_session_id(&line)? { + if expected_session + .as_deref() + .is_none_or(|expected| expected == engine_id) + { + self.capture_session_id(engine_id); + } + } + collected.push(line); + } + + let mut stderr_bytes = Vec::new(); + stderr_pipe + .read_to_end(&mut stderr_bytes) + .await + .map_err(|e| AgentSessionError::Io(e.to_string()))?; + let status = child + .lock() + .await + .wait() + .await + .map_err(|e| AgentSessionError::Io(e.to_string()))?; + Ok((expected_session, collected, stderr_bytes, status)) + } + .await; + self.clear_current_child(&child); + let (expected_session, collected, stderr_bytes, status) = outcome?; let stderr = String::from_utf8_lossy(&stderr_bytes); let parsed_session = expected_session.or_else(|| self.conversation_id()); let parsed = parse_jsonl_turn_scoped(&collected, parsed_session.as_deref()); @@ -474,6 +509,23 @@ impl AgentSession for OpenCodeSession { async fn shutdown(&self) -> Result<(), AgentSessionError> { Ok(()) } + + async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> { + let child = self + .current_child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if let Some(child) = child { + child + .lock() + .await + .kill() + .await + .map_err(|e| AgentSessionError::Io(format!("annulation OpenCode: {e}")))?; + } + Ok(()) + } } #[cfg(test)] diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 669f3df..79c976d 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -161,4 +161,13 @@ describe("TauriAgentGateway invoke payloads", () => { request: { projectId: "proj-1", agentId: "agent-2", effort: null }, }); }); + + it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => { + await new TauriAgentGateway().cancelAgentChat("chat-session-1"); + + expect(invoke).toHaveBeenCalledWith("cancel_agent_chat", { + sessionId: "chat-session-1", + }); + expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything()); + }); }); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index f416ec4..a8666bb 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -18,15 +18,18 @@ import type { Agent, AgentContextDocument, EffortSelection, + ReplyChunk, ResumableAgent, TerminalSession, } from "@/domain"; import type { + AgentChatHandle, AgentGateway, ConversationDetails, CreateAgentInput, LiveAgent, OpenTerminalOptions, + ReattachAgentChatResult, ReattachResult, StoppedLiveAgent, TerminalHandle, @@ -175,6 +178,69 @@ export class TauriAgentGateway implements AgentGateway { }; } + async launchAgentChat( + projectId: string, + agentId: string, + options: OpenTerminalOptions, + ): Promise { + // `launch_agent` is the routing authority. For structured/headless profiles + // the backend ignores the PTY output channel and returns a structured session + // id; for non-structured profiles it may still route to PTY, so callers gate + // this method on `profile.structuredAdapter`. + const channel = new Channel(); + const res = await invoke("launch_agent", { + request: { + projectId, + agentId, + rows: options.rows, + cols: options.cols, + conversationId: options.conversationId ?? null, + nodeId: options.nodeId ?? null, + }, + onOutput: channel, + }); + return { + sessionId: res.sessionId, + ...(res.assignedConversationId + ? { assignedConversationId: res.assignedConversationId } + : {}), + }; + } + + async reattachAgentChat( + sessionId: string, + onChunk: (chunk: ReplyChunk) => void, + ): Promise { + const channel = new Channel(); + channel.onmessage = onChunk; + return invoke("reattach_agent_chat", { + sessionId, + onReply: channel, + }); + } + + async sendAgentChat( + sessionId: string, + prompt: string, + onChunk: (chunk: ReplyChunk) => void, + ): Promise { + const channel = new Channel(); + channel.onmessage = onChunk; + await invoke("agent_send", { + sessionId, + prompt, + onReply: channel, + }); + } + + async closeAgentChat(sessionId: string): Promise { + await invoke("close_agent_session", { sessionId }); + } + + async cancelAgentChat(sessionId: string): Promise { + await invoke("cancel_agent_chat", { sessionId }); + } + async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index f3e7058..b8554ff 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -99,6 +99,7 @@ import type { Unsubscribe, } from "@/domain"; import type { + AgentChatHandle, AgentGateway, ConversationGateway, ConversationPageRequest, @@ -145,6 +146,7 @@ import type { PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ReattachResult, + ReattachAgentChatResult, RemoteGateway, ReviewPluginPackageInput, SaveOpenCodeProviderProfileInput, @@ -392,6 +394,10 @@ export class MockAgentGateway implements AgentGateway { private liveByAgent = new Map(); /** Live PTY session id per agent (`agentId → sessionId`). */ private liveSessionByAgent = new Map(); + /** Runtime kind per live agent (`agentId → kind`). */ + private liveKindByAgent = new Map(); + /** Retained structured reply chunks per live chat session. */ + private chatScrollback = new Map(); private getAgents(projectId: string): Agent[] { if (!this.agents.has(projectId)) this.agents.set(projectId, []); @@ -416,7 +422,7 @@ export class MockAgentGateway implements AgentGateway { agentId, nodeId, sessionId: this.liveSessionByAgent.get(agentId)!, - kind: "pty" as const, + kind: this.liveKindByAgent.get(agentId) ?? "pty", })); } @@ -449,7 +455,11 @@ export class MockAgentGateway implements AgentGateway { throw err; } const sessionId = this.liveSessionByAgent.get(agentId); - if (!sessionId || !this.sessions.has(sessionId)) { + if ( + !sessionId || + (this.liveKindByAgent.get(agentId) !== "structured" && + !this.sessions.has(sessionId)) + ) { const err: GatewayError = { code: "NOT_FOUND", message: `agent ${agentId} has no live session`, @@ -457,7 +467,12 @@ export class MockAgentGateway implements AgentGateway { throw err; } this.liveByAgent.set(agentId, nodeId); - return { agentId, nodeId, sessionId, kind: "pty" }; + return { + agentId, + nodeId, + sessionId, + kind: this.liveKindByAgent.get(agentId) ?? "pty", + }; } async stopLiveAgent(projectId: string, agentId: string): Promise { @@ -480,9 +495,12 @@ export class MockAgentGateway implements AgentGateway { const session = this.sessions.get(sessionId); if (session) session.closed = true; this.sessions.delete(sessionId); + this.chatScrollback.delete(sessionId); this.liveSessionByAgent.delete(agentId); this.liveByAgent.delete(agentId); - return { agentId, sessionId, kind: "pty" }; + const kind = this.liveKindByAgent.get(agentId) ?? "pty"; + this.liveKindByAgent.delete(agentId); + return { agentId, sessionId, kind }; } async createAgent(projectId: string, input: CreateAgentInput): Promise { @@ -718,11 +736,13 @@ export class MockAgentGateway implements AgentGateway { if (options.nodeId) { this.liveByAgent.set(agentId, options.nodeId); this.liveSessionByAgent.set(agentId, sessionId); + this.liveKindByAgent.set(agentId, "pty"); } const clearLive = () => { if (this.liveByAgent.get(agentId) === options.nodeId) { this.liveByAgent.delete(agentId); this.liveSessionByAgent.delete(agentId); + this.liveKindByAgent.delete(agentId); } }; @@ -752,6 +772,101 @@ export class MockAgentGateway implements AgentGateway { return handle; } + async launchAgentChat( + projectId: string, + agentId: string, + options: OpenTerminalOptions, + ): Promise { + const list = this.getAgents(projectId); + if (!list.some((a) => a.id === agentId)) { + throw { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + } as GatewayError; + } + const liveNode = this.liveByAgent.get(agentId); + if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) { + throw { + code: "AGENT_ALREADY_RUNNING", + message: `agent ${agentId} is already running in cell ${liveNode}`, + } as GatewayError; + } + this.sessionSeq += 1; + const sessionId = `mock-agent-chat-${this.sessionSeq}`; + this.chatScrollback.set(sessionId, []); + if (options.nodeId) { + this.liveByAgent.set(agentId, options.nodeId); + this.liveSessionByAgent.set(agentId, sessionId); + this.liveKindByAgent.set(agentId, "structured"); + } + return { + sessionId, + ...(!options.conversationId + ? { assignedConversationId: `mock-conversation-${sessionId}` } + : {}), + }; + } + + async reattachAgentChat( + sessionId: string, + _onChunk: (chunk: ReplyChunk) => void, + ): Promise { + const chunks = this.chatScrollback.get(sessionId); + if (!chunks) { + throw { + code: "NOT_FOUND", + message: `agent chat session ${sessionId} is not alive`, + } as GatewayError; + } + return { sessionId, scrollback: structuredClone(chunks) }; + } + + async sendAgentChat( + sessionId: string, + prompt: string, + onChunk: (chunk: ReplyChunk) => void, + ): Promise { + const chunks = this.chatScrollback.get(sessionId); + if (!chunks) { + throw { + code: "NOT_FOUND", + message: `agent chat session ${sessionId} not found`, + } as GatewayError; + } + const content = `Agent: reçu « ${prompt} ».`; + const streamed: ReplyChunk[] = [ + { kind: "toolActivity", label: "Analyse du prompt" }, + { kind: "textDelta", text: "Agent: reçu " }, + { kind: "textDelta", text: `« ${prompt} ».` }, + { kind: "final", content }, + ]; + for (const chunk of streamed) { + await Promise.resolve(); + chunks.push(structuredClone(chunk)); + onChunk(chunk); + } + } + + async closeAgentChat(sessionId: string): Promise { + this.chatScrollback.delete(sessionId); + for (const [agentId, liveSessionId] of this.liveSessionByAgent) { + if (liveSessionId === sessionId) { + this.liveSessionByAgent.delete(agentId); + this.liveByAgent.delete(agentId); + this.liveKindByAgent.delete(agentId); + } + } + } + + async cancelAgentChat(sessionId: string): Promise { + if (!this.chatScrollback.has(sessionId)) { + throw { + code: "NOT_FOUND", + message: `agent chat session ${sessionId} not found`, + } as GatewayError; + } + } + async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, @@ -772,6 +887,7 @@ export class MockAgentGateway implements AgentGateway { if (liveSessionId === sessionId) { this.liveSessionByAgent.delete(agentId); this.liveByAgent.delete(agentId); + this.liveKindByAgent.delete(agentId); } } }), diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx new file mode 100644 index 0000000..56815ce --- /dev/null +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { DIProvider } from "@/app/di"; +import type { AgentProfile } from "@/domain"; +import type { Gateways } from "@/ports"; +import { CustomAgentChatView } from "./CustomAgentChatView"; + +const profile: AgentProfile = { + id: "structured", + name: "Structured Codex", + command: "codex", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: null, + cwdTemplate: "{projectRoot}", + structuredAdapter: "codex", +}; + +describe("CustomAgentChatView", () => { + it("cancels only the current turn and keeps the structured session alive", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(() => new Promise(() => {})), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const onSessionId = vi.fn(); + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + fireEvent.change(screen.getByLabelText(/message CLI custom/), { + target: { value: "first turn" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await waitFor(() => + expect(agent.sendAgentChat).toHaveBeenCalledWith( + "chat-session-1", + "first turn", + expect.any(Function), + ), + ); + + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); + + await waitFor(() => + expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"), + ); + expect(agent.closeAgentChat).not.toHaveBeenCalled(); + expect(onSessionId).not.toHaveBeenCalledWith(null); + expect(screen.getByText("Tour interrompu.")).toBeTruthy(); + + fireEvent.change(screen.getByLabelText(/message CLI custom/), { + target: { value: "second turn" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2)); + expect(agent.sendAgentChat).toHaveBeenLastCalledWith( + "chat-session-1", + "second turn", + expect.any(Function), + ); + }); +}); diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx new file mode 100644 index 0000000..7d79681 --- /dev/null +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -0,0 +1,388 @@ +/** + * Custom agent CLI for structured/headless profiles (#147). + * + * This is an alternative human view for an agent cell, not a replacement for + * the native TUI. It uses the structured chat commands when available and + * deliberately does not try to parse PTY bytes. + */ + +import { useEffect, useMemo, useRef, useState } from "react"; + +import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain"; +import { useGateways } from "@/app/di"; +import { Button, Spinner, cn } from "@/shared"; + +export interface CustomAgentChatViewProps { + projectId: string; + agentId: string; + agentName: string; + profile: AgentProfile; + cwd: string; + nodeId: string; + sessionId: string | null; + conversationId: string | null; + onSessionId: (sessionId: string | null) => void; + onConversationId: (conversationId: string | null) => void; +} + +type ChatTurn = + | { role: "user"; text: string; attachment?: string } + | { role: "agent"; text: string; pending?: boolean } + | { role: "tool"; label: string } + | { role: "final"; text: string } + | { role: "error"; text: string } + | { role: "unknown"; text: string }; + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +function unknownChunkLabel(chunk: unknown): string { + try { + return JSON.stringify(chunk); + } catch { + return String(chunk); + } +} + +function isReplyRecord(chunk: unknown): chunk is Record { + return Boolean(chunk && typeof chunk === "object" && "kind" in chunk); +} + +function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] { + const next = [...turns]; + const last = next[next.length - 1]; + if (last?.role === "agent") { + next[next.length - 1] = { + role: "agent", + text: last.text + text, + pending: true, + }; + return next; + } + next.push({ role: "agent", text, pending: true }); + return next; +} + +function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] { + if (!isReplyRecord(raw)) { + return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; + } + switch (raw.kind) { + case "textDelta": + return appendAgentDelta(turns, String(raw.text ?? "")); + case "toolActivity": + return [...turns, { role: "tool", label: String(raw.label ?? "Activité") }]; + case "final": { + const content = String(raw.content ?? ""); + const next = [...turns]; + const last = next[next.length - 1]; + if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false }; + next.push({ role: "final", text: content }); + return next; + } + case "error": { + const next = [...turns]; + const last = next[next.length - 1]; + if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false }; + next.push({ role: "error", text: String(raw.message ?? "Erreur agent") }); + return next; + } + case "userPrompt": + case "UserPrompt": + return [...turns, { role: "user", text: String(raw.text ?? raw.prompt ?? "") }]; + default: + return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; + } +} + +export function CustomAgentChatView({ + projectId, + agentId, + agentName, + profile, + cwd, + nodeId, + sessionId, + conversationId, + onSessionId, + onConversationId, +}: CustomAgentChatViewProps) { + const { agent, system } = useGateways(); + const [turns, setTurns] = useState([]); + const [currentSession, setCurrentSession] = useState(sessionId); + const [draft, setDraft] = useState(""); + const [attachment, setAttachment] = useState(null); + const [opening, setOpening] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + const sessionRef = useRef(sessionId); + sessionRef.current = currentSession; + const onSessionIdRef = useRef(onSessionId); + onSessionIdRef.current = onSessionId; + const onConversationIdRef = useRef(onConversationId); + onConversationIdRef.current = onConversationId; + + const supported = Boolean( + profile.structuredAdapter && + agent.launchAgentChat && + agent.reattachAgentChat && + agent.sendAgentChat && + agent.cancelAgentChat && + agent.closeAgentChat, + ); + + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [turns]); + + useEffect(() => { + if (!supported) return; + let cancelled = false; + const receive = (chunk: ReplyChunk) => { + setTurns((prev) => foldChunk(prev, chunk)); + if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); + }; + + async function openOrAttach() { + setOpening(true); + setError(null); + try { + if (sessionId) { + const reattached = await agent.reattachAgentChat!(sessionId, receive); + if (cancelled) return; + setCurrentSession(reattached.sessionId); + setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[])); + return; + } + const launched = await agent.launchAgentChat!(projectId, agentId, { + cwd, + rows: 24, + cols: 80, + conversationId: conversationId ?? undefined, + nodeId, + }); + if (cancelled) return; + setCurrentSession(launched.sessionId); + onSessionIdRef.current(launched.sessionId); + if (launched.assignedConversationId) { + onConversationIdRef.current(launched.assignedConversationId); + } + // Attach the view so any in-flight chunks can be replayed after launch. + await agent.reattachAgentChat!(launched.sessionId, receive).catch(() => {}); + } catch (e) { + if (!cancelled) setError(describe(e)); + } finally { + if (!cancelled) setOpening(false); + } + } + + void openOrAttach(); + return () => { + cancelled = true; + }; + }, [ + supported, + agent, + projectId, + agentId, + cwd, + nodeId, + sessionId, + conversationId, + ]); + + const canSend = useMemo( + () => + supported && + Boolean(currentSession) && + Boolean(draft.trim()) && + !busy && + !opening, + [supported, currentSession, draft, busy, opening], + ); + + async function pickAttachment() { + const path = await system.pickFile(); + if (path) setAttachment(path); + } + + async function send() { + const text = draft.trim(); + if (!canSend || !currentSession || !agent.sendAgentChat) return; + const prompt = attachment ? `${text}\n\n[Fichier joint: ${attachment}]` : text; + setDraft(""); + setAttachment(null); + setBusy(true); + setError(null); + setTurns((prev) => [...prev, { role: "user", text, attachment: attachment ?? undefined }]); + try { + await agent.sendAgentChat(currentSession, prompt, (chunk) => { + setTurns((prev) => foldChunk(prev, chunk)); + if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); + }); + } catch (e) { + setBusy(false); + setError(describe(e)); + setTurns((prev) => [...prev, { role: "error", text: describe(e) }]); + } + } + + async function cancel() { + const sid = sessionRef.current; + if (!sid || !agent.cancelAgentChat) return; + setBusy(false); + setOpening(false); + setError(null); + try { + await agent.cancelAgentChat(sid); + setTurns((prev) => [...prev, { role: "tool", label: "Tour interrompu." }]); + } catch (e) { + setError(describe(e)); + } + } + + return ( +
+
+
+
{agentName}
+
+ CLI custom · {profile.name} +
+
+ {(opening || busy) && ( + + )} +
+ + {!supported && ( +

+ CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native. +

+ )} + {error && ( +

+ {error} +

+ )} + +
+ {opening && turns.length === 0 ? ( +
+ + Ouverture de la session structurée… +
+ ) : turns.length === 0 ? ( +

+ Envoyez un message pour démarrer la conversation structurée. +

+ ) : ( + turns.map((turn, index) => ) + )} +
+ +
+ {attachment && ( +
+ Fichier joint: {attachment} + +
+ )} +
+