//! 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, OrchestrationSource}; use domain::ids::NodeId; use domain::ids::SkillId; 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, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{ AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter, }; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; use domain::terminal::{SessionKind, TerminalSession}; use domain::{PtySize, SessionId}; use uuid::Uuid; use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext, }; use infrastructure::{ process_request_file, FsOrchestratorWatcher, InMemoryMailbox, 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() } /// Seeds the manifest with an already-existing agent so `find_agent_id_by_name` /// resolves it without going through `CreateAgentFromScratch`. Returns the id. fn seed_agent(&self, name: &str) -> AgentId { let id = AgentId::from_uuid(Uuid::new_v4()); let mut inner = self.0.lock().unwrap(); inner.manifest.entries.push(ManifestEntry { agent_id: id, name: name.to_owned(), md_path: format!("agents/{name}.md"), profile_id: ProfileId::from_uuid(Uuid::from_u128(9)), template_id: None, synchronized: false, synced_template_version: None, skills: Vec::new(), }); 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()) } } #[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(()) } } /// An empty [`MemoryRecall`]: no project memory ⇒ no memory section injected, /// leaving the watcher-driven launch behaviour unchanged. #[derive(Default)] struct FakeRecall; #[async_trait] impl domain::ports::MemoryRecall for FakeRecall { async fn recall( &self, _root: &ProjectPath, _query: &domain::ports::MemoryQuery, ) -> Result, domain::ports::MemoryError> { Ok(Vec::new()) } } struct FakeRuntime; impl AgentRuntime for FakeRuntime { fn detect(&self, _p: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( &self, profile: &AgentProfile, _ctx: &PreparedContext, cwd: &ProjectPath, _session: &SessionPlan, ) -> Result { Ok(SpawnSpec { command: profile.command.clone(), args: profile.args.clone(), cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), sandbox: None, }) } } #[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 { // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : // seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible // d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`. 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}", None, ) .unwrap() .with_structured_adapter(StructuredAdapter::Claude) .with_mcp(McpCapability::new( McpConfigStrategy::config_file(".mcp.json").unwrap(), McpTransport::Stdio, ))]))); 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(), Arc::new(SeqIds(Mutex::new(1))), Arc::new(FakeRecall), None, )); 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))); let create_skill = Arc::new(CreateSkill::new( Arc::new(FakeSkills) as Arc, Arc::new(SeqIds(Mutex::new(1))), )); Arc::new(OrchestratorService::new( create, launch, list, close, update, create_skill, Arc::clone(&profiles) as Arc, sessions, )) } /// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1 /// « Terminal + MCP », B-3/B-4), so an `agent.message` blocks on the mailbox and an /// `agent.reply` resolves it. Returns the service, the shared mailbox (to observe /// pending tickets) and the PTY registry (to pre-seed a live target). fn build_service_with_mailbox( contexts: FakeContexts, ) -> ( Arc, Arc, Arc, ) { // Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) : // seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible // d'`idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`. 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}", None, ) .unwrap() .with_structured_adapter(StructuredAdapter::Claude) .with_mcp(McpCapability::new( McpConfigStrategy::config_file(".mcp.json").unwrap(), McpTransport::Stdio, ))]))); let sessions = Arc::new(TerminalSessions::new()); let mailbox = Arc::new(InMemoryMailbox::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(), Arc::new(SeqIds(Mutex::new(1))), Arc::new(FakeRecall), None, )); 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))); let create_skill = Arc::new(CreateSkill::new( Arc::new(FakeSkills) as Arc, Arc::new(SeqIds(Mutex::new(1))), )); let service = Arc::new( OrchestratorService::new( create, launch, list, close, update, create_skill, Arc::clone(&profiles) as Arc, Arc::clone(&sessions), ) .with_input_mediator( Arc::new(infrastructure::MediatedInbox::with_pty( Arc::clone(&mailbox), Arc::new(infrastructure::SystemMillisClock), Arc::new(FakePty) as Arc, )) as Arc, Arc::clone(&mailbox) as Arc, ) .with_conversations( Arc::new(infrastructure::InMemoryConversationRegistry::new()) as Arc, ), ); (service, mailbox, sessions) } /// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it. fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) { sessions.insert( PtyHandle { session_id }, TerminalSession::starting( session_id, NodeId::from_uuid(Uuid::from_u128(7)), ProjectPath::new("/home/me/proj").unwrap(), SessionKind::Agent { agent_id }, PtySize { rows: 24, cols: 80 }, ), ); } 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") } /// Reads the raw `*.response.json` bytes as a UTF-8 string, for assertions on the /// *literal wire shape* (e.g. that the `"reply"` key is absent — not just `None` /// after deserialisation). fn read_response_raw(request_path: &std::path::Path) -> String { 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); std::fs::read_to_string(&response_path).expect("response file written") } #[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 valid_agent_run_request_succeeds_and_reports_type() { let tmp = TempDir::new(); let contexts = FakeContexts::new(); let service = build_service(contexts.clone()); let req = tmp.0.join("req-agent-run.json"); std::fs::write( &req, br#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "architect", "profile": "claude-code", "task": "Analyse", "visibility": "background" }"#, ) .unwrap(); let response = process_request_file(&req, &project(), &service).await; assert!(response.ok, "expected ok, got {response:?}"); assert_eq!(response.action.as_deref(), Some("agent.run")); assert!(!req.exists(), "request file must be removed"); let on_disk = read_response(&req); assert!(on_disk.ok); assert_eq!(contexts.entries().len(), 1); assert_eq!(contexts.entries()[0].name, "architect"); } #[tokio::test] async fn valid_create_skill_request_succeeds_and_is_consumed() { let tmp = TempDir::new(); let service = build_service(FakeContexts::new()); let req = tmp.0.join("skill-req.json"); std::fs::write( &req, br##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##, ) .unwrap(); let response = process_request_file(&req, &project(), &service).await; assert!(response.ok, "expected ok, got {response:?}"); assert_eq!(response.action.as_deref(), Some("create_skill")); // Request consumed; a response sibling written and marked ok. assert!(!req.exists(), "request file must be removed"); assert!(read_response(&req).ok); } #[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")); } // --------------------------------------------------------------------------- // LOT D6b — `reply` surfaced into the `*.response.json` wire format. // // D6b makes `OrchestratorResponse` carry an optional `reply` (the queried agent's // content for `ask`/`agent.message`), `#[serde(skip_serializing_if = "Option::is_none")]` // so a response with no reply keeps the exact legacy shape. // --------------------------------------------------------------------------- /// Point 1 — an `agent.message` (`ask`) whose target replies ⇒ the on-disk /// `*.response.json` carries the `reply` content **and** `detail` (both coexist), /// alongside `ok: true`. #[tokio::test] async fn ask_request_surfaces_reply_alongside_detail() { // Option 1 (B-3/B-4): an `agent.message` request blocks awaiting the target's // `idea_reply`. Over the file protocol we drive the ask request on a task, wait // for the mailbox to hold a ticket, then process a separate `agent.reply` request // (carrying the target's id as `requestedBy`, the handshake identity) which // resolves it. The ask's `*.response.json` then carries reply + detail. let tmp = TempDir::new(); let contexts = FakeContexts::new(); let agent_id = contexts.seed_agent("architect"); let (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone()); // Target already live in the PTY registry, so the ask reuses its terminal. seed_live_pty( &sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)), ); let req = tmp.0.join("ask-req.json"); std::fs::write( &req, br#"{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "architect", "task": "What is the answer?" }"#, ) .unwrap(); let svc = Arc::clone(&service); let proj = project(); let ask_req = req.clone(); let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await }); // Wait until the ask has enqueued its ticket (blocked awaiting the reply). tokio::time::timeout(std::time::Duration::from_secs(10), async { while mailbox.pending(&agent_id) == 0 { tokio::task::yield_now().await; } }) .await .expect("ask must enqueue a ticket"); // The target renders its result via an `agent.reply` request whose `requestedBy` // is its own id (the handshake identity the MCP server would inject as `from`). let reply_req = tmp.0.join("reply-req.json"); std::fs::write( &reply_req, format!( r#"{{ "type": "agent.reply", "requestedBy": "{agent_id}", "result": "the answer is 42" }}"# ) .as_bytes(), ) .unwrap(); let reply_resp = process_request_file(&reply_req, &project(), &service).await; assert!(reply_resp.ok, "reply request ok: {reply_resp:?}"); let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask) .await .expect("ask completes after reply") .expect("join ok"); assert!(response.ok, "expected ok, got {response:?}"); assert_eq!(response.action.as_deref(), Some("agent.message")); // reply carries the target's rendered result... assert_eq!(response.reply.as_deref(), Some("the answer is 42")); // ...and detail still summarises what IdeA did (the two coexist). assert!( response .detail .as_deref() .unwrap_or_default() .contains("architect"), "detail should still be present: {response:?}" ); // The on-disk wire format actually contains both keys with the right values. let on_disk = read_response(&req); assert!(on_disk.ok); assert_eq!(on_disk.reply.as_deref(), Some("the answer is 42")); assert!(on_disk.detail.is_some()); let raw = read_response_raw(&req); let json: serde_json::Value = serde_json::from_str(&raw).unwrap(); assert_eq!(json["reply"], serde_json::json!("the answer is 42")); assert!(json.get("detail").is_some()); assert!(!req.exists(), "request file must be removed"); } /// Point 2 — a non-`ask` command (here `spawn_agent`, reply `None`) ⇒ the `reply` /// key is **absent** from the serialised JSON (`skip_serializing_if`). Anti-always-green: /// we assert on the *literal absence* of the key in the raw bytes, so the test would /// fail if `reply: null` were emitted instead. #[tokio::test] async fn non_ask_request_omits_reply_key() { let tmp = TempDir::new(); let contexts = FakeContexts::new(); let service = build_service(contexts.clone()); let req = tmp.0.join("spawn-no-reply.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!(response.reply.is_none(), "spawn must not produce a reply"); // Deserialised: reply is None. let on_disk = read_response(&req); assert!(on_disk.reply.is_none()); // Raw wire: the "reply" key must not appear at all. (Guard the assertion itself: // confirm we ARE looking at the right file by checking a key we know is present.) let raw = read_response_raw(&req); assert!( raw.contains("\"ok\""), "sanity: response JSON should contain ok — got {raw}" ); assert!( !raw.contains("reply"), "reply key must be skipped when None — got {raw}" ); // And via the parsed tree, the key is structurally absent (not null). let json: serde_json::Value = serde_json::from_str(&raw).unwrap(); assert!( json.get("reply").is_none(), "reply key must be structurally absent — got {json}" ); } /// Point 3 — round-trip a legacy `*.response.json` written **before** D6b (no /// `reply` key): it deserialises to `reply == None`, and re-serialising does **not** /// reintroduce the key (forward/backward wire compatibility). #[test] fn legacy_response_without_reply_round_trips_without_reintroducing_key() { // A response payload exactly as a pre-D6b IdeA would have written it. let legacy = r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#; let parsed: OrchestratorResponse = serde_json::from_str(legacy).expect("legacy response must still parse"); assert!(parsed.ok); assert_eq!(parsed.action.as_deref(), Some("spawn_agent")); assert!( parsed.reply.is_none(), "absent reply key must deserialise to None" ); // Re-serialising must not bring a "reply" key back. let reserialised = serde_json::to_string(&parsed).unwrap(); assert!( !reserialised.contains("reply"), "round-trip must not reintroduce the reply key — got {reserialised}" ); let json: serde_json::Value = serde_json::from_str(&reserialised).unwrap(); assert!(json.get("reply").is_none()); } /// Point 4 — a failure response carries no `reply` (the field is `None` for every /// `failure(..)`), so the key is absent from the failing `*.response.json` too. #[tokio::test] async fn failure_response_omits_reply_key() { let tmp = TempDir::new(); let service = build_service(FakeContexts::new()); let req = tmp.0.join("broken-no-reply.json"); std::fs::write(&req, b"{ not json at all").unwrap(); let response = process_request_file(&req, &project(), &service).await; assert!(!response.ok); assert!(response.reply.is_none(), "failure must not carry a reply"); let raw = read_response_raw(&req); assert!( raw.contains("\"error\""), "sanity: a failure JSON should contain error — got {raw}" ); assert!( !raw.contains("reply"), "reply key must be absent on failure — got {raw}" ); } // --------------------------------------------------------------------------- // M4 — observability: the filesystem watcher tags processed requests source=File. // // Drives the *public* `FsOrchestratorWatcher::start` against a real temp project // root with a capturing events closure (the same sink shape the composition root // wires), then polls (bounded) until the OrchestratorRequestProcessed beacon for // the dropped request lands. Asserts the tag is `OrchestrationSource::File`. // --------------------------------------------------------------------------- #[tokio::test] async fn watcher_publishes_processed_event_tagged_file_source() { use std::time::Duration; let tmp = TempDir::new(); // A project rooted at the temp dir so the watcher scans /.ideai/requests/. let project = Project::new( ProjectId::from_uuid(Uuid::from_u128(2000)), "demo", ProjectPath::new(tmp.0.to_str().unwrap()).unwrap(), RemoteRef::local(), 1_700_000_000_000, ) .unwrap(); let service = build_service(FakeContexts::new()); let captured = Arc::new(Mutex::new(Vec::::new())); let sink = captured.clone(); let publish: Arc = Arc::new(move |e| sink.lock().unwrap().push(e)); let handle = FsOrchestratorWatcher::start(project, Arc::clone(&service), publish); // Drop a valid request under .ideai/requests//. let req_dir = tmp.0.join(".ideai").join("requests").join("Main"); std::fs::create_dir_all(&req_dir).unwrap(); std::fs::write( req_dir.join("req-1.json"), br#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code" }"#, ) .unwrap(); // Poll (bounded) until the processed beacon for our request shows up. let mut found: Option = None; for _ in 0..50 { if let Some(e) = captured.lock().unwrap().iter().find(|e| { matches!(e, DomainEvent::OrchestratorRequestProcessed { action, .. } if action == "spawn_agent") }) { found = Some(e.clone()); break; } tokio::time::sleep(Duration::from_millis(50)).await; } handle.stop(); let event = found.expect("watcher must publish a processed beacon within the poll budget"); match event { DomainEvent::OrchestratorRequestProcessed { source, ok, requester_id, .. } => { assert_eq!(source, OrchestrationSource::File, "fs door must tag File"); assert!(ok, "the spawn request succeeded"); assert_eq!(requester_id, "Main", "requester id = request subdirectory"); } other => panic!("expected OrchestratorRequestProcessed, got {other:?}"), } }