use protocol_droid_schema::wire::v2025_06_18 as wire;
use super::{AUDIO_TYPE, IMAGE_TYPE, META_KEY, RESOURCE_LINK_TYPE, RESOURCE_TYPE, TEXT_TYPE};
use crate::domain::ir::Codec;
use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
use crate::domain::ir::{
Annotations, AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent,
JsonObject, ResourceLinkContent, Role, TextContent, ToolResult,
};
use crate::domain::{
CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
resolve,
};
fn lift_annotations(annotations: &wire::Annotations) -> Annotations {
Annotations {
audience: annotations
.audience
.iter()
.flatten()
.map(|role| match role {
wire::Role::ASSISTANT => Role::Assistant,
wire::Role::USER => Role::User,
})
.collect(),
priority: annotations.priority,
last_modified: annotations.last_modified.clone(),
extensions: Extensions::from_json_object(annotations.extra.clone()),
}
}
fn lower_annotations(annotations: Option<&Annotations>) -> Option<wire::Annotations> {
let annotations = annotations?;
let extra = annotations.extensions.to_json_object();
if annotations.audience.is_empty()
&& annotations.priority.is_none()
&& annotations.last_modified.is_none()
&& extra.is_empty()
{
return None;
}
Some(wire::Annotations {
audience: (!annotations.audience.is_empty()).then(|| {
annotations
.audience
.iter()
.map(|role| match role {
Role::Assistant => wire::Role::ASSISTANT,
Role::User => wire::Role::USER,
})
.collect()
}),
last_modified: annotations.last_modified.clone(),
priority: annotations.priority,
extra,
})
}
const RAW_STRUCTURED_KEY: &str = "structuredContent";
fn take_meta(extensions: &Extensions) -> Option<serde_json::Value> {
let mut extensions = extensions.clone();
extensions
.take(META_KEY)
.map(|value| serde_json::Value::from(&value))
}
#[derive(Debug, Clone, Copy)]
pub struct V2025_06_18;
impl Codec for V2025_06_18 {
type Wire = wire::CallToolResult;
fn version(&self) -> ProtocolVersion {
ProtocolVersion::V2025_06_18
}
fn capabilities(&self) -> FeatureProfile {
let caps = CapabilitySet::all_for(self.version());
resolve(Negotiation {
version: self.version(),
client_caps: caps,
server_caps: caps,
transport: TransportKind::Stdio,
})
}
fn lift(&self, wire: &Self::Wire) -> ToolResult {
let mut extensions = Extensions::from_json_object(wire.extra.clone());
if let Some(meta) = &wire.meta {
extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
}
let structured_content = match &wire.structured_content {
Some(value) if value.is_object() => JsonObject::new(value.to_string()).ok(),
Some(value) => {
extensions.insert(RAW_STRUCTURED_KEY, ExtensionValue::from(value.clone()));
None
}
None => None,
};
ToolResult {
content: wire.content.iter().map(lift_block).collect(),
structured_content,
is_error: wire.is_error.unwrap_or(false),
result_type: None,
extensions,
}
}
fn lower(
&self,
value: ToolResult,
policy: DegradePolicy,
report: &mut TranslationReport,
) -> Result<Self::Wire, LowerError> {
let (value, lowered) =
lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
report.absorb(&lowered);
let structured_content = match value.extensions.get(RAW_STRUCTURED_KEY) {
Some(raw) => Some(serde_json::Value::from(raw)),
None => value
.structured_content
.as_ref()
.and_then(|object| serde_json::from_str(object.as_str()).ok()),
};
Ok(wire::CallToolResult {
meta: value.extensions.get(META_KEY).map(serde_json::Value::from),
content: value
.content
.iter()
.map(lower_block)
.collect::<Result<Vec<_>, _>>()?,
is_error: value.is_error.then_some(true),
structured_content,
extra: value.extensions.to_json_object(),
})
}
}
fn lift_block(block: &wire::ContentBlock) -> Content {
match block {
wire::ContentBlock::Member0(text) => {
let mut extensions = Extensions::from_json_object(text.extra.clone());
if let Some(meta) = &text.meta {
extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
}
Content::Text(TextContent {
text: text.text.clone(),
annotations: text.annotations.as_ref().map(lift_annotations),
extensions,
})
}
wire::ContentBlock::Member1(image) => {
let mut extensions = Extensions::from_json_object(image.extra.clone());
if let Some(meta) = &image.meta {
extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
}
Content::Image(ImageContent {
data: image.data.clone(),
mime_type: image.mime_type.clone(),
annotations: image.annotations.as_ref().map(lift_annotations),
extensions,
})
}
wire::ContentBlock::Member2(audio) => {
let mut extensions = Extensions::from_json_object(audio.extra.clone());
if let Some(meta) = &audio.meta {
extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
}
Content::Audio(AudioContent {
data: audio.data.clone(),
mime_type: audio.mime_type.clone(),
annotations: audio.annotations.as_ref().map(lift_annotations),
extensions,
})
}
wire::ContentBlock::Member3(link) => {
let mut extensions = Extensions::from_json_object(link.extra.clone());
if let Some(meta) = &link.meta {
extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
}
Content::ResourceLink(ResourceLinkContent {
uri: link.uri.clone(),
name: link.name.clone(),
description: link.description.clone(),
mime_type: link.mime_type.clone(),
size: link.size.and_then(|size| u64::try_from(size).ok()),
icons: Vec::new(),
title: link.title.clone(),
annotations: link.annotations.as_ref().map(lift_annotations),
extensions,
})
}
wire::ContentBlock::Member4(resource) => {
let wire::EmbeddedResource {
meta,
annotations,
resource,
extra,
..
} = resource;
let (uri, mime_type, text, blob) = match resource {
wire::EmbeddedResourceResourceChoice::Member0(text) => (
text.uri.clone(),
text.mime_type.clone(),
Some(text.text.clone()),
None,
),
wire::EmbeddedResourceResourceChoice::Member1(blob) => (
blob.uri.clone(),
blob.mime_type.clone(),
None,
Some(blob.blob.clone()),
),
};
let mut extensions = Extensions::from_json_object(extra.clone());
if let Some(meta) = meta {
extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
}
Content::EmbeddedResource(EmbeddedResource {
uri,
mime_type,
text,
blob,
annotations: annotations.as_ref().map(lift_annotations),
extensions,
})
}
}
}
fn lower_block(block: &Content) -> Result<wire::ContentBlock, LowerError> {
let lowered = match block {
Content::Text(text) => wire::ContentBlock::Member0(wire::TextContent {
meta: take_meta(&text.extensions),
annotations: lower_annotations(text.annotations.as_ref()),
text: text.text.clone(),
r#type: TEXT_TYPE.to_owned(),
extra: text.extensions.to_json_object(),
}),
Content::Image(image) => wire::ContentBlock::Member1(wire::ImageContent {
meta: take_meta(&image.extensions),
annotations: lower_annotations(image.annotations.as_ref()),
data: image.data.clone(),
mime_type: image.mime_type.clone(),
r#type: IMAGE_TYPE.to_owned(),
extra: image.extensions.to_json_object(),
}),
Content::Audio(audio) => wire::ContentBlock::Member2(wire::AudioContent {
meta: take_meta(&audio.extensions),
annotations: lower_annotations(audio.annotations.as_ref()),
data: audio.data.clone(),
mime_type: audio.mime_type.clone(),
r#type: AUDIO_TYPE.to_owned(),
extra: audio.extensions.to_json_object(),
}),
Content::ResourceLink(link) => {
let size = link.size.map_or(Ok(None), |size| {
i64::try_from(size)
.map(Some)
.map_err(|_| LowerError::FieldNotRepresentable {
field: "size".to_owned(),
expected: "a non-negative size",
detail: format!("{size} does not fit the i64 this revision uses"),
target: ProtocolVersion::V2025_06_18,
})
})?;
if !link.icons.is_empty() {
return Err(LowerError::FieldNotRepresentable {
field: "icons".to_owned(),
expected: "not a field of 2025-06-18 resource links",
detail: "icons arrive at 2025-11-25".to_owned(),
target: ProtocolVersion::V2025_06_18,
});
}
wire::ContentBlock::Member3(wire::ResourceLink {
meta: take_meta(&link.extensions),
annotations: lower_annotations(link.annotations.as_ref()),
description: link.description.clone(),
mime_type: link.mime_type.clone(),
name: link.name.clone(),
size,
title: link.title.clone(),
r#type: RESOURCE_LINK_TYPE.to_owned(),
uri: link.uri.clone(),
extra: link.extensions.to_json_object(),
})
}
Content::EmbeddedResource(resource) => lower_embedded(resource)?,
Content::Unknown(unknown) => {
return Err(LowerError::FieldNotRepresentable {
field: unknown.type_name.clone(),
expected: "a 2025-06-18 content block",
detail: "this build does not model the block, and this revision has no \
member that could carry it"
.to_owned(),
target: ProtocolVersion::V2025_06_18,
});
}
};
Ok(lowered)
}
fn lower_embedded(resource: &EmbeddedResource) -> Result<wire::ContentBlock, LowerError> {
let contents = if let Some(text) = &resource.text {
wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
meta: None,
mime_type: resource.mime_type.clone(),
text: text.clone(),
uri: resource.uri.clone(),
extra: serde_json::Map::new(),
})
} else if let Some(blob) = &resource.blob {
wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
meta: None,
blob: blob.clone(),
mime_type: resource.mime_type.clone(),
uri: resource.uri.clone(),
extra: serde_json::Map::new(),
})
} else {
return Err(LowerError::FieldNotRepresentable {
field: "resource".to_owned(),
expected: "a text or blob body",
detail: "the resource has neither `text` nor `blob`".to_owned(),
target: ProtocolVersion::V2025_06_18,
});
};
Ok(wire::ContentBlock::Member4(wire::EmbeddedResource {
meta: take_meta(&resource.extensions),
annotations: lower_annotations(resource.annotations.as_ref()),
resource: contents,
r#type: RESOURCE_TYPE.to_owned(),
extra: resource.extensions.to_json_object(),
}))
}
#[cfg(test)]
mod tests {
use super::V2025_06_18;
use crate::domain::DegradePolicy;
use crate::domain::ir::Codec;
use crate::domain::ir::lower::{LowerError, TranslationReport};
use crate::domain::ir::{Content, ResourceLinkContent, ToolResult};
use crate::domain::{Feature, ProtocolVersion};
type TestError = Box<dyn std::error::Error>;
fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
let codec = V2025_06_18;
let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
let mut report = TranslationReport::new(codec.version());
let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
Ok(serde_json::to_value(&lowered)?)
}
#[test]
fn a_link_and_a_resource_land_in_different_arms() -> Result<(), TestError> {
let codec = V2025_06_18;
let link: super::wire::CallToolResult = serde_json::from_str(
r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a"}]}"#,
)?;
let resource: super::wire::CallToolResult = serde_json::from_str(
r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"b"}}]}"#,
)?;
assert!(matches!(
codec
.lift(&link)
.content
.first()
.ok_or("the link has no blocks")?,
Content::ResourceLink(_)
));
assert!(matches!(
codec
.lift(&resource)
.content
.first()
.ok_or("the resource has no blocks")?,
Content::EmbeddedResource(_)
));
Ok(())
}
#[test]
fn a_result_round_trips_through_json() -> Result<(), TestError> {
for json in [
r#"{"content":[{"type":"text","text":"hello"}]}"#,
r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"body"}}]}"#,
r#"{"content":[],"structuredContent":{"a":1}}"#,
r#"{"content":[{"type":"text","text":"x","annotations":{"lastModified":"2025-06-18T00:00:00Z","priority":0.1}}]}"#,
] {
assert_eq!(
round_trip(json)?,
serde_json::from_str::<serde_json::Value>(json)?,
"did not survive the crossing"
);
}
Ok(())
}
#[test]
fn a_blocks_meta_survives_as_a_field() -> Result<(), TestError> {
let json = r#"{"content":[{"type":"text","text":"x","_meta":{"tag":"a"}}]}"#;
let codec = V2025_06_18;
let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
let ir = codec.lift(&wire);
let mut report = TranslationReport::new(codec.version());
let lowered = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
let block = lowered.content.first().ok_or("the result has no blocks")?;
let super::wire::ContentBlock::Member0(text) = block else {
return Err("a text block must lower to the text arm".into());
};
assert_eq!(text.meta, Some(serde_json::json!({"tag": "a"})));
Ok(())
}
#[test]
fn a_non_object_structured_content_survives_whole() -> Result<(), TestError> {
let json = r#"{"content":[],"structuredContent":[1,2]}"#;
assert_eq!(
round_trip(json)?,
serde_json::from_str::<serde_json::Value>(json)?
);
Ok(())
}
#[test]
fn a_size_this_revision_cannot_hold_is_refused() -> Result<(), TestError> {
let codec = V2025_06_18;
let ir = ToolResult::new(vec![Content::ResourceLink(ResourceLinkContent {
size: Some(u64::MAX),
..ResourceLinkContent::new("file:///a", "a")
})]);
let mut report = TranslationReport::new(codec.version());
let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
return Err("i64 cannot hold this size, so it must be refused".into());
};
assert!(error.to_string().contains("size"), "{error}");
Ok(())
}
#[test]
fn icons_are_refused_by_the_gate_rather_than_dropped() -> Result<(), TestError> {
use std::assert_matches;
let codec = V2025_06_18;
let link = ResourceLinkContent {
icons: vec![crate::domain::ir::ResourceIcon::new("file:///i.png")],
..ResourceLinkContent::new("file:///a", "a")
};
let mut report = TranslationReport::new(codec.version());
let Err(error) = codec.lower(
ToolResult::new(vec![Content::ResourceLink(link)]),
DegradePolicy::strict(),
&mut report,
) else {
return Err("icons arrive at 2025-11-25, so this must be refused".into());
};
assert_matches!(
error,
LowerError::FeatureUnavailable {
feature: Feature::ToolResourceLinkIcons,
negotiated: ProtocolVersion::V2025_06_18,
}
);
Ok(())
}
}