1use protocol_droid_schema::wire::v2025_06_18 as wire;
27
28use super::{AUDIO_TYPE, IMAGE_TYPE, META_KEY, RESOURCE_LINK_TYPE, RESOURCE_TYPE, TEXT_TYPE};
29use crate::domain::ir::Codec;
30use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
31use crate::domain::ir::{
32 Annotations, AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent,
33 JsonObject, ResourceLinkContent, Role, TextContent, ToolResult,
34};
35use crate::domain::{
36 CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
37 resolve,
38};
39
40fn lift_annotations(annotations: &wire::Annotations) -> Annotations {
41 Annotations {
42 audience: annotations
43 .audience
44 .iter()
45 .flatten()
46 .map(|role| match role {
47 wire::Role::ASSISTANT => Role::Assistant,
48 wire::Role::USER => Role::User,
49 })
50 .collect(),
51 priority: annotations.priority,
52 last_modified: annotations.last_modified.clone(),
53 extensions: Extensions::from_json_object(annotations.extra.clone()),
54 }
55}
56
57fn lower_annotations(annotations: Option<&Annotations>) -> Option<wire::Annotations> {
58 let annotations = annotations?;
59 let extra = annotations.extensions.to_json_object();
60 if annotations.audience.is_empty()
61 && annotations.priority.is_none()
62 && annotations.last_modified.is_none()
63 && extra.is_empty()
64 {
65 return None;
66 }
67 Some(wire::Annotations {
68 audience: (!annotations.audience.is_empty()).then(|| {
69 annotations
70 .audience
71 .iter()
72 .map(|role| match role {
73 Role::Assistant => wire::Role::ASSISTANT,
74 Role::User => wire::Role::USER,
75 })
76 .collect()
77 }),
78 last_modified: annotations.last_modified.clone(),
79 priority: annotations.priority,
80 extra,
81 })
82}
83
84const RAW_STRUCTURED_KEY: &str = "structuredContent";
93
94fn take_meta(extensions: &Extensions) -> Option<serde_json::Value> {
101 let mut extensions = extensions.clone();
102 extensions
103 .take(META_KEY)
104 .map(|value| serde_json::Value::from(&value))
105}
106
107#[derive(Debug, Clone, Copy)]
109pub struct V2025_06_18;
110
111impl Codec for V2025_06_18 {
112 type Wire = wire::CallToolResult;
113
114 fn version(&self) -> ProtocolVersion {
115 ProtocolVersion::V2025_06_18
116 }
117
118 fn capabilities(&self) -> FeatureProfile {
119 let caps = CapabilitySet::all_for(self.version());
120 resolve(Negotiation {
121 version: self.version(),
122 client_caps: caps,
123 server_caps: caps,
124 transport: TransportKind::Stdio,
125 })
126 }
127
128 fn lift(&self, wire: &Self::Wire) -> ToolResult {
129 let mut extensions = Extensions::from_json_object(wire.extra.clone());
130 if let Some(meta) = &wire.meta {
131 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
132 }
133 let structured_content = match &wire.structured_content {
138 Some(value) if value.is_object() => JsonObject::new(value.to_string()).ok(),
139 Some(value) => {
140 extensions.insert(RAW_STRUCTURED_KEY, ExtensionValue::from(value.clone()));
141 None
142 }
143 None => None,
144 };
145 ToolResult {
146 content: wire.content.iter().map(lift_block).collect(),
147 structured_content,
148 is_error: wire.is_error.unwrap_or(false),
149 result_type: None,
150 extensions,
151 }
152 }
153
154 fn lower(
155 &self,
156 value: ToolResult,
157 policy: DegradePolicy,
158 report: &mut TranslationReport,
159 ) -> Result<Self::Wire, LowerError> {
160 let (value, lowered) =
161 lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
162 report.absorb(&lowered);
163 let structured_content = match value.extensions.get(RAW_STRUCTURED_KEY) {
167 Some(raw) => Some(serde_json::Value::from(raw)),
168 None => value
169 .structured_content
170 .as_ref()
171 .and_then(|object| serde_json::from_str(object.as_str()).ok()),
172 };
173 Ok(wire::CallToolResult {
174 meta: value.extensions.get(META_KEY).map(serde_json::Value::from),
175 content: value
176 .content
177 .iter()
178 .map(lower_block)
179 .collect::<Result<Vec<_>, _>>()?,
180 is_error: value.is_error.then_some(true),
181 structured_content,
182 extra: value.extensions.to_json_object(),
183 })
184 }
185}
186
187fn lift_block(block: &wire::ContentBlock) -> Content {
188 match block {
189 wire::ContentBlock::Member0(text) => {
190 let mut extensions = Extensions::from_json_object(text.extra.clone());
191 if let Some(meta) = &text.meta {
192 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
193 }
194 Content::Text(TextContent {
195 text: text.text.clone(),
196 annotations: text.annotations.as_ref().map(lift_annotations),
197 extensions,
198 })
199 }
200 wire::ContentBlock::Member1(image) => {
201 let mut extensions = Extensions::from_json_object(image.extra.clone());
202 if let Some(meta) = &image.meta {
203 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
204 }
205 Content::Image(ImageContent {
206 data: image.data.clone(),
207 mime_type: image.mime_type.clone(),
208 annotations: image.annotations.as_ref().map(lift_annotations),
209 extensions,
210 })
211 }
212 wire::ContentBlock::Member2(audio) => {
213 let mut extensions = Extensions::from_json_object(audio.extra.clone());
214 if let Some(meta) = &audio.meta {
215 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
216 }
217 Content::Audio(AudioContent {
218 data: audio.data.clone(),
219 mime_type: audio.mime_type.clone(),
220 annotations: audio.annotations.as_ref().map(lift_annotations),
221 extensions,
222 })
223 }
224 wire::ContentBlock::Member3(link) => {
225 let mut extensions = Extensions::from_json_object(link.extra.clone());
226 if let Some(meta) = &link.meta {
227 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
228 }
229 Content::ResourceLink(ResourceLinkContent {
230 uri: link.uri.clone(),
231 name: link.name.clone(),
232 description: link.description.clone(),
233 mime_type: link.mime_type.clone(),
234 size: link.size.and_then(|size| u64::try_from(size).ok()),
238 icons: Vec::new(),
241 title: link.title.clone(),
242 annotations: link.annotations.as_ref().map(lift_annotations),
243 extensions,
244 })
245 }
246 wire::ContentBlock::Member4(resource) => {
247 let wire::EmbeddedResource {
248 meta,
249 annotations,
250 resource,
251 extra,
252 ..
253 } = resource;
254 let (uri, mime_type, text, blob) = match resource {
255 wire::EmbeddedResourceResourceChoice::Member0(text) => (
256 text.uri.clone(),
257 text.mime_type.clone(),
258 Some(text.text.clone()),
259 None,
260 ),
261 wire::EmbeddedResourceResourceChoice::Member1(blob) => (
262 blob.uri.clone(),
263 blob.mime_type.clone(),
264 None,
265 Some(blob.blob.clone()),
266 ),
267 };
268 let mut extensions = Extensions::from_json_object(extra.clone());
269 if let Some(meta) = meta {
270 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
271 }
272 Content::EmbeddedResource(EmbeddedResource {
273 uri,
274 mime_type,
275 text,
276 blob,
277 annotations: annotations.as_ref().map(lift_annotations),
278 extensions,
279 })
280 }
281 }
282}
283
284fn lower_block(block: &Content) -> Result<wire::ContentBlock, LowerError> {
285 let lowered = match block {
286 Content::Text(text) => wire::ContentBlock::Member0(wire::TextContent {
287 meta: take_meta(&text.extensions),
288 annotations: lower_annotations(text.annotations.as_ref()),
289 text: text.text.clone(),
290 r#type: TEXT_TYPE.to_owned(),
291 extra: text.extensions.to_json_object(),
292 }),
293 Content::Image(image) => wire::ContentBlock::Member1(wire::ImageContent {
294 meta: take_meta(&image.extensions),
295 annotations: lower_annotations(image.annotations.as_ref()),
296 data: image.data.clone(),
297 mime_type: image.mime_type.clone(),
298 r#type: IMAGE_TYPE.to_owned(),
299 extra: image.extensions.to_json_object(),
300 }),
301 Content::Audio(audio) => wire::ContentBlock::Member2(wire::AudioContent {
302 meta: take_meta(&audio.extensions),
303 annotations: lower_annotations(audio.annotations.as_ref()),
304 data: audio.data.clone(),
305 mime_type: audio.mime_type.clone(),
306 r#type: AUDIO_TYPE.to_owned(),
307 extra: audio.extensions.to_json_object(),
308 }),
309 Content::ResourceLink(link) => {
310 let size = link.size.map_or(Ok(None), |size| {
311 i64::try_from(size)
312 .map(Some)
313 .map_err(|_| LowerError::FieldNotRepresentable {
314 field: "size".to_owned(),
315 expected: "a non-negative size",
316 detail: format!("{size} does not fit the i64 this revision uses"),
317 target: ProtocolVersion::V2025_06_18,
318 })
319 })?;
320 if !link.icons.is_empty() {
321 return Err(LowerError::FieldNotRepresentable {
322 field: "icons".to_owned(),
323 expected: "not a field of 2025-06-18 resource links",
324 detail: "icons arrive at 2025-11-25".to_owned(),
325 target: ProtocolVersion::V2025_06_18,
326 });
327 }
328 wire::ContentBlock::Member3(wire::ResourceLink {
329 meta: take_meta(&link.extensions),
330 annotations: lower_annotations(link.annotations.as_ref()),
331 description: link.description.clone(),
332 mime_type: link.mime_type.clone(),
333 name: link.name.clone(),
334 size,
335 title: link.title.clone(),
336 r#type: RESOURCE_LINK_TYPE.to_owned(),
337 uri: link.uri.clone(),
338 extra: link.extensions.to_json_object(),
339 })
340 }
341 Content::EmbeddedResource(resource) => lower_embedded(resource)?,
342 Content::Unknown(unknown) => {
343 return Err(LowerError::FieldNotRepresentable {
344 field: unknown.type_name.clone(),
345 expected: "a 2025-06-18 content block",
346 detail: "this build does not model the block, and this revision has no \
347 member that could carry it"
348 .to_owned(),
349 target: ProtocolVersion::V2025_06_18,
350 });
351 }
352 };
353 Ok(lowered)
354}
355
356fn lower_embedded(resource: &EmbeddedResource) -> Result<wire::ContentBlock, LowerError> {
361 let contents = if let Some(text) = &resource.text {
362 wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
363 meta: None,
364 mime_type: resource.mime_type.clone(),
365 text: text.clone(),
366 uri: resource.uri.clone(),
367 extra: serde_json::Map::new(),
368 })
369 } else if let Some(blob) = &resource.blob {
370 wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
371 meta: None,
372 blob: blob.clone(),
373 mime_type: resource.mime_type.clone(),
374 uri: resource.uri.clone(),
375 extra: serde_json::Map::new(),
376 })
377 } else {
378 return Err(LowerError::FieldNotRepresentable {
379 field: "resource".to_owned(),
380 expected: "a text or blob body",
381 detail: "the resource has neither `text` nor `blob`".to_owned(),
382 target: ProtocolVersion::V2025_06_18,
383 });
384 };
385 Ok(wire::ContentBlock::Member4(wire::EmbeddedResource {
386 meta: take_meta(&resource.extensions),
387 annotations: lower_annotations(resource.annotations.as_ref()),
388 resource: contents,
389 r#type: RESOURCE_TYPE.to_owned(),
390 extra: resource.extensions.to_json_object(),
391 }))
392}
393#[cfg(test)]
394mod tests {
395 use super::V2025_06_18;
396 use crate::domain::DegradePolicy;
397 use crate::domain::ir::Codec;
398 use crate::domain::ir::lower::{LowerError, TranslationReport};
399 use crate::domain::ir::{Content, ResourceLinkContent, ToolResult};
400 use crate::domain::{Feature, ProtocolVersion};
401
402 type TestError = Box<dyn std::error::Error>;
408
409 fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
410 let codec = V2025_06_18;
411 let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
412 let mut report = TranslationReport::new(codec.version());
413 let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
414 Ok(serde_json::to_value(&lowered)?)
415 }
416
417 #[test]
421 fn a_link_and_a_resource_land_in_different_arms() -> Result<(), TestError> {
422 let codec = V2025_06_18;
423 let link: super::wire::CallToolResult = serde_json::from_str(
424 r#"{"content":[{"type":"resource_link","uri":"file:///a","name":"a"}]}"#,
425 )?;
426 let resource: super::wire::CallToolResult = serde_json::from_str(
427 r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"b"}}]}"#,
428 )?;
429
430 assert!(matches!(
431 codec
432 .lift(&link)
433 .content
434 .first()
435 .ok_or("the link has no blocks")?,
436 Content::ResourceLink(_)
437 ));
438 assert!(matches!(
439 codec
440 .lift(&resource)
441 .content
442 .first()
443 .ok_or("the resource has no blocks")?,
444 Content::EmbeddedResource(_)
445 ));
446 Ok(())
447 }
448
449 #[test]
450 fn a_result_round_trips_through_json() -> Result<(), TestError> {
451 for json in [
452 r#"{"content":[{"type":"text","text":"hello"}]}"#,
453 r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"body"}}]}"#,
454 r#"{"content":[],"structuredContent":{"a":1}}"#,
455 r#"{"content":[{"type":"text","text":"x","annotations":{"lastModified":"2025-06-18T00:00:00Z","priority":0.1}}]}"#,
456 ] {
457 assert_eq!(
458 round_trip(json)?,
459 serde_json::from_str::<serde_json::Value>(json)?,
460 "did not survive the crossing"
461 );
462 }
463 Ok(())
464 }
465
466 #[test]
469 fn a_blocks_meta_survives_as_a_field() -> Result<(), TestError> {
470 let json = r#"{"content":[{"type":"text","text":"x","_meta":{"tag":"a"}}]}"#;
471 let codec = V2025_06_18;
472 let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
473 let ir = codec.lift(&wire);
474
475 let mut report = TranslationReport::new(codec.version());
476 let lowered = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
477 let block = lowered.content.first().ok_or("the result has no blocks")?;
478 let super::wire::ContentBlock::Member0(text) = block else {
479 return Err("a text block must lower to the text arm".into());
480 };
481 assert_eq!(text.meta, Some(serde_json::json!({"tag": "a"})));
482 Ok(())
483 }
484
485 #[test]
489 fn a_non_object_structured_content_survives_whole() -> Result<(), TestError> {
490 let json = r#"{"content":[],"structuredContent":[1,2]}"#;
491 assert_eq!(
492 round_trip(json)?,
493 serde_json::from_str::<serde_json::Value>(json)?
494 );
495 Ok(())
496 }
497
498 #[test]
502 fn a_size_this_revision_cannot_hold_is_refused() -> Result<(), TestError> {
503 let codec = V2025_06_18;
504 let ir = ToolResult::new(vec![Content::ResourceLink(ResourceLinkContent {
505 size: Some(u64::MAX),
506 ..ResourceLinkContent::new("file:///a", "a")
507 })]);
508
509 let mut report = TranslationReport::new(codec.version());
510 let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
511 return Err("i64 cannot hold this size, so it must be refused".into());
512 };
513 assert!(error.to_string().contains("size"), "{error}");
514 Ok(())
515 }
516
517 #[test]
525 fn icons_are_refused_by_the_gate_rather_than_dropped() -> Result<(), TestError> {
526 use std::assert_matches;
527
528 let codec = V2025_06_18;
529 let link = ResourceLinkContent {
530 icons: vec![crate::domain::ir::ResourceIcon::new("file:///i.png")],
531 ..ResourceLinkContent::new("file:///a", "a")
532 };
533
534 let mut report = TranslationReport::new(codec.version());
535 let Err(error) = codec.lower(
536 ToolResult::new(vec![Content::ResourceLink(link)]),
537 DegradePolicy::strict(),
538 &mut report,
539 ) else {
540 return Err("icons arrive at 2025-11-25, so this must be refused".into());
541 };
542 assert_matches!(
543 error,
544 LowerError::FeatureUnavailable {
545 feature: Feature::ToolResourceLinkIcons,
546 negotiated: ProtocolVersion::V2025_06_18,
547 }
548 );
549 Ok(())
550 }
551}