protocol_droid/adapters/secondary/codec/result/
by_version.rs1use protocol_droid_schema::wire::{
41 v2024_11_05 as schema_2024_11_05, v2025_03_26 as schema_2025_03_26,
42 v2025_06_18 as schema_2025_06_18, v2025_11_25 as schema_2025_11_25,
43 v2026_07_28 as schema_2026_07_28,
44};
45use serde::Serialize;
46use serde::de::DeserializeOwned;
47use serde_json::Value;
48
49use super::{
50 v2024_11_05::V2024_11_05, v2025_03_26::V2025_03_26, v2025_06_18::V2025_06_18,
51 v2025_11_25::V2025_11_25, v2026_07_28::V2026_07_28,
52};
53use crate::application::bridge::ToolResultCodec;
54use crate::domain::ir::{Codec, LowerError, ToolResult, TranslationReport};
55use crate::domain::{DegradePolicy, ProtocolVersion};
56
57#[derive(Debug, Clone, Copy, Default)]
62pub struct WireToolResults;
63
64fn read<T: DeserializeOwned>(wire: &Value) -> Option<T> {
72 serde_json::from_value::<T>(wire.clone()).ok()
73}
74
75fn encode(version: ProtocolVersion, wire: impl Serialize) -> Result<Value, LowerError> {
76 serde_json::to_value(wire).map_err(|error| LowerError::FieldNotRepresentable {
77 field: "result".to_owned(),
78 expected: "a result that serialises",
79 detail: error.to_string(),
80 target: version,
81 })
82}
83
84impl ToolResultCodec for WireToolResults {
85 fn lift(&self, version: ProtocolVersion, wire: &Value) -> Option<ToolResult> {
89 match version {
90 ProtocolVersion::V2024_11_05 => read::<schema_2024_11_05::CallToolResult>(wire)
91 .map(|typed| V2024_11_05.lift(&typed)),
92 ProtocolVersion::V2025_03_26 => read::<schema_2025_03_26::CallToolResult>(wire)
93 .map(|typed| V2025_03_26.lift(&typed)),
94 ProtocolVersion::V2025_06_18 => read::<schema_2025_06_18::CallToolResult>(wire)
95 .map(|typed| V2025_06_18.lift(&typed)),
96 ProtocolVersion::V2025_11_25 => read::<schema_2025_11_25::CallToolResult>(wire)
97 .map(|typed| V2025_11_25.lift(&typed)),
98 ProtocolVersion::V2026_07_28 => read::<schema_2026_07_28::CallToolResult>(wire)
99 .map(|typed| V2026_07_28.lift(&typed)),
100 ProtocolVersion::Unknown(_) => None,
111 }
112 }
113
114 fn lower(
115 &self,
116 version: ProtocolVersion,
117 value: ToolResult,
118 policy: DegradePolicy,
119 report: &mut TranslationReport,
120 ) -> Result<Value, LowerError> {
121 match version {
122 ProtocolVersion::V2024_11_05 => {
123 encode(version, V2024_11_05.lower(value, policy, report)?)
124 }
125 ProtocolVersion::V2025_03_26 => {
126 encode(version, V2025_03_26.lower(value, policy, report)?)
127 }
128 ProtocolVersion::V2025_06_18 => {
129 encode(version, V2025_06_18.lower(value, policy, report)?)
130 }
131 ProtocolVersion::V2025_11_25 => {
132 encode(version, V2025_11_25.lower(value, policy, report)?)
133 }
134 ProtocolVersion::V2026_07_28 => {
135 encode(version, V2026_07_28.lower(value, policy, report)?)
136 }
137 ProtocolVersion::Unknown(_) => Err(LowerError::FieldNotRepresentable {
142 field: "result".to_owned(),
143 expected: "a revision this build has generated wire types for",
144 detail: format!(
145 "{version} has no schema vendored here, so there is no \
146 CallToolResult shape to write the value into"
147 ),
148 target: version,
149 }),
150 }
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use serde_json::json;
157
158 use super::WireToolResults;
159 use crate::application::bridge::ToolResultCodec;
160 use crate::domain::ir::lower::check_expressible;
161 use crate::domain::ir::{LowerError, ToolResult, TranslationReport};
162 use crate::domain::{
163 CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
164 resolve,
165 };
166
167 type TestError = Box<dyn std::error::Error>;
168
169 fn vendored() -> Vec<ProtocolVersion> {
172 vec![
173 ProtocolVersion::V2024_11_05,
174 ProtocolVersion::V2025_03_26,
175 ProtocolVersion::V2025_06_18,
176 ProtocolVersion::V2025_11_25,
177 ProtocolVersion::V2026_07_28,
178 ]
179 }
180
181 fn plain() -> ToolResult {
184 ToolResult::text("hello")
185 }
186
187 #[test]
190 fn every_vendored_revision_reads_back_what_it_wrote() -> Result<(), TestError> {
191 let codec = WireToolResults;
192 let value = plain();
193
194 for version in vendored() {
195 let mut report = TranslationReport::new(version);
196 let wire = codec.lower(
197 version,
198 value.clone(),
199 DegradePolicy::permissive(),
200 &mut report,
201 )?;
202 let back = codec
203 .lift(version, &wire)
204 .ok_or("the codec refused to read back its own output")?;
205
206 assert_eq!(back.content, value.content, "content differs at {version}");
207 assert_eq!(
208 back.is_error, value.is_error,
209 "isError differs at {version}"
210 );
211 assert!(
212 report.is_empty(),
213 "{version} downgraded a plain text result"
214 );
215 }
216 Ok(())
217 }
218
219 #[test]
222 fn a_block_this_build_cannot_read_lifts_to_nothing() {
223 let codec = WireToolResults;
224 let wire = json!({
225 "content": [{ "type": "annotation", "audience": ["assistant"] }]
226 });
227 assert_eq!(codec.lift(ProtocolVersion::V2026_07_28, &wire), None);
228 }
229
230 #[test]
232 fn a_payload_that_is_not_a_result_lifts_to_nothing() {
233 let codec = WireToolResults;
234 let wire = json!([{ "name": "echo" }]);
235 assert_eq!(codec.lift(ProtocolVersion::V2025_03_26, &wire), None);
236 }
237
238 #[test]
243 fn an_unknown_revision_is_refused_rather_than_guessed() -> Result<(), TestError> {
244 let version = ProtocolVersion::Unknown("2027-01-01".parse()?);
245 let codec = WireToolResults;
246
247 assert_eq!(
248 codec.lift(version, &json!({ "content": [] })),
249 None,
250 "an unknown revision is forwarded, not read"
251 );
252
253 let mut report = TranslationReport::new(version);
254 let outcome = codec.lower(version, plain(), DegradePolicy::permissive(), &mut report);
255
256 assert!(
257 matches!(outcome, Err(LowerError::FieldNotRepresentable { .. })),
258 "an unknown revision must refuse rather than fabricate a shape, got {outcome:?}"
259 );
260 Ok(())
261 }
262
263 #[test]
268 fn the_published_revision_answers_for_its_version() -> Result<(), TestError> {
269 let version = ProtocolVersion::V2026_07_28;
270 let caps = CapabilitySet::all_for(version);
271 let profile = resolve(Negotiation {
272 version,
273 client_caps: caps,
274 server_caps: caps,
275 transport: TransportKind::Stdio,
276 });
277
278 let value = ToolResult {
281 result_type: Some("search".to_owned()),
282 ..ToolResult::text("hello")
283 };
284 assert!(
285 check_expressible(&value.required_features(), &profile, version).is_ok(),
286 "the fixture must be expressible at its own revision"
287 );
288
289 let mut report = TranslationReport::new(version);
290 let wire = WireToolResults.lower(version, value, DegradePolicy::strict(), &mut report)?;
291
292 assert_eq!(
293 wire.get("resultType"),
294 Some(&json!("search")),
295 "the newest schema must have been used"
296 );
297 Ok(())
298 }
299
300 #[test]
303 fn draft_is_still_a_working_codec() -> Result<(), TestError> {
304 let mut report = TranslationReport::new(ProtocolVersion::V2026_07_28);
305 let wire = WireToolResults.lower(
306 ProtocolVersion::V2026_07_28,
307 plain(),
308 DegradePolicy::strict(),
309 &mut report,
310 )?;
311 let back = WireToolResults.lift(ProtocolVersion::V2026_07_28, &wire);
312 assert!(back.is_some(), "the published revision must round-trip");
313 Ok(())
314 }
315
316 #[test]
319 fn a_profile_with_no_features_expresses_only_what_needs_nothing() {
320 assert!(
321 check_expressible(
322 &plain().required_features(),
323 &FeatureProfile::empty(),
324 ProtocolVersion::V2024_11_05,
325 )
326 .is_ok()
327 );
328 }
329}