//! L6 tests for the agent lifecycle use cases (`CreateAgentFromScratch`, //! `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, //! `LaunchAgent`). //! //! Every port is faked in-memory so the use cases run without real I/O: //! - [`FakeContexts`] — an [`AgentContextStore`] holding the manifest + a //! `md_path → content` map, //! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list, //! - [`FakeRuntime`] — an [`AgentRuntime`] whose `prepare_invocation` records the //! call into a shared **trace** and returns a configurable injection plan, //! - [`FakeFs`] — a [`FileSystem`] recording writes into the same trace, //! - [`FakePty`] — a [`PtyPort`] recording `spawn` into the trace, //! - [`SpyBus`], [`SeqIds`] — event recorder and deterministic id generator. //! //! The shared trace lets us assert the **call ordering** contract of //! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`. use std::collections::HashMap; 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::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SpawnSpec, StoreError, }; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::{PtySize, SessionId}; use uuid::Uuid; use application::{ CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, }; // --------------------------------------------------------------------------- // Shared trace (ordering) // --------------------------------------------------------------------------- type Trace = Arc>>; /// A recorded list of `(target, bytes)` writes, keyed by whatever addresses the /// target (a path for the fs, a [`SessionId`] for the pty). type WriteLog = Arc)>>>; fn trace() -> Trace { Arc::new(Mutex::new(Vec::new())) } // --------------------------------------------------------------------------- // FakeContexts (AgentContextStore) // --------------------------------------------------------------------------- #[derive(Default)] struct ContextsInner { manifest: AgentManifest, contents: HashMap, } #[derive(Clone)] struct FakeContexts(Arc>); impl FakeContexts { fn new() -> Self { Self(Arc::new(Mutex::new(ContextsInner { manifest: AgentManifest { version: 1, entries: Vec::new(), }, contents: HashMap::new(), }))) } fn with_agent(agent: &Agent, content: &str) -> Self { let me = Self::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 manifest(&self) -> AgentManifest { self.0.lock().unwrap().manifest.clone() } fn content(&self, md_path: &str) -> Option { self.0.lock().unwrap().contents.get(md_path).cloned() } 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()) } } #[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.content(&md_path) .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.manifest()) } 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(()) } } // --------------------------------------------------------------------------- // FakeRuntime (AgentRuntime) — records prepare + returns a configured plan // --------------------------------------------------------------------------- struct FakeRuntime { trace: Trace, plan: Option, } impl FakeRuntime { fn new(trace: Trace, plan: Option) -> Self { Self { trace, plan } } } impl AgentRuntime for FakeRuntime { fn detect(&self, _profile: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( &self, profile: &AgentProfile, _ctx: &PreparedContext, cwd: &ProjectPath, ) -> Result { self.trace.lock().unwrap().push("prepare".to_owned()); Ok(SpawnSpec { command: profile.command.clone(), args: profile.args.clone(), cwd: cwd.clone(), env: Vec::new(), context_plan: self.plan.clone(), }) } } // --------------------------------------------------------------------------- // FakeFs (FileSystem) — records writes into the trace // --------------------------------------------------------------------------- #[derive(Clone)] struct FakeFs { trace: Trace, writes: WriteLog, created_dirs: Arc>>, } impl FakeFs { fn new(trace: Trace) -> Self { Self { trace, writes: Arc::new(Mutex::new(Vec::new())), created_dirs: Arc::new(Mutex::new(Vec::new())), } } fn writes(&self) -> Vec<(String, Vec)> { self.writes.lock().unwrap().clone() } fn created_dirs(&self) -> Vec { self.created_dirs.lock().unwrap().clone() } } #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { Err(FsError::NotFound(path.as_str().to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { self.trace.lock().unwrap().push("fs.write".to_owned()); self.writes .lock() .unwrap() .push((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> { self.created_dirs .lock() .unwrap() .push(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(()) } } // --------------------------------------------------------------------------- // FakePty (PtyPort) — records spawn into the trace // --------------------------------------------------------------------------- #[derive(Clone)] struct FakePty { trace: Trace, next_id: SessionId, spawns: Arc>>, writes: WriteLog, } impl FakePty { fn new(trace: Trace, next_id: SessionId) -> Self { Self { trace, next_id, spawns: Arc::new(Mutex::new(Vec::new())), writes: Arc::new(Mutex::new(Vec::new())), } } fn spawns(&self) -> Vec { self.spawns.lock().unwrap().clone() } fn writes(&self) -> Vec<(SessionId, Vec)> { self.writes.lock().unwrap().clone() } } #[async_trait] impl PtyPort for FakePty { async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result { self.trace.lock().unwrap().push("spawn".to_owned()); self.spawns.lock().unwrap().push(spec); Ok(PtyHandle { session_id: self.next_id, }) } fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> { self.writes .lock() .unwrap() .push((handle.session_id, data.to_vec())); 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 { Ok(ExitStatus { code: Some(0) }) } } // --------------------------------------------------------------------------- // SpyBus + SeqIds // --------------------------------------------------------------------------- #[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 // --------------------------------------------------------------------------- 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 project() -> Project { Project::new( ProjectId::from_uuid(Uuid::from_u128(1000)), "demo", ProjectPath::new("/home/me/proj").unwrap(), RemoteRef::local(), 1_700_000_000_000, ) .unwrap() } fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile { AgentProfile::new( id, "Claude Code", "claude", Vec::new(), injection, Some("claude --version".to_owned()), "{agentRunDir}", ) .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() } // --------------------------------------------------------------------------- // CreateAgentFromScratch // --------------------------------------------------------------------------- #[tokio::test] async fn create_persists_manifest_entry_and_initial_context() { let contexts = FakeContexts::new(); let bus = SpyBus::default(); let create = CreateAgentFromScratch::new( Arc::new(contexts.clone()), Arc::new(SeqIds::new()), Arc::new(bus.clone()), ); let out = create .execute(CreateAgentInput { project: project(), name: "Backend Dev".to_owned(), profile_id: pid(9), initial_content: Some("# Backend".to_owned()), }) .await .expect("create succeeds"); // md_path is slugified from the name. assert_eq!(out.agent.context_path, "agents/backend-dev.md"); assert_eq!(out.agent.profile_id, pid(9)); assert!(matches!(out.agent.origin, AgentOrigin::Scratch)); assert!(!out.agent.synchronized); // Manifest has exactly one entry for this agent; context stored under md_path. let manifest = contexts.manifest(); assert_eq!(manifest.entries.len(), 1); assert_eq!(manifest.entries[0].agent_id, out.agent.id); assert_eq!( contexts.content("agents/backend-dev.md").as_deref(), Some("# Backend") ); } #[tokio::test] async fn create_disambiguates_md_path_on_name_collision() { // Seed a project that already has `agents/backend.md`. let existing = scratch_agent(aid(50), "Backend", "agents/backend.md", pid(9)); let contexts = FakeContexts::with_agent(&existing, "old"); let create = CreateAgentFromScratch::new( Arc::new(contexts.clone()), Arc::new(SeqIds::new()), Arc::new(SpyBus::default()), ); let out = create .execute(CreateAgentInput { project: project(), name: "Backend".to_owned(), profile_id: pid(9), initial_content: None, }) .await .unwrap(); assert_eq!(out.agent.context_path, "agents/backend-2.md"); assert_eq!(contexts.manifest().entries.len(), 2); } // --------------------------------------------------------------------------- // ListAgents / Read / Update / Delete // --------------------------------------------------------------------------- #[tokio::test] async fn list_reconstructs_agents_from_manifest() { let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); let contexts = FakeContexts::with_agent(&a, "ctx"); let list = ListAgents::new(Arc::new(contexts)); let out = list .execute(ListAgentsInput { project: project() }) .await .unwrap(); assert_eq!(out.agents, vec![a]); } #[tokio::test] async fn read_then_update_context_roundtrips() { let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); let contexts = FakeContexts::with_agent(&a, "original"); let read = ReadAgentContext::new(Arc::new(contexts.clone())); let update = UpdateAgentContext::new(Arc::new(contexts.clone())); let before = read .execute(ReadAgentContextInput { project: project(), agent_id: a.id, }) .await .unwrap(); assert_eq!(before.content.as_str(), "original"); update .execute(UpdateAgentContextInput { project: project(), agent_id: a.id, content: "edited".to_owned(), }) .await .unwrap(); assert_eq!(contexts.content("agents/backend.md").as_deref(), Some("edited")); } #[tokio::test] async fn delete_removes_entry_then_unknown_is_not_found() { let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); let contexts = FakeContexts::with_agent(&a, "ctx"); let delete = DeleteAgent::new(Arc::new(contexts.clone()), Arc::new(SpyBus::default())); delete .execute(DeleteAgentInput { project: project(), agent_id: a.id, }) .await .unwrap(); assert!(contexts.manifest().entries.is_empty()); // Second delete: the agent is gone → NotFound. let err = delete .execute(DeleteAgentInput { project: project(), agent_id: a.id, }) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } // --------------------------------------------------------------------------- // LaunchAgent // --------------------------------------------------------------------------- /// Everything a launch test needs to drive `LaunchAgent` and assert over the /// fakes: the use case, the seeded agent, the recording fs/pty, the event spy, /// the session registry and the shared ordering trace. type LaunchFixture = ( LaunchAgent, Agent, FakeFs, FakePty, SpyBus, Arc, Trace, ); /// Wires a LaunchAgent over fakes for a given injection strategy/plan. fn launch_fixture(injection: ContextInjection, plan: Option) -> LaunchFixture { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); let contexts = FakeContexts::with_agent(&agent, "# ctx body"); let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]); let tr = trace(); let runtime = FakeRuntime::new(Arc::clone(&tr), plan); let fs = FakeFs::new(Arc::clone(&tr)); let pty = FakePty::new(Arc::clone(&tr), sid(777)); let sessions = Arc::new(TerminalSessions::new()); let bus = SpyBus::default(); let launch = LaunchAgent::new( Arc::new(contexts), Arc::new(profiles), Arc::new(runtime), Arc::new(fs.clone()), Arc::new(pty.clone()), Arc::clone(&sessions), Arc::new(bus.clone()), ); (launch, agent, fs, pty, bus, sessions, tr) } fn launch_input(agent_id: AgentId) -> LaunchAgentInput { LaunchAgentInput { project: project(), agent_id, rows: 24, cols: 80, node_id: None, } } #[tokio::test] async fn launch_orders_prepare_then_injection_then_spawn() { // conventionFile strategy → an fs.write must happen between prepare and spawn. let (launch, agent, fs, pty, bus, sessions, tr) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let out = launch.execute(launch_input(agent.id)).await.expect("launch"); // Ordering contract. assert_eq!( *tr.lock().unwrap(), vec!["prepare".to_owned(), "fs.write".to_owned(), "spawn".to_owned()], "prepare → injection → spawn" ); // The conventionFile was written inside the agent's isolated run directory // (`.ideai/run//CLAUDE.md`) — NOT at the project root. Its content // is the *composed* document: an absolute project-root header followed by the // agent persona `.md`. let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let writes = fs.writes(); assert_eq!(writes.len(), 1); assert_eq!(writes[0].0, format!("{run_dir}/CLAUDE.md")); let written = String::from_utf8(writes[0].1.clone()).unwrap(); assert!( written.contains("/home/me/proj"), "convention file must carry the absolute project root, got: {written}" ); assert!( written.contains("# ctx body"), "convention file must carry the agent persona, got: {written}" ); // The run directory was created (via the FileSystem port) before spawn. assert_eq!(fs.created_dirs(), vec![run_dir.clone()]); // Spawn happened at the isolated run dir with the profile command. let spawns = pty.spawns(); assert_eq!(spawns.len(), 1); assert_eq!(spawns[0].command, "claude"); assert_eq!(spawns[0].cwd.as_str(), run_dir); // The session adopts the PTY id, is Running, and is registered as an agent. assert_eq!(out.session.id, sid(777)); assert!(matches!( out.session.kind, domain::SessionKind::Agent { agent_id } if agent_id == agent.id )); assert!(sessions.session(&sid(777)).is_some()); // AgentLaunched announced. assert_eq!( bus.events(), vec![DomainEvent::AgentLaunched { agent_id: agent.id, session_id: sid(777), }] ); } /// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same* /// profile on the *same* project root must launch into two **distinct** cwd — /// each its own `.ideai/run//` — and each writes its convention file /// inside its own run dir, never colliding at the project root. #[tokio::test] async fn two_agents_same_root_get_distinct_run_dirs_no_collision() { let injection = ContextInjection::convention_file("CLAUDE.md").unwrap(); let plan = Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }); // Two agents, same profile (pid(9)), same project root. let agent_a = scratch_agent(aid(1), "Alpha", "agents/alpha.md", pid(9)); let agent_b = scratch_agent(aid(2), "Bravo", "agents/bravo.md", pid(9)); let contexts = FakeContexts::with_agent(&agent_a, "# alpha"); { let mut inner = contexts.0.lock().unwrap(); inner .manifest .entries .push(ManifestEntry::from_agent(&agent_b)); inner .contents .insert(agent_b.context_path.clone(), "# bravo".to_owned()); } let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]); let tr = trace(); let fs = FakeFs::new(Arc::clone(&tr)); let pty = FakePty::new(Arc::clone(&tr), sid(777)); let sessions = Arc::new(TerminalSessions::new()); let launch = LaunchAgent::new( Arc::new(contexts), Arc::new(profiles), Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)), Arc::new(fs.clone()), Arc::new(pty.clone()), Arc::clone(&sessions), Arc::new(SpyBus::default()), ); launch.execute(launch_input(agent_a.id)).await.unwrap(); launch.execute(launch_input(agent_b.id)).await.unwrap(); let dir_a = format!("/home/me/proj/.ideai/run/{}", agent_a.id); let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id); assert_ne!(dir_a, dir_b, "the two agents must map to different run dirs"); // Two distinct run dirs were created. assert_eq!(fs.created_dirs(), vec![dir_a.clone(), dir_b.clone()]); // Two spawns at two distinct cwd — the core anti-collision guarantee. let spawns = pty.spawns(); assert_eq!(spawns.len(), 2); assert_eq!(spawns[0].cwd.as_str(), dir_a); assert_eq!(spawns[1].cwd.as_str(), dir_b); assert_ne!(spawns[0].cwd, spawns[1].cwd); // Two convention files, each inside its own run dir (no shared root file). let writes = fs.writes(); assert_eq!(writes.len(), 2); assert_eq!(writes[0].0, format!("{dir_a}/CLAUDE.md")); assert_eq!(writes[1].0, format!("{dir_b}/CLAUDE.md")); assert_ne!(writes[0].0, writes[1].0); // Neither writes to the project root. assert!(writes.iter().all(|(p, _)| p != "/home/me/proj/CLAUDE.md")); // Each convention file carries its own persona. assert!(String::from_utf8(writes[0].1.clone()).unwrap().contains("# alpha")); assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo")); } #[tokio::test] async fn launch_stdin_strategy_pipes_context_after_spawn() { let (launch, agent, fs, pty, _bus, _sessions, tr) = launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin)); launch.execute(launch_input(agent.id)).await.unwrap(); // No file written for stdin; content is piped to the PTY post-spawn. assert!(fs.writes().is_empty(), "stdin must not write a file"); assert_eq!(*tr.lock().unwrap(), vec!["prepare".to_owned(), "spawn".to_owned()]); let writes = pty.writes(); assert_eq!(writes.len(), 1); assert_eq!(writes[0].0, sid(777)); assert_eq!(writes[0].1, b"# ctx body"); } #[tokio::test] async fn launch_unknown_agent_is_not_found() { let (launch, _agent, _fs, pty, _bus, _sessions, _tr) = launch_fixture( ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin), ); let err = launch.execute(launch_input(aid(404))).await.unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert!(pty.spawns().is_empty(), "no spawn for unknown agent"); } #[tokio::test] async fn launch_unknown_profile_is_not_found() { // The agent references pid(9) but the store only knows pid(1). let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); let contexts = FakeContexts::with_agent(&agent, "ctx"); let profiles = FakeProfiles::new(vec![profile(pid(1), ContextInjection::stdin())]); let tr = trace(); let pty = FakePty::new(Arc::clone(&tr), sid(777)); let launch = LaunchAgent::new( Arc::new(contexts), Arc::new(profiles), Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))), Arc::new(FakeFs::new(Arc::clone(&tr))), Arc::new(pty.clone()), Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), ); let err = launch.execute(launch_input(agent.id)).await.unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert!(pty.spawns().is_empty(), "no spawn when profile unresolved"); }