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

@ -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(),

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

View File

@ -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,

View File

@ -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(),

View File

@ -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),
)

View File

@ -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" },
],

View File

@ -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<String>,
},
/// 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())
}

View File

@ -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,
}
}

View File

@ -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)
// ---------------------------------------------------------------------------

View File

@ -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,
}
}

View File

@ -163,6 +163,7 @@ fn agent_leaf(node: NodeId, agent: Option<AgentId>, conv: Option<&str>, running:
conversation_id: conv.map(str::to_string),
engine_session_id: None,
agent_was_running: running,
preferred_view: domain::PreferredView::Tui,
}
}

View File

@ -183,6 +183,7 @@ fn agent_leaf(node: NodeId, agent: Option<AgentId>) -> LeafCell {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}
}

View File

@ -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,
})),
}
}

View File

@ -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<String>,
},
/// 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<TerminalSession> 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 {

View File

@ -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<Self, LayoutError> {
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`.
///

View File

@ -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};

View File

@ -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

View File

@ -124,6 +124,7 @@ fn leaf_cell(id: u128, sess: Option<u128>) -> LeafCell {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}
}

View File

@ -22,6 +22,7 @@ fn leaf(id: u128, sess: Option<u128>) -> 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<u128>) -> 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);

View File

@ -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!(

View File

@ -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 {

View File

@ -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<Option<String>>,
current_child: Mutex<Option<Arc<AsyncMutex<tokio::process::Child>>>>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
impl OpenCodeSession {
fn clear_current_child(&self, child: &Arc<AsyncMutex<tokio::process::Child>>) {
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)]