//! LOT D3 (ARCHITECTURE §17, ligne §17.9 D3) — tests du **chemin structuré** de //! [`LaunchAgent`] et de la réconciliation A/B, 100 % via des fakes (jamais le vrai //! claude/codex). //! //! Couvre, en injectant une fake [`AgentSessionFactory`] (+ fake [`AgentSession`]) //! via les builders additifs `with_structured(...)` : //! //! 1. **Routage `LaunchAgent`** : un profil porteur d'un `structured_adapter` + une //! factory câblée ⇒ `factory.start` appelé, session enregistrée dans //! [`StructuredSessions`] (retrouvable par `session_for_agent`), **aucun** //! `pty.spawn`, `AgentLaunched` publié, `LaunchAgentOutput.structured = Some(..)` //! avec les bons `agent_id`/`node_id`/`conversation_id` ; un profil non structuré //! suit le chemin PTY inchangé ; invariant « 1 session vivante/agent » côté //! structuré (rebind/idempotence, pas de 2e `start`). //! 2. **Réconciliation A** (`ChangeAgentProfile`) : session structurée vivante ⇒ //! `shutdown()` polymorphe (pas un kill PTY) puis relance via la factory ; //! session PTY vivante ⇒ A1 d'origine (kill PTY) inchangé ; la détection consulte //! les deux registres. //! 3. **Réconciliation B** : `resolve_session_plan` renvoie `Resume{conversation_id}` //! pour un profil structuré dont la cellule porte une conversation (le runtime //! fake capture le `SessionPlan` reçu). //! //! Le fake `AgentSession` enregistre ses `shutdown()` dans un compteur partagé, et //! le fake `AgentSessionFactory` enregistre chaque `start` (profil + plan de session) //! — c'est ainsi qu'on prouve « start appelé une seule fois », « shutdown appelé », //! « le bon SessionPlan transmis ». use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; use domain::events::DomainEvent; use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::layout::Workspace; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PreparedContext, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; use domain::{MemoryIndexEntry, NodeId, PtySize, SessionId, SessionKind, SkillId}; use uuid::Uuid; use application::{ ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, LaunchAgentInput, StructuredSessions, TerminalSessions, }; // --------------------------------------------------------------------------- // FakeContexts (AgentContextStore) // --------------------------------------------------------------------------- #[derive(Default)] struct ContextsInner { manifest: AgentManifest, contents: HashMap, } #[derive(Clone)] struct FakeContexts(Arc>); impl FakeContexts { fn with_agent(agent: &Agent, content: &str) -> Self { let me = Self(Arc::new(Mutex::new(ContextsInner { manifest: AgentManifest { version: 1, entries: Vec::new(), }, contents: HashMap::new(), }))); { let mut inner = me.0.lock().unwrap(); inner .manifest .entries .push(ManifestEntry::from_agent(agent)); inner .contents .insert(agent.context_path.clone(), content.to_owned()); } me } fn md_path_of(&self, agent: &AgentId) -> Option { self.0 .lock() .unwrap() .manifest .entries .iter() .find(|e| &e.agent_id == agent) .map(|e| e.md_path.clone()) } fn profile_of(&self, agent: &AgentId) -> Option { self.0 .lock() .unwrap() .manifest .entries .iter() .find(|e| &e.agent_id == agent) .map(|e| e.profile_id) } } #[async_trait] impl AgentContextStore for FakeContexts { async fn read_context( &self, _project: &Project, agent: &AgentId, ) -> Result { let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; self.0 .lock() .unwrap() .contents .get(&md_path) .cloned() .map(MarkdownDoc::new) .ok_or(StoreError::NotFound) } async fn write_context( &self, _project: &Project, agent: &AgentId, md: &MarkdownDoc, ) -> Result<(), StoreError> { let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; self.0 .lock() .unwrap() .contents .insert(md_path, md.as_str().to_owned()); Ok(()) } async fn load_manifest(&self, _project: &Project) -> Result { Ok(self.0.lock().unwrap().manifest.clone()) } async fn save_manifest( &self, _project: &Project, manifest: &AgentManifest, ) -> Result<(), StoreError> { self.0.lock().unwrap().manifest = manifest.clone(); Ok(()) } } // --------------------------------------------------------------------------- // FakeProfiles (ProfileStore) // --------------------------------------------------------------------------- #[derive(Clone)] struct FakeProfiles(Arc>); impl FakeProfiles { fn new(profiles: Vec) -> Self { Self(Arc::new(profiles)) } } #[async_trait] impl ProfileStore for FakeProfiles { async fn list(&self) -> Result, StoreError> { Ok((*self.0).clone()) } async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> { Ok(()) } async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> { Ok(()) } async fn is_configured(&self) -> Result { Ok(true) } async fn mark_configured(&self) -> Result<(), StoreError> { Ok(()) } } // --------------------------------------------------------------------------- // FakeStore (ProjectStore) // --------------------------------------------------------------------------- #[derive(Default, Clone)] struct FakeStore(Arc>>); #[async_trait] impl ProjectStore for FakeStore { async fn list_projects(&self) -> Result, StoreError> { Ok(self.0.lock().unwrap().clone()) } async fn load_project(&self, id: ProjectId) -> Result { self.0 .lock() .unwrap() .iter() .find(|p| p.id == id) .cloned() .ok_or(StoreError::NotFound) } async fn save_project(&self, project: &Project) -> Result<(), StoreError> { self.0.lock().unwrap().push(project.clone()); Ok(()) } async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> { Ok(()) } async fn load_workspace(&self) -> Result { Ok(Workspace::default()) } } // --------------------------------------------------------------------------- // FakeFs (FileSystem) — HashMap-backed // --------------------------------------------------------------------------- #[derive(Default)] struct FakeFsInner { files: HashMap>, } #[derive(Default, Clone)] struct FakeFs(Arc>); #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.0 .lock() .unwrap() .files .get(path.as_str()) .cloned() .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { self.0 .lock() .unwrap() .files .insert(path.as_str().to_owned(), data.to_vec()); Ok(()) } async fn exists(&self, _path: &RemotePath) -> Result { Ok(false) } async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } // --------------------------------------------------------------------------- // FakeRuntime (AgentRuntime) — capture le SessionPlan reçu (B) // --------------------------------------------------------------------------- struct FakeRuntime { last_session: Arc>>, } impl FakeRuntime { fn new() -> Self { Self { last_session: Arc::new(Mutex::new(None)), } } fn session_probe(&self) -> Arc>> { Arc::clone(&self.last_session) } } impl AgentRuntime for FakeRuntime { fn detect(&self, _profile: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( &self, profile: &AgentProfile, _ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, ) -> Result { *self.last_session.lock().unwrap() = Some(session.clone()); Ok(SpawnSpec { command: profile.command.clone(), args: profile.args.clone(), cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), }) } } // --------------------------------------------------------------------------- // FakePty (PtyPort) — records spawns and kills // --------------------------------------------------------------------------- #[derive(Clone)] struct FakePty { next_id: SessionId, spawns: Arc>>, kills: Arc>>, } impl FakePty { fn new(next_id: SessionId) -> Self { Self { next_id, spawns: Arc::new(Mutex::new(Vec::new())), kills: Arc::new(Mutex::new(Vec::new())), } } fn spawn_count(&self) -> usize { self.spawns.lock().unwrap().len() } fn kills(&self) -> Vec { self.kills.lock().unwrap().clone() } } #[async_trait] impl PtyPort for FakePty { async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result { self.spawns.lock().unwrap().push(spec); Ok(PtyHandle { session_id: self.next_id, }) } fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> { Ok(()) } fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> { Ok(()) } fn subscribe_output(&self, _handle: &PtyHandle) -> Result { Ok(Box::new(std::iter::empty())) } fn scrollback(&self, _handle: &PtyHandle) -> Result, PtyError> { Ok(Vec::new()) } async fn kill(&self, handle: &PtyHandle) -> Result { self.kills.lock().unwrap().push(handle.session_id); Ok(ExitStatus { code: Some(0) }) } } // --------------------------------------------------------------------------- // FakeSkills / FakeRecall / SpyBus / SeqIds // --------------------------------------------------------------------------- #[derive(Clone, Default)] struct FakeSkills; #[async_trait] impl SkillStore for FakeSkills { async fn list(&self, _s: SkillScope, _r: &ProjectPath) -> Result, StoreError> { Ok(Vec::new()) } async fn get( &self, _s: SkillScope, _r: &ProjectPath, _id: SkillId, ) -> Result { Err(StoreError::NotFound) } async fn save(&self, _skill: &Skill, _r: &ProjectPath) -> Result<(), StoreError> { Ok(()) } async fn delete( &self, _s: SkillScope, _r: &ProjectPath, _id: SkillId, ) -> Result<(), StoreError> { Ok(()) } } #[derive(Clone, Default)] struct FakeRecall; #[async_trait] impl MemoryRecall for FakeRecall { async fn recall( &self, _root: &ProjectPath, _query: &MemoryQuery, ) -> Result, MemoryError> { Ok(Vec::new()) } } #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn events(&self) -> Vec { self.0.lock().unwrap().clone() } } impl EventBus for SpyBus { fn publish(&self, event: DomainEvent) { self.0.lock().unwrap().push(event); } fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } struct SeqIds(Mutex); impl SeqIds { fn new() -> Self { Self(Mutex::new(1)) } } impl IdGenerator for SeqIds { fn new_uuid(&self) -> Uuid { let mut n = self.0.lock().unwrap(); let id = Uuid::from_u128(*n); *n += 1; id } } // --------------------------------------------------------------------------- // FakeAgentSession + FakeAgentSessionFactory (le cœur du chemin structuré) // --------------------------------------------------------------------------- /// Session structurée fake : id + conversation id figés, `shutdown()` enregistré /// dans un compteur partagé (preuve du kill polymorphe), `send()` borné minimal. struct FakeAgentSession { id: SessionId, conversation_id: Option, shutdowns: Arc, } #[async_trait] impl AgentSession for FakeAgentSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { self.conversation_id.clone() } async fn send(&self, prompt: &str) -> Result { let stream: ReplyStream = Box::new( vec![ReplyEvent::Final { content: prompt.to_owned(), }] .into_iter(), ); Ok(stream) } async fn shutdown(&self) -> Result<(), AgentSessionError> { self.shutdowns.fetch_add(1, Ordering::SeqCst); Ok(()) } } /// Fabrique fake : enregistre chaque `start` (profil + SessionPlan reçus) et rend une /// [`FakeAgentSession`] avec l'id/conversation configurés. Partage le compteur de /// `shutdown` avec les sessions qu'elle crée, pour prouver le kill polymorphe. #[derive(Clone)] struct FakeFactory { /// Id de session attribué aux sessions créées (incrémenté à chaque start). next_id: Arc>, /// L'id de conversation moteur que la session exposera (`None` = neuf). conversation_id: Option, /// Trace des `(command, SessionPlan)` reçus par `start`. starts: Arc>>, /// Compteur de `shutdown()` partagé avec les sessions créées. shutdowns: Arc, } impl FakeFactory { fn new(first_id: u128, conversation_id: Option<&str>) -> Self { Self { next_id: Arc::new(Mutex::new(first_id)), conversation_id: conversation_id.map(str::to_owned), starts: Arc::new(Mutex::new(Vec::new())), shutdowns: Arc::new(AtomicUsize::new(0)), } } fn start_count(&self) -> usize { self.starts.lock().unwrap().len() } fn starts(&self) -> Vec<(String, SessionPlan)> { self.starts.lock().unwrap().clone() } fn shutdown_count(&self) -> usize { self.shutdowns.load(Ordering::SeqCst) } } #[async_trait] impl AgentSessionFactory for FakeFactory { fn supports(&self, profile: &AgentProfile) -> bool { profile.structured_adapter.is_some() } async fn start( &self, profile: &AgentProfile, _ctx: &PreparedContext, _cwd: &ProjectPath, session: &SessionPlan, ) -> Result, AgentSessionError> { self.starts .lock() .unwrap() .push((profile.command.clone(), session.clone())); let id = { let mut n = self.next_id.lock().unwrap(); let id = SessionId::from_uuid(Uuid::from_u128(*n)); *n += 1; id }; Ok(Arc::new(FakeAgentSession { id, conversation_id: self.conversation_id.clone(), shutdowns: Arc::clone(&self.shutdowns), })) } } // --------------------------------------------------------------------------- // Builders // --------------------------------------------------------------------------- const ROOT: &str = "/home/me/proj"; fn pid(n: u128) -> ProfileId { ProfileId::from_uuid(Uuid::from_u128(n)) } fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } fn sid(n: u128) -> SessionId { SessionId::from_uuid(Uuid::from_u128(n)) } fn nid(n: u128) -> NodeId { NodeId::from_uuid(Uuid::from_u128(n)) } fn project() -> Project { Project::new( ProjectId::from_uuid(Uuid::from_u128(1000)), "demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::local(), 1_700_000_000_000, ) .unwrap() } /// Profil **structuré** (porte un `structured_adapter`), convention file CLAUDE.md. fn structured_profile(id: ProfileId) -> AgentProfile { AgentProfile::new( id, "Claude Structuré", "claude", Vec::new(), ContextInjection::convention_file("CLAUDE.md").unwrap(), Some("claude --version".to_owned()), "{agentRunDir}", None, ) .unwrap() .with_structured_adapter(StructuredAdapter::Claude) } /// Profil **non structuré** (chemin PTY), convention file CLAUDE.md. fn pty_profile(id: ProfileId) -> AgentProfile { AgentProfile::new( id, "Claude PTY", "claude", Vec::new(), ContextInjection::convention_file("CLAUDE.md").unwrap(), Some("claude --version".to_owned()), "{agentRunDir}", None, ) .unwrap() } fn scratch_agent(id: AgentId, name: &str, md: &str, profile_id: ProfileId) -> Agent { Agent::new(id, name, md, profile_id, AgentOrigin::Scratch, false).unwrap() } fn launch_input(agent_id: AgentId) -> LaunchAgentInput { LaunchAgentInput { project: project(), agent_id, rows: 24, cols: 80, node_id: None, conversation_id: None, } } /// Inserts a live PTY agent session into the registry, pinned on `node`. fn seed_live_pty_session( sessions: &TerminalSessions, agent_id: AgentId, node: NodeId, session_id: SessionId, ) { let size = PtySize::new(24, 80).unwrap(); let mut session = domain::TerminalSession::starting( session_id, node, ProjectPath::new("/home/me/proj/.ideai/run/x").unwrap(), SessionKind::Agent { agent_id }, size, ); session.status = domain::SessionStatus::Running; sessions.insert(PtyHandle { session_id }, session); } // --------------------------------------------------------------------------- // Fixture (LaunchAgent câblé structuré) // --------------------------------------------------------------------------- struct LaunchFixture { launch: Arc, agent: Agent, pty: FakePty, bus: SpyBus, sessions: Arc, structured: Arc, factory: FakeFactory, session_probe: Arc>>, } /// Wires a `LaunchAgent.with_structured(...)` for a given profile + factory. fn launch_fixture(profile: AgentProfile, factory: FakeFactory) -> 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]); let runtime = FakeRuntime::new(); let session_probe = runtime.session_probe(); let fs = FakeFs::default(); let pty = FakePty::new(sid(777)); let sessions = Arc::new(TerminalSessions::new()); let structured = Arc::new(StructuredSessions::new()); let bus = SpyBus::default(); let launch = LaunchAgent::new( Arc::new(contexts), Arc::new(profiles), Arc::new(runtime), Arc::new(fs), Arc::new(pty.clone()), Arc::new(FakeSkills), Arc::clone(&sessions), Arc::new(bus.clone()), Arc::new(SeqIds::new()), Arc::new(FakeRecall), None, ) .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); LaunchFixture { launch: Arc::new(launch), agent, pty, bus, sessions, structured, factory, session_probe, } } // =========================================================================== // 1. Routage LaunchAgent : chemin structuré // =========================================================================== /// Profil structuré + factory câblée ⇒ `factory.start` appelé une fois, session /// enregistrée dans `StructuredSessions`, AUCUN `pty.spawn`, `AgentLaunched` publié, /// `output.structured = Some(descriptor)` avec les bons champs. #[tokio::test] async fn structured_launch_starts_session_registers_no_pty_spawn() { // Factory : 1re session id = 500, conversation moteur "engine-conv". let factory = FakeFactory::new(500, Some("engine-conv")); let f = launch_fixture(structured_profile(pid(9)), factory); let mut input = launch_input(f.agent.id); input.node_id = Some(nid(3)); let out = f.launch.execute(input).await.expect("structured launch"); // factory.start appelé exactement une fois. assert_eq!(f.factory.start_count(), 1, "factory.start called once"); // AUCUN spawn PTY. assert_eq!(f.pty.spawn_count(), 0, "no pty spawn on structured path"); // La session est enregistrée dans le registre structuré, retrouvable par agent. let registered = f .structured .session_for_agent(&f.agent.id) .expect("structured session registered"); assert_eq!(registered.id(), sid(500), "session id is the factory's"); assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(nid(3))); // Rien côté registre PTY. assert!(f.sessions.session_for_agent(&f.agent.id).is_none()); // AgentLaunched publié avec l'id de session structurée. assert_eq!( f.bus.events(), vec![DomainEvent::AgentLaunched { agent_id: f.agent.id, session_id: sid(500), }] ); // output.structured renseigné avec les bons agent/node/conversation. let desc = out.structured.expect("structured descriptor present"); assert_eq!(desc.session_id, sid(500)); assert_eq!(desc.agent_id, f.agent.id); assert_eq!(desc.node_id, nid(3)); assert_eq!(desc.conversation_id.as_deref(), Some("engine-conv")); // Le snapshot de session reste cohérent (kind = Agent, id = celui de la session). assert_eq!(out.session.id, sid(500)); assert!(matches!( out.session.kind, SessionKind::Agent { agent_id } if agent_id == f.agent.id )); // L'id de conversation moteur est exposé pour persistance sur la cellule. assert_eq!(out.assigned_conversation_id.as_deref(), Some("engine-conv")); } /// Profil **non structuré** (même câblage structuré présent) ⇒ chemin PTY inchangé : /// `pty.spawn` appelé, factory jamais sollicitée, `output.structured = None`. #[tokio::test] async fn non_structured_profile_takes_pty_path_unchanged() { let factory = FakeFactory::new(500, Some("engine-conv")); let f = launch_fixture(pty_profile(pid(9)), factory); let out = f .launch .execute(launch_input(f.agent.id)) .await .expect("pty launch"); // PTY spawn appelé, factory jamais sollicitée. assert_eq!(f.pty.spawn_count(), 1, "pty path spawns"); assert_eq!(f.factory.start_count(), 0, "factory not called for pty profile"); // Session côté registre PTY, rien côté structuré. assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777))); assert!(f.structured.session_for_agent(&f.agent.id).is_none()); // output.structured = None ; session PTY classique. assert!(out.structured.is_none(), "no structured descriptor on pty path"); assert_eq!(out.session.id, sid(777)); } /// Invariant « 1 agent = 1 employé » côté structuré (R0a) : un **lancement neuf** /// (sans `conversation_id`) sur une **autre** cellule B d'un agent structuré déjà /// vivant en cellule A est un second lancement ⇒ **refus** /// `AppError::AgentAlreadyRunning { node_id: A }`, **pas** de 2e `factory.start`, /// une seule session structurée vivante. #[tokio::test] async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() { let factory = FakeFactory::new(500, Some("engine-conv")); let f = launch_fixture(structured_profile(pid(9)), factory); // 1er lancement sur la cellule A. let host = nid(1); let mut first = launch_input(f.agent.id); first.node_id = Some(host); f.launch.execute(first).await.expect("first launch"); assert_eq!(f.factory.start_count(), 1); assert_eq!(f.structured.len(), 1); // Lancement NEUF (conversation_id: None) dans une cellule B ⇒ refus. let mut second = launch_input(f.agent.id); let target = nid(2); second.node_id = Some(target); let err = second_launch_err(&f, second).await; match err { application::AppError::AgentAlreadyRunning { agent_id, node_id } => { assert_eq!(agent_id, f.agent.id, "reports the live agent"); assert_eq!(node_id, host, "reports the live HOST node A, not target B"); } other => panic!("expected AgentAlreadyRunning, got {other:?}"), } assert_eq!( f.factory.start_count(), 1, "no second factory.start on a refused launch" ); assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn"); assert_eq!(f.structured.len(), 1, "still a single live structured session"); assert_eq!( f.structured.node_for_agent(&f.agent.id), Some(host), "session stays pinned on its host node A" ); } /// **Réattache légitime même node (R0a, structuré)** : relancer sur la **même** /// cellule hôte ⇒ rebind, pas d'erreur, pas de 2e `factory.start`, même session. #[tokio::test] async fn structured_relaunch_same_node_rebinds_no_second_start() { let factory = FakeFactory::new(500, Some("engine-conv")); let f = launch_fixture(structured_profile(pid(9)), factory); let host = nid(1); let mut first = launch_input(f.agent.id); first.node_id = Some(host); f.launch.execute(first).await.expect("first launch"); assert_eq!(f.factory.start_count(), 1); // Re-ouverture de la MÊME cellule. let mut again = launch_input(f.agent.id); again.node_id = Some(host); let out = f.launch.execute(again).await.expect("same-node rebind"); assert_eq!(f.factory.start_count(), 1, "no second factory.start"); assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn"); assert_eq!(f.structured.len(), 1, "single live structured session"); assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(host)); let desc = out.structured.expect("descriptor on rebind"); assert_eq!(desc.session_id, sid(500), "same live session id"); assert_eq!(desc.node_id, host); } /// **Réattache explicite par conversation (R0a, structuré)** : relancer sur une /// **autre** cellule B mais avec un `conversation_id` ⇒ rebind légitime de la vue, /// pas d'erreur, pas de 2e `factory.start`, même session, vue déplacée sur B. #[tokio::test] async fn structured_relaunch_other_cell_with_conversation_id_rebinds() { let factory = FakeFactory::new(500, Some("engine-conv")); let f = launch_fixture(structured_profile(pid(9)), factory); let host = nid(1); let mut first = launch_input(f.agent.id); first.node_id = Some(host); f.launch.execute(first).await.expect("first launch"); assert_eq!(f.factory.start_count(), 1); // Cellule B + signal de réattache explicite. let mut second = launch_input(f.agent.id); let target = nid(2); second.node_id = Some(target); second.conversation_id = Some("conv-live".to_owned()); let out = f.launch.execute(second).await.expect("explicit reattach"); assert_eq!( f.factory.start_count(), 1, "no second factory.start on explicit reattach" ); assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn"); assert_eq!(f.structured.len(), 1, "still a single live structured session"); assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(target)); let desc = out.structured.expect("descriptor on rebind"); assert_eq!(desc.session_id, sid(500), "same live session id"); assert_eq!(desc.node_id, target); } /// 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 .execute(input) .await .expect_err("fresh second launch elsewhere is refused") } // =========================================================================== // 2. Réconciliation A : ChangeAgentProfile (kill polymorphe) // =========================================================================== /// Câble un `ChangeAgentProfile.with_structured(...)` partageant les mêmes registres /// (PTY + structuré) et la même factory que le `LaunchAgent` composé. struct SwapFixture { swap: ChangeAgentProfile, contexts: FakeContexts, pty: FakePty, sessions: Arc, structured: Arc, factory: FakeFactory, } /// Wires the swap use case. Both `pid(1)` (current) and `pid(2)` (target) are known; /// `pid(2)` is structured so a relaunch routes through the factory. fn swap_fixture(agent: &Agent, target_structured: bool, factory: FakeFactory) -> SwapFixture { let target = if target_structured { structured_profile(pid(2)) } else { pty_profile(pid(2)) }; let profiles_vec = vec![structured_profile(pid(1)), target]; let contexts = FakeContexts::with_agent(agent, "# persona"); let profiles = FakeProfiles::new(profiles_vec); let store = FakeStore::default(); let fs = FakeFs::default(); let pty = FakePty::new(sid(777)); let sessions = Arc::new(TerminalSessions::new()); let structured = Arc::new(StructuredSessions::new()); let bus = SpyBus::default(); // Register the project so ProjectStore::load_project resolves (for conv cleanup). { let mut v = store.0.lock().unwrap(); v.push(project()); } let launch = LaunchAgent::new( Arc::new(contexts.clone()), Arc::new(profiles.clone()), Arc::new(FakeRuntime::new()), Arc::new(fs.clone()), Arc::new(pty.clone()), Arc::new(FakeSkills), Arc::clone(&sessions), Arc::new(bus.clone()), Arc::new(SeqIds::new()), Arc::new(FakeRecall), None, ) .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); let swap = ChangeAgentProfile::new( Arc::new(contexts.clone()), Arc::new(profiles), Arc::new(store), Arc::new(fs), Arc::clone(&sessions), Arc::new(pty.clone()), Arc::new(launch), Arc::new(bus), ) .with_structured(Arc::clone(&structured)); SwapFixture { swap, contexts, pty, sessions, structured, factory, } } fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileInput { ChangeAgentProfileInput { project: project(), agent_id, profile_id, rows: 24, cols: 80, } } /// Agent avec session **structurée** vivante ⇒ changement de profil ⇒ `shutdown()` /// appelé sur la session structurée (PAS un kill PTY), puis relance via la factory. #[tokio::test] async fn swap_structured_live_session_shuts_down_then_relaunches() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); // La factory crée la session de relance (id 600) ; la session vivante initiale // partage le même compteur de shutdown via la factory. let factory = FakeFactory::new(600, Some("relaunch-conv")); let f = swap_fixture(&agent, true, factory); // Pré-seed : une session structurée vivante (id 500) sur la cellule N, via la // MÊME factory pour partager le compteur de shutdown. let host = nid(5); { // Démarre une session "manuellement" pour la pré-seeder dans le registre. let profile = structured_profile(pid(1)); let ctx = PreparedContext { content: MarkdownDoc::new("# persona"), relative_path: "agents/backend.md".to_owned(), }; let cwd = ProjectPath::new(ROOT).unwrap(); let session = f .factory .start(&profile, &ctx, &cwd, &SessionPlan::None) .await .expect("seed structured session"); f.structured.insert(session, agent.id, host); } // La factory a maintenant été appelée 1 fois (le seed) ; reset logique : on // comptera les start APRÈS, donc on mémorise la base. let starts_before = f.factory.start_count(); assert_eq!(starts_before, 1, "seed used one start"); assert_eq!(f.structured.len(), 1); let out = f .swap .execute(change_input(agent.id, pid(2))) .await .expect("hot swap succeeds"); // shutdown() appelé sur la session structurée (kill polymorphe), PAS de kill PTY. assert_eq!( f.factory.shutdown_count(), 1, "structured session shut down exactly once" ); assert!( f.pty.kills().is_empty(), "no PTY kill for a structured live session" ); assert_eq!(f.pty.spawn_count(), 0, "no PTY spawn (target is structured)"); // Relance via la factory : un nouveau start (donc total 2). assert_eq!( f.factory.start_count(), 2, "exactly one relaunch start after the seed" ); // La nouvelle session structurée (id 600) est enregistrée sur la même cellule. // `ChangeAgentProfileOutput` ne surface qu'un snapshot `relaunched` (TerminalSession) // — on vérifie donc le registre structuré + le snapshot. // Seed consumed id 600; the relaunch's session is the factory's next id (601). let relaunched = out.relaunched.expect("a live structured agent is relaunched"); assert_eq!(relaunched.id, sid(601), "relaunch adopts the new structured id"); assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell"); assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601))); assert_eq!(f.structured.node_for_agent(&agent.id), Some(host)); assert_eq!(f.structured.len(), 1, "single live structured session after swap"); // Manifeste muté vers le nouveau profil. assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); } /// Agent avec session **PTY** vivante ⇒ comportement A1 d'origine (kill PTY) /// inchangé (non-régression), même quand le registre structuré est branché. #[tokio::test] async fn swap_pty_live_session_keeps_a1_kill_behaviour() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); // Cible PTY (pid(2) non structuré) ⇒ relance par spawn PTY. let factory = FakeFactory::new(600, None); let f = swap_fixture(&agent, false, factory); // Pré-seed : session PTY vivante (id 42) sur la cellule N. let host = nid(5); seed_live_pty_session(&f.sessions, agent.id, host, sid(42)); let out = f .swap .execute(change_input(agent.id, pid(2))) .await .expect("hot swap succeeds"); // A1 d'origine : le PTY vivant est tué. assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed"); // Aucune session structurée n'a été créée ni shutdown. assert_eq!(f.factory.start_count(), 0, "no structured start on pty swap"); assert_eq!(f.factory.shutdown_count(), 0, "no structured shutdown"); // Relance PTY : un spawn, même cellule. assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once"); let relaunched = out.relaunched.expect("a live agent is relaunched"); assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell"); assert_eq!(relaunched.id, sid(777)); // The relaunched session lives in the PTY registry, not the structured one. assert_eq!(f.sessions.session_for_agent(&agent.id), Some(sid(777))); assert!(f.structured.session_for_agent(&agent.id).is_none()); assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); } /// Détection de session vivante : un agent **dead** (aucune session dans aucun des /// deux registres) ⇒ pas de kill, pas de shutdown, pas de relance ; manifeste muté. #[tokio::test] async fn swap_dead_agent_no_kill_no_shutdown_no_relaunch() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let factory = FakeFactory::new(600, Some("c")); let f = swap_fixture(&agent, true, factory); let out = f .swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); assert!(out.relaunched.is_none()); assert!(f.structured.is_empty(), "no structured session created"); assert!(f.pty.kills().is_empty()); assert_eq!(f.factory.shutdown_count(), 0); assert_eq!(f.factory.start_count(), 0, "nothing to relaunch"); assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); } // =========================================================================== // 3. Réconciliation B : resolve_session_plan pour un profil structuré // =========================================================================== /// Une cellule structurée portant une conversation ⇒ le plan de session transmis au /// runtime est `Resume{conversation_id}` (le runtime fake le capture), même sans /// bloc `session` sur le profil (la reprise structurée dépend de l'adapter). #[tokio::test] async fn structured_profile_with_cell_conversation_resolves_to_resume() { let factory = FakeFactory::new(500, Some("engine-conv")); let f = launch_fixture(structured_profile(pid(9)), factory); let mut input = launch_input(f.agent.id); input.conversation_id = Some("conv-existing".to_owned()); f.launch.execute(input).await.expect("launch resumes"); // Le runtime (prepare_invocation) a reçu un SessionPlan::Resume avec l'id cellule. assert_eq!( *f.session_probe.lock().unwrap(), Some(SessionPlan::Resume { conversation_id: "conv-existing".to_owned() }), "structured profile with a cell conversation must resume" ); // Le plan Resume est aussi celui transmis à la factory.start (preuve bout-en-bout). let starts = f.factory.starts(); assert_eq!(starts.len(), 1); assert_eq!( starts[0].1, SessionPlan::Resume { conversation_id: "conv-existing".to_owned() } ); } /// Une cellule structurée **neuve** (pas de conversation) sur un profil sans bloc /// `session` ⇒ `SessionPlan::None` (rien à reprendre ; l'id moteur sera capté au 1er /// tour). #[tokio::test] async fn structured_profile_fresh_cell_resolves_to_none() { let factory = FakeFactory::new(500, None); let f = launch_fixture(structured_profile(pid(9)), factory); f.launch .execute(launch_input(f.agent.id)) .await .expect("fresh launch"); let starts = f.factory.starts(); assert_eq!(starts.len(), 1); assert_eq!( starts[0].1, SessionPlan::None, "fresh structured cell without a session block plans None" ); }