diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index b11518f..58d172e 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -44,7 +44,7 @@ use crate::dto::{ parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id, parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto, AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto, - AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, + AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, @@ -1972,6 +1972,7 @@ pub async fn launch_agent( conversation_id: request.conversation_id.clone(), mcp_runtime, allow_structured_alongside_pty: false, + require_structured: request.cell_kind == Some(CellKind::Chat), }) .await .map_err(ErrorDto::from)?; diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index b001fd7..f99ee3a 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -3,9 +3,9 @@ //! and `From` for [`TerminalSessionDto`]. use app_tauri_lib::dto::{ - parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto, - InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto, - UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, + parse_agent_id, AgentDto, AgentListDto, CellKind, ConversationDetailsDto, + CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, + TerminalSessionDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, }; use application::AppError; use application::{ @@ -192,6 +192,8 @@ fn launch_agent_request_deserialises_camelcase() { assert_eq!(dto.conversation_id, None); // Omitting the node id defaults to None (a fresh node is minted backend-side). assert_eq!(dto.node_id, None); + // Omitting the requested cell kind preserves the historical PTY launch path. + assert_eq!(dto.cell_kind, None); } #[test] @@ -211,6 +213,19 @@ fn launch_agent_request_carries_node_id() { // No snake_case leak on the wire. } +#[test] +fn launch_agent_request_can_require_chat_cell_kind() { + let raw = json!({ + "projectId": Uuid::from_u128(1).to_string(), + "agentId": Uuid::from_u128(2).to_string(), + "rows": 24, + "cols": 80, + "cellKind": "chat" + }); + let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!(dto.cell_kind, Some(CellKind::Chat)); +} + // --------------------------------------------------------------------------- // AGENT_ALREADY_RUNNING error code + LiveAgentListDto (T2/T3) // --------------------------------------------------------------------------- diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 3f83c40..37ae124 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -959,6 +959,7 @@ impl ChangeAgentProfile { // endpoint is re-driven through the app-tauri launch path. mcp_runtime: None, allow_structured_alongside_pty: false, + require_structured: false, }) .await?; Ok(Some(output.session)) @@ -1112,6 +1113,12 @@ pub struct LaunchAgentInput { /// /// Ordinary UI launches keep this `false` and retain the singleton/rebind guard. pub allow_structured_alongside_pty: bool, + /// UI routing intent for the custom chat CLI: when `true`, a profile carrying a + /// `structured_adapter` must be launched as an [`AgentSession`] even on the + /// human-facing launcher, instead of taking the native TUI/PTY fallback. + /// + /// Historical callers leave this `false`, preserving the PTY launch contract. + pub require_structured: bool, } /// OS/runtime facts injected by the composition root (`app-tauri`) to materialise @@ -1813,6 +1820,25 @@ impl LaunchAgent { let agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; + let mut profile = self + .profiles + .list() + .await? + .into_iter() + .find(|p| p.id == agent.profile_id) + .ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?; + if input.require_structured && profile.structured_adapter.is_none() { + return Err(AppError::Invalid(format!( + "agent {}: le lancement structuré a été demandé, mais le profil {} \ + ne déclare pas d'adaptateur structured/headless", + input.agent_id, profile.id + ))); + } + let wants_structured = input.require_structured + || matches!( + self.structured_routing_mode, + StructuredRoutingMode::RequireStructured + ); // 1b. Enforce the "one live session per agent" invariant (decision: an // agent is a singleton that runs in a single cell at a time). This @@ -1847,12 +1873,16 @@ impl LaunchAgent { let host_node = self .sessions .node_for_agent_in_project(input.project.id, &input.agent_id); - if input.allow_structured_alongside_pty && input.node_id.is_none() { + if input.allow_structured_alongside_pty && input.node_id.is_none() && wants_structured { crate::diag!( "[launch] existing PTY kept while structured launch proceeds: agent={} \ pty_session={existing_id}", input.agent_id ); + } else if input.require_structured && profile.structured_adapter.is_some() { + if let Some(handle) = self.sessions.remove(&existing_id) { + self.pty.kill(&handle).await?; + } } else { match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref()) { @@ -1947,13 +1977,6 @@ impl LaunchAgent { .contexts .read_context(&input.project, &agent.id) .await?; - let mut profile = self - .profiles - .list() - .await? - .into_iter() - .find(|p| p.id == agent.profile_id) - .ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?; if input.allow_structured_alongside_pty && profile.structured_adapter.is_none() { return Err(AppError::Invalid(format!( "agent {}: le lancement headless structuré a été demandé, mais le profil {} \ @@ -2109,10 +2132,10 @@ impl LaunchAgent { profile.structured_adapter.is_some(), self.session_factory.as_ref(), self.structured.as_ref(), - self.structured_routing_mode, + wants_structured, ) { (false, _, _, _) => {} - (true, Some(factory), Some(structured), _) => { + (true, Some(factory), Some(structured), true) => { // ── Clé **logique** de la cellule = id de paire IdeA (ARCHITECTURE §19.7, // lot P8a) ── // - cellule porteuse d'un `conversation_id` (resume, ou lancement délégué @@ -2152,10 +2175,10 @@ impl LaunchAgent { ) .await; } - (true, _, _, StructuredRoutingMode::HumanPtyFallback) => { + (true, _, _, false) => { // Fallback humain intentionnel : cellule interactive native en PTY. } - (true, _, _, StructuredRoutingMode::RequireStructured) => { + (true, _, _, true) => { return Err(AppError::Process( "structured profile requires structured session factory".to_owned(), )); diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index d41a6a4..85da2e6 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -1733,6 +1733,7 @@ impl OrchestratorService { // agent is (re)launched through the app-tauri composition root. mcp_runtime: None, allow_structured_alongside_pty: false, + require_structured: false, }) .await?; @@ -2568,6 +2569,7 @@ impl OrchestratorService { .as_ref() .and_then(|p| p.runtime_for(project, agent_id)), allow_structured_alongside_pty: false, + require_structured: false, }) .await?; @@ -2648,6 +2650,7 @@ impl OrchestratorService { .as_ref() .and_then(|p| p.runtime_for(project, agent_id)), allow_structured_alongside_pty: true, + require_structured: false, }) .await?; diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index d3fb802..256f572 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -516,6 +516,7 @@ struct FakePty { next_id: SessionId, spawns: Arc>>, writes: WriteLog, + kills: Arc>>, } impl FakePty { @@ -525,6 +526,7 @@ impl FakePty { next_id, spawns: Arc::new(Mutex::new(Vec::new())), writes: Arc::new(Mutex::new(Vec::new())), + kills: Arc::new(Mutex::new(Vec::new())), } } fn spawns(&self) -> Vec { @@ -533,6 +535,9 @@ impl FakePty { fn writes(&self) -> Vec<(SessionId, Vec)> { self.writes.lock().unwrap().clone() } + fn kills(&self) -> Vec { + self.kills.lock().unwrap().clone() + } } #[async_trait] @@ -566,7 +571,8 @@ impl PtyPort for FakePty { fn try_wait(&self, _handle: &PtyHandle) -> Result, PtyError> { Ok(Some(ExitStatus { code: Some(0) })) } - async fn kill(&self, _handle: &PtyHandle) -> Result { + async fn kill(&self, handle: &PtyHandle) -> Result { + self.kills.lock().unwrap().push(handle.session_id); Ok(ExitStatus { code: Some(0) }) } } @@ -1130,6 +1136,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput { conversation_id: None, mcp_runtime: None, allow_structured_alongside_pty: false, + require_structured: false, } } @@ -1257,6 +1264,86 @@ async fn structured_profile_with_factory_routes_to_structured_session_without_pt ); } +#[tokio::test] +async fn human_launcher_with_chat_intent_routes_structured_profile_to_chat_session() { + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_structured_adapter(StructuredAdapter::Claude); + let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888)); + let structured = Arc::new(StructuredSessions::new()); + let launch = launch + .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + + let mut input = launch_input(agent.id); + input.node_id = Some(nid(77)); + input.require_structured = true; + let out = launch.execute(input).await.unwrap(); + + assert_eq!(factory.starts(), vec![pid(9)]); + assert!(pty.spawns().is_empty(), "custom chat must not spawn PTY"); + assert!( + out.structured.is_some(), + "custom chat receives a descriptor" + ); + assert_eq!( + structured.node_for_agent_in_project(project().id, &agent.id), + Some(nid(77)) + ); +} + +#[tokio::test] +async fn chat_intent_replaces_existing_pty_instead_of_returning_pty_session() { + let profile = profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ) + .with_structured_adapter(StructuredAdapter::Claude); + let (launch, agent, _fs, pty, _bus, sessions, tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + seed_live_agent_session(&sessions, agent.id, nid(7), sid(42)); + let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888)); + let structured = Arc::new(StructuredSessions::new()); + let launch = launch + .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + + let mut input = launch_input(agent.id); + input.node_id = Some(nid(7)); + input.require_structured = true; + let out = launch.execute(input).await.unwrap(); + + assert_eq!(pty.kills(), vec![sid(42)], "old PTY is closed"); + assert!( + pty.spawns().is_empty(), + "replacement uses structured, not PTY" + ); + assert!( + sessions + .session_for_agent_in_project(project().id, &agent.id) + .is_none(), + "old PTY registration is removed" + ); + assert_eq!(factory.starts(), vec![pid(9)]); + assert_eq!(out.session.id, sid(888)); + assert!( + out.structured.is_some(), + "backend returns cellKind=chat via DTO" + ); +} + #[tokio::test] async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() { let profile = profile( diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 3abafb8..4d9be4f 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -771,6 +771,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput { conversation_id: None, mcp_runtime: None, allow_structured_alongside_pty: false, + require_structured: false, } } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index cab00aa..227e205 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -2823,6 +2823,11 @@ pub struct LaunchAgentRequestDto { /// refused). #[serde(default)] pub node_id: Option, + /// Requested render/runtime kind for this launch. Omitted by historical callers + /// and native TUI cells, which keeps the backend on the PTY path. The custom chat + /// view sends `"chat"` to require a structured/headless session. + #[serde(default)] + pub cell_kind: Option, } impl From for TerminalSessionDto { diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 2d62081..f66337a 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -725,6 +725,7 @@ impl WakeSessionProvider for AppWakeSessionProvider { conversation_id: None, mcp_runtime: None, allow_structured_alongside_pty: true, + require_structured: false, }) .await .map_err(|err| WakeError::Session(err.to_string()))?; @@ -844,6 +845,7 @@ impl AgentResumer for AppAgentResumer { conversation_id, mcp_runtime, allow_structured_alongside_pty: false, + require_structured: false, }) .await?; @@ -2028,9 +2030,9 @@ impl BackendCore { // LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal // use cases — indispensable for transport bridges to work correctly. // - // The human-facing launcher intentionally stays PTY-only: when the user opens an - // agent cell, they keep the native Claude/Codex CLI and its commands. Inter-agent - // delegation gets its own launcher below, wired to structured/headless sessions. + // The human-facing launcher defaults to PTY for native Claude/Codex TUI cells, but + // it also carries the structured ports so the custom chat CLI can explicitly + // request a structured/headless launch through the same Tauri command. // --- Permission projectors (lot LP3-5) --- // UN seul registre, source unique de vérité, injecté à l'identique dans @@ -2084,7 +2086,11 @@ impl BackendCore { clock: Arc::clone(&clock) as Arc, }) as Arc) .with_local_model_server(Arc::clone(&ensure_local_model_server)) - .with_secret_store(Arc::clone(&secret_store_port)), + .with_secret_store(Arc::clone(&secret_store_port)) + .with_structured( + Arc::clone(&session_factory), + Arc::clone(&structured_sessions), + ), ); // Inter-agent launcher: same context, memory, permissions and live-state diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 2523c32..2f1b377 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -1516,6 +1516,7 @@ async fn execute_launch_agent_for_ws( conversation_id: request.conversation_id.clone(), mcp_runtime, allow_structured_alongside_pty: false, + require_structured: request.cell_kind == Some(backend::dto::CellKind::Chat), }) .await .map_err(ErrorDto::from)?;