//! Integration tests for [`OrchestratorService`] (ARCHITECTURE §14.3). //! //! The service is wired over the *real* agent/terminal use cases, themselves //! backed by in-memory fakes (the same fake patterns as `agent_lifecycle.rs`). //! This proves the dispatch contract end-to-end without real I/O: //! //! - `spawn_agent` on an **unknown** agent → create + launch (manifest grows, PTY //! spawns, `AgentLaunched` published), //! - `spawn_agent` on a **known** agent → launch only (no second manifest entry), //! - `stop_agent` → the agent's live session is killed and de-registered, //! - `update_agent_context` → the agent `.md` is overwritten, //! - unknown profile / unknown agent → `NotFound`, no 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::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId}; use uuid::Uuid; use application::{ CloseTerminal, CreateAgentFromScratch, LaunchAgent, ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext, }; // --------------------------------------------------------------------------- // Fakes (mirror agent_lifecycle.rs) // --------------------------------------------------------------------------- #[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(()) } } #[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(()) } } struct FakeRuntime; impl AgentRuntime for FakeRuntime { fn detect(&self, _profile: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( &self, profile: &AgentProfile, _ctx: &PreparedContext, cwd: &ProjectPath, ) -> Result { Ok(SpawnSpec { command: profile.command.clone(), args: profile.args.clone(), cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), }) } } #[derive(Clone, Default)] struct FakeFs; #[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> { Ok(()) } async fn exists(&self, _path: &RemotePath) -> Result { Ok(false) } async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } #[derive(Clone)] struct FakePty { next_id: SessionId, kills: Arc>>, } impl FakePty { fn new(next_id: SessionId) -> Self { Self { next_id, kills: Arc::new(Mutex::new(Vec::new())), } } fn kills(&self) -> Vec { self.kills.lock().unwrap().clone() } } #[async_trait] impl PtyPort for FakePty { async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result { 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) }) } } #[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 claude_profile() -> AgentProfile { AgentProfile::new( pid(9), "Claude Code", "claude", Vec::new(), ContextInjection::stdin(), Some("claude --version".to_owned()), "{agentRunDir}", ) .unwrap() } fn scratch_agent(id: AgentId, name: &str, md: &str) -> Agent { Agent::new(id, name, md, pid(9), AgentOrigin::Scratch, false).unwrap() } /// Everything wired for a dispatch test. struct Fixture { service: OrchestratorService, contexts: FakeContexts, pty: FakePty, bus: SpyBus, sessions: Arc, } fn fixture(contexts: FakeContexts) -> Fixture { let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()])); let sessions = Arc::new(TerminalSessions::new()); let pty = FakePty::new(sid(777)); let bus = SpyBus::default(); let create = Arc::new(CreateAgentFromScratch::new( Arc::new(contexts.clone()), Arc::new(SeqIds::new()), Arc::new(bus.clone()), )); let launch = Arc::new(LaunchAgent::new( Arc::new(contexts.clone()), Arc::clone(&profiles) as Arc, Arc::new(FakeRuntime), Arc::new(FakeFs), Arc::new(pty.clone()), Arc::clone(&sessions), Arc::new(bus.clone()), )); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let close = Arc::new(CloseTerminal::new( Arc::new(pty.clone()), Arc::clone(&sessions), )); let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone()))); let service = OrchestratorService::new( create, launch, list, close, update, Arc::clone(&profiles) as Arc, Arc::clone(&sessions), ); Fixture { service, contexts, pty, bus, sessions, } } fn cmd(json: &str) -> OrchestratorCommand { serde_json::from_str::(json) .unwrap() .validate() .unwrap() } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[tokio::test] async fn spawn_unknown_agent_creates_then_launches() { let fx = fixture(FakeContexts::new()); let out = fx .service .dispatch( &project(), cmd(r#"{ "action":"spawn_agent", "name":"dev-backend", "profile":"claude-code" }"#), ) .await .expect("dispatch ok"); assert!(out.detail.contains("dev-backend")); // The agent was created (manifest grew to one entry) and launched (session // registered as an agent, AgentLaunched published). let manifest = fx.contexts.manifest(); assert_eq!(manifest.entries.len(), 1); assert_eq!(manifest.entries[0].name, "dev-backend"); assert!(fx.sessions.session(&sid(777)).is_some()); let launched = fx .bus .events() .into_iter() .any(|e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777))); assert!(launched, "AgentLaunched must be published"); } #[tokio::test] async fn spawn_known_agent_launches_without_recreating() { let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md"); let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); fx.service .dispatch( &project(), cmd(r#"{ "action":"spawn_agent", "name":"dev-backend", "profile":"claude-code" }"#), ) .await .expect("dispatch ok"); // No second manifest entry — the existing agent was reused, just launched. assert_eq!(fx.contexts.manifest().entries.len(), 1); assert_eq!(fx.contexts.manifest().entries[0].agent_id, agent.id); assert!(fx.sessions.session(&sid(777)).is_some()); } #[tokio::test] async fn stop_agent_kills_the_right_session() { let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md"); let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); // Launch it first so a session is registered for the agent. fx.service .dispatch( &project(), cmd(r#"{ "action":"spawn_agent", "name":"dev-backend", "profile":"claude" }"#), ) .await .unwrap(); assert!(fx.sessions.session(&sid(777)).is_some()); // Now stop it. fx.service .dispatch( &project(), cmd(r#"{ "action":"stop_agent", "name":"dev-backend" }"#), ) .await .expect("stop ok"); // The PTY for that session was killed and the session de-registered. assert_eq!(fx.pty.kills(), vec![sid(777)]); assert!(fx.sessions.session(&sid(777)).is_none()); } #[tokio::test] async fn stop_agent_without_live_session_is_not_found() { let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md"); let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); let err = fx .service .dispatch( &project(), cmd(r#"{ "action":"stop_agent", "name":"dev-backend" }"#), ) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } #[tokio::test] async fn update_agent_context_overwrites_md() { let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md"); let fx = fixture(FakeContexts::with_agent(&agent, "# old")); fx.service .dispatch( &project(), cmd( r##"{ "action":"update_agent_context", "name":"dev-backend", "context":"# new body" }"##, ), ) .await .expect("update ok"); assert_eq!( fx.contexts.content("agents/dev-backend.md").as_deref(), Some("# new body") ); } #[tokio::test] async fn spawn_with_unknown_profile_is_not_found_and_does_not_create() { let fx = fixture(FakeContexts::new()); let err = fx .service .dispatch( &project(), cmd(r#"{ "action":"spawn_agent", "name":"x", "profile":"does-not-exist" }"#), ) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); // No agent was created when the profile could not be resolved. assert!(fx.contexts.manifest().entries.is_empty()); assert!(fx.sessions.session(&sid(777)).is_none()); }