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>
386 lines
12 KiB
Rust
386 lines
12 KiB
Rust
//! 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<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 entries(&self) -> Vec<ManifestEntry> {
|
|
self.0.lock().unwrap().manifest.entries.clone()
|
|
}
|
|
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 = 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<AgentManifest, StoreError> {
|
|
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<Vec<AgentProfile>>);
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, 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<bool, StoreError> {
|
|
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<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, _p: &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, p: &RemotePath) -> Result<Vec<u8>, 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<bool, FsError> {
|
|
Ok(false)
|
|
}
|
|
async fn create_dir_all(&self, _p: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
async fn list(&self, _p: &RemotePath) -> Result<Vec<DirEntry>, 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<PtyHandle, PtyError> {
|
|
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<OutputStream, PtyError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
fn scrollback(&self, _h: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn kill(&self, _h: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
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<u128>);
|
|
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<OrchestratorService> {
|
|
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<dyn ProfileStore>,
|
|
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<dyn ProfileStore>,
|
|
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"));
|
|
}
|