Coeur inter-agents consolidé et surface front réalignée sur la décision "terminal natif PTY, pas d'UI chat" (Option 1). Domaine - nouveaux modules conversation, mailbox, input, fileguard (ports + types) - orchestrator/profile/events étendus (conversation par paire, FIFO) Application / Infrastructure - orchestrator/service + context_guard : sérialisation FIFO par agent, garde RW mémoire/contexte, dispatch ask/reply - adapters in-memory conversation / mailbox / input / fileguard - registry session + lifecycle agent durcis (1 agent = 1 session vivante) - outils MCP idea_* alignés sur le nouveau dispatch Frontend - MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA, terminal = vue sortie inchangée - suppression de la vue chat structurée (AgentChatView) — abandonnée - adapter input + ports mis à jour Divers - .ideai/ : mémoire projet + briefs de cadrage versionnés ; requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA) Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
884 lines
30 KiB
Rust
884 lines
30 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, OrchestrationSource};
|
|
use domain::ids::SkillId;
|
|
use domain::ids::{AgentId, ProfileId, ProjectId};
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::ids::NodeId;
|
|
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<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()
|
|
}
|
|
/// 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<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(())
|
|
}
|
|
}
|
|
|
|
/// 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<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
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,
|
|
_session: &SessionPlan,
|
|
) -> 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> {
|
|
// 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<dyn ProfileStore>,
|
|
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<dyn SkillStore>,
|
|
Arc::new(SeqIds(Mutex::new(1))),
|
|
));
|
|
Arc::new(OrchestratorService::new(
|
|
create,
|
|
launch,
|
|
list,
|
|
close,
|
|
update,
|
|
create_skill,
|
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
|
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<OrchestratorService>,
|
|
Arc<InMemoryMailbox>,
|
|
Arc<TerminalSessions>,
|
|
) {
|
|
// 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<dyn ProfileStore>,
|
|
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<dyn SkillStore>,
|
|
Arc::new(SeqIds(Mutex::new(1))),
|
|
));
|
|
let service = Arc::new(
|
|
OrchestratorService::new(
|
|
create,
|
|
launch,
|
|
list,
|
|
close,
|
|
update,
|
|
create_skill,
|
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
|
Arc::clone(&sessions),
|
|
)
|
|
.with_input_mediator(
|
|
Arc::new(infrastructure::MediatedInbox::with_pty(
|
|
Arc::clone(&mailbox),
|
|
Arc::new(infrastructure::SystemMillisClock),
|
|
Arc::new(FakePty) as Arc<dyn PtyPort>,
|
|
)) as Arc<dyn domain::input::InputMediator>,
|
|
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
|
|
)
|
|
.with_conversations(
|
|
Arc::new(infrastructure::InMemoryConversationRegistry::new())
|
|
as Arc<dyn domain::conversation::ConversationRegistry>,
|
|
),
|
|
);
|
|
(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 <tmp>/.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::<DomainEvent>::new()));
|
|
let sink = captured.clone();
|
|
let publish: Arc<dyn Fn(DomainEvent) + Send + Sync> =
|
|
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/<requester>/.
|
|
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<DomainEvent> = 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:?}"),
|
|
}
|
|
}
|