1use protocol_droid_schema::wire::v2024_11_05 as wire;
33
34use super::{IMAGE_TYPE, META_KEY, RESOURCE_TYPE, TEXT_TYPE};
35
36use crate::domain::ir::Codec;
37use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
38use crate::domain::ir::{
39 Annotations, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent, Role,
40 TextContent, ToolResult,
41};
42use crate::domain::{
43 CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
44 resolve,
45};
46
47const RAW_ANNOTATIONS_KEY: &str = "annotations";
53
54fn lift_annotations(value: &serde_json::Value) -> Annotations {
65 let Some(object) = value.as_object() else {
66 return Annotations {
67 extensions: Extensions::empty()
68 .with(RAW_ANNOTATIONS_KEY, ExtensionValue::from(value.clone())),
69 ..Annotations::empty()
70 };
71 };
72
73 let mut annotations = Annotations::empty();
74
75 if let Some(priority) = object.get("priority").and_then(serde_json::Value::as_f64) {
76 annotations.priority = Some(priority);
77 }
78 if let Some(last_modified) = object
79 .get("lastModified")
80 .and_then(serde_json::Value::as_str)
81 {
82 annotations.last_modified = Some(last_modified.to_owned());
83 }
84 if let Some(roles) = object.get("audience").and_then(serde_json::Value::as_array) {
85 annotations.audience = roles
86 .iter()
87 .filter_map(|role| role.as_str())
88 .filter_map(|role| {
89 if role == Role::User.as_str() {
90 Some(Role::User)
91 } else if role == Role::Assistant.as_str() {
92 Some(Role::Assistant)
93 } else {
94 None
95 }
96 })
97 .collect();
98 }
99
100 for (key, value) in object {
101 if !matches!(key.as_str(), "priority" | "lastModified" | "audience") {
102 annotations
103 .extensions
104 .insert(key.clone(), ExtensionValue::from(value.clone()));
105 }
106 }
107 annotations
108}
109
110fn lower_annotations(annotations: Option<&Annotations>) -> Option<serde_json::Value> {
117 let annotations = annotations?;
118
119 let mut object = annotations.extensions.to_json_object();
120
121 if let Some(raw) = object.remove(RAW_ANNOTATIONS_KEY) {
125 return Some(raw);
126 }
127
128 if !annotations.audience.is_empty() {
129 object.insert(
130 "audience".to_owned(),
131 serde_json::Value::Array(
132 annotations
133 .audience
134 .iter()
135 .map(|role| serde_json::Value::String((*role).as_str().to_owned()))
136 .collect(),
137 ),
138 );
139 }
140 if let Some(priority) = annotations.priority {
141 object.insert(
142 "priority".to_owned(),
143 serde_json::Number::from_f64(priority)
144 .map_or(serde_json::Value::Null, serde_json::Value::Number),
145 );
146 }
147 if let Some(last_modified) = &annotations.last_modified {
148 object.insert(
149 "lastModified".to_owned(),
150 serde_json::Value::String(last_modified.clone()),
151 );
152 }
153 if object.is_empty() {
154 return None;
155 }
156 Some(serde_json::Value::Object(object))
157}
158
159#[derive(Debug, Clone, Copy)]
161pub struct V2024_11_05;
162
163impl Codec for V2024_11_05 {
164 type Wire = wire::CallToolResult;
165
166 fn version(&self) -> ProtocolVersion {
167 ProtocolVersion::V2024_11_05
168 }
169
170 fn capabilities(&self) -> FeatureProfile {
171 let caps = CapabilitySet::all_for(self.version());
172 resolve(Negotiation {
173 version: self.version(),
174 client_caps: caps,
175 server_caps: caps,
176 transport: TransportKind::Stdio,
177 })
178 }
179
180 fn lift(&self, wire: &Self::Wire) -> ToolResult {
181 let mut extensions = Extensions::from_json_object(wire.extra.clone());
182 if let Some(meta) = &wire.meta {
183 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
184 }
185
186 ToolResult {
187 content: wire.content.iter().map(lift_block).collect(),
188 structured_content: None,
189 is_error: wire.is_error.unwrap_or(false),
190 result_type: None,
191 extensions,
192 }
193 }
194
195 fn lower(
196 &self,
197 value: ToolResult,
198 policy: DegradePolicy,
199 report: &mut TranslationReport,
200 ) -> Result<Self::Wire, LowerError> {
201 let (value, lowered) =
202 lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
203 report.absorb(&lowered);
204
205 let extra = value.extensions.to_json_object();
206 let meta = value.extensions.get(META_KEY).map(serde_json::Value::from);
207
208 let content = value
209 .content
210 .iter()
211 .map(lower_block)
212 .collect::<Result<Vec<_>, _>>()?;
213
214 Ok(wire::CallToolResult {
215 meta,
216 content,
217 is_error: value.is_error.then_some(true),
218 extra,
219 })
220 }
221}
222
223fn lift_block(block: &wire::CallToolResultContentChoice) -> Content {
229 match block {
230 wire::CallToolResultContentChoice::Member0(text) => Content::Text(TextContent {
231 text: text.text.clone(),
232 annotations: text.annotations.as_ref().map(lift_annotations),
233 extensions: Extensions::from_json_object(text.extra.clone()),
234 }),
235 wire::CallToolResultContentChoice::Member1(image) => Content::Image(ImageContent {
236 data: image.data.clone(),
237 mime_type: image.mime_type.clone(),
238 annotations: image.annotations.as_ref().map(lift_annotations),
239 extensions: Extensions::from_json_object(image.extra.clone()),
240 }),
241 wire::CallToolResultContentChoice::Member2(resource) => {
242 let wire::EmbeddedResource {
243 annotations,
244 resource,
245 extra,
246 ..
247 } = resource;
248 let (uri, mime_type, text, blob) = match resource {
249 wire::EmbeddedResourceResourceChoice::Member0(text) => (
250 text.uri.clone(),
251 text.mime_type.clone(),
252 Some(text.text.clone()),
253 None,
254 ),
255 wire::EmbeddedResourceResourceChoice::Member1(blob) => (
256 blob.uri.clone(),
257 blob.mime_type.clone(),
258 None,
259 Some(blob.blob.clone()),
260 ),
261 };
262 Content::EmbeddedResource(EmbeddedResource {
263 uri,
264 mime_type,
265 text,
266 blob,
267 annotations: annotations.as_ref().map(lift_annotations),
268 extensions: Extensions::from_json_object(extra.clone()),
269 })
270 }
271 }
272}
273
274fn lower_block(block: &Content) -> Result<wire::CallToolResultContentChoice, LowerError> {
289 let lowered = match block {
290 Content::Text(text) => wire::CallToolResultContentChoice::Member0(wire::TextContent {
291 annotations: lower_annotations(text.annotations.as_ref()),
292 text: text.text.clone(),
293 r#type: TEXT_TYPE.to_owned(),
294 extra: text.extensions.to_json_object(),
295 }),
296 Content::Image(image) => wire::CallToolResultContentChoice::Member1(wire::ImageContent {
297 annotations: lower_annotations(image.annotations.as_ref()),
298 data: image.data.clone(),
299 mime_type: image.mime_type.clone(),
300 r#type: IMAGE_TYPE.to_owned(),
301 extra: image.extensions.to_json_object(),
302 }),
303 Content::EmbeddedResource(resource) => {
304 let contents = if let Some(text) = &resource.text {
305 wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
306 mime_type: resource.mime_type.clone(),
307 text: text.clone(),
308 uri: resource.uri.clone(),
309 extra: serde_json::Map::new(),
310 })
311 } else if let Some(blob) = &resource.blob {
312 wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
313 blob: blob.clone(),
314 mime_type: resource.mime_type.clone(),
315 uri: resource.uri.clone(),
316 extra: serde_json::Map::new(),
317 })
318 } else {
319 return Err(LowerError::FieldNotRepresentable {
323 field: "resource".to_owned(),
324 expected: "a text or blob body",
325 detail: "the resource has neither `text` nor `blob`".to_owned(),
326 target: ProtocolVersion::V2024_11_05,
327 });
328 };
329 wire::CallToolResultContentChoice::Member2(wire::EmbeddedResource {
330 annotations: lower_annotations(resource.annotations.as_ref()),
331 resource: contents,
332 r#type: RESOURCE_TYPE.to_owned(),
333 extra: resource.extensions.to_json_object(),
334 })
335 }
336 Content::Unknown(unknown) => {
337 return Err(LowerError::FieldNotRepresentable {
338 field: unknown.type_name.clone(),
339 expected: "a 2024-11-05 content block",
340 detail: "this build does not model the block, and this revision has no \
341 member that could carry it"
342 .to_owned(),
343 target: ProtocolVersion::V2024_11_05,
344 });
345 }
346 Content::Audio(_) | Content::ResourceLink(_) => {
347 return Err(LowerError::FieldNotRepresentable {
348 field: "content".to_owned(),
349 expected: "a 2024-11-05 content block",
350 detail: "the gate admitted a block this revision cannot express".to_owned(),
351 target: ProtocolVersion::V2024_11_05,
352 });
353 }
354 };
355 Ok(lowered)
356}
357
358#[cfg(test)]
359mod tests {
360
361 type TestError = Box<dyn std::error::Error>;
367
368 use super::{V2024_11_05, lift_annotations, lower_annotations, wire};
369 use crate::domain::DegradePolicy;
370 use crate::domain::ir::Codec;
371 use crate::domain::ir::lower::{TranslationReport, lower_tool_result};
372 use crate::domain::ir::{
373 Annotations, Content, EmbeddedResource, ExtensionValue, ImageContent, JsonObject, Role,
374 TextContent, ToolResult, UnknownContent,
375 };
376
377 fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
385 let codec = V2024_11_05;
386 let wire: wire::CallToolResult = serde_json::from_str(json)?;
387 let mut report = TranslationReport::new(codec.version());
388 let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
389 Ok(serde_json::to_value(&lowered)?)
390 }
391
392 fn parsed(json: &str) -> Result<serde_json::Value, TestError> {
394 Ok(serde_json::from_str(json)?)
395 }
396
397 #[test]
403 fn a_result_round_trips_through_json() -> Result<(), TestError> {
404 for json in [
405 r#"{"content":[{"type":"text","text":"hello"}]}"#,
406 r#"{"content":[{"type":"text","text":"hi","annotations":{"audience":["user"],"priority":0.5}}]}"#,
407 r#"{"content":[{"type":"image","data":"AAA","mimeType":"image/png"}]}"#,
408 r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"body"}}]}"#,
409 r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","blob":"AAA"}}]}"#,
410 r#"{"content":[],"isError":true}"#,
411 ] {
412 assert_eq!(
413 round_trip(json)?,
414 parsed(json)?,
415 "did not survive the crossing"
416 );
417 }
418 Ok(())
419 }
420
421 #[test]
425 fn meta_survives_as_a_field_rather_than_a_bag_member() -> Result<(), TestError> {
426 let json = r#"{"_meta":{"progress":0.5},"content":[]}"#;
431 let codec = V2024_11_05;
432 let wire: wire::CallToolResult = serde_json::from_str(json)?;
433 let ir = codec.lift(&wire);
434 assert!(
435 ir.extensions
436 .contains(crate::adapters::secondary::codec::result::META_KEY)
437 );
438
439 let mut report = TranslationReport::new(codec.version());
440 let lowered = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
441 assert_eq!(lowered.meta, Some(serde_json::json!({"progress": 0.5})));
442 assert_eq!(serde_json::to_value(&lowered)?, parsed(json)?);
443 Ok(())
444 }
445
446 #[test]
453 fn unmodelled_annotations_are_returned_whole() -> Result<(), TestError> {
454 let json = r#"{"content":[{"type":"text","text":"x","annotations":{"priority":0.5,"unheardOf":true}}]}"#;
455 assert_eq!(round_trip(json)?, parsed(json)?);
456 Ok(())
457 }
458
459 #[test]
462 fn modelled_annotations_are_typed_in_the_ir() {
463 let value = serde_json::json!({"audience": ["user", "assistant"], "priority": 0.25});
464 let annotations = lift_annotations(&value);
465 assert_eq!(annotations.audience, vec![Role::User, Role::Assistant]);
466 assert_eq!(annotations.priority, Some(0.25));
467 assert_eq!(lower_annotations(Some(&annotations)), Some(value));
468 }
469
470 #[test]
474 fn an_unrecognised_member_is_kept_alongside_the_modelled_ones() {
475 let value = serde_json::json!({"priority": 0.5, "unheardOf": true});
476 let annotations = lift_annotations(&value);
477 assert_eq!(annotations.priority, Some(0.5));
478 assert_eq!(
479 annotations.extensions.get("unheardOf"),
480 Some(&ExtensionValue::Bool(true))
481 );
482 assert_eq!(lower_annotations(Some(&annotations)), Some(value));
483 }
484
485 #[test]
489 fn annotations_that_are_not_an_object_survive_whole() {
490 let value = serde_json::json!("just a string");
491 let annotations = lift_annotations(&value);
492 assert!(annotations.audience.is_empty());
493 assert_eq!(lower_annotations(Some(&annotations)), Some(value));
494 }
495
496 #[test]
499 fn priority_is_not_narrowed_on_the_way_through() -> Result<(), TestError> {
500 let json = r#"{"content":[{"type":"text","text":"x","annotations":{"priority":0.1}}]}"#;
501 assert_eq!(round_trip(json)?, parsed(json)?);
502 Ok(())
503 }
504
505 #[test]
511 fn an_unmodelled_block_is_refused_rather_than_dropped() -> Result<(), TestError> {
512 let codec = V2024_11_05;
513 let mut ir = ToolResult::new(vec![Content::Text(TextContent::new("x"))]);
514 ir.content.push(Content::Unknown(UnknownContent {
515 type_name: "video".to_owned(),
516 raw: JsonObject::new(r#"{"type":"video"}"#.to_owned())?,
517 extensions: crate::domain::ir::Extensions::empty(),
518 }));
519
520 let mut report = TranslationReport::new(codec.version());
521 let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
522 return Err("this must be refused".into());
523 };
524 assert!(
525 error.to_string().contains("video"),
526 "the error must name the block: {error}"
527 );
528 Ok(())
529 }
530
531 #[test]
535 fn audio_is_refused_by_the_gate_before_the_codec_sees_it() -> Result<(), TestError> {
536 let codec = V2024_11_05;
537 let ir = ToolResult::new(vec![Content::Audio(crate::domain::ir::AudioContent::new(
538 "AAA",
539 "audio/wav",
540 ))]);
541
542 let mut report = TranslationReport::new(codec.version());
543 let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
544 return Err("this must be refused".into());
545 };
546
547 let gate = lower_tool_result(
548 ToolResult::new(vec![Content::Audio(crate::domain::ir::AudioContent::new(
549 "AAA",
550 "audio/wav",
551 ))]),
552 &codec.capabilities(),
553 codec.version(),
554 DegradePolicy::strict(),
555 );
556 assert!(gate.is_err(), "the gate is what refuses, not the codec");
557 assert!(!error.to_string().is_empty());
558 Ok(())
559 }
560
561 #[test]
564 fn a_typed_result_survives_lift_and_lower() -> Result<(), TestError> {
565 let codec = V2024_11_05;
566 let ir = ToolResult::new(vec![
567 Content::Text(TextContent::new("hello")),
568 Content::Image(ImageContent::new("AAA", "image/png")),
569 Content::EmbeddedResource(EmbeddedResource::text("file:///a", "body")),
570 ]);
571
572 let mut report = TranslationReport::new(codec.version());
573 let wire = codec.lower(ir.clone(), DegradePolicy::strict(), &mut report)?;
574 assert!(report.is_empty());
575 assert_eq!(codec.lift(&wire), ir);
576 Ok(())
577 }
578
579 #[test]
583 fn absent_annotations_stay_absent() {
584 assert_eq!(lower_annotations(None), None);
585 assert_eq!(lower_annotations(Some(&Annotations::empty())), None);
586 }
587}