//! 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::SkillId; use domain::ids::{AgentId, NodeId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, ReplyStream, 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::terminal::{SessionKind, TerminalSession}; use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId}; use uuid::Uuid; use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, OrchestratorService, StructuredSessions, 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(()) } } // Empty skill store: the orchestrator 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 launch behaviour unchanged for the service-level tests. #[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()) } } /// A [`SkillStore`] that records every `save` so a `create_skill` dispatch can be /// asserted against the persisted skill (name + scope). #[derive(Clone, Default)] struct RecordingSkills(Arc>>); #[async_trait] impl SkillStore for RecordingSkills { async fn list( &self, _scope: SkillScope, _root: &ProjectPath, ) -> Result, StoreError> { Ok(self.0.lock().unwrap().clone()) } async fn get( &self, _scope: SkillScope, _root: &ProjectPath, id: SkillId, ) -> Result { self.0 .lock() .unwrap() .iter() .find(|s| s.id == id) .cloned() .ok_or(StoreError::NotFound) } async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { self.0.lock().unwrap().push(skill.clone()); Ok(()) } async fn delete( &self, _scope: SkillScope, _root: &ProjectPath, _id: SkillId, ) -> 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, _session: &SessionPlan, ) -> 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>>, spawns: Arc>>, } impl FakePty { fn new(next_id: SessionId) -> Self { Self { next_id, kills: Arc::new(Mutex::new(Vec::new())), spawns: Arc::new(Mutex::new(Vec::new())), } } fn spawns(&self) -> Vec { self.spawns.lock().unwrap().clone() } fn kills(&self) -> Vec { self.kills.lock().unwrap().clone() } } #[async_trait] impl PtyPort for FakePty { async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result { self.spawns.lock().unwrap().push(self.next_id); 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()) } } // --------------------------------------------------------------------------- // D6 fakes: structured session + factory (inter-agent messaging) // --------------------------------------------------------------------------- /// What the scripted session does on its next `send`. enum SendScript { /// Emit this event stream verbatim (drained to its `Final`). Stream(Vec), /// Sleep `delay` *before* emitting the stream — forces a `send_blocking` timeout. Delayed { delay: std::time::Duration, events: Vec, }, } /// A fake [`AgentSession`] that records every `send` prompt and every `shutdown`, /// so a test can assert the rendezvous happened (send) and the session was *not* /// killed (zero shutdowns) on timeout. struct FakeSession { id: SessionId, script: Mutex>, sends: Arc>>, shutdowns: Arc>, } impl FakeSession { fn new(id: SessionId, script: SendScript) -> Arc { Arc::new(Self { id, script: Mutex::new(Some(script)), sends: Arc::new(Mutex::new(Vec::new())), shutdowns: Arc::new(Mutex::new(0)), }) } } #[async_trait] impl AgentSession for FakeSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { None } async fn send(&self, prompt: &str) -> Result { self.sends.lock().unwrap().push(prompt.to_owned()); let script = self .script .lock() .unwrap() .take() .expect("send scripted exactly once"); match script { SendScript::Stream(events) => Ok(Box::new(events.into_iter())), SendScript::Delayed { delay, events } => { tokio::time::sleep(delay).await; Ok(Box::new(events.into_iter())) } } } async fn shutdown(&self) -> Result<(), AgentSessionError> { *self.shutdowns.lock().unwrap() += 1; Ok(()) } } /// A fake [`AgentSessionFactory`] that hands out a pre-built [`FakeSession`] on /// `start`, so launching a *dead* target in structured mode registers a session /// the orchestrator can then drive. `supports` is true for any profile bearing a /// `structured_adapter` (mirrors the real factory's gate). struct FakeFactory { session: Arc, starts: Arc>, } impl FakeFactory { fn new(session: Arc) -> Self { Self { session, starts: Arc::new(Mutex::new(0)), } } } #[async_trait] impl AgentSessionFactory for FakeFactory { fn supports(&self, profile: &AgentProfile) -> bool { profile.structured_adapter.is_some() } async fn start( &self, _profile: &AgentProfile, _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, ) -> Result, AgentSessionError> { *self.starts.lock().unwrap() += 1; Ok(Arc::clone(&self.session) as Arc) } } fn final_(c: &str) -> ReplyEvent { ReplyEvent::Final { content: c.to_owned(), } } 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 nid(n: u128) -> NodeId { NodeId::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}", None, ) .unwrap() } fn scratch_agent(id: AgentId, name: &str, md: &str) -> Agent { Agent::new(id, name, md, pid(9), AgentOrigin::Scratch, false).unwrap() } /// Same id (`pid(9)`) as [`claude_profile`] so agents created with it resolve to a /// structured-capable profile, but bearing a `structured_adapter` so `LaunchAgent` /// routes through the (fake) [`AgentSessionFactory`] instead of the PTY. fn structured_profile() -> AgentProfile { AgentProfile::new( pid(9), "Claude Code", "claude", Vec::new(), ContextInjection::stdin(), Some("claude --version".to_owned()), "{agentRunDir}", None, ) .unwrap() .with_structured_adapter(StructuredAdapter::Claude) } /// Everything wired for a dispatch test. struct Fixture { service: OrchestratorService, contexts: FakeContexts, pty: FakePty, bus: SpyBus, sessions: Arc, skills: RecordingSkills, } 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::new(FakeSkills), Arc::clone(&sessions), Arc::new(bus.clone()), Arc::new(SeqIds::new()), Arc::new(FakeRecall), None, )); 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 skills = RecordingSkills::default(); let create_skill = Arc::new(CreateSkill::new( Arc::new(skills.clone()) as Arc, Arc::new(SeqIds::new()), )); let service = OrchestratorService::new( create, launch, list, close, update, create_skill, Arc::clone(&profiles) as Arc, Arc::clone(&sessions), ); Fixture { service, contexts, pty, bus, sessions, skills, } } fn cmd(json: &str) -> OrchestratorCommand { serde_json::from_str::(json) .unwrap() .validate() .unwrap() } // --------------------------------------------------------------------------- // D6 fixture: orchestrator wired for `agent.message` (inter-agent rendezvous) // --------------------------------------------------------------------------- /// Everything wired for an `agent.message` dispatch test (§17.4). struct AskFixture { service: OrchestratorService, structured: Arc, /// PTY (terminal) registry the service holds — lets a test register a raw /// terminal session for an agent to exercise the PTY-only-live branch. sessions: Arc, bus: SpyBus, /// PTY spawn/kill spy of the underlying `LaunchAgent` — used to prove a /// dead-target launch did *not* fall back to a raw PTY (zero spawns). pty: FakePty, /// `start` counter of the fake factory (dead-target launch path). factory_starts: Arc>, } /// Builds an orchestrator with the structured registry + event bus wired, and a /// `LaunchAgent` whose structured routing uses `factory` over `profiles`. /// /// `pty_only` controls the launched profile: `false` ⇒ structured-capable profile /// (factory hands out a session); `true` ⇒ plain PTY profile (no /// `structured_adapter`) so `launched.structured` stays `None`. fn ask_fixture( contexts: FakeContexts, factory_session: Arc, pty_only: bool, ) -> AskFixture { let profile = if pty_only { claude_profile() } else { structured_profile() }; let profiles = Arc::new(FakeProfiles::new(vec![profile])); let sessions = Arc::new(TerminalSessions::new()); let structured = Arc::new(StructuredSessions::new()); let pty = FakePty::new(sid(777)); let bus = SpyBus::default(); let factory = Arc::new(FakeFactory::new(factory_session)); let factory_starts = Arc::clone(&factory.starts); 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::new(FakeSkills), Arc::clone(&sessions), Arc::new(bus.clone()), Arc::new(SeqIds::new()), Arc::new(FakeRecall), None, ) .with_structured(factory as Arc, Arc::clone(&structured)), ); 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 skills = RecordingSkills::default(); let create_skill = Arc::new(CreateSkill::new( Arc::new(skills) as Arc, Arc::new(SeqIds::new()), )); let service = OrchestratorService::new( create, launch, list, close, update, create_skill, Arc::clone(&profiles) as Arc, Arc::clone(&sessions), ) .with_structured(Arc::clone(&structured)) .with_events(Arc::new(bus.clone())); AskFixture { service, structured, sessions, bus, pty, factory_starts, } } // --------------------------------------------------------------------------- // 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 agent_run_existing_agent_does_not_require_profile() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); fx.service .dispatch( &project(), cmd(r#"{ "type":"agent.run", "targetAgent":"architect", "task":"Analyse" }"#), ) .await .expect("dispatch ok"); assert_eq!(fx.contexts.manifest().entries.len(), 1); assert!(fx.sessions.session(&sid(777)).is_some()); } #[tokio::test] async fn agent_run_visible_reattaches_existing_session() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); let first_cell = nid(10); let next_cell = nid(20); fx.service .dispatch( &project(), cmd(&format!( r#"{{ "action":"spawn_agent", "name":"architect", "profile":"claude", "visibility":"visible", "nodeId":"{first_cell}" }}"# )), ) .await .expect("first launch ok"); let out = fx .service .dispatch( &project(), cmd(&format!( r#"{{ "type":"agent.run", "targetAgent":"architect", "visibility":"visible", "attachToCell":"{next_cell}" }}"# )), ) .await .expect("reattach ok"); assert!(out.detail.contains("attached agent architect")); let session = fx.sessions.session(&sid(777)).expect("live session"); assert_eq!(session.node_id, next_cell); assert_eq!(fx.pty.spawns().len(), 1, "reattach must not respawn"); } #[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()); } #[tokio::test] async fn create_skill_persists_a_project_scoped_skill_by_default() { let fx = fixture(FakeContexts::new()); let out = fx .service .dispatch( &project(), cmd(r##"{ "action":"create_skill", "name":"deploy", "context":"# Deploy" }"##), ) .await .expect("create_skill ok"); assert!(out.detail.contains("deploy")); let saved = fx.skills.0.lock().unwrap(); assert_eq!(saved.len(), 1); assert_eq!(saved[0].name, "deploy"); assert_eq!(saved[0].scope, SkillScope::Project); } #[tokio::test] async fn create_skill_honours_global_scope() { let fx = fixture(FakeContexts::new()); fx.service .dispatch( &project(), cmd( r##"{ "action":"create_skill", "name":"shared", "context":"# Shared", "scope":"global" }"##, ), ) .await .expect("create_skill ok"); let saved = fx.skills.0.lock().unwrap(); assert_eq!(saved.len(), 1); assert_eq!(saved[0].scope, SkillScope::Global); } // --------------------------------------------------------------------------- // D6 — agent.message (synchronous inter-agent rendezvous, §17.4) // --------------------------------------------------------------------------- const ASK_JSON: &str = r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#; /// Zone 1 — live structured target ⇒ `send_blocking` is driven and the outcome's /// `reply` carries the turn's `Final` content. Anti-always-green: the assertion is /// on the *exact* content, so it fails for `None` or any other string. #[tokio::test] async fn ask_live_structured_target_returns_final_content() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); // A live session already registered for the agent in the *structured* registry. let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_("the §17 answer")])); let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); let fx = ask_fixture( FakeContexts::with_agent(&agent, "# persona"), throwaway, false, ); fx.structured .insert(Arc::clone(&session) as Arc, aid(1), nid(1)); let out = fx .service .dispatch(&project(), cmd(ASK_JSON)) .await .expect("ask ok"); // The exact Final content is returned (would fail on None or any other text). assert_eq!(out.reply.as_deref(), Some("the §17 answer")); // The prompt actually reached the live session. assert_eq!(session.sends.lock().unwrap().as_slice(), &["Analyse §17"]); // Live session reused: factory never started a new one, no PTY spawned. assert_eq!(*fx.factory_starts.lock().unwrap(), 0); assert!(fx.pty.spawns().is_empty(), "must not spawn a PTY"); // Session not killed. assert_eq!(*session.shutdowns.lock().unwrap(), 0); } /// Anti-always-green guard, explicit negative: the same fixture but the session /// returns a *different* Final ⇒ the strict equality from the test above must NOT /// hold. Proves the assertion has teeth. #[tokio::test] async fn ask_reply_assertion_is_content_sensitive() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_("SOMETHING ELSE")])); let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); let fx = ask_fixture( FakeContexts::with_agent(&agent, "# persona"), throwaway, false, ); fx.structured .insert(session as Arc, aid(1), nid(1)); let out = fx .service .dispatch(&project(), cmd(ASK_JSON)) .await .expect("ask ok"); assert_ne!(out.reply.as_deref(), Some("the §17 answer")); assert_eq!(out.reply.as_deref(), Some("SOMETHING ELSE")); } /// Zone 4 — on success, `DomainEvent::AgentReplied` is published with the right /// agent id and `reply_len` (byte length of the content). #[tokio::test] async fn ask_success_publishes_agent_replied_event() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let content = "réponse"; // multibyte: len() in bytes, not chars let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_(content)])); let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); let fx = ask_fixture( FakeContexts::with_agent(&agent, "# persona"), throwaway, false, ); fx.structured .insert(session as Arc, aid(1), nid(1)); fx.service .dispatch(&project(), cmd(ASK_JSON)) .await .expect("ask ok"); let replied = fx .bus .events() .into_iter() .find_map(|e| match e { DomainEvent::AgentReplied { agent_id, reply_len, } => Some((agent_id, reply_len)), _ => None, }) .expect("AgentReplied must be published"); assert_eq!(replied.0, aid(1)); assert_eq!(replied.1, content.len()); } /// Zone 2 (orchestrator seam) — a failing turn (stream ends without a `Final`) /// surfaces a typed `PROCESS` error WITHOUT the orchestrator killing the session, /// and the session stays registered (retry possible). The *genuine* timeout /// variant of this invariant is asserted in /// [`timeout_invariant_send_blocking_does_not_kill_session`] (the 300s prod bound /// cannot be awaited here, so we drive the same "never shutdown on ask" branch via /// the no-`Final` error and the bounded `send_blocking` directly). #[tokio::test] async fn ask_failed_turn_does_not_kill_session_and_errors() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let session = FakeSession::new(sid(500), SendScript::Stream(vec![/* no Final */])); let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); let fx = ask_fixture( FakeContexts::with_agent(&agent, "# persona"), throwaway, false, ); fx.structured .insert(Arc::clone(&session) as Arc, aid(1), nid(1)); let err = fx .service .dispatch(&project(), cmd(ASK_JSON)) .await .unwrap_err(); // Typed PROCESS error (send_blocking's AgentSessionError → AppError::Process). assert_eq!(err.code(), "PROCESS", "got {err:?}"); // Session NOT killed and STILL registered (retry possible). assert_eq!( *session.shutdowns.lock().unwrap(), 0, "ask must never shutdown the target session" ); assert!( fx.structured.session_for_agent(&aid(1)).is_some(), "session must remain in the registry after a failed turn" ); } /// Zone 2 (real timeout) — uses a *short-bounded* `send_blocking` directly against /// the orchestrator's session is impossible (bound is internal/300s); instead we /// prove the genuine timeout-does-not-kill invariant at the `send_blocking` seam /// with a real delayed session, mirroring how the orchestrator awaits it. #[tokio::test] async fn timeout_invariant_send_blocking_does_not_kill_session() { use application::send_blocking; let session = FakeSession::new( sid(500), SendScript::Delayed { delay: std::time::Duration::from_secs(1), events: vec![final_("too late")], }, ); let out = send_blocking( session.as_ref(), "Analyse §17", Some(std::time::Duration::from_millis(20)), ) .await; assert_eq!(out, Err(AgentSessionError::Timeout)); assert_eq!( *session.shutdowns.lock().unwrap(), 0, "timeout must not kill the session" ); // Negative control: had we waited (no bound) the call would succeed — proves the // timeout above is the cause of the error, not a broken session. let session2 = FakeSession::new( sid(501), SendScript::Delayed { delay: std::time::Duration::from_millis(5), events: vec![final_("eventually")], }, ); let ok = send_blocking(session2.as_ref(), "x", None).await; assert_eq!(ok, Ok("eventually".to_owned())); } /// Zone 3 — dead target ⇒ `LaunchAgent` is invoked in structured mode (factory /// `start` called, structured session registered) *then* `send_blocking`; the /// reply is returned and no raw PTY is spawned. #[tokio::test] async fn ask_dead_target_launches_structured_then_sends() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); // The session the fake factory will hand out on launch. let session = FakeSession::new(sid(700), SendScript::Stream(vec![final_("launched reply")])); let fx = ask_fixture( FakeContexts::with_agent(&agent, "# persona"), Arc::clone(&session), false, ); // No live session up front. assert!(fx.structured.session_for_agent(&aid(1)).is_none()); let out = fx .service .dispatch(&project(), cmd(ASK_JSON)) .await .expect("ask ok"); assert_eq!(out.reply.as_deref(), Some("launched reply")); // Factory was started exactly once (structured launch), the prompt was sent. assert_eq!(*fx.factory_starts.lock().unwrap(), 1); assert_eq!(session.sends.lock().unwrap().as_slice(), &["Analyse §17"]); // Structured-mode launch ⇒ NO raw PTY spawn. assert!(fx.pty.spawns().is_empty(), "structured launch must not spawn a PTY"); // The session is now registered (1 session/agent). assert!(fx.structured.session_for_agent(&aid(1)).is_some()); } /// Zone 5a — target already live in the **PTY** registry (raw terminal, no /// structured channel) ⇒ typed `INVALID`, never an ACK, never a launch. #[tokio::test] async fn ask_pty_live_target_is_invalid_no_ack() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); let fx = ask_fixture( FakeContexts::with_agent(&agent, "# persona"), throwaway, false, ); // Register the agent as live in the **PTY** (terminal) registry only — a raw // terminal with no structured reply channel. let pty_session = TerminalSession::starting( sid(800), nid(2), project().root.clone(), SessionKind::Agent { agent_id: aid(1) }, PtySize::new(24, 80).unwrap(), ); fx.sessions .insert(PtyHandle { session_id: sid(800) }, pty_session); assert!(fx.structured.session_for_agent(&aid(1)).is_none()); let err = fx .service .dispatch(&project(), cmd(ASK_JSON)) .await .unwrap_err(); assert_eq!( err.code(), "INVALID", "PTY-live target is not addressable by ask: {err:?}" ); // Never launched, never sent: factory untouched, no PTY spawned. assert_eq!(*fx.factory_starts.lock().unwrap(), 0); assert!(fx.pty.spawns().is_empty()); // No structured session conjured. assert!(fx.structured.session_for_agent(&aid(1)).is_none()); } /// Zone 5b — after launching, the target turns out PTY-only (profile without a /// `structured_adapter` ⇒ `launched.structured` is `None`) ⇒ typed `INVALID`, no /// ACK, and the orchestrator does not pretend a reply. #[tokio::test] async fn ask_pty_only_profile_after_launch_is_invalid() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); // pty_only = true ⇒ launched profile has no structured_adapter ⇒ structured None. let fx = ask_fixture( FakeContexts::with_agent(&agent, "# persona"), throwaway, true, ); assert!(fx.structured.session_for_agent(&aid(1)).is_none()); let err = fx .service .dispatch(&project(), cmd(ASK_JSON)) .await .unwrap_err(); assert_eq!(err.code(), "INVALID", "PTY-only target must error typed: {err:?}"); // No structured session was registered, no reply pretended. assert!(fx.structured.session_for_agent(&aid(1)).is_none()); } /// Zone 6/7 — structured registry not wired ⇒ `ask` is INVALID (legacy service). #[tokio::test] async fn ask_without_structured_wired_is_invalid() { // The default `fixture` builds the service via `OrchestratorService::new` // WITHOUT `.with_structured(...)`, so AskAgent cannot be served. let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); let err = fx .service .dispatch(&project(), cmd(ASK_JSON)) .await .unwrap_err(); assert_eq!(err.code(), "INVALID", "got {err:?}"); } /// Zone 6/7 — unknown target agent ⇒ typed `NOT_FOUND`, no launch, no reply. #[tokio::test] async fn ask_unknown_target_is_not_found() { let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")])); let fx = ask_fixture(FakeContexts::new(), throwaway, false); let err = fx .service .dispatch( &project(), cmd(r#"{ "type":"agent.message", "targetAgent":"ghost", "task":"hi" }"#), ) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert_eq!(*fx.factory_starts.lock().unwrap(), 0); assert!(fx.pty.spawns().is_empty()); } /// Zone 7 — non-regression: `agent.run` (fire-and-forget) still yields `reply: /// None` even through a structured-wired orchestrator. #[tokio::test] async fn non_regression_agent_run_reply_is_none() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let session = FakeSession::new(sid(700), SendScript::Stream(vec![final_("x")])); let fx = ask_fixture( FakeContexts::with_agent(&agent, "# persona"), session, false, ); let out = fx .service .dispatch( &project(), cmd(r#"{ "type":"agent.run", "targetAgent":"architect", "task":"go" }"#), ) .await .expect("run ok"); assert_eq!(out.reply, None, "agent.run must not carry a reply"); }