//! 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::permission::{ EffectivePermissions, PermissionProjection, PermissionProjector, ProjectedFile, ProjectionContext, ProjectorKey, }; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PermissionStore, PreparedContext, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, 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::{ LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PermissionSet, Posture, ProjectPermissions, PtySize, SessionId, SessionKind, SkillId, }; use uuid::Uuid; use application::{ ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, PermissionProjectorRegistry, 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, /// Paths passed to `remove_file`, in order (lot LP3-4 cleanup assertions). removed: Vec, } #[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 } /// Whether a file is currently present in the fake's state. fn has_file(&self, path: &str) -> bool { self.0.lock().unwrap().files.contains_key(path) } /// The ordered list of paths passed to `remove_file` (lot LP3-4). fn removed(&self) -> Vec { self.0.lock().unwrap().removed.clone() } } #[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(()) } /// Overrides the no-op default (lot LP3-4): records the path and actually /// removes it from the fake's state, so a deletion is assertable. Idempotent — /// removing an absent file still succeeds (best-effort cleanup contract). async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> { let mut inner = self.0.lock().unwrap(); inner.removed.push(path.as_str().to_owned()); inner.files.remove(path.as_str()); 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(), }), 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() } /// Args of the most recent spawn (used to assert folded projection args on a /// relaunch, lot LP3-4). fn last_spawn_args(&self) -> Vec { self.spawns .lock() .unwrap() .last() .map(|s| s.args.clone()) .unwrap_or_default() } } #[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, } } // --------------------------------------------------------------------------- // LP3-4 — permission-file cleanup at swap: fakes, projectors, fixture // --------------------------------------------------------------------------- /// In-memory [`PermissionStore`] returning a fixed document (so the relaunch's /// projection resolves a non-empty `eff` and actually writes the new CLI config). struct FakePermissionStore(ProjectPermissions); #[async_trait] impl PermissionStore for FakePermissionStore { async fn load_permissions(&self, _project: &Project) -> Result { Ok(self.0.clone()) } async fn save_permissions( &self, _project: &Project, _permissions: &ProjectPermissions, ) -> Result<(), StoreError> { Ok(()) } } /// Faithful Claude projector double (LP3-2 mapping): owns the `Replace` settings /// seed `.claude/settings.local.json`. struct FakeClaudeProjector; impl PermissionProjector for FakeClaudeProjector { fn key(&self) -> ProjectorKey { ProjectorKey::Claude } fn project( &self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext, ) -> PermissionProjection { if eff.is_none() { return PermissionProjection::empty(); } let contents = format!( "{{\"permissions\":{{\"additionalDirectories\":[\"{}\"]}}}}\n", ctx.project_root ); PermissionProjection { files: vec![ProjectedFile::Replace { rel_path: ".claude/settings.local.json".to_owned(), contents, }], args: Vec::new(), env: Vec::new(), } } fn owned_replace_paths(&self) -> Vec { vec![".claude/settings.local.json".to_owned()] } } /// Faithful Codex projector double (LP3-2 mapping): a co-owned `MergeToml` + the /// `--sandbox`/`--ask-for-approval` args. Owns **no** `Replace` file. struct FakeCodexProjector; impl PermissionProjector for FakeCodexProjector { fn key(&self) -> ProjectorKey { ProjectorKey::Codex } fn project( &self, eff: Option<&EffectivePermissions>, _ctx: &ProjectionContext, ) -> PermissionProjection { if eff.is_none() { return PermissionProjection::empty(); } PermissionProjection { files: vec![ProjectedFile::MergeToml { rel_path: ".codex/config.toml".to_owned(), managed_tables: Vec::new(), managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()], contents: "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n" .to_owned(), }], args: vec![ "--sandbox".to_owned(), "workspace-write".to_owned(), "--ask-for-approval".to_owned(), "never".to_owned(), ], env: Vec::new(), } } fn owned_replace_paths(&self) -> Vec { Vec::new() } } /// A registry carrying both faithful projector doubles. fn full_registry() -> Arc { Arc::new( PermissionProjectorRegistry::new() .with(Arc::new(FakeClaudeProjector)) .with(Arc::new(FakeCodexProjector)), ) } /// A Claude profile (convention `CLAUDE.md` ⇒ legacy Claude selection). fn claude_profile(id: ProfileId) -> AgentProfile { profile(id) } /// A Codex profile carrying an explicit `ProjectorKey::Codex` (and the Codex /// structured adapter, so the legacy fallback would also select Codex). fn codex_profile(id: ProfileId) -> AgentProfile { AgentProfile::new( id, "OpenAI Codex CLI", "codex", Vec::new(), ContextInjection::convention_file("AGENTS.md").unwrap(), Some("codex --version".to_owned()), "{agentRunDir}", None, ) .unwrap() .with_structured_adapter(StructuredAdapter::Codex) .with_projector(ProjectorKey::Codex) } /// The (stable) run dir of `agent` under the test project root. fn run_dir_of(agent: &AgentId) -> String { format!("{ROOT}/.ideai/run/{agent}") } /// Wires a swap fixture with a projector registry on BOTH the swap and the /// composed relaunch, plus an optional permission document on the relaunch (so /// the relaunch's projection is non-empty). Mirrors [`fixture_with_profiles`]. async fn fixture_with_projection( agent: &Agent, profiles: Vec, registry: Arc, perm_doc: Option, ) -> 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(); store.save(&project()).await; let mut 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, ) .with_handoff_provider(Arc::new(handoffs.clone())) .with_permission_projectors(Arc::clone(®istry)); if let Some(doc) = perm_doc { launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc))); } 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()), ) .with_permission_projectors(Arc::clone(®istry)); Fixture { swap, contexts, fs, pty, bus, sessions, runtime, handoffs, } } /// The full `permissions.json` posing a project-level `Allow` policy ⇒ a relaunch /// resolves `Some(eff)` and the projector writes the new CLI config. fn allow_perm_doc() -> ProjectPermissions { ProjectPermissions::new(Some(PermissionSet::new(vec![], Posture::Allow)), Vec::new()) } const CLAUDE_SEED_REL: &str = ".claude/settings.local.json"; const CODEX_CONFIG_REL: &str = ".codex/config.toml"; /// Seeds a live agent session + a persisted layout cell hosting it, so the swap /// reaches its relaunch step (kill → relaunch in the same cell). fn seed_live_for_relaunch(f: &Fixture, agent: &AgentId) { let host = nid(1); seed_live_agent_session(&f.sessions, *agent, host, sid(42)); let tree = LayoutTree::single(agent_leaf(host, Some(*agent), Some(&pair_uuid()), true)); seed_layouts(&f.fs, lid(1), &tree); } /// A stable UUID-shaped pair id used by the relaunch scenarios. fn pair_uuid() -> String { Uuid::from_u128(0xABCD).to_string() } // =========================================================================== // LP3-4 — cleanup of orphan permission files at a cross-profile swap // =========================================================================== /// (1) **Claude→Codex (phare)**: the pre-existing `.claude/settings.local.json` /// is removed at the swap, and the relaunch projects the Codex sandbox config + /// args. The orphan Replace file of the old projector is cleaned up; the new /// projector (Codex) owns no Replace file, so nothing protects it from deletion. #[tokio::test] async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture_with_projection( &agent, vec![claude_profile(pid(1)), codex_profile(pid(2))], full_registry(), Some(allow_perm_doc()), ) .await; let run_dir = run_dir_of(&agent.id); let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}"); let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}"); // The old Claude seed exists in the (stable) run dir before the swap. f.fs.put(&seed_path, b"{\"permissions\":{}}"); seed_live_for_relaunch(&f, &agent.id); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); // The orphan Claude seed was deleted (recorded AND gone from the FS state). assert!( f.fs.removed().iter().any(|p| p == &seed_path), "the orphan .claude seed must be removed; removed={:?}", f.fs.removed() ); assert!(!f.fs.has_file(&seed_path), "the seed is gone from the FS state"); // The relaunch projected the Codex sandbox config + args. assert!( f.fs.has_file(&codex_path), "the relaunch must project the Codex config" ); let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap(); assert!(toml.contains("sandbox_mode = \"workspace-write\""), "{toml}"); assert!( f.pty.last_spawn_args().contains(&"--sandbox".to_owned()), "sandbox args folded into the relaunch spawn: {:?}", f.pty.last_spawn_args() ); } /// (2) **Claude→Claude**: `owned(old) − owned(new)` is empty, so the seed is NOT /// removed (it is re-clobbered by the relaunch, never deleted). #[tokio::test] async fn swap_claude_to_claude_does_not_remove_seed() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture_with_projection( &agent, vec![claude_profile(pid(1)), claude_profile(pid(2))], full_registry(), Some(allow_perm_doc()), ) .await; let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id)); f.fs.put(&seed_path, b"{\"permissions\":{}}"); seed_live_for_relaunch(&f, &agent.id); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); assert!( !f.fs.removed().iter().any(|p| p == &seed_path), "same-family swap must NOT delete the shared seed; removed={:?}", f.fs.removed() ); // It is still present (re-clobbered by the relaunch's projection). assert!(f.fs.has_file(&seed_path), "the seed survives the same-family swap"); } /// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the /// cleanup side; the relaunch writes the Claude seed; the co-owned /// `.codex/config.toml` is never deleted. #[tokio::test] async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture_with_projection( &agent, vec![codex_profile(pid(1)), claude_profile(pid(2))], full_registry(), Some(allow_perm_doc()), ) .await; let run_dir = run_dir_of(&agent.id); let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}"); let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}"); // A pre-existing co-owned Codex config that must survive the swap. f.fs.put(&codex_path, b"sandbox_mode = \"read-only\"\n"); seed_live_for_relaunch(&f, &agent.id); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); // Nothing removed (Codex has no Replace-owned path). assert!( f.fs.removed().is_empty(), "Codex→Claude removes no permission file; removed={:?}", f.fs.removed() ); // The co-owned Codex config is untouched by cleanup… assert!(f.fs.has_file(&codex_path), "the .codex/config.toml is never deleted"); assert!( !f.fs.removed().iter().any(|p| p == &codex_path), "the .codex/config.toml is not in the removed list" ); // …and the relaunch projected the new Claude seed. assert!(f.fs.has_file(&seed_path), "the relaunch writes the Claude seed"); } /// (4a) **No-op (no registry)**: without a projector registry wired on the swap, /// no cleanup runs at all — even on a Claude→Codex swap with the seed present. #[tokio::test] async fn swap_without_registry_removes_nothing() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); // The default fixture wires NO projector registry on the swap. let f = fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await; let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id)); f.fs.put(&seed_path, b"{\"permissions\":{}}"); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); assert!( f.fs.removed().is_empty(), "no registry ⇒ no cleanup; removed={:?}", f.fs.removed() ); assert!(f.fs.has_file(&seed_path), "the seed is left untouched"); } /// (4b) **No-op (previous profile deleted)**: if the old profile id is no longer /// in the store, the cleanup is skipped (the old projector can't be resolved) and /// the swap still succeeds. #[tokio::test] async fn swap_with_unknown_previous_profile_skips_cleanup() { // Agent records pid(1), but the store only knows pid(2) (the target) and pid(3): // pid(1) was deleted between launch and swap. let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture_with_projection( &agent, vec![codex_profile(pid(2)), claude_profile(pid(3))], full_registry(), Some(allow_perm_doc()), ) .await; let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id)); f.fs.put(&seed_path, b"{\"permissions\":{}}"); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds even with an unknown previous profile"); assert!( f.fs.removed().is_empty(), "unknown previous profile ⇒ cleanup skipped; removed={:?}", f.fs.removed() ); } /// (5) **Best-effort**: when the orphan seed file does NOT exist on disk, the /// delete is still attempted (idempotent) and the swap succeeds without error. #[tokio::test] async fn swap_claude_to_codex_succeeds_when_seed_absent() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture_with_projection( &agent, vec![claude_profile(pid(1)), codex_profile(pid(2))], full_registry(), Some(allow_perm_doc()), ) .await; // Intentionally do NOT seed `.claude/settings.local.json`. let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id)); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds even when the orphan file is already gone"); // The delete was attempted (best-effort, idempotent) on the missing path. assert!( f.fs.removed().iter().any(|p| p == &seed_path), "a remove was still attempted on the absent seed; removed={:?}", f.fs.removed() ); } /// (6) **Non-regression P8d**: the cleanup must not disturb the preserved pair id. /// A live Claude→Codex swap with a handoff seeded under the leaf's pair id still /// re-injects that handoff into the new engine's convention file (proving the /// pair `conversation_id` survived the cleanup step unchanged). #[tokio::test] async fn swap_with_cleanup_preserves_pair_id_and_handoff() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); let f = fixture_with_projection( &agent, vec![claude_profile(pid(1)), codex_profile(pid(2))], full_registry(), Some(allow_perm_doc()), ) .await; // Seed the orphan Claude seed (so cleanup actually fires) and a handoff under // the leaf's pair id. let run_dir = run_dir_of(&agent.id); f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}"); let pair = pair_uuid(); f.handoffs.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif")); let host = nid(1); seed_live_agent_session(&f.sessions, agent.id, host, sid(42)); let tree = LayoutTree::single(agent_leaf(host, Some(agent.id), Some(&pair), true)); seed_layouts(&f.fs, lid(1), &tree); f.swap .execute(change_input(agent.id, pid(2))) .await .expect("swap succeeds"); // The cleanup fired… assert!( f.fs.removed() .iter() .any(|p| p == &format!("{run_dir}/{CLAUDE_SEED_REL}")), "the orphan seed was cleaned up" ); // …and the pair id was preserved on the persisted leaf (P8d invariant). let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted"); assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved"); assert!(!running, "engine running flag reset by invalidate_engine_link"); // …and the handoff (keyed by that pair id) was re-injected into the new engine's // convention file — proving the relaunch threaded the preserved pair id. (The // shared FakeRuntime always materialises the convention file as `CLAUDE.md`.) let conv_md = String::from_utf8( f.fs.read_file(&format!("{run_dir}/CLAUDE.md")) .expect("the relaunch wrote the new engine's convention file"), ) .unwrap(); assert!( conv_md.contains("LP3-4"), "handoff re-injected under the preserved pair id: {conv_md}" ); } // --------------------------------------------------------------------------- // 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" ); }