//! Integration tests for the orchestrator filesystem adapter (ARCHITECTURE §14.3). //! //! These drive [`process_request_file`] — the standalone "parse + dispatch + write //! response + delete request" unit — against a real temp directory, with an //! [`OrchestratorService`] wired over in-memory fakes. We assert: //! //! - a **valid** `spawn_agent` request → success response, request deleted, agent //! created + launched, //! - an **invalid JSON** request → error response (no panic, request deleted), //! - an unknown action → error response carrying the rejection. use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::agent::{AgentManifest, 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, SkillStore, SpawnSpec, StoreError, }; use domain::ids::SkillId; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; use domain::{PtySize, SessionId}; use uuid::Uuid; use application::{ CloseTerminal, CreateAgentFromScratch, LaunchAgent, ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext, }; use infrastructure::{process_request_file, OrchestratorResponse}; // --- temp dir (mirror local_fs.rs) --- struct TempDir(PathBuf); impl TempDir { fn new() -> Self { let p = std::env::temp_dir().join(format!("idea-orch-{}", Uuid::new_v4())); std::fs::create_dir_all(&p).unwrap(); Self(p) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } // --- minimal fakes --- #[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 entries(&self) -> Vec { self.0.lock().unwrap().manifest.entries.clone() } 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 = self.md_path_of(agent).ok_or(StoreError::NotFound)?; Ok(MarkdownDoc::new( self.0.lock().unwrap().contents.get(&md).cloned().unwrap_or_default(), )) } async fn write_context( &self, _project: &Project, agent: &AgentId, md: &MarkdownDoc, ) -> Result<(), StoreError> { let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; self.0 .lock() .unwrap() .contents .insert(path, md.as_str().to_owned()); Ok(()) } async fn load_manifest(&self, _project: &Project) -> Result { Ok(self.0.lock().unwrap().manifest.clone()) } async fn save_manifest( &self, _project: &Project, manifest: &AgentManifest, ) -> Result<(), StoreError> { self.0.lock().unwrap().manifest = manifest.clone(); Ok(()) } } #[derive(Clone)] struct FakeProfiles(Arc>); #[async_trait] impl ProfileStore for FakeProfiles { async fn list(&self) -> Result, StoreError> { Ok((*self.0).clone()) } async fn save(&self, _p: &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(()) } } // Empty skill store: the watcher tests spawn agents with no assigned skills. #[derive(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(()) } } struct FakeRuntime; impl AgentRuntime for FakeRuntime { fn detect(&self, _p: &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, p: &RemotePath) -> Result, FsError> { Err(FsError::NotFound(p.as_str().to_owned())) } async fn write(&self, _p: &RemotePath, _d: &[u8]) -> Result<(), FsError> { Ok(()) } async fn exists(&self, _p: &RemotePath) -> Result { Ok(false) } async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> { Ok(()) } async fn list(&self, _p: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _s: &RemotePath, _d: &RemotePath) -> Result<(), FsError> { Ok(()) } } #[derive(Clone)] struct FakePty; #[async_trait] impl PtyPort for FakePty { async fn spawn(&self, _s: SpawnSpec, _z: PtySize) -> Result { Ok(PtyHandle { session_id: SessionId::from_uuid(Uuid::from_u128(777)), }) } fn write(&self, _h: &PtyHandle, _d: &[u8]) -> Result<(), PtyError> { Ok(()) } fn resize(&self, _h: &PtyHandle, _z: PtySize) -> Result<(), PtyError> { Ok(()) } fn subscribe_output(&self, _h: &PtyHandle) -> Result { Ok(Box::new(std::iter::empty())) } fn scrollback(&self, _h: &PtyHandle) -> Result, PtyError> { Ok(Vec::new()) } async fn kill(&self, _h: &PtyHandle) -> Result { Ok(ExitStatus { code: Some(0) }) } } #[derive(Default, Clone)] struct NoopBus; impl EventBus for NoopBus { fn publish(&self, _e: DomainEvent) {} fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } struct SeqIds(Mutex); 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 } } 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 build_service(contexts: FakeContexts) -> Arc { let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( ProfileId::from_uuid(Uuid::from_u128(9)), "Claude Code", "claude", Vec::new(), ContextInjection::stdin(), None, "{agentRunDir}", ) .unwrap()]))); let sessions = Arc::new(TerminalSessions::new()); let bus = Arc::new(NoopBus); let create = Arc::new(CreateAgentFromScratch::new( Arc::new(contexts.clone()), Arc::new(SeqIds(Mutex::new(1))), 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(FakePty), Arc::new(FakeSkills), Arc::clone(&sessions), bus.clone(), )); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); Arc::new(OrchestratorService::new( create, launch, list, close, update, Arc::clone(&profiles) as Arc, sessions, )) } fn read_response(request_path: &std::path::Path) -> OrchestratorResponse { let mut name = request_path.file_name().unwrap().to_string_lossy().into_owned(); name.push_str(".response.json"); let response_path = request_path.with_file_name(name); let bytes = std::fs::read(&response_path).expect("response file written"); serde_json::from_slice(&bytes).expect("response parses") } #[tokio::test] async fn valid_spawn_request_succeeds_and_is_consumed() { let tmp = TempDir::new(); let contexts = FakeContexts::new(); let service = build_service(contexts.clone()); let req = tmp.0.join("req-1.json"); std::fs::write( &req, br#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code" }"#, ) .unwrap(); let response = process_request_file(&req, &project(), &service).await; assert!(response.ok, "expected ok, got {response:?}"); assert_eq!(response.action.as_deref(), Some("spawn_agent")); // Request consumed; a response sibling written. assert!(!req.exists(), "request file must be removed"); let on_disk = read_response(&req); assert!(on_disk.ok); // The agent was actually created through the use cases. assert_eq!(contexts.entries().len(), 1); assert_eq!(contexts.entries()[0].name, "dev-backend"); } #[tokio::test] async fn invalid_json_request_yields_error_response() { let tmp = TempDir::new(); let service = build_service(FakeContexts::new()); let req = tmp.0.join("broken.json"); std::fs::write(&req, b"{ this is not json").unwrap(); let response = process_request_file(&req, &project(), &service).await; assert!(!response.ok); assert!( response.error.as_deref().unwrap_or_default().contains("invalid json"), "got {response:?}" ); assert!(!req.exists(), "poisoned request must still be removed"); assert!(!read_response(&req).ok); } #[tokio::test] async fn unknown_action_yields_error_response() { let tmp = TempDir::new(); let service = build_service(FakeContexts::new()); let req = tmp.0.join("weird.json"); std::fs::write(&req, br#"{ "action": "explode", "name": "x" }"#).unwrap(); let response = process_request_file(&req, &project(), &service).await; assert!(!response.ok); assert_eq!(response.action.as_deref(), Some("explode")); assert!(response.error.as_deref().unwrap_or_default().contains("unknown orchestrator action")); }