1use protocol_droid_schema::wire::v2025_03_26 as wire;
23
24use super::{AUDIO_TYPE, IMAGE_TYPE, META_KEY, RESOURCE_TYPE, TEXT_TYPE};
25use crate::domain::ir::Codec;
26use crate::domain::ir::lower::{LowerError, TranslationReport, lower_tool_result};
27use crate::domain::ir::{
28 Annotations, AudioContent, Content, EmbeddedResource, ExtensionValue, Extensions, ImageContent,
29 Role, TextContent, ToolResult,
30};
31use crate::domain::{
32 CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
33 resolve,
34};
35
36fn lift_annotations(annotations: &wire::Annotations) -> Annotations {
42 Annotations {
43 audience: annotations
44 .audience
45 .iter()
46 .flatten()
47 .map(|role| match role {
48 wire::Role::ASSISTANT => Role::Assistant,
49 wire::Role::USER => Role::User,
50 })
51 .collect(),
52 priority: annotations.priority,
53 last_modified: None,
57 extensions: Extensions::from_json_object(annotations.extra.clone()),
58 }
59}
60
61fn lower_annotations(
70 annotations: Option<&Annotations>,
71) -> Result<Option<wire::Annotations>, LowerError> {
72 let Some(annotations) = annotations else {
73 return Ok(None);
74 };
75 if annotations.last_modified.is_some() {
76 return Err(LowerError::FieldNotRepresentable {
77 field: "annotations.lastModified".to_owned(),
78 expected: "not a field of 2025-03-26 annotations",
79 detail: "the value carries `lastModified`, which this revision's \
80 `Annotations` struct has no member for"
81 .to_owned(),
82 target: ProtocolVersion::V2025_03_26,
83 });
84 }
85
86 let extra = annotations.extensions.to_json_object();
87 if annotations.audience.is_empty() && annotations.priority.is_none() && extra.is_empty() {
88 return Ok(None);
89 }
90 Ok(Some(wire::Annotations {
91 audience: (!annotations.audience.is_empty()).then(|| {
92 annotations
93 .audience
94 .iter()
95 .map(|role| match role {
96 Role::Assistant => wire::Role::ASSISTANT,
97 Role::User => wire::Role::USER,
98 })
99 .collect()
100 }),
101 priority: annotations.priority,
102 extra,
103 }))
104}
105
106#[derive(Debug, Clone, Copy)]
108pub struct V2025_03_26;
109
110impl Codec for V2025_03_26 {
111 type Wire = wire::CallToolResult;
112
113 fn version(&self) -> ProtocolVersion {
114 ProtocolVersion::V2025_03_26
115 }
116
117 fn capabilities(&self) -> FeatureProfile {
118 let caps = CapabilitySet::all_for(self.version());
119 resolve(Negotiation {
120 version: self.version(),
121 client_caps: caps,
122 server_caps: caps,
123 transport: TransportKind::Stdio,
124 })
125 }
126
127 fn lift(&self, wire: &Self::Wire) -> ToolResult {
128 let mut extensions = Extensions::from_json_object(wire.extra.clone());
129 if let Some(meta) = &wire.meta {
130 extensions.insert(META_KEY, ExtensionValue::from(meta.clone()));
131 }
132 ToolResult {
133 content: wire.content.iter().map(lift_block).collect(),
134 structured_content: None,
138 is_error: wire.is_error.unwrap_or(false),
139 result_type: None,
140 extensions,
141 }
142 }
143
144 fn lower(
145 &self,
146 value: ToolResult,
147 policy: DegradePolicy,
148 report: &mut TranslationReport,
149 ) -> Result<Self::Wire, LowerError> {
150 let (value, lowered) =
151 lower_tool_result(value, &self.capabilities(), self.version(), policy)?;
152 report.absorb(&lowered);
153 Ok(wire::CallToolResult {
154 meta: value.extensions.get(META_KEY).map(serde_json::Value::from),
155 content: value
156 .content
157 .iter()
158 .map(lower_block)
159 .collect::<Result<Vec<_>, _>>()?,
160 is_error: value.is_error.then_some(true),
161 extra: value.extensions.to_json_object(),
162 })
163 }
164}
165
166fn lift_block(block: &wire::CallToolResultContentChoice) -> Content {
167 match block {
168 wire::CallToolResultContentChoice::Member0(text) => Content::Text(TextContent {
169 text: text.text.clone(),
170 annotations: text.annotations.as_ref().map(lift_annotations),
171 extensions: Extensions::from_json_object(text.extra.clone()),
172 }),
173 wire::CallToolResultContentChoice::Member1(image) => Content::Image(ImageContent {
174 data: image.data.clone(),
175 mime_type: image.mime_type.clone(),
176 annotations: image.annotations.as_ref().map(lift_annotations),
177 extensions: Extensions::from_json_object(image.extra.clone()),
178 }),
179 wire::CallToolResultContentChoice::Member2(audio) => Content::Audio(AudioContent {
180 data: audio.data.clone(),
181 mime_type: audio.mime_type.clone(),
182 annotations: audio.annotations.as_ref().map(lift_annotations),
183 extensions: Extensions::from_json_object(audio.extra.clone()),
184 }),
185 wire::CallToolResultContentChoice::Member3(resource) => {
186 let wire::EmbeddedResource {
187 annotations,
188 resource,
189 extra,
190 ..
191 } = resource;
192 let (uri, mime_type, text, blob) = match resource {
193 wire::EmbeddedResourceResourceChoice::Member0(text) => (
194 text.uri.clone(),
195 text.mime_type.clone(),
196 Some(text.text.clone()),
197 None,
198 ),
199 wire::EmbeddedResourceResourceChoice::Member1(blob) => (
200 blob.uri.clone(),
201 blob.mime_type.clone(),
202 None,
203 Some(blob.blob.clone()),
204 ),
205 };
206 Content::EmbeddedResource(EmbeddedResource {
207 uri,
208 mime_type,
209 text,
210 blob,
211 annotations: annotations.as_ref().map(lift_annotations),
212 extensions: Extensions::from_json_object(extra.clone()),
213 })
214 }
215 }
216}
217
218fn lower_block(block: &Content) -> Result<wire::CallToolResultContentChoice, LowerError> {
219 let lowered = match block {
220 Content::Text(text) => wire::CallToolResultContentChoice::Member0(wire::TextContent {
221 annotations: lower_annotations(text.annotations.as_ref())?,
222 text: text.text.clone(),
223 r#type: TEXT_TYPE.to_owned(),
224 extra: text.extensions.to_json_object(),
225 }),
226 Content::Image(image) => wire::CallToolResultContentChoice::Member1(wire::ImageContent {
227 annotations: lower_annotations(image.annotations.as_ref())?,
228 data: image.data.clone(),
229 mime_type: image.mime_type.clone(),
230 r#type: IMAGE_TYPE.to_owned(),
231 extra: image.extensions.to_json_object(),
232 }),
233 Content::Audio(audio) => wire::CallToolResultContentChoice::Member2(wire::AudioContent {
234 annotations: lower_annotations(audio.annotations.as_ref())?,
235 data: audio.data.clone(),
236 mime_type: audio.mime_type.clone(),
237 r#type: AUDIO_TYPE.to_owned(),
238 extra: audio.extensions.to_json_object(),
239 }),
240 Content::EmbeddedResource(resource) => {
241 let contents = if let Some(text) = &resource.text {
242 wire::EmbeddedResourceResourceChoice::Member0(wire::TextResourceContents {
243 mime_type: resource.mime_type.clone(),
244 text: text.clone(),
245 uri: resource.uri.clone(),
246 extra: serde_json::Map::new(),
247 })
248 } else if let Some(blob) = &resource.blob {
249 wire::EmbeddedResourceResourceChoice::Member1(wire::BlobResourceContents {
250 blob: blob.clone(),
251 mime_type: resource.mime_type.clone(),
252 uri: resource.uri.clone(),
253 extra: serde_json::Map::new(),
254 })
255 } else {
256 return Err(LowerError::FieldNotRepresentable {
257 field: "resource".to_owned(),
258 expected: "a text or blob body",
259 detail: "the resource has neither `text` nor `blob`".to_owned(),
260 target: ProtocolVersion::V2025_03_26,
261 });
262 };
263 wire::CallToolResultContentChoice::Member3(wire::EmbeddedResource {
264 annotations: lower_annotations(resource.annotations.as_ref())?,
265 resource: contents,
266 r#type: RESOURCE_TYPE.to_owned(),
267 extra: resource.extensions.to_json_object(),
268 })
269 }
270 Content::Unknown(unknown) => {
271 return Err(LowerError::FieldNotRepresentable {
272 field: unknown.type_name.clone(),
273 expected: "a 2025-03-26 content block",
274 detail: "this build does not model the block, and this revision has no \
275 member that could carry it"
276 .to_owned(),
277 target: ProtocolVersion::V2025_03_26,
278 });
279 }
280 Content::ResourceLink(_) => {
281 return Err(LowerError::FieldNotRepresentable {
282 field: "content".to_owned(),
283 expected: "a 2025-03-26 content block",
284 detail: "resource links arrive at 2025-06-18".to_owned(),
285 target: ProtocolVersion::V2025_03_26,
286 });
287 }
288 };
289 Ok(lowered)
290}
291
292#[cfg(test)]
293mod tests {
294
295 type TestError = Box<dyn std::error::Error>;
301
302 use super::V2025_03_26;
303 use std::assert_matches;
304
305 use crate::domain::DegradePolicy;
306 use crate::domain::ir::Codec;
307 use crate::domain::ir::lower::{LowerError, TranslationReport};
308 use crate::domain::ir::{Content, Role, ToolResult};
309 use crate::domain::{Feature, ProtocolVersion};
310
311 fn round_trip(json: &str) -> Result<serde_json::Value, TestError> {
312 let codec = V2025_03_26;
313 let wire: super::wire::CallToolResult = serde_json::from_str(json)?;
314 let mut report = TranslationReport::new(codec.version());
315 let lowered = codec.lower(codec.lift(&wire), DegradePolicy::strict(), &mut report)?;
316 Ok(serde_json::to_value(&lowered)?)
317 }
318
319 #[test]
340 fn audio_and_image_land_in_different_arms() -> Result<(), TestError> {
341 let codec = V2025_03_26;
342 let audio: super::wire::CallToolResult = serde_json::from_str(
343 r#"{"content":[{"type":"audio","data":"AAA","mimeType":"audio/wav"}]}"#,
344 )?;
345 let image: super::wire::CallToolResult = serde_json::from_str(
346 r#"{"content":[{"type":"image","data":"AAA","mimeType":"image/png"}]}"#,
347 )?;
348
349 assert!(matches!(
350 codec.lift(&audio).content.first().ok_or("no audio block")?,
351 Content::Audio(_)
352 ));
353 assert!(matches!(
354 codec.lift(&image).content.first().ok_or("no image block")?,
355 Content::Image(_)
356 ));
357 Ok(())
358 }
359
360 #[test]
361 fn a_result_round_trips_through_json() -> Result<(), TestError> {
362 for json in [
363 r#"{"content":[{"type":"text","text":"hello"}]}"#,
364 r#"{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"body"}}]}"#,
365 r#"{"content":[{"type":"text","text":"x","annotations":{"audience":["user"],"priority":0.5}}]}"#,
366 r#"{"content":[],"isError":true}"#,
367 ] {
368 assert_eq!(
369 round_trip(json)?,
370 serde_json::from_str::<serde_json::Value>(json)?,
371 "did not survive the crossing"
372 );
373 }
374 Ok(())
375 }
376
377 #[test]
380 fn an_unmodelled_annotation_member_survives_the_structs_extra_bag() -> Result<(), TestError> {
381 let json = r#"{"content":[{"type":"text","text":"x","annotations":{"audience":["user"],"unheardOf":true}}]}"#;
382 assert_eq!(
383 round_trip(json)?,
384 serde_json::from_str::<serde_json::Value>(json)?
385 );
386 Ok(())
387 }
388
389 #[test]
402 fn last_modified_is_refused_by_the_gate() -> Result<(), TestError> {
403 let codec = V2025_03_26;
404 let ir = ToolResult::new(vec![Content::Text(crate::domain::ir::TextContent {
405 text: "x".to_owned(),
406 annotations: Some(crate::domain::ir::Annotations {
407 last_modified: Some("2025-06-18T00:00:00Z".to_owned()),
408 audience: vec![Role::User],
409 ..crate::domain::ir::Annotations::empty()
410 }),
411 extensions: crate::domain::ir::Extensions::empty(),
412 })]);
413
414 let mut report = TranslationReport::new(codec.version());
415 let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
416 return Err("this must be refused".into());
417 };
418 assert_matches!(
419 error,
420 LowerError::FeatureUnavailable {
421 feature: Feature::ToolAnnotationsLastModified,
422 negotiated: ProtocolVersion::V2025_03_26,
423 }
424 );
425 Ok(())
426 }
427
428 #[test]
431 fn structured_content_is_refused_by_the_gate() -> Result<(), TestError> {
432 let codec = V2025_03_26;
433 let ir = ToolResult::new(vec![Content::Text(crate::domain::ir::TextContent::new(
434 "x",
435 ))])
436 .with_structured(r#"{"a":1}"#)?;
437
438 let mut report = TranslationReport::new(codec.version());
439 let Err(error) = codec.lower(ir, DegradePolicy::strict(), &mut report) else {
440 return Err("this must be refused".into());
441 };
442 assert!(!error.to_string().is_empty());
443 Ok(())
444 }
445}