//! A1 tests for [`ChangeAgentProfile`] (ARCHITECTURE §15.1, §15.4 line A1). //! //! The hot-swap of an agent's runtime profile is a *composed* use case: it mutates //! the manifest, cleans the (now foreign) `conversation_id` / `agent_was_running` //! on every persisted layout cell hosting the agent, and — when the agent is live — //! kills its PTY and relaunches it in the same cell via [`LaunchAgent`]. //! //! Every port is faked in-memory (100 % without real I/O): //! - [`FakeContexts`] — [`AgentContextStore`] (manifest + `md_path → content`), //! - [`FakeProfiles`] — [`ProfileStore`] returning a fixed profile list, //! - [`FakeStore`] — [`ProjectStore`] holding the project, //! - [`FakeFs`] — [`FileSystem`] serving/recording files (the `layouts.json` the //! conversation-cleanup walks, plus the run-dir/convention writes of a relaunch), //! - [`FakeRuntime`] / [`FakePty`] — the runtime + PTY, the PTY recording **kills** //! so we can assert a live session is torn down before the relaunch, //! - [`SpyBus`] / [`SeqIds`] / [`FakeSkills`] / [`FakeRecall`] — event spy, ids, //! empty skill store and empty memory recall (behaviour unchanged). //! //! The cleanup helpers ([`seed_layouts`], [`leaf_state`]) are borrowed from the //! `snapshot_running_agents` style: the FakeFs serves a real serialized //! `LayoutsDoc`, so the use case's `resolve_doc → walk → persist_doc` round-trips //! through genuine serde, exactly as in production. use std::collections::{HashMap, HashSet}; 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, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PreparedContext, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; use domain::{ LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PtySize, SessionId, SessionKind, SkillId, }; use uuid::Uuid; use application::{ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions}; // --------------------------------------------------------------------------- // FakeContexts (AgentContextStore) — manifest + md_path → content // --------------------------------------------------------------------------- #[derive(Default)] struct ContextsInner { manifest: AgentManifest, contents: HashMap, /// Number of `save_manifest` calls observed. saves: usize, } #[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(), saves: 0, }))); { 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 } /// The persisted profile id currently recorded for `agent` in the manifest. 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) } 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()) } /// Count of `save_manifest` calls — proves a no-op path leaves the manifest /// untouched (no mutating write). fn manifest_saves(&self) -> usize { self.0.lock().unwrap().saves } } #[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> { let mut inner = self.0.lock().unwrap(); inner.manifest = manifest.clone(); inner.saves += 1; 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>>); impl FakeStore { async fn save(&self, project: &Project) { self.0.lock().unwrap().push(project.clone()); } } #[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: serves layouts.json + records writes // --------------------------------------------------------------------------- #[derive(Default)] struct FakeFsInner { files: HashMap>, dirs: HashSet, write_count: usize, } #[derive(Default, Clone)] struct FakeFs(Arc>); impl FakeFs { fn put(&self, path: &str, data: &[u8]) { self.0 .lock() .unwrap() .files .insert(path.to_owned(), data.to_vec()); } fn read_file(&self, path: &str) -> Option> { self.0.lock().unwrap().files.get(path).cloned() } /// Number of `write` calls observed (used to assert a no-op path writes /// nothing through the filesystem). fn write_count(&self) -> usize { self.0.lock().unwrap().write_count } } #[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> { let mut inner = self.0.lock().unwrap(); inner.write_count += 1; inner.files.insert(path.as_str().to_owned(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { let inner = self.0.lock().unwrap(); Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str())) } async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { self.0.lock().unwrap().dirs.insert(path.as_str().to_owned()); 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) — minimal; only exercised on a relaunch // --------------------------------------------------------------------------- /// Records the [`SessionPlan`] handed to `prepare_invocation` on the (re)launch, /// so a swap test can prove the relaunch routes a fresh `SessionPlan::None` /// (the foreign engine resumable is **never** replayed) rather than a `Resume`. #[derive(Clone, Default)] struct FakeRuntime { plans: Arc>>, } impl FakeRuntime { fn new() -> Self { Self::default() } /// The last `SessionPlan` the launcher built for a (re)launch, if any. fn last_plan(&self) -> Option { self.plans.lock().unwrap().last().cloned() } } 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.plans.lock().unwrap().push(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, _scope: SkillScope, _root: &ProjectPath, ) -> Result, StoreError> { Ok(Vec::new()) } async fn get( &self, _scope: SkillScope, _root: &ProjectPath, _id: SkillId, ) -> Result { Err(StoreError::NotFound) } async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { Ok(()) } async fn delete( &self, _scope: SkillScope, _root: &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 } } // --------------------------------------------------------------------------- // Builders // --------------------------------------------------------------------------- const ROOT: &str = "/home/me/proj"; const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json"; 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 lid(n: u128) -> LayoutId { LayoutId::from_uuid(Uuid::from_u128(n)) } fn proj_id(n: u128) -> ProjectId { ProjectId::from_uuid(Uuid::from_u128(n)) } fn project() -> Project { Project::new( proj_id(1000), "demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::local(), 1_700_000_000_000, ) .unwrap() } fn profile(id: ProfileId) -> AgentProfile { AgentProfile::new( id, "Some CLI", "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() } /// A leaf cell hosting `agent`, optionally carrying a conversation + running flag. fn agent_leaf( node: NodeId, agent: Option, conversation_id: Option<&str>, agent_was_running: bool, ) -> LeafCell { LeafCell { id: node, session: None, agent, conversation_id: conversation_id.map(str::to_owned), engine_session_id: None, agent_was_running, } } /// Like [`agent_leaf`], additionally seeding an `engine_session_id` (the engine /// resumable cache) so we can assert it is invalidated on a profile swap. fn agent_leaf_with_engine( node: NodeId, agent: Option, conversation_id: Option<&str>, engine_session_id: Option<&str>, agent_was_running: bool, ) -> LeafCell { LeafCell { engine_session_id: engine_session_id.map(str::to_owned), ..agent_leaf(node, agent, conversation_id, agent_was_running) } } /// Seeds a valid `layouts.json` (a single active layout holding `tree`). fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) { let doc = serde_json::json!({ "version": 1, "activeId": id.to_string(), "layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ], }); fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap()); } /// Reads back the persisted `(conversation_id, agent_was_running)` of leaf `node`. fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option, bool)> { let bytes = fs.read_file(LAYOUTS_PATH)?; let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let tree: LayoutTree = serde_json::from_value(doc["layouts"][0]["tree"].clone()).unwrap(); fn find(n: &LayoutNode, target: NodeId) -> Option<(Option, bool)> { match n { LayoutNode::Leaf(l) if l.id == target => { Some((l.conversation_id.clone(), l.agent_was_running)) } LayoutNode::Leaf(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)), } } find(&tree.root, node) } /// Reads back the persisted `engine_session_id` of leaf `node`. fn leaf_engine_session(fs: &FakeFs, node: NodeId) -> Option> { let bytes = fs.read_file(LAYOUTS_PATH)?; let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let tree: LayoutTree = serde_json::from_value(doc["layouts"][0]["tree"].clone()).unwrap(); fn find(n: &LayoutNode, target: NodeId) -> Option> { match n { LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()), LayoutNode::Leaf(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)), } } find(&tree.root, node) } /// Seeds a live agent session pinned on `node` into the registry. fn seed_live_agent_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); } // --------------------------------------------------------------------------- // FakeHandoffs (HandoffProvider) — root-scoped store seeded with one handoff // --------------------------------------------------------------------------- /// A [`HandoffProvider`] backed by an in-memory [`HandoffStore`] keyed by pair id. /// Wiring this into the relaunch's `LaunchAgent` lets a test prove the **threaded /// pair id** end-to-end: the `# Reprise de la conversation` section appears in the /// relaunch's convention file **only if** the relaunch carried the exact /// `conversation_id` under which the handoff was seeded. #[derive(Clone, Default)] struct FakeHandoffs(Arc>>); impl FakeHandoffs { /// Seeds a handoff under conversation id `conv` (a UUID-shaped pair id string). fn seed(&self, conv: &str, summary: &str, objective: Option<&str>) { let uuid = Uuid::parse_str(conv).expect("seed conv id must be a UUID"); let handoff = domain::Handoff::new( summary.to_owned(), domain::TurnId::from_uuid(Uuid::from_u128(0xABCD)), objective.map(str::to_owned), ); self.0.lock().unwrap().insert(uuid, handoff); } } #[async_trait] impl domain::HandoffStore for FakeHandoffs { async fn load( &self, conversation: domain::ConversationId, ) -> Result, StoreError> { Ok(self.0.lock().unwrap().get(&conversation.as_uuid()).cloned()) } async fn save( &self, conversation: domain::ConversationId, handoff: domain::Handoff, ) -> Result<(), StoreError> { self.0 .lock() .unwrap() .insert(conversation.as_uuid(), handoff); Ok(()) } } impl application::HandoffProvider for FakeHandoffs { fn handoff_store_for(&self, _root: &ProjectPath) -> Option> { Some(Arc::new(self.clone())) } } // --------------------------------------------------------------------------- // Fixture // --------------------------------------------------------------------------- /// Everything a change-profile test needs. struct Fixture { swap: ChangeAgentProfile, contexts: FakeContexts, fs: FakeFs, pty: FakePty, bus: SpyBus, sessions: Arc, /// The runtime used by the composed relaunch — records the `SessionPlan`. runtime: FakeRuntime, /// The handoff store wired into the relaunch (seed via [`FakeHandoffs::seed`]). handoffs: FakeHandoffs, } /// Wires a [`ChangeAgentProfile`] over fakes. The agent starts on `pid(1)`; the /// profile store knows both `pid(1)` and `pid(2)` (the valid swap target). async fn fixture(agent: &Agent) -> Fixture { fixture_with_profiles(agent, vec![profile(pid(1)), profile(pid(2))]).await } async fn fixture_with_profiles(agent: &Agent, profiles: Vec) -> Fixture { let contexts = FakeContexts::with_agent(agent, "# persona"); let profiles = FakeProfiles::new(profiles); let store = FakeStore::default(); let fs = FakeFs::default(); let pty = FakePty::new(sid(777)); let sessions = Arc::new(TerminalSessions::new()); let bus = SpyBus::default(); let runtime = FakeRuntime::new(); let handoffs = FakeHandoffs::default(); // Register the project so ProjectStore::load_project resolves. store.save(&project()).await; let launch = LaunchAgent::new( Arc::new(contexts.clone()), Arc::new(profiles.clone()), Arc::new(runtime.clone()), 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, ) // Wire the handoff provider so a relaunch re-injects the (pair-keyed) handoff // into the new engine's convention file — the end-to-end proof of P7+P8d. .with_handoff_provider(Arc::new(handoffs.clone())); let swap = ChangeAgentProfile::new( Arc::new(contexts.clone()), Arc::new(profiles), Arc::new(store), Arc::new(fs.clone()), Arc::clone(&sessions), Arc::new(pty.clone()), Arc::new(launch), Arc::new(bus.clone()), ); Fixture { swap, contexts, fs, pty, bus, sessions, runtime, handoffs, } } fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileInput { ChangeAgentProfileInput { project: project(), agent_id, profile_id, rows: 24, cols: 80, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- /// No-op: swapping to the *same* profile leaves the agent unchanged, relaunches /// nothing, publishes no event, kills nothing, and writes no manifest/layout. #[tokio::test] async fn same_profile_is_noop() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; let out = f .swap .execute(change_input(agent.id, pid(1))) .await .expect("no-op succeeds"); // Agent returned unchanged, no relaunch. assert_eq!(out.agent.profile_id, pid(1)); assert!(out.relaunched.is_none(), "no relaunch on a no-op"); // No event published. assert!(f.bus.events().is_empty(), "no-op publishes no event"); // No kill, no spawn. assert!(f.pty.kills().is_empty(), "no-op kills nothing"); assert_eq!(f.pty.spawn_count(), 0, "no-op spawns nothing"); // Manifest never re-saved (no observable mutation). assert_eq!( f.contexts.manifest_saves(), 0, "no-op must not rewrite the manifest" ); // No filesystem write at all. assert_eq!(f.fs.write_count(), 0, "no-op must not write any layout"); // Persisted profile is still the original. assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(1))); } /// Unknown target profile ⇒ NotFound, and the agent is **not** mutated (the /// manifest keeps the original profile id). #[tokio::test] async fn unknown_profile_is_not_found_and_does_not_mutate() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; // pid(99) is not in the store (only pid(1), pid(2)). let err = f .swap .execute(change_input(agent.id, pid(99))) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); // Manifest unchanged. assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(1))); assert_eq!(f.contexts.manifest_saves(), 0); assert!(f.pty.kills().is_empty()); assert!(f.bus.events().is_empty()); } /// Unknown agent ⇒ NotFound. #[tokio::test] async fn unknown_agent_is_not_found() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; let err = f .swap .execute(change_input(aid(404), pid(2))) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert_eq!(f.contexts.manifest_saves(), 0); assert!(f.bus.events().is_empty()); } /// Success: the persisted manifest carries the **new** profile id, and the /// returned agent reflects it. #[tokio::test] async fn success_mutates_manifest_to_new_profile() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; let out = f .swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); assert_eq!( out.agent.profile_id, pid(2), "returned agent carries new profile" ); assert_eq!( f.contexts.profile_of(&agent.id), Some(pid(2)), "manifest persisted with the new profile" ); assert_eq!(f.contexts.manifest_saves(), 1, "exactly one manifest save"); // Dead agent (no live session) ⇒ no relaunch. assert!(out.relaunched.is_none()); } /// Engine-link invalidation (P8d new contract): a layout cell hosting the agent /// **preserves** its stable pair `conversation_id`, **clears** the foreign engine /// resumable cache (`engine_session_id`) and resets `agent_was_running` on the /// persisted layouts after the swap. #[tokio::test] async fn invalidates_engine_link_preserving_pair_id_on_persisted_layouts() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; // Seed a layout: leaf nid(10) hosts the agent with a pair id + engine cache. let leaf = nid(10); seed_layouts( &f.fs, lid(1), &LayoutTree::single(agent_leaf_with_engine( leaf, Some(agent.id), Some("pair-stable"), Some("engine-old"), true, )), ); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); // P8d: the stable pair id is PRESERVED; only the running flag is reset. assert_eq!( leaf_state(&f.fs, leaf), Some((Some("pair-stable".to_owned()), false)), "pair conversation_id must be preserved; agent_was_running reset" ); // The foreign engine resumable cache is invalidated. assert_eq!( leaf_engine_session(&f.fs, leaf), Some(None), "engine_session_id must be cleared" ); } /// Cleanup leaves foreign agents untouched: a second agent's cell keeps its own /// conversation id. #[tokio::test] async fn cleanup_leaves_other_agents_untouched() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; let mine = nid(10); let other = nid(11); let other_agent = aid(2); let tree = LayoutTree::new(LayoutNode::Split(domain::SplitContainer { id: nid(1), direction: domain::Direction::Row, children: vec![ domain::WeightedChild { node: LayoutNode::Leaf(agent_leaf_with_engine( mine, Some(agent.id), Some("conv-mine"), Some("engine-mine"), true, )), weight: 1.0, }, domain::WeightedChild { node: LayoutNode::Leaf(agent_leaf_with_engine( other, Some(other_agent), Some("conv-other"), Some("engine-other"), true, )), weight: 1.0, }, ], })); seed_layouts(&f.fs, lid(1), &tree); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); // P8d: my pair id is preserved; running reset; engine cache cleared. assert_eq!( leaf_state(&f.fs, mine), Some((Some("conv-mine".to_owned()), false)), "mine: pair id preserved, running reset" ); assert_eq!( leaf_engine_session(&f.fs, mine), Some(None), "mine: engine_session_id cleared" ); // The other agent's cell is entirely untouched (pair id, engine, running). assert_eq!( leaf_state(&f.fs, other), Some((Some("conv-other".to_owned()), true)), "the other agent's cell is untouched" ); assert_eq!( leaf_engine_session(&f.fs, other), Some(Some("engine-other".to_owned())), "the other agent's engine cache is untouched" ); } /// Live agent ⇒ the PTY is killed and the session is relaunched in the SAME cell /// (node N). Post-P8d the stable pair id is **preserved** and threaded into the /// relaunch (`LaunchAgent` invoked at node N); the engine cache is invalidated. #[tokio::test] async fn live_agent_is_killed_and_relaunched_in_same_cell() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; // The agent is live in cell N, session sid(42). let host = nid(5); seed_live_agent_session(&f.sessions, agent.id, host, sid(42)); // Seed a layout cell for that node carrying the stable pair id + engine cache. seed_layouts( &f.fs, lid(1), &LayoutTree::single(agent_leaf_with_engine( host, Some(agent.id), Some("pair-stable"), Some("engine-old"), true, )), ); let out = f .swap .execute(change_input(agent.id, pid(2))) .await .expect("hot swap succeeds"); // The old PTY was killed. assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed"); // Exactly one relaunch spawn. assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once"); // The relaunched session is returned and pinned on the SAME cell N. 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), "relaunch adopts the new PTY id"); // The relaunched session is registered and tagged for this agent. assert!(matches!( relaunched.kind, SessionKind::Agent { agent_id } if agent_id == agent.id )); assert_eq!( f.sessions.session_for_agent(&agent.id), Some(sid(777)), "the registry now holds the relaunched session" ); // Manifest carries the new profile. assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); } /// Dead agent (no live session) ⇒ no kill, no relaunch; the manifest is still /// mutated to the new profile. #[tokio::test] async fn dead_agent_mutates_without_relaunch() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; // No live session seeded. let out = f .swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); assert!(out.relaunched.is_none(), "no relaunch for a dead agent"); assert!(f.pty.kills().is_empty(), "nothing to kill"); assert_eq!(f.pty.spawn_count(), 0, "no spawn for a dead agent"); // Manifest still mutated. assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); } /// Event: a successful mutating swap publishes `AgentProfileChanged` exactly once, /// carrying the agent id and the NEW profile id. #[tokio::test] async fn publishes_agent_profile_changed_once_on_success() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); let profile_changed: Vec<_> = f .bus .events() .into_iter() .filter(|e| { matches!( e, DomainEvent::AgentProfileChanged { agent_id, profile_id } if *agent_id == agent.id && *profile_id == pid(2) ) }) .collect(); assert_eq!( profile_changed.len(), 1, "AgentProfileChanged published exactly once with the new profile" ); } // --------------------------------------------------------------------------- // P8d capstone — cross-profile swap threads the PRESERVED pair id into the // relaunch, never replays the old engine resumable, and re-injects the handoff. // --------------------------------------------------------------------------- /// The relaunch's convention file path: `/.ideai/run//CLAUDE.md` /// (the `FakeRuntime` plan targets `CLAUDE.md`, written into the agent run dir). fn relaunch_convention_path(agent: &AgentId) -> String { format!("{ROOT}/.ideai/run/{agent}/CLAUDE.md") } /// Case 2 — live agent, swap relaunches threading the **preserved pair id** and a /// **fresh** `SessionPlan::None` (the old engine resumable is NEVER replayed). /// /// The pair id on the leaf is a real UUID under which a handoff is seeded. After /// the swap the new engine's convention file carries `# Reprise de la conversation` /// — provable only if the relaunch's `conversation_id` equalled that exact pair id. /// And the `SessionPlan` built for the relaunch is `None`, never `Resume{engine}`. #[tokio::test] async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; // A UUID-shaped pair id (so resolve_handoff can parse it) and a seeded handoff. let pair = "11111111-1111-1111-1111-111111111111"; f.handoffs.seed( pair, "Résumé : on a fini l'étape 2.", Some("Livrer le lot P8d"), ); // Live agent on cell N with a foreign engine resumable cache. let host = nid(5); seed_live_agent_session(&f.sessions, agent.id, host, sid(42)); seed_layouts( &f.fs, lid(1), &LayoutTree::single(agent_leaf_with_engine( host, Some(agent.id), Some(pair), Some("engine-old"), true, )), ); let out = f .swap .execute(change_input(agent.id, pid(2))) .await .expect("hot swap succeeds"); // The old PTY was killed and a single relaunch happened in the SAME cell. assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed"); let relaunched = out.relaunched.expect("a live agent is relaunched"); assert_eq!( relaunched.node_id, host, "relaunch reopens in the same cell" ); // The pair id is preserved on the persisted leaf; the engine cache is cleared. assert_eq!( leaf_state(&f.fs, host), Some((Some(pair.to_owned()), false)), "pair id preserved, running reset" ); assert_eq!( leaf_engine_session(&f.fs, host), Some(None), "engine_session_id cleared" ); // The relaunch threaded the PRESERVED pair id: the handoff seeded under that // exact id is re-injected into the new engine's convention file. let conv = String::from_utf8( f.fs.read_file(&relaunch_convention_path(&agent.id)) .expect("relaunch wrote a convention file"), ) .unwrap(); assert!( conv.contains("# Reprise de la conversation"), "relaunch must re-inject the handoff under the preserved pair id: {conv}" ); assert!( conv.contains("Résumé : on a fini l'étape 2."), "handoff summary present in the new engine's convention file: {conv}" ); // The old engine resumable is NEVER replayed: the relaunch's SessionPlan is // None (providers.json[new provider] is empty ⇒ fresh engine, fidelity carried // by the handoff). It is emphatically NOT Resume{"engine-old"}. let plan = f .runtime .last_plan() .expect("the relaunch prepared an invocation"); assert_eq!( plan, SessionPlan::None, "the foreign engine resumable must never be replayed on a swap" ); } /// Case 3 — live agent with NO hosting cell (background session ⇒ step 5 returns /// `None`). The relaunch must derive the pair id deterministically via /// `ConversationId::for_pair(User, agent)` (== the agent's own UUID). /// /// Proven end-to-end: a handoff seeded under `for_pair(User, agent)` is re-injected /// into the relaunch's convention file, which can only happen if the relaunch /// threaded that derived id as its `conversation_id`. #[tokio::test] async fn live_swap_without_cell_relaunches_with_for_pair_id() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; // The deterministic User↔agent pair id equals the agent's own UUID. let derived = domain::ConversationId::for_pair( domain::ConversationParty::User, domain::ConversationParty::agent(agent.id), ) .to_string(); assert_eq!( derived, agent.id.to_string(), "for_pair(User, agent) == agent uuid (sanity)" ); f.handoffs .seed(&derived, "Repris depuis une session de fond.", None); // Live agent but NO seeded layout cell ⇒ node_for_agent yields None // (background session) ⇒ step 5 finds no hosting leaf ⇒ pair_id None. let host = nid(5); seed_live_agent_session(&f.sessions, agent.id, host, sid(42)); // Intentionally NO seed_layouts: there is no persisted hosting cell. let out = f .swap .execute(change_input(agent.id, pid(2))) .await .expect("hot swap succeeds"); assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed"); assert!(out.relaunched.is_some(), "a live agent is relaunched"); // The relaunch derived the pair id via for_pair: the handoff seeded under it is // re-injected into the convention file (proves the threaded conversation id). let conv = String::from_utf8( f.fs.read_file(&relaunch_convention_path(&agent.id)) .expect("relaunch wrote a convention file"), ) .unwrap(); assert!( conv.contains("# Reprise de la conversation"), "relaunch must use for_pair(User, agent) as the conversation id: {conv}" ); assert!( conv.contains("Repris depuis une session de fond."), "handoff (keyed by for_pair) re-injected: {conv}" ); // Still no engine resume: a swap never replays a foreign resumable. assert_eq!( f.runtime.last_plan(), Some(SessionPlan::None), "no engine resume on a swap (for_pair path)" ); } /// Case 1 (completeness) — preservation + invalidation read straight off the /// persisted leaf with a **UUID** pair id (the realistic post-P8a shape), pairing /// the existing `pair-stable` opaque-string test. The pair id survives untouched; /// the engine cache is cleared; the running flag is reset. #[tokio::test] async fn swap_preserves_uuid_pair_id_and_clears_engine_cache() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture(&agent).await; let leaf = nid(10); let pair = "22222222-2222-2222-2222-222222222222"; seed_layouts( &f.fs, lid(1), &LayoutTree::single(agent_leaf_with_engine( leaf, Some(agent.id), Some(pair), Some("engine-old"), true, )), ); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); assert_eq!( leaf_state(&f.fs, leaf), Some((Some(pair.to_owned()), false)), "UUID pair id preserved; agent_was_running reset" ); assert_eq!( leaf_engine_session(&f.fs, leaf), Some(None), "engine_session_id cleared on swap" ); }