1use protocol_droid_schema::wire::v2025_11_25 as wire;
29
30use super::{AUDIO_TYPE, IMAGE_TYPE, META_KEY, RESOURCE_LINK_TYPE, RESOURCE_TYPE, TEXT_TYPE};
31use crate::domain::ir::Codec;
32use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
33use crate::domain::ir::{
34 Annotations, AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent,
35 JsonObject, ResourceIcon, ResourceLinkContent, Role, TextContent, ToolResult,
36};
37use crate::domain::{
38 CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
39 resolve,
40};
41
42fn lift_annotations(annotations: &wire::Annotations) -> Annotations {
43 Annotations {
44 audience: annotations
45 .audience
46 .iter()
47 .flatten()
48 .map(|role| match role {
49 wire::Role::ASSISTANT => Role::Assistant,
50 wire::Role::USER => Role::User,
51 })
52 .collect(),
53 priority: annotations.priority,
54 last_modified: annotations.last_modified.clone(),
55 extensions: Extensions::from_json_object(annotations.extra.clone()),
56 }
57}
58
59fn lower_annotations(annotations: Option<&Annotations>) -> Option<wire::Annotations> {
60 let annotations = annotations?;
61 let extra = annotations.extensions.to_json_object();
62 if annotations.audience.is_empty()
63 && annotations.priority.is_none()
64 && annotations.last_modified.is_none()
65 && extra.is_empty()
66 {
67 return None;
68 }
69 Some(wire::Annotations {
70 audience: (!annotations.audience.is_empty()).then(|| {
71 annotations
72 .audience
73 .iter()
74 .map(|role| match role {
75 Role::Assistant => wire::Role::ASSISTANT,
76 Role::User => wire::Role::USER,
77 })
78 .collect()
79 }),
80 last_modified: annotations.last_modified.clone(),
81 priority: annotations.priority,
82 extra,
83 })
84}
85
86const RAW_STRUCTURED_KEY: &str = "structuredContent";
95
96fn take_meta(extensions: &Extensions) -> Option<serde_json::Value> {
103 let mut extensions = extensions.clone();
104 extensions
105 .take(META_KEY)
106 .map(|value| serde_json::Value::from(&value))
107}
108
109#[derive(Debug, Clone, Copy)]
111pub struct V2025_11_25;
112
113impl Codec for V2025_11_25 {
114 type Wire = wire::CallToolResult;
115
116 fn version(&self) -> ProtocolVersion {
117 ProtocolVersion::V2025_11_25
118 }
119
120 fn capabilities(&self) -> FeatureProfile {
121 let caps = CapabilitySet::all_for(self.version());
122 resolve(Negotiation {
123 version: self.version(),
124 client_caps: caps,
125 server_caps: caps,
126 transport: TransportKind::Stdio,
127 })
128 }
129
130 fn lift(&self, wire: &Self::Wire) -> ToolResult {
131 let mut extensions = Extensions::from_json_object(wire.extra.clone());
132 if let Some(meta) = &wire.meta {
133 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
134 }
135 let structured_content = match &wire.structured_content {
140 Some(value) if value.is_object() => JsonObject::new(value.to_string()).ok(),
141 Some(value) => {
142 extensions.insert(RAW_STRUCTURED_KEY, ExtensionValue::from(value.clone()));
143 None
144 }
145 None => None,
146 };
147 ToolResult {
148 content: wire.content.iter().map(lift_block).collect(),
149 structured_content,
150 is_error: wire.is_error.unwrap_or(false),
151 result_type: None,
152 extensions,
153 }
154 }
155
156 fn lower(
157 &self,
158 value: ToolResult,
159 policy: DegradePolicy,
160 report: &mut TranslationReport,
161 ) -> Result<Self::Wire, LowerError> {
162 let (value, lowered) =
163 lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
164 report.absorb(&lowered);
165 let structured_content = match value.extensions.get(RAW_STRUCTURED_KEY) {
169 Some(raw) => Some(serde_json::Value::from(raw)),
170 None => value
171 .structured_content
172 .as_ref()
173 .and_then(|object| serde_json::from_str(object.as_str()).ok()),
174 };
175 Ok(wire::CallToolResult {
176 meta: value.extensions.get(META_KEY).map(serde_json::Value::from),
177 content: value
178 .content
179 .iter()
180 .map(lower_block)
181 .collect::<Result<Vec<_>, _>>()?,
182 is_error: value.is_error.then_some(true),
183 structured_content,
184 extra: value.extensions.to_json_object(),
185 })
186 }
187}
188
189fn lift_icon(icon: &wire::Icon) -> ResourceIcon {
193 ResourceIcon {
194 src: icon.src.clone(),
195 mime_type: icon.mime_type.clone(),
196 sizes: icon.sizes.clone().unwrap_or_default(),
201 theme: icon.theme.clone(),
202 extensions: Extensions::from_json_object(icon.extra.clone()),
203 }
204}
205
206fn lower_icon(icon: &ResourceIcon) -> wire::Icon {
213 wire::Icon {
214 src: icon.src.clone(),
215 mime_type: icon.mime_type.clone(),
216 sizes: (!icon.sizes.is_empty()).then(|| icon.sizes.clone()),
217 theme: icon.theme.clone(),
218 extra: icon.extensions.to_json_object(),
219 }
220}
221
222fn lift_block(block: &wire::ContentBlock) -> Content {
223 match block {
224 wire::ContentBlock::Member0(text) => {
225 let mut extensions = Extensions::from_json_object(text.extra.clone());
226 if let Some(meta) = &text.meta {
227 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
228 }
229 Content::Text(TextContent {
230 text: text.text.clone(),
231 annotations: text.annotations.as_ref().map(lift_annotations),
232 extensions,
233 })
234 }
235 wire::ContentBlock::Member1(image) => {
236 let mut extensions = Extensions::from_json_object(image.extra.clone());
237 if let Some(meta) = &image.meta {
238 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
239 }
240 Content::Image(ImageContent {
241 data: image.data.clone(),
242 mime_type: image.mime_type.clone(),
243 annotations: image.annotations.as_ref().map(lift_annotations),
244 extensions,
245 })
246 }
247 wire::ContentBlock::Member2(audio) => {
248 let mut extensions = Extensions::from_json_object(audio.extra.clone());
249 if let Some(meta) = &audio.meta {
250 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
251 }
252 Content::Audio(AudioContent {
253 data: audio.data.clone(),
254 mime_type: audio.mime_type.clone(),
255 annotations: audio.annotations.as_ref().map(lift_annotations),
256 extensions,
257 })
258 }
259 wire::ContentBlock::Member3(link) => {
260 let mut extensions = Extensions::from_json_object(link.extra.clone());
261 if let Some(meta) = &link.meta {
262 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
263 }
264 Content::ResourceLink(ResourceLinkContent {
265 uri: link.uri.clone(),
266 name: link.name.clone(),
267 description: link.description.clone(),
268 mime_type: link.mime_type.clone(),
269 size: link.size.and_then(|size| u64::try_from(size).ok()),
273 icons: link.icons.iter().flatten().map(lift_icon).collect(),
274 title: link.title.clone(),
275 annotations: link.annotations.as_ref().map(lift_annotations),
276 extensions,
277 })
278 }
279 wire::ContentBlock::Member4(resource) => {
280 let wire::EmbeddedResource {
281 meta,
282 annotations,
283 resource,
284 extra,
285 ..
286 } = resource;
287 let (uri, mime_type, text, blob) = match resource {
288 wire::EmbeddedResourceResourceChoice::Member0(text) => (
289 text.uri.clone(),
290 text.mime_type.clone(),
291 Some(text.text.clone()),
292 None,
293 ),
294 wire::EmbeddedResourceResourceChoice::Member1(blob) => (
295 blob.uri.clone(),
296 blob.mime_type.clone(),
297 None,
298 Some(blob.blob.clone()),
299 ),
300 };
301 let mut extensions = Extensions::from_json_object(extra.clone());
302 if let Some(meta) = meta {
303 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
304 }
305 Content::EmbeddedResource(EmbeddedResource {
306 uri,
307 mime_type,
308 text,
309 blob,
310 annotations: annotations.as_ref().map(lift_annotations),
311 extensions,
312 })
313 }
314 }
315}
316
317fn lower_block(block: &Content) -> Result<wire::ContentBlock, LowerError> {
318 let lowered = match block {
319 Content::Text(text) => wire::ContentBlock::Member0(wire::TextContent {
320 meta: take_meta(&text.extensions),
321 annotations: lower_annotations(text.annotations.as_ref()),
322 text: text.text.clone(),
323 r#type: TEXT_TYPE.to_owned(),
324 extra: text.extensions.to_json_object(),
325 }),
326 Content::Image(image) => wire::ContentBlock::Member1(wire::ImageContent {
327 meta: take_meta(&image.extensions),
328 annotations: lower_annotations(image.annotations.as_ref()),
329 data: image.data.clone(),
330 mime_type: image.mime_type.clone(),
331 r#type: IMAGE_TYPE.to_owned(),
332 extra: image.extensions.to_json_object(),
333 }),
334 Content::Audio(audio) => wire::ContentBlock::Member2(wire::AudioContent {
335 meta: take_meta(&audio.extensions),
336 annotations: lower_annotations(audio.annotations.as_ref()),
337 data: audio.data.clone(),
338 mime_type: audio.mime_type.clone(),
339 r#type: AUDIO_TYPE.to_owned(),
340 extra: audio.extensions.to_json_object(),
341 }),
342 Content::ResourceLink(link) => {
343 let size = link.size.map_or(Ok(None), |size| {
344 i64::try_from(size)
345 .map(Some)
346 .map_err(|_| LowerError::FieldNotRepresentable {
347 field: "size".to_owned(),
348 expected: "a non-negative size",
349 detail: format!("{size} does not fit the i64 this revision uses"),
350 target: ProtocolVersion::V2025_11_25,
351 })
352 })?;
353 wire::ContentBlock::Member3(wire::ResourceLink {
354 meta: take_meta(&link.extensions),
355 annotations: lower_annotations(link.annotations.as_ref()),
356 description: link.description.clone(),
357 mime_type: link.mime_type.clone(),
358 name: link.name.clone(),
359 size,
360 title: link.title.clone(),
361 r#type: RESOURCE_LINK_TYPE.to_owned(),
362 uri: link.uri.clone(),
363 icons: (!link.icons.is_empty())
364 .then(|| link.icons.iter().map(lower_icon).collect()),
365 extra: link.extensions.to_json_object(),
366 })
367 }
368 Content::EmbeddedResource(resource) => {
369 let contents = if let Some(text) = &resource.text {
370 wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
371 meta: None,
372 mime_type: resource.mime_type.clone(),
373 text: text.clone(),
374 uri: resource.uri.clone(),
375 extra: serde_json::Map::new(),
376 })
377 } else if let Some(blob) = &resource.blob {
378 wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
379 meta: None,
380 blob: blob.clone(),
381 mime_type: resource.mime_type.clone(),
382 uri: resource.uri.clone(),
383 extra: serde_json::Map::new(),
384 })
385 } else {
386 return Err(LowerError::FieldNotRepresentable {
387 field: "resource".to_owned(),
388 expected: "a text or blob body",
389 detail: "the resource has neither `text` nor `blob`".to_owned(),
390 target: ProtocolVersion::V2025_11_25,
391 });
392 };
393 wire::ContentBlock::Member4(wire::EmbeddedResource {
394 meta: take_meta(&resource.extensions),
395 annotations: lower_annotations(resource.annotations.as_ref()),
396 resource: contents,
397 r#type: RESOURCE_TYPE.to_owned(),
398 extra: resource.extensions.to_json_object(),
399 })
400 }
401 Content::Unknown(unknown) => {
402 return Err(LowerError::FieldNotRepresentable {
403 field: unknown.type_name.clone(),
404 expected: "a 2025-11-25 content block",
405 detail: "this build does not model the block, and this revision has no \
406 member that could carry it"
407 .to_owned(),
408 target: ProtocolVersion::V2025_11_25,
409 });
410 }
411 };
412 Ok(lowered)
413}
414
415#[cfg(test)]
416mod tests {
417 type TestError = Box<dyn std::error::Error>;
423
424 use super::V2025_11_25;
425 use crate::domain::DegradePolicy;
426 use crate::domain::ir::Codec;
427 use crate::domain::ir::lower::TranslationReport;
428 use crate::domain::ir::{Content, ResourceIcon, ResourceLinkContent, ToolResult};
429
430 fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
431 let codec = V2025_11_25;
432 let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
433 let mut report = TranslationReport::new(codec.version());
434 let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
435 Ok(serde_json::to_value(&lowered)?)
436 }
437
438 #[test]
443 fn a_resource_link_is_the_fourth_arm() -> Result<(), TestError> {
444 let value =
445 round_trip(r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a"}]}"#)?;
446 let content = value
447 .get("content")
448 .and_then(|c| c.as_array())
449 .and_then(|a| a.first())
450 .ok_or("the result has no blocks")?;
451 assert_eq!(
452 content.get("type").and_then(|t| t.as_str()),
453 Some("resource_link")
454 );
455 Ok(())
456 }
457
458 #[test]
462 fn an_audio_block_is_not_returned_as_an_image() -> Result<(), TestError> {
463 let value =
464 round_trip(r#"{"content":[{"type":"audio","data":"AAA","mimeType":"audio/wav"}]}"#)?;
465 let content = value
466 .get("content")
467 .and_then(|c| c.as_array())
468 .and_then(|a| a.first())
469 .ok_or("the result has no blocks")?;
470 assert_eq!(content.get("type").and_then(|t| t.as_str()), Some("audio"));
471 Ok(())
472 }
473
474 #[test]
478 fn every_icon_member_survives_the_crossing() -> Result<(), TestError> {
479 let value = round_trip(
480 r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a",
481 "icons":[{"src":"file:///i.png","mimeType":"image/png",
482 "sizes":["48x48","any"],"theme":"dark"},
483 {"src":"file:///j.svg"}]}]}"#,
484 )?;
485 let icons = value
486 .get("content")
487 .and_then(|c| c.as_array())
488 .and_then(|a| a.first())
489 .and_then(|b| b.get("icons"))
490 .and_then(|i| i.as_array())
491 .ok_or("the fixture has no blocks where the test expects one")?;
492 assert_eq!(icons.len(), 2);
493 assert_eq!(
494 icons
495 .first()
496 .ok_or("the fixture has no icon there")?
497 .get("src")
498 .and_then(|s| s.as_str()),
499 Some("file:///i.png")
500 );
501 assert_eq!(
502 icons
503 .first()
504 .ok_or("the fixture has no icon there")?
505 .get("mimeType")
506 .and_then(|s| s.as_str()),
507 Some("image/png")
508 );
509 assert_eq!(
510 icons
511 .first()
512 .ok_or("the fixture has no icon there")?
513 .get("sizes")
514 .and_then(|s| s.as_array())
515 .map(Vec::len),
516 Some(2)
517 );
518 assert_eq!(
519 icons
520 .first()
521 .ok_or("the fixture has no icon there")?
522 .get("theme")
523 .and_then(|t| t.as_str()),
524 Some("dark")
525 );
526 assert_eq!(
528 icons
529 .get(1)
530 .ok_or("the fixture has no icon there")?
531 .get("src")
532 .and_then(|s| s.as_str()),
533 Some("file:///j.svg")
534 );
535 assert!(
536 icons
537 .get(1)
538 .ok_or("the fixture has no icon there")?
539 .get("sizes")
540 .is_none(),
541 "an absent list is not an empty one"
542 );
543 Ok(())
544 }
545
546 #[test]
550 fn an_empty_icon_list_is_not_written_out() -> Result<(), TestError> {
551 let value = round_trip(
552 r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a",
553 "icons":[]}]}"#,
554 )?;
555 let block = value
556 .get("content")
557 .and_then(|c| c.as_array())
558 .and_then(|a| a.first())
559 .ok_or("the result has no blocks")?;
560 assert!(
561 block.get("icons").is_none(),
562 "got {}",
563 block.get("icons").unwrap_or(&serde_json::Value::Null)
564 );
565 Ok(())
566 }
567
568 #[test]
572 fn an_unmodelled_icon_member_is_returned_whole() -> Result<(), TestError> {
573 let value = round_trip(
574 r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a",
575 "icons":[{"src":"file:///i.png","unheardOf":true}]}]}"#,
576 )?;
577 let icon = value
578 .get("content")
579 .and_then(|c| c.as_array())
580 .and_then(|a| a.first())
581 .and_then(|b| b.get("icons"))
582 .and_then(|i| i.as_array())
583 .and_then(|a| a.first())
584 .ok_or("the result has no blocks")?;
585 assert_eq!(
586 icon.get("unheardOf").and_then(serde_json::Value::as_bool),
587 Some(true)
588 );
589 Ok(())
590 }
591
592 #[test]
596 fn a_hand_built_icon_reaches_the_wire() -> Result<(), TestError> {
597 let codec = V2025_11_25;
598 let ir = ToolResult::new(vec![Content::ResourceLink(ResourceLinkContent {
599 icons: vec![ResourceIcon {
600 src: "file:///i.png".to_owned(),
601 mime_type: Some("image/svg+xml".to_owned()),
602 sizes: vec!["96x96".to_owned()],
603 ..ResourceIcon::new("")
604 }],
605 ..ResourceLinkContent::new("file:///a", "a")
606 })]);
607 let mut report = TranslationReport::new(codec.version());
608 let wire = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
609 let value = serde_json::to_value(&wire)?;
610 let icon = value
611 .get("content")
612 .and_then(|c| c.as_array())
613 .and_then(|a| a.first())
614 .and_then(|b| b.get("icons"))
615 .and_then(|i| i.as_array())
616 .and_then(|a| a.first())
617 .ok_or("the result has no blocks")?;
618 assert_eq!(
619 icon.get("mimeType").and_then(|s| s.as_str()),
620 Some("image/svg+xml")
621 );
622 assert_eq!(
623 icon.get("sizes").and_then(|s| s.as_array()).map(Vec::len),
624 Some(1)
625 );
626 Ok(())
627 }
628}