diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 37ae124..9388d80 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -1924,51 +1924,58 @@ impl LaunchAgent { } } } - // Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de - // façon identique — rebind de la cellule-vue pour une réattache légitime, - // idempotence sans node/conversation, refus d'un second lancement neuf ailleurs. - if let Some(structured) = &self.structured { - if let Some(existing) = - structured.session_for_agent_in_project(input.project.id, &input.agent_id) - { - let host_node = - structured.node_for_agent_in_project(input.project.id, &input.agent_id); - let node_id = match reattach_decision( - input.node_id, - host_node, - input.conversation_id.as_deref(), - ) { - ReattachDecision::Rebind { node_id } => { - let _ = structured.rebind_agent_node_in_project( - input.project.id, - &input.agent_id, - node_id, - ); - node_id - } - // Idempotent — garder le node hôte courant, sinon un node neuf. - ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random), - ReattachDecision::Refuse { node_id } => { - return Err(AppError::AgentAlreadyRunning { + // Garde structurée (§17.4) : même sémantique côté registre IA, mais seulement + // pour un lancement qui vise lui-même la surface structurée. Un lancement + // humain PTY/TUI explicite doit pouvoir coexister avec une conversation + // inter-agent/headless déjà vivante pour le même agent : les canaux d'entrée + // et de sortie sont distincts, et la garde PTY ci-dessus reste la source de + // vérité de l'occupation TUI utilisateur. + if wants_structured { + if let Some(structured) = &self.structured { + if let Some(existing) = + structured.session_for_agent_in_project(input.project.id, &input.agent_id) + { + let host_node = + structured.node_for_agent_in_project(input.project.id, &input.agent_id); + let node_id = match reattach_decision( + input.node_id, + host_node, + input.conversation_id.as_deref(), + ) { + ReattachDecision::Rebind { node_id } => { + let _ = structured.rebind_agent_node_in_project( + input.project.id, + &input.agent_id, + node_id, + ); + node_id + } + // Idempotent — garder le node hôte courant, sinon un node neuf. + ReattachDecision::Idempotent => { + host_node.unwrap_or_else(NodeId::new_random) + } + ReattachDecision::Refuse { node_id } => { + return Err(AppError::AgentAlreadyRunning { + agent_id: input.agent_id, + node_id, + }); + } + }; + return Ok(LaunchAgentOutput { + session: structured_snapshot(&existing, input.agent_id, node_id, size), + assigned_conversation_id: None, + // Rebind de vue : aucune (ré)assignation ⇒ rien de neuf à cacher. + engine_session_id: None, + structured: Some(StructuredSessionDescriptor { + session_id: existing.id(), agent_id: input.agent_id, node_id, - }); - } - }; - return Ok(LaunchAgentOutput { - session: structured_snapshot(&existing, input.agent_id, node_id, size), - assigned_conversation_id: None, - // Rebind de vue : aucune (ré)assignation ⇒ rien de neuf à cacher. - engine_session_id: None, - structured: Some(StructuredSessionDescriptor { - session_id: existing.id(), - agent_id: input.agent_id, - node_id, - conversation_id: existing.conversation_id(), - }), - // Réattache structurée (rebind de vue) : aucun profil re-résolu. - profile: None, - }); + conversation_id: existing.conversation_id(), + }), + // Réattache structurée (rebind de vue) : aucun profil re-résolu. + profile: None, + }); + } } } diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 4d9be4f..ac27ea5 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -50,8 +50,8 @@ use domain::{MemoryIndexEntry, NodeId, PtySize, SessionId, SessionKind, SkillId} use uuid::Uuid; use application::{ - ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput, StructuredSessions, - TerminalSessions, + ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput, + StructuredRoutingMode, StructuredSessions, TerminalSessions, }; // --------------------------------------------------------------------------- @@ -811,6 +811,14 @@ struct LaunchFixture { /// Wires a `LaunchAgent.with_structured(...)` for a given profile + factory. fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> LaunchFixture { + launch_fixture_with_mode(profile, factory, StructuredRoutingMode::RequireStructured) +} + +fn launch_fixture_with_mode( + profile: AgentProfile, + factory: FakeFactory, + mode: StructuredRoutingMode, +) -> LaunchFixture { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); let contexts = FakeContexts::with_agent(&agent, "# ctx body"); let profiles = FakeProfiles::new(vec![profile]); @@ -834,7 +842,8 @@ fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> LaunchFixture Arc::new(FakeRecall), None, ) - .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)) + .with_structured_routing_mode(mode); LaunchFixture { launch: Arc::new(launch), agent, @@ -1092,6 +1101,58 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() { assert_eq!(desc.node_id, target); } +/// #169/#170 — une session structurée vivante (chat/headless) ne doit pas bloquer +/// un lancement PTY/TUI utilisateur explicite du même agent. Les deux surfaces ont +/// des canaux distincts ; la garde singleton de la TUI reste portée par le registre +/// PTY, pas par `StructuredSessions`. +#[tokio::test] +async fn pty_launch_is_allowed_while_structured_session_is_live() { + let factory = FakeFactory::new(500, Some("engine-conv")); + let f = launch_fixture_with_mode( + structured_profile(pid(9)), + factory, + StructuredRoutingMode::HumanPtyFallback, + ); + + let chat_node = nid(1); + let mut chat = launch_input(f.agent.id); + chat.node_id = Some(chat_node); + chat.require_structured = true; + f.launch.execute(chat).await.expect("structured launch"); + assert_eq!(f.factory.start_count(), 1); + assert_eq!( + f.structured + .session_id_for_agent_in_project(project_id(), &f.agent.id), + Some(sid(500)) + ); + + let tui_node = nid(2); + let mut tui = launch_input(f.agent.id); + tui.node_id = Some(tui_node); + tui.require_structured = false; + let out = f.launch.execute(tui).await.expect("pty launch"); + + assert_eq!( + f.factory.start_count(), + 1, + "the PTY launch must not re-enter the structured session factory" + ); + assert_eq!(f.pty.spawn_count(), 1, "the user TUI gets its own PTY"); + assert_eq!(out.session.id, sid(777)); + assert!(out.structured.is_none(), "output describes the PTY surface"); + assert_eq!( + f.sessions + .session_for_agent_in_project(project_id(), &f.agent.id), + Some(sid(777)) + ); + assert_eq!( + f.structured + .session_id_for_agent_in_project(project_id(), &f.agent.id), + Some(sid(500)), + "the structured conversation remains live and distinct" + ); +} + /// Helper: executes a launch that is expected to be refused, returning the error. async fn second_launch_err(f: &LaunchFixture, input: LaunchAgentInput) -> application::AppError { f.launch