Files
IdeA/crates/application/tests/orchestrator_service.rs
Blomios fbcf7bd436 test(orchestrator): inject SkillStore into LaunchAgent after skills merge
The orchestrator branch predated the skills feature; its LaunchAgent test
construction lagged the new 8-arg signature. Add an empty FakeSkills to both
the service and watcher tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:17:31 +02:00

544 lines
16 KiB
Rust

//! 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, 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::{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<String, String>,
}
#[derive(Clone)]
struct FakeContexts(Arc<Mutex<ContextsInner>>);
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<String> {
self.0.lock().unwrap().contents.get(md_path).cloned()
}
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
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<MarkdownDoc, StoreError> {
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<AgentManifest, StoreError> {
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<Vec<AgentProfile>>);
impl FakeProfiles {
fn new(profiles: Vec<AgentProfile>) -> Self {
Self(Arc::new(profiles))
}
}
#[async_trait]
impl ProfileStore for FakeProfiles {
async fn list(&self) -> Result<Vec<AgentProfile>, 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<bool, StoreError> {
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<Vec<Skill>, StoreError> {
Ok(Vec::new())
}
async fn get(
&self,
_scope: SkillScope,
_root: &ProjectPath,
_id: SkillId,
) -> Result<Skill, StoreError> {
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, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
Ok(true)
}
fn prepare_invocation(
&self,
profile: &AgentProfile,
_ctx: &PreparedContext,
cwd: &ProjectPath,
) -> Result<SpawnSpec, RuntimeError> {
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<Vec<u8>, 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<bool, FsError> {
Ok(false)
}
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
Ok(())
}
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
#[derive(Clone)]
struct FakePty {
next_id: SessionId,
kills: Arc<Mutex<Vec<SessionId>>>,
}
impl FakePty {
fn new(next_id: SessionId) -> Self {
Self {
next_id,
kills: Arc::new(Mutex::new(Vec::new())),
}
}
fn kills(&self) -> Vec<SessionId> {
self.kills.lock().unwrap().clone()
}
}
#[async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
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<OutputStream, PtyError> {
Ok(Box::new(std::iter::empty()))
}
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
self.kills.lock().unwrap().push(handle.session_id);
Ok(ExitStatus { code: Some(0) })
}
}
#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
fn events(&self) -> Vec<DomainEvent> {
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<u128>);
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<TerminalSessions>,
}
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<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(FakeFs),
Arc::new(pty.clone()),
Arc::new(FakeSkills),
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<dyn ProfileStore>,
Arc::clone(&sessions),
);
Fixture {
service,
contexts,
pty,
bus,
sessions,
}
}
fn cmd(json: &str) -> OrchestratorCommand {
serde_json::from_str::<OrchestratorRequest>(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());
}