use std::sync::Arc;
use pretty_assertions::assert_eq;
use serde_json::json;
use rho_sdk::{
model::{ContentBlock, ModelIdentity, ModelResponse},
provider::{ScriptedProvider, ScriptedTurn},
HostInputResponse, SessionId,
};
use rmcp::model::CreateMessageRequestParams;
use super::{
McpInFlightCalls, McpSamplingBridge, McpSamplingModel, McpSamplingPolicy, McpSamplingService,
};
const CONFIGURED_MODEL: &str = "rho-configured-model";
fn configured_provider(reply: &str) -> Arc<ScriptedProvider> {
Arc::new(ScriptedProvider::new(
ModelIdentity::new("scripted", "test", CONFIGURED_MODEL),
[ScriptedTurn::completed(ModelResponse::Assistant(vec![
ContentBlock::Text(reply.into()),
]))],
))
}
fn bound_bridge(provider: Arc<ScriptedProvider>) -> McpSamplingBridge {
let bridge = McpSamplingBridge::new();
bridge.bind(McpSamplingModel {
provider,
session_id: SessionId::new(),
workspace_path: std::path::PathBuf::from("/"),
});
bridge
}
fn request(model_preferences: Option<serde_json::Value>) -> CreateMessageRequestParams {
let mut params = json!({
"messages": [{"role": "user", "content": {"type": "text", "text": "summarize this"}}],
"maxTokens": 64,
"systemPrompt": "You summarize.",
});
if let Some(preferences) = model_preferences {
params["modelPreferences"] = preferences;
}
serde_json::from_value(params).expect("sampling request fixture parses")
}
async fn with_answer<T>(
calls: &McpInFlightCalls,
allow: &'static str,
work: impl std::future::Future<Output = T>,
) -> T {
let (registration, mut questions) = calls.register();
let (outcome, ()) = tokio::join!(work, async {
let question = questions
.recv()
.await
.expect("the confirmation reached the caller");
let _ = question
.reply
.send(Ok(HostInputResponse::new().answer("allow", [allow])));
});
drop(registration);
outcome
}
#[tokio::test]
async fn a_server_that_did_not_opt_in_is_rejected() {
let provider = configured_provider("never asked");
let calls = McpInFlightCalls::new();
let (_registration, _questions) = calls.register();
let service = McpSamplingService::new(
"live",
McpSamplingPolicy::Deny,
bound_bridge(Arc::clone(&provider)),
calls,
);
let error = service.create_message(request(None)).await.unwrap_err();
assert_eq!(
error.message,
"this MCP server is not configured for sampling in Rho"
);
assert!(provider.recorded_requests().is_empty());
}
#[tokio::test]
async fn a_refused_request_never_reaches_the_model() {
let provider = configured_provider("never asked");
let calls = McpInFlightCalls::new();
let service = McpSamplingService::new(
"live",
McpSamplingPolicy::Ask,
bound_bridge(Arc::clone(&provider)),
calls.clone(),
);
let error = with_answer(&calls, "no", service.create_message(request(None)))
.await
.unwrap_err();
assert_eq!(error.message, "the user refused this sampling request");
assert!(provider.recorded_requests().is_empty());
}
#[tokio::test]
async fn model_preferences_do_not_change_the_model() {
let provider = configured_provider("a summary");
let calls = McpInFlightCalls::new();
let service = McpSamplingService::new(
"live",
McpSamplingPolicy::Ask,
bound_bridge(Arc::clone(&provider)),
calls.clone(),
);
let preferences = json!({"hints": [{"name": "some-other-model"}], "costPriority": 0.0});
let result = with_answer(
&calls,
"yes",
service.create_message(request(Some(preferences))),
)
.await
.unwrap();
assert_eq!(result.model, CONFIGURED_MODEL);
assert_eq!(
result
.message
.content
.first()
.and_then(|block| block.as_text())
.map(|text| text.text.clone()),
Some("a summary".into())
);
let recorded = provider.recorded_requests();
assert_eq!(recorded.len(), 1);
assert_eq!(
recorded[0].messages,
vec![
rho_sdk::model::Message::System("You summarize.".into()),
rho_sdk::model::Message::user_text("User: summarize this"),
]
);
}
#[tokio::test]
async fn an_unbound_model_fails_closed() {
let calls = McpInFlightCalls::new();
let (_registration, _questions) = calls.register();
let bridge = McpSamplingBridge::new();
let service = McpSamplingService::new("live", McpSamplingPolicy::Ask, bridge.clone(), calls);
let unbound = service.create_message(request(None)).await.unwrap_err();
assert_eq!(
unbound.message,
"Rho has no model bound for MCP sampling in this run"
);
bridge.bind(McpSamplingModel {
provider: configured_provider("unused"),
session_id: SessionId::new(),
workspace_path: std::path::PathBuf::from("/"),
});
bridge.unbind();
let after_unbind = service.create_message(request(None)).await.unwrap_err();
assert_eq!(
after_unbind.message,
"Rho has no model bound for MCP sampling in this run"
);
}
#[tokio::test]
async fn a_request_with_no_call_in_flight_is_rejected() {
let provider = configured_provider("never asked");
let service = McpSamplingService::new(
"live",
McpSamplingPolicy::Ask,
bound_bridge(Arc::clone(&provider)),
McpInFlightCalls::new(),
);
let error = service.create_message(request(None)).await.unwrap_err();
assert_eq!(
error.message,
"Rho has no MCP tool call in flight to attribute this request to"
);
assert!(provider.recorded_requests().is_empty());
}