feat(cross-project): découverte et contact inter-projets — tools MCP idea_project_{list,read_context,list_agents,ask_agent,cancel_agent}, permission contactable par agent + default_contactable persistée dans les templates, routage orchestrator par projectId+agentId — #260 (tests ciblés verts, QA en attente)
This commit is contained in:
@ -24,8 +24,9 @@ use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle,
|
||||
BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore,
|
||||
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
|
||||
IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, PtyError,
|
||||
PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore,
|
||||
ProjectStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore,
|
||||
SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
@ -172,6 +173,45 @@ impl ProfileStore for FakeProfiles {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeProjects(Arc<Vec<Project>>);
|
||||
|
||||
impl FakeProjects {
|
||||
fn new(projects: Vec<Project>) -> Self {
|
||||
Self(Arc::new(projects))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeProjects {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok((*self.0).clone())
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|project| project.id == id)
|
||||
.cloned()
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_workspace(
|
||||
&self,
|
||||
_workspace: &domain::layout::Workspace,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_workspace(&self) -> Result<domain::layout::Workspace, StoreError> {
|
||||
Ok(domain::layout::Workspace::default())
|
||||
}
|
||||
}
|
||||
|
||||
// Empty skill store: the orchestrator tests spawn agents with no assigned skills.
|
||||
#[derive(Default)]
|
||||
struct FakeSkills;
|
||||
@ -795,10 +835,103 @@ fn cmd(json: &str) -> OrchestratorCommand {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn other_project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(2000)),
|
||||
"other",
|
||||
ProjectPath::new("/home/me/other").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_001,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic dispatch coverage (spawn / stop / update / skill) — non-ask commands.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_project_list_projects_reads_known_project_registry() {
|
||||
let fx = fixture(FakeContexts::new());
|
||||
let svc = fx
|
||||
.service
|
||||
.with_project_store(Arc::new(FakeProjects::new(vec![
|
||||
project(),
|
||||
other_project(),
|
||||
])));
|
||||
|
||||
let out = svc
|
||||
.list_projects_for_mcp()
|
||||
.await
|
||||
.expect("project registry is readable");
|
||||
let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap();
|
||||
|
||||
assert_eq!(rows.as_array().unwrap().len(), 2);
|
||||
assert_eq!(rows[0]["id"], project_id().to_string());
|
||||
assert_eq!(rows[1]["id"], other_project().id.to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_project_list_agents_filters_to_contactable_agents() {
|
||||
let visible = scratch_agent(aid(1), "Visible", "agents/visible.md").with_contactable(true);
|
||||
let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md");
|
||||
let contexts = {
|
||||
let me = FakeContexts::with_agent(&visible, "# visible");
|
||||
let mut inner = me.0.lock().unwrap();
|
||||
inner
|
||||
.manifest
|
||||
.entries
|
||||
.push(ManifestEntry::from_agent(&hidden));
|
||||
inner
|
||||
.contents
|
||||
.insert(hidden.context_path.clone(), "# hidden".to_owned());
|
||||
drop(inner);
|
||||
me
|
||||
};
|
||||
let fx = fixture(contexts);
|
||||
let target_project = other_project();
|
||||
let svc = fx
|
||||
.service
|
||||
.with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()])));
|
||||
|
||||
let out = svc
|
||||
.list_project_agents_for_mcp(target_project.id)
|
||||
.await
|
||||
.expect("agent manifest is readable");
|
||||
let rows: serde_json::Value = serde_json::from_str(&out.reply.unwrap()).unwrap();
|
||||
|
||||
assert_eq!(rows.as_array().unwrap().len(), 1);
|
||||
assert_eq!(rows[0]["id"], visible.id.to_string());
|
||||
assert_eq!(rows[0]["name"], "Visible");
|
||||
assert_eq!(rows[0]["contactable"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_project_ask_rejects_non_contactable_agent_before_launch() {
|
||||
let hidden = scratch_agent(aid(2), "Hidden", "agents/hidden.md");
|
||||
let fx = fixture(FakeContexts::with_agent(&hidden, "# hidden"));
|
||||
let target_project = other_project();
|
||||
let svc = fx
|
||||
.service
|
||||
.with_project_store(Arc::new(FakeProjects::new(vec![target_project.clone()])));
|
||||
|
||||
let err = svc
|
||||
.ask_project_agent_for_mcp(
|
||||
target_project.id,
|
||||
hidden.id,
|
||||
"work".to_owned(),
|
||||
project().id,
|
||||
aid(99),
|
||||
"corr-1".to_owned(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
assert!(err.to_string().contains("not contactable"));
|
||||
assert!(fx.pty.spawns().is_empty(), "agent must not be launched");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_unknown_agent_creates_then_launches() {
|
||||
let fx = fixture(FakeContexts::new());
|
||||
|
||||
Reference in New Issue
Block a user