#![expect(
clippy::unused_async,
reason = "the generated handler awaits the call, and a plain value has nothing to await"
)]
#![expect(
clippy::unused_async_trait_impl,
reason = "the generated handler awaits the call, and a plain value has nothing to await"
)]
use std::sync::Arc;
use protocol_droid::application::server::context::{
CallbackExchange, ExchangeError, PeerSession, RequestContext,
};
use protocol_droid::application::server::{DispatchError, Dispatcher, Server};
use protocol_droid::application::session::{FrameSink, SinkError};
use protocol_droid::domain::feature::{Negotiation, resolve};
use protocol_droid::domain::ir::{
AudioContent, Content, EmbeddedResource, ImageContent, JsonObject, JsonObjectError,
TextContent, ToolResult,
};
use protocol_droid::domain::registry::AuthContext;
use protocol_droid::domain::session::{CancellationToken, SessionId};
use protocol_droid::domain::{CapabilitySet, ProtocolVersion, RequestId, TransportKind};
const PNG_1X1: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
const WAV_SILENT: &str = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQAAAAA=";
struct NoExchange;
impl CallbackExchange for NoExchange {
fn exchange(
&self,
method: &str,
_params: &JsonObject,
) -> impl std::future::Future<Output = Result<JsonObject, ExchangeError>> + Send {
let _ = method;
std::future::ready(Err(ExchangeError::new("no callbacks here")))
}
}
struct NoSink;
impl FrameSink for NoSink {
fn send(
&self,
_frame: &str,
) -> impl std::future::Future<Output = Result<(), SinkError>> + Send {
std::future::ready(Ok(()))
}
}
struct ContentFixture;
#[protocol_droid::server(name = "content-fixture")]
impl ContentFixture {
#[protocol_droid::tool]
async fn test_simple_text() -> ToolResult {
ToolResult::text("This is a simple text response.")
}
#[protocol_droid::tool]
async fn test_image_content() -> ToolResult {
ToolResult::new(vec![Content::Image(ImageContent::new(
PNG_1X1,
"image/png",
))])
}
#[protocol_droid::tool]
async fn test_audio_content() -> ToolResult {
ToolResult::new(vec![Content::Audio(AudioContent::new(
WAV_SILENT,
"audio/wav",
))])
}
#[protocol_droid::tool]
async fn test_embedded_resource() -> ToolResult {
ToolResult::new(vec![Content::EmbeddedResource(EmbeddedResource::text(
"test://embedded-resource",
"This is an embedded resource's text.",
))])
}
#[protocol_droid::tool]
async fn test_multiple_content_types() -> ToolResult {
ToolResult::new(vec![
Content::Text(TextContent::new("This is a multi-type response.")),
Content::Image(ImageContent::new(PNG_1X1, "image/png")),
Content::EmbeddedResource(EmbeddedResource::text(
"test://embedded-resource",
"This is an embedded resource's text.",
)),
])
}
}
#[derive(Debug)]
enum Failure {
Build(String),
Dispatch(String),
Shape(String),
}
impl core::fmt::Display for Failure {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Build(text) | Self::Dispatch(text) | Self::Shape(text) => f.write_str(text),
}
}
}
impl std::error::Error for Failure {}
impl From<JsonObjectError> for Failure {
fn from(error: JsonObjectError) -> Self {
Self::Build(error.to_string())
}
}
impl From<DispatchError> for Failure {
fn from(error: DispatchError) -> Self {
Self::Dispatch(error.to_string())
}
}
type TestError = Failure;
fn dispatcher(server: Server) -> Arc<Dispatcher> {
Arc::new(Dispatcher::new(Arc::new(server)))
}
fn context(version: ProtocolVersion, id: &str) -> RequestContext {
let peer = PeerSession::new(
version,
resolve(Negotiation {
version,
client_caps: CapabilitySet::all_for(version),
server_caps: CapabilitySet::all_for(version),
transport: TransportKind::StreamableHttp,
}),
SessionId::new(1),
TransportKind::Stdio,
Arc::new(NoExchange),
Arc::new(NoSink),
)
.with_auth(AuthContext::unrestricted("tester"));
RequestContext::for_request(&peer, RequestId::text(id), CancellationToken::new(), None)
}
fn call(name: &str) -> JsonObject {
JsonObject::from_value(serde_json::json!({ "name": name, "arguments": {} })).unwrap_or_default()
}
fn parsed(answer: &JsonObject) -> Result<serde_json::Value, Failure> {
serde_json::from_str(answer.as_str())
.map_err(|error| Failure::Shape(format!("the answer is not JSON: {error}")))
}
fn blocks(answer: &JsonObject) -> Result<Vec<serde_json::Value>, Failure> {
parsed(answer)?
.get("content")
.and_then(serde_json::Value::as_array)
.cloned()
.ok_or_else(|| Failure::Shape(format!("the answer has no content array: {answer}")))
}
fn block_type(block: &serde_json::Value) -> Result<&str, Failure> {
block
.get("type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Failure::Shape(format!("a content block has no type: {block}")))
}
fn block_type_or_empty(block: &serde_json::Value) -> &str {
block
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
}
fn claims_error(answer: &JsonObject) -> Result<bool, Failure> {
Ok(matches!(
parsed(answer)?.get("isError"),
Some(serde_json::Value::Bool(true))
))
}
#[tokio::test]
async fn a_simple_text_tool_answers_with_one_text_block() -> Result<(), TestError> {
let version = ProtocolVersion::V2025_06_18;
let dispatcher = dispatcher(ContentFixture.into_server()?);
let answer = dispatcher
.dispatch(
&context(version, "r1"),
"tools/call",
&call("test_simple_text"),
)
.await?;
let blocks = blocks(&answer)?;
if blocks.len() != 1 {
return Err(Failure::Shape(format!(
"expected 1 block, got {}",
blocks.len()
)));
}
let first = blocks
.first()
.ok_or_else(|| Failure::Shape("no block".to_owned()))?;
if block_type(first)? != "text" {
return Err(Failure::Shape(format!("the block was {first}")));
}
if claims_error(&answer)? {
return Err(Failure::Shape(format!("claimed an error: {answer}")));
}
Ok(())
}
#[tokio::test]
async fn an_image_tool_answers_with_an_image_block() -> Result<(), TestError> {
let version = ProtocolVersion::V2025_06_18;
let dispatcher = dispatcher(ContentFixture.into_server()?);
let answer = dispatcher
.dispatch(
&context(version, "r1"),
"tools/call",
&call("test_image_content"),
)
.await?;
let blocks = blocks(&answer)?;
let first = blocks
.first()
.ok_or_else(|| Failure::Shape("no block".to_owned()))?;
if block_type(first)? != "image" {
return Err(Failure::Shape(format!("the block was {first}")));
}
match first.get("mimeType").and_then(serde_json::Value::as_str) {
Some("image/png") => {}
other => return Err(Failure::Shape(format!("the mimeType was {other:?}"))),
}
if first.get("data").and_then(serde_json::Value::as_str) != Some(PNG_1X1) {
return Err(Failure::Shape(format!("the data was {first}")));
}
Ok(())
}
#[tokio::test]
async fn an_embedded_resource_tool_answers_with_a_resource_block() -> Result<(), TestError> {
let version = ProtocolVersion::V2025_06_18;
let dispatcher = dispatcher(ContentFixture.into_server()?);
let answer = dispatcher
.dispatch(
&context(version, "r1"),
"tools/call",
&call("test_embedded_resource"),
)
.await?;
let blocks = blocks(&answer)?;
let first = blocks
.first()
.ok_or_else(|| Failure::Shape("no block".to_owned()))?;
if block_type(first)? != "resource" {
return Err(Failure::Shape(format!("the block was {first}")));
}
match first
.get("resource")
.and_then(|resource| resource.get("uri"))
.and_then(serde_json::Value::as_str)
{
Some("test://embedded-resource") => Ok(()),
other => Err(Failure::Shape(format!("the uri was {other:?}"))),
}
}
#[tokio::test]
async fn a_multi_type_tool_answers_with_every_block_in_order() -> Result<(), TestError> {
let version = ProtocolVersion::V2025_06_18;
let dispatcher = dispatcher(ContentFixture.into_server()?);
let answer = dispatcher
.dispatch(
&context(version, "r1"),
"tools/call",
&call("test_multiple_content_types"),
)
.await?;
let blocks = blocks(&answer)?;
let types: Vec<&str> = blocks.iter().map(block_type_or_empty).collect();
if types != ["text", "image", "resource"] {
return Err(Failure::Shape(format!("the blocks were {types:?}")));
}
Ok(())
}
#[tokio::test]
async fn audio_is_answered_after_its_revision_and_refused_before_it() -> Result<(), TestError> {
let later = dispatcher(ContentFixture.into_server()?);
let answer = later
.dispatch(
&context(ProtocolVersion::V2025_03_26, "r1"),
"tools/call",
&call("test_audio_content"),
)
.await?;
let blocks = blocks(&answer)?;
let first = blocks
.first()
.ok_or_else(|| Failure::Shape("no block".to_owned()))?;
if block_type(first)? != "audio" {
return Err(Failure::Shape(format!("the block was {first}")));
}
let earlier = dispatcher(ContentFixture.into_server()?);
let refused = earlier
.dispatch(
&context(ProtocolVersion::V2024_11_05, "r1"),
"tools/call",
&call("test_audio_content"),
)
.await?;
if !claims_error(&refused)? {
return Err(Failure::Shape(format!(
"2024-11-05 answered with an audio block it does not have: {refused}"
)));
}
Ok(())
}