//! 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(), orchestrator: None, }, 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) } } #[async_trait] impl AgentRuntime for FakeRuntime { async 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(), }), sandbox: None, }) } } // --------------------------------------------------------------------------- // 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, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> 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), })) } } // --------------------------------------------------------------------------- // FakeProviderSessionStore + FakeProviderSessionProvider (lot P8b) // --------------------------------------------------------------------------- use application::ProviderSessionProvider; use domain::{ConversationId, ProviderSessionStore}; /// In-memory [`ProviderSessionStore`] for the P8b launch tests: a /// `(conversation, provider_id) → resumable_id` map, observable after the launch. /// `set` can be made to fail (best-effort scenario) so we can prove a write error /// never degrades the launch. #[derive(Clone, Default)] struct FakeProviderSessionStore { entries: Arc>>, fail_set: bool, } impl FakeProviderSessionStore { fn new() -> Self { Self::default() } /// A store whose `set` always errors (best-effort proof). fn failing() -> Self { Self { entries: Arc::new(Mutex::new(HashMap::new())), fail_set: true, } } /// Pre-seeds a `(conversation, provider) → resumable_id` mapping synchronously, /// so a P8c launch reading via [`ProviderSessionStore::get`] resolves the engine /// resumable for that pair (mirrors what a previous P8b launch would have stored). fn seed_sync(&self, conversation: ConversationId, provider: &str, resumable_id: &str) { self.entries .lock() .unwrap() .insert((conversation, provider.to_owned()), resumable_id.to_owned()); } /// Observed resumable id for a `(conversation, provider)` couple, if any. fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option { self.entries .lock() .unwrap() .get(&(conversation, provider.to_owned())) .cloned() } fn len(&self) -> usize { self.entries.lock().unwrap().len() } } #[async_trait] impl ProviderSessionStore for FakeProviderSessionStore { async fn get( &self, conversation: ConversationId, provider_id: &str, ) -> Result, StoreError> { Ok(self.get_sync(conversation, provider_id)) } async fn set( &self, conversation: ConversationId, provider_id: &str, resumable_id: &str, ) -> Result<(), StoreError> { if self.fail_set { return Err(StoreError::Io("forced set failure".to_owned())); } self.entries.lock().unwrap().insert( (conversation, provider_id.to_owned()), resumable_id.to_owned(), ); Ok(()) } } /// [`ProviderSessionProvider`] that hands back the same store for any root (the /// launch tests use a single project root). #[derive(Clone)] struct FakeProviderSessionProvider(Arc); impl ProviderSessionProvider for FakeProviderSessionProvider { fn provider_session_store_for( &self, _root: &ProjectPath, ) -> Option> { Some(Arc::clone(&self.0) as Arc) } } // --------------------------------------------------------------------------- // 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, mcp_runtime: None, allow_structured_alongside_pty: false, } } /// 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 )); // P8a (ARCHITECTURE §19.7) : la CELLULE porte l'**id de paire IdeA**, pas l'id // moteur. Cellule neuve, lancement direct (aucun requester) ⇒ `pair(User, agent)` // dérivé via `ConversationId::for_pair` = l'UUID de l'agent (`aid(1)`). assert_eq!( out.assigned_conversation_id.as_deref(), Some("00000000-0000-0000-0000-000000000001"), "cell carries the IdeA pair id (pivot logique), not the engine resumable" ); // L'id de session MOTEUR (resumable provider) part dans le cache séparé. assert_eq!( out.engine_session_id.as_deref(), Some("engine-conv"), "engine resumable id is cached separately from the pair key" ); } /// 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(), project_root: ROOT.to_owned(), }; let cwd = ProjectPath::new(ROOT).unwrap(); let session = f .factory .start(&profile, &ctx, &cwd, &SessionPlan::None, &[], 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" ); } // =========================================================================== // LOT P8b — écriture de `providers.json` au lancement structuré // // Prouve `persist_provider_session` (best-effort) : après un lancement structuré // exposant un id de session moteur, IdeA range `(pair, provider_key) → // engine_session_id` via le `ProviderSessionStore` câblé. Toutes les conditions de // skip (provider absent, id moteur absent) et la robustesse (set en échec) sont // couvertes — le lancement réussit toujours. // =========================================================================== /// Profil **structuré Codex** (porte `StructuredAdapter::Codex`) pour prouver la clé /// provider `"codex"`. fn structured_codex_profile(id: ProfileId) -> AgentProfile { AgentProfile::new( id, "Codex Structuré", "codex", Vec::new(), ContextInjection::convention_file("AGENTS.md").unwrap(), Some("codex --version".to_owned()), "{agentRunDir}", None, ) .unwrap() .with_structured_adapter(StructuredAdapter::Codex) } /// Builds a `LaunchAgent.with_structured(...).with_provider_session_provider(...)` /// over the given profile/factory, returning the launcher, the agent and the /// observable provider store. When `provider` is `None`, no provider is wired /// (legacy path). fn launch_fixture_p8b( profile: AgentProfile, factory: FakeFactory, store: Option>, ) -> (Arc, Agent) { 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 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 mut launch = LaunchAgent::new( Arc::new(contexts), Arc::new(profiles), Arc::new(runtime), Arc::new(fs), Arc::new(pty), Arc::new(FakeSkills), Arc::clone(&sessions), Arc::new(bus), Arc::new(SeqIds::new()), Arc::new(FakeRecall), None, ) .with_structured(Arc::new(factory), Arc::clone(&structured)); if let Some(store) = store { let provider = Arc::new(FakeProviderSessionProvider(store)) as Arc; launch = launch.with_provider_session_provider(provider); } (Arc::new(launch), agent) } /// **Cas nominal Claude** : provider câblé, profil structuré Claude, moteur exposant /// `conversation_id() == Some("engine-xyz")`, cellule portant une `conversation_id` /// UUID valide ⇒ le store contient `(pair, "claude") == "engine-xyz"`, où la clé de /// conversation est exactement le `pair_conversation_id` (= l'uuid d'entrée). #[tokio::test] async fn p8b_nominal_claude_writes_engine_id_under_pair_and_claude_key() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, Some("engine-xyz")); let (launch, agent) = launch_fixture_p8b( structured_profile(pid(9)), factory, Some(Arc::clone(&store)), ); // La cellule porte un id de paire UUID valide : c'est lui qui sert de clé. let pair = ConversationId::from_uuid(Uuid::from_u128(123)); let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); launch.execute(input).await.expect("structured launch"); assert_eq!( store.get_sync(pair, "claude").as_deref(), Some("engine-xyz"), "engine resumable stored under (pair, claude)" ); assert_eq!(store.len(), 1, "exactly one entry written"); // La clé de conversation est bien le pair_conversation_id (l'uuid d'entrée), pas // l'id moteur : un lookup sous l'id moteur (non-UUID) ne renverrait rien. assert!( store.get_sync(pair, "codex").is_none(), "nothing written under a different provider key" ); } /// **Cas nominal Codex** : même scénario, profil structuré Codex ⇒ clé provider /// `"codex"`. #[tokio::test] async fn p8b_nominal_codex_writes_engine_id_under_codex_key() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, Some("engine-codex")); let (launch, agent) = launch_fixture_p8b( structured_codex_profile(pid(9)), factory, Some(Arc::clone(&store)), ); let pair = ConversationId::from_uuid(Uuid::from_u128(456)); let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); launch.execute(input).await.expect("structured launch"); assert_eq!( store.get_sync(pair, "codex").as_deref(), Some("engine-codex"), "engine resumable stored under (pair, codex)" ); assert_eq!(store.len(), 1); } /// **provider_key correct** : test direct du contrat de persistance. #[test] fn p8b_provider_key_mapping_is_stable() { assert_eq!(StructuredAdapter::Claude.provider_key(), "claude"); assert_eq!(StructuredAdapter::Codex.provider_key(), "codex"); } /// **No-op sans provider** : sans `with_provider_session_provider`, le lancement /// réussit et rien n'est écrit (le store, non câblé, reste vide — on en garde une /// copie pour le prouver). #[tokio::test] async fn p8b_no_provider_wired_writes_nothing_launch_ok() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, Some("engine-xyz")); // store NON câblé (None) ⇒ aucune écriture possible. let (launch, agent) = launch_fixture_p8b(structured_profile(pid(9)), factory, None); let pair = ConversationId::from_uuid(Uuid::from_u128(123)); let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); launch .execute(input) .await .expect("launch ok without provider"); assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written"); } /// **No-op sans id moteur** : la session fake n'expose aucun id /// (`conversation_id() == None`) ⇒ aucune écriture, le lancement réussit. #[tokio::test] async fn p8b_no_engine_id_writes_nothing_launch_ok() { let store = Arc::new(FakeProviderSessionStore::new()); // Factory avec conversation_id None ⇒ session.conversation_id() == None. let factory = FakeFactory::new(500, None); let (launch, agent) = launch_fixture_p8b( structured_profile(pid(9)), factory, Some(Arc::clone(&store)), ); let pair = ConversationId::from_uuid(Uuid::from_u128(123)); let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); launch .execute(input) .await .expect("launch ok without engine id"); assert_eq!( store.len(), 0, "no engine session id ⇒ persist skipped, launch unaffected" ); } /// **Best-effort** : un store dont `set` renvoie `Err` ⇒ `execute` réussit quand /// même (la sortie de lancement n'est pas dégradée : descripteur structuré présent, /// engine_session_id exposé). #[tokio::test] async fn p8b_store_set_error_does_not_fail_launch() { let store = Arc::new(FakeProviderSessionStore::failing()); let factory = FakeFactory::new(500, Some("engine-xyz")); let (launch, agent) = launch_fixture_p8b( structured_profile(pid(9)), factory, Some(Arc::clone(&store)), ); let pair = ConversationId::from_uuid(Uuid::from_u128(123)); let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); // Le lancement réussit malgré l'échec d'écriture du resumable. let out = launch .execute(input) .await .expect("launch must succeed despite store set failure"); // Sortie non dégradée : descripteur structuré + id moteur toujours exposés. let desc = out.structured.expect("structured descriptor still present"); assert_eq!(desc.session_id, sid(500)); assert_eq!(out.engine_session_id.as_deref(), Some("engine-xyz")); // Le store n'a (logiquement) rien persisté. assert_eq!(store.len(), 0, "failing set wrote nothing"); } // =========================================================================== // LOT P8c — routage du `--resume` moteur via providers.json // // Prouve `resolve_session_plan` (privée) **par son contrat observable** : le // `SessionPlan` que `LaunchAgent::execute` transmet à `factory.start` (capturé par // la `FakeFactory`). Pour un profil **structuré** avec un store provider câblé, le // resume moteur est lu dans `providers.json` keyé par (id de paire, provider_key) — // **jamais** l'id de paire lui-même n'est passé en `--resume`. // // Le fallback (provider non câblé) et le chemin non structuré sont déjà attestés par // `structured_profile_with_cell_conversation_resolves_to_resume`, // `structured_profile_fresh_cell_resolves_to_none` (cas 3 structuré non câblé) et les // suites existantes ; on ajoute ici le **cœur P8c** : la lecture du store. // =========================================================================== /// Variante de [`launch_fixture_p8b`] qui **rend aussi la factory** (clone partageant /// `starts` via `Arc`), pour observer le `SessionPlan` transmis à `start`. fn launch_fixture_p8c( profile: AgentProfile, factory: FakeFactory, store: Option>, ) -> (Arc, Agent, FakeFactory) { let observed = factory.clone(); let (launch, agent) = launch_fixture_p8b(profile, factory, store); (launch, agent, observed) } /// **Cas 1 — Claude câblé, store contient l'engine id** : la cellule porte l'id de /// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit /// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory — /// **et surtout PAS** l'id de paire. #[tokio::test] async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() { let store = Arc::new(FakeProviderSessionStore::new()); // Le moteur n'attribue pas d'id neuf (None) : le resume vient du store, pas du run. let factory = FakeFactory::new(500, None); let (launch, agent, observed) = launch_fixture_p8c( structured_profile(pid(9)), factory, Some(Arc::clone(&store)), ); // Pré-remplit le store : (pair, "claude") → "engine-x". let pair = ConversationId::from_uuid(Uuid::from_u128(123)); store.seed_sync(pair, "claude", "engine-x"); // La cellule porte l'id de **paire** (UUID), jamais l'id moteur. let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); launch .execute(input) .await .expect("structured launch resumes"); // Le SessionPlan transmis à factory.start est Resume avec l'**engine id du store**. let starts = observed.starts(); assert_eq!(starts.len(), 1, "factory.start called once"); assert_eq!( starts[0].1, SessionPlan::Resume { conversation_id: "engine-x".to_owned() }, "resume must carry the engine resumable id from providers.json" ); // GARDE-FOU EXPLICITE : ce n'est PAS l'id de paire qui part en --resume. match &starts[0].1 { SessionPlan::Resume { conversation_id } => { assert_ne!( conversation_id, &pair.to_string(), "the pair id must NEVER be passed as the engine --resume" ); } other => panic!("expected Resume, got {other:?}"), } } /// **Cas 1bis — Codex câblé, store contient l'engine id sous la clé `"codex"`** : /// même contrat, clé provider `"codex"`. #[tokio::test] async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, None); let (launch, agent, observed) = launch_fixture_p8c( structured_codex_profile(pid(9)), factory, Some(Arc::clone(&store)), ); let pair = ConversationId::from_uuid(Uuid::from_u128(456)); store.seed_sync(pair, "codex", "engine-codex-x"); let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); launch .execute(input) .await .expect("structured codex launch"); let starts = observed.starts(); assert_eq!(starts.len(), 1); assert_eq!( starts[0].1, SessionPlan::Resume { conversation_id: "engine-codex-x".to_owned() }, "codex resume reads its own provider key" ); } /// **Cas 1ter — la clé provider discrimine** : le store ne contient l'engine id que /// sous `"codex"`, mais le profil est **Claude** ⇒ le lookup sous `"claude"` ne trouve /// rien ⇒ `SessionPlan::None` (premier lancement propre, jamais l'id de paire). #[tokio::test] async fn p8c_provider_key_mismatch_falls_back_to_none() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, None); let (launch, agent, observed) = launch_fixture_p8c( structured_profile(pid(9)), factory, Some(Arc::clone(&store)), ); let pair = ConversationId::from_uuid(Uuid::from_u128(123)); // Engine id rangé sous une AUTRE clé provider. store.seed_sync(pair, "codex", "engine-codex"); let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); launch.execute(input).await.expect("structured launch"); let starts = observed.starts(); assert_eq!(starts.len(), 1); assert_eq!( starts[0].1, SessionPlan::None, "a claude profile must not pick up a codex-keyed resumable" ); } /// **Cas 2 — store câblé mais vide** (`get` → `None`) : la cellule porte un id de /// paire valide mais aucun resumable n'a été rangé ⇒ `SessionPlan::None` (premier /// lancement propre). On vérifie aussi que l'id de paire n'est jamais transmis. #[tokio::test] async fn p8c_structured_store_empty_resolves_to_none() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, None); let (launch, agent, observed) = launch_fixture_p8c( structured_profile(pid(9)), factory, Some(Arc::clone(&store)), ); let pair = ConversationId::from_uuid(Uuid::from_u128(123)); let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); launch.execute(input).await.expect("structured launch"); let starts = observed.starts(); assert_eq!(starts.len(), 1); assert_eq!( starts[0].1, SessionPlan::None, "empty store ⇒ clean first launch, never the pair id" ); } /// **Cas 2bis — store câblé, cellule neuve (pas d'id de paire)** : aucun id à /// chercher ⇒ `SessionPlan::None`. #[tokio::test] async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, None); let (launch, agent, observed) = launch_fixture_p8c( structured_profile(pid(9)), factory, Some(Arc::clone(&store)), ); // Cellule neuve : conversation_id = None. launch .execute(launch_input(agent.id)) .await .expect("fresh structured launch"); let starts = observed.starts(); assert_eq!(starts.len(), 1); assert_eq!( starts[0].1, SessionPlan::None, "no cell id ⇒ nothing to look up ⇒ None" ); } /// **Robustesse — id de paire non-UUID + store câblé** : un `conversation_id` qui ne /// parse pas en UUID (donnée legacy/corrompue) ⇒ pas de lookup possible ⇒ /// `SessionPlan::None`, jamais l'id de paire passé tel quel en `--resume`. #[tokio::test] async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, None); let (launch, agent, observed) = launch_fixture_p8c( structured_profile(pid(9)), factory, Some(Arc::clone(&store)), ); let mut input = launch_input(agent.id); input.conversation_id = Some("not-a-uuid".to_owned()); launch.execute(input).await.expect("structured launch"); let starts = observed.starts(); assert_eq!(starts.len(), 1); assert_eq!( starts[0].1, SessionPlan::None, "a non-UUID pair id must degrade to None, never resume the raw string" ); }