use std::sync::Arc;
use cpm_planner::{
BasicCpmPlanner, PlanServer, TOOL_ACQUIRE_COHORT, TOOL_FORCE_RELEASE, TOOL_HEARTBEAT,
TOOL_MARK_STATUS, TOOL_STATUS, TOOL_SUBMIT,
};
use rmcp::model::{CallToolRequestParams, JsonObject};
use serde_json::{json, Value};
fn server() -> PlanServer {
PlanServer::new(Arc::new(BasicCpmPlanner::new()))
}
fn call_args(name: &str, args: Value) -> CallToolRequestParams {
let map: JsonObject = match args {
Value::Object(m) => m,
_ => panic!("call_args expects a JSON object"),
};
CallToolRequestParams::new(name.to_string()).with_arguments(map)
}
fn sample_graph() -> Value {
json!({
"deliverables": [
{
"id": "d1",
"owned_files": ["src/a.rs"],
"prerequisites": [],
"estimated_effort_hours": 1.0,
"metadata": { "description": "first" }
},
{
"id": "d2",
"owned_files": ["src/b.rs"],
"prerequisites": ["d1"],
"estimated_effort_hours": 2.0,
"metadata": { "description": "second" }
}
],
"max_chained_dispatch": null
})
}
async fn submit_plan(server: &PlanServer) -> String {
let req = call_args(TOOL_SUBMIT, json!({ "graph": sample_graph() }));
let resp = server
.dispatch_call(req)
.await
.expect("plan.submit returns Ok");
resp["plan_id"]
.as_str()
.expect("plan_id is a string")
.to_string()
}
#[tokio::test]
async fn plan_submit_roundtrip() {
let server = server();
let req = call_args(TOOL_SUBMIT, json!({ "graph": sample_graph() }));
let resp = server
.dispatch_call(req)
.await
.expect("plan.submit returns Ok");
let plan_id = resp["plan_id"].as_str().expect("plan_id field present");
assert!(!plan_id.is_empty(), "plan_id must be non-empty");
assert!(
plan_id.starts_with("plan_"),
"plan_id should carry the BasicCpmPlanner `plan_<uuid>` prefix; got {plan_id}"
);
}
#[tokio::test]
async fn plan_acquire_cohort_roundtrip() {
let server = server();
let plan_id = submit_plan(&server).await;
let req = call_args(
TOOL_ACQUIRE_COHORT,
json!({
"plan_id": plan_id,
"caller_id": "orchestrator-001",
"max_count": 4
}),
);
let resp = server
.dispatch_call(req)
.await
.expect("plan.acquire_cohort returns Ok");
assert_eq!(resp["plan_id"].as_str(), Some(plan_id.as_str()));
let deliverables = resp["deliverables"]
.as_array()
.expect("deliverables is an array");
let locks = resp["locks"].as_array().expect("locks is an array");
assert_eq!(
deliverables.len(),
1,
"only d1 should be ready in the initial cohort"
);
assert_eq!(deliverables[0]["id"].as_str(), Some("d1"));
assert_eq!(locks.len(), 1, "one lock per acquired deliverable");
assert_eq!(locks[0]["deliverable_id"].as_str(), Some("d1"));
assert_eq!(locks[0]["caller_id"].as_str(), Some("orchestrator-001"));
}
#[tokio::test]
async fn plan_heartbeat_roundtrip() {
let server = server();
let plan_id = submit_plan(&server).await;
let _ = server
.dispatch_call(call_args(
TOOL_ACQUIRE_COHORT,
json!({
"plan_id": plan_id,
"caller_id": "orchestrator-001",
"max_count": 4
}),
))
.await
.expect("acquire ok");
let resp = server
.dispatch_call(call_args(
TOOL_HEARTBEAT,
json!({
"plan_id": plan_id,
"deliverable_id": "d1",
"caller_id": "orchestrator-001"
}),
))
.await
.expect("plan.heartbeat returns Ok");
assert_eq!(resp["ok"].as_bool(), Some(true));
}
#[tokio::test]
async fn plan_mark_status_complete_roundtrip() {
let server = server();
let plan_id = submit_plan(&server).await;
let _ = server
.dispatch_call(call_args(
TOOL_ACQUIRE_COHORT,
json!({
"plan_id": plan_id,
"caller_id": "orchestrator-001",
"max_count": 4
}),
))
.await
.expect("acquire ok");
let resp = server
.dispatch_call(call_args(
TOOL_MARK_STATUS,
json!({
"plan_id": plan_id,
"deliverable_id": "d1",
"caller_id": "orchestrator-001",
"status": { "status": "complete" }
}),
))
.await
.expect("plan.mark_status returns Ok");
assert_eq!(resp["ok"].as_bool(), Some(true));
let status = server
.dispatch_call(call_args(TOOL_STATUS, json!({ "plan_id": plan_id })))
.await
.expect("plan.status returns Ok");
let locks = status["locks_held"]
.as_array()
.expect("locks_held array present");
assert!(
locks.is_empty(),
"completing d1 should have released its lock; got {locks:?}"
);
}
#[tokio::test]
async fn plan_status_roundtrip() {
let server = server();
let plan_id = submit_plan(&server).await;
let resp = server
.dispatch_call(call_args(TOOL_STATUS, json!({ "plan_id": plan_id })))
.await
.expect("plan.status returns Ok");
assert_eq!(resp["plan_id"].as_str(), Some(plan_id.as_str()));
let deliverables = resp["deliverables"]
.as_array()
.expect("deliverables is an array");
assert_eq!(deliverables.len(), 2, "graph has two deliverables");
let first = &deliverables[0];
assert_eq!(first[0].as_str(), Some("d1"));
assert_eq!(first[1]["status"].as_str(), Some("ready"));
let cp = resp["critical_path"]
.as_array()
.expect("critical_path is an array");
assert!(
!cp.is_empty(),
"critical_path must be populated for a non-empty graph"
);
assert!(
resp["critical_path_hours"]
.as_f64()
.map(|h| h > 0.0)
.unwrap_or(false),
"critical_path_hours must be positive; got {}",
resp["critical_path_hours"]
);
}
#[tokio::test]
async fn plan_force_release_roundtrip() {
let server = server();
let plan_id = submit_plan(&server).await;
let _ = server
.dispatch_call(call_args(
TOOL_ACQUIRE_COHORT,
json!({
"plan_id": plan_id,
"caller_id": "orchestrator-001",
"max_count": 4
}),
))
.await
.expect("acquire ok");
let resp = server
.dispatch_call(call_args(
TOOL_FORCE_RELEASE,
json!({
"plan_id": plan_id,
"deliverable_id": "d1",
"reason": "orchestrator crashed; releasing manually"
}),
))
.await
.expect("plan.force_release returns Ok");
assert_eq!(resp["ok"].as_bool(), Some(true));
let status = server
.dispatch_call(call_args(TOOL_STATUS, json!({ "plan_id": plan_id })))
.await
.expect("status ok");
let locks = status["locks_held"]
.as_array()
.expect("locks_held array present");
assert!(
locks.is_empty(),
"force_release should have removed the lock; got {locks:?}"
);
}
#[tokio::test]
async fn plan_invalid_graph_returns_error() {
let server = server();
let cyclic = json!({
"deliverables": [
{ "id": "a", "owned_files": ["src/a.rs"], "prerequisites": ["b"] },
{ "id": "b", "owned_files": ["src/b.rs"], "prerequisites": ["a"] }
]
});
let err = server
.dispatch_call(call_args(TOOL_SUBMIT, json!({ "graph": cyclic })))
.await
.expect_err("cyclic graph must be rejected");
assert!(
err.message.contains("INVALID_GRAPH"),
"MCP error must carry the INVALID_GRAPH prefix; got: {}",
err.message
);
}
#[tokio::test]
async fn plan_wrong_caller_returns_lock_not_held() {
let server = server();
let plan_id = submit_plan(&server).await;
let _ = server
.dispatch_call(call_args(
TOOL_ACQUIRE_COHORT,
json!({
"plan_id": plan_id,
"caller_id": "owner-001",
"max_count": 4
}),
))
.await
.expect("acquire ok");
let err = server
.dispatch_call(call_args(
TOOL_MARK_STATUS,
json!({
"plan_id": plan_id,
"deliverable_id": "d1",
"caller_id": "imposter-002",
"status": { "status": "complete" }
}),
))
.await
.expect_err("wrong caller must be rejected");
assert!(
err.message.contains("LOCK_NOT_HELD"),
"MCP error must carry the LOCK_NOT_HELD prefix; got: {}",
err.message
);
}
#[tokio::test]
async fn plan_submit_rejects_unknown_fields() {
let server = server();
let err = server
.dispatch_call(call_args(
TOOL_SUBMIT,
json!({
"graph": sample_graph(),
"stray_field": "should be rejected"
}),
))
.await
.expect_err("unknown fields must be rejected at the wire boundary");
assert!(
err.message.contains("stray_field") || err.message.contains("unknown field"),
"expected wire-level rejection of unknown field; got: {}",
err.message
);
}
#[tokio::test]
async fn plan_mark_status_failed_carries_reason() {
let server = server();
let plan_id = submit_plan(&server).await;
let _ = server
.dispatch_call(call_args(
TOOL_ACQUIRE_COHORT,
json!({
"plan_id": plan_id,
"caller_id": "orchestrator-001",
"max_count": 4
}),
))
.await
.expect("acquire ok");
let resp = server
.dispatch_call(call_args(
TOOL_MARK_STATUS,
json!({
"plan_id": plan_id,
"deliverable_id": "d1",
"caller_id": "orchestrator-001",
"status": { "status": "failed", "reason": "intentional test failure" }
}),
))
.await
.expect("mark_status failed must succeed when caller holds the lock");
assert_eq!(resp["ok"], json!(true));
let status_resp = server
.dispatch_call(call_args(TOOL_STATUS, json!({ "plan_id": plan_id })))
.await
.expect("status ok");
let deliverables = status_resp["deliverables"]
.as_array()
.expect("deliverables array present");
let d1 = deliverables
.iter()
.find(|row| row[0].as_str() == Some("d1"))
.expect("d1 entry present");
assert_eq!(d1[1]["status"], json!("failed"));
assert_eq!(d1[1]["reason"], json!("intentional test failure"));
}