#![expect(
clippy::expect_used,
reason = "property tests assert on fallible calls; propagating would replace a shrunk counterexample with a generic error"
)]
use proptest::prelude::*;
use protocol_droid::adapters::secondary::codec::result::draft::Draft;
use protocol_droid::adapters::secondary::codec::result::v2024_11_05::V2024_11_05;
use protocol_droid::adapters::secondary::codec::result::v2025_03_26::V2025_03_26;
use protocol_droid::adapters::secondary::codec::result::v2025_06_18::V2025_06_18;
use protocol_droid::adapters::secondary::codec::result::v2025_11_25::V2025_11_25;
use protocol_droid::adapters::secondary::codec::result::v2026_07_28::V2026_07_28;
use protocol_droid::domain::ir::Codec;
use protocol_droid::domain::ir::lower::{LowerError, TranslationReport, check_expressible};
use protocol_droid::domain::ir::{
Annotations, AudioContent, Content, EmbeddedResource, ImageContent, JsonObject, ResourceIcon,
ResourceLinkContent, Role, TextContent, ToolResult,
};
use protocol_droid::domain::{
CapabilitySet, DegradePolicy, FeatureProfile, Negotiation, ProtocolVersion, TransportKind,
resolve,
};
const COMPLETE: &str = "complete";
const DRAFT_REVISION: ProtocolVersion = ProtocolVersion::V2026_07_28;
fn session(version: ProtocolVersion) -> FeatureProfile {
let caps = CapabilitySet::all_for(version);
resolve(Negotiation {
version,
client_caps: caps,
server_caps: caps,
transport: TransportKind::Stdio,
})
}
fn round_trip<C: Codec>(
codec: &C,
ir: ToolResult,
) -> Result<(ToolResult, TranslationReport), LowerError> {
let mut report = TranslationReport::new(codec.version());
let wire = codec.lower(ir, DegradePolicy::strict(), &mut report)?;
Ok((codec.lift(&wire), report))
}
fn round_trip_at(
version: ProtocolVersion,
ir: ToolResult,
) -> Result<(ToolResult, TranslationReport), LowerError> {
match version {
ProtocolVersion::V2024_11_05 => round_trip(&V2024_11_05, ir),
ProtocolVersion::V2025_03_26 => round_trip(&V2025_03_26, ir),
ProtocolVersion::V2025_06_18 => round_trip(&V2025_06_18, ir),
ProtocolVersion::V2025_11_25 => round_trip(&V2025_11_25, ir),
ProtocolVersion::V2026_07_28 | ProtocolVersion::Unknown(_) => round_trip(&V2026_07_28, ir),
}
}
fn canonical(mut ir: ToolResult, version: ProtocolVersion) -> ToolResult {
if version >= ProtocolVersion::V2026_07_28 && ir.result_type.is_none() {
ir.result_type = Some(COMPLETE.to_owned());
}
for block in &mut ir.content {
drop_empty_annotations(block);
}
ir
}
fn is_empty(annotations: &Annotations) -> bool {
annotations.audience.is_empty()
&& annotations.priority.is_none()
&& annotations.last_modified.is_none()
&& annotations.extensions.iter().next().is_none()
}
fn drop_empty_annotations(block: &mut Content) {
let slot = match block {
Content::Text(text) => &mut text.annotations,
Content::Image(image) => &mut image.annotations,
Content::Audio(audio) => &mut audio.annotations,
Content::ResourceLink(link) => &mut link.annotations,
Content::EmbeddedResource(resource) => &mut resource.annotations,
Content::Unknown(_) => return,
};
if slot.as_ref().is_some_and(is_empty) {
*slot = None;
}
}
fn any_text() -> Boxed<String> {
"[a-z0-9 _-]{1,12}".prop_map(String::from).boxed()
}
fn any_uri() -> Boxed<String> {
any_text()
.prop_map(|name| format!("file:///a-{name}"))
.boxed()
}
fn any_data() -> Boxed<String> {
"[A-Za-z0-9+/]{1,12}".prop_map(String::from).boxed()
}
fn any_annotations() -> Boxed<Annotations> {
(
prop::option::of(prop::sample::select(vec![Role::User, Role::Assistant])),
prop::option::of(0.0f64..=1.0),
)
.prop_map(|(role, priority)| Annotations {
audience: role.into_iter().collect(),
priority,
last_modified: None,
..Annotations::empty()
})
.boxed()
}
fn annotated(mut block: Content, annotations: Option<Annotations>) -> Content {
let slot = match &mut block {
Content::Text(text) => &mut text.annotations,
Content::Image(image) => &mut image.annotations,
Content::Audio(audio) => &mut audio.annotations,
Content::ResourceLink(link) => &mut link.annotations,
Content::EmbeddedResource(resource) => &mut resource.annotations,
Content::Unknown(_) => return block,
};
*slot = annotations;
block
}
fn any_link() -> Boxed<Content> {
let plain = (any_uri(), any_text(), prop::option::of(any_text()))
.boxed()
.prop_map(|(uri, name, description)| {
Content::ResourceLink(ResourceLinkContent {
description,
..ResourceLinkContent::new(uri, name)
})
})
.boxed();
let with_icons = (any_uri(), any_text())
.boxed()
.prop_map(|(uri, name)| {
Content::ResourceLink(ResourceLinkContent {
icons: vec![ResourceIcon::new(format!("{uri}.png"))],
..ResourceLinkContent::new(uri, name)
})
})
.boxed();
plain.prop_union(with_icons).boxed()
}
type Boxed<T> = BoxedStrategy<T>;
fn any_block() -> Boxed<Content> {
let bare: Boxed<Content> = prop_oneof![
any_text()
.prop_map(|t| Content::Text(TextContent::new(t)))
.boxed(),
any_data()
.prop_map(|d| Content::Image(ImageContent::new(d, "image/png")))
.boxed(),
any_data()
.prop_map(|d| Content::Audio(AudioContent::new(d, "audio/wav")))
.boxed(),
any_uri()
.prop_map(|u| Content::EmbeddedResource(EmbeddedResource::text(u, "body")))
.boxed(),
]
.boxed();
(bare, prop::option::of(any_annotations()))
.boxed()
.prop_map(|(block, annotations)| annotated(block, annotations))
.boxed()
.prop_union(any_link())
.boxed()
}
fn any_structured() -> Boxed<JsonObject> {
(any_text(), any_text())
.prop_map(|(key, value)| {
JsonObject::new(format!("{{{key:?}:{value:?}}}"))
.expect("a two-member object is valid JSON")
})
.boxed()
}
fn any_result() -> Boxed<ToolResult> {
(
prop::collection::vec(any_block(), 0..3),
prop::option::of(any_structured()),
any::<bool>(),
prop::option::of(prop::sample::select(vec![
"complete".to_owned(),
"partial".to_owned(),
])),
)
.prop_map(
|(content, structured_content, is_error, result_type)| ToolResult {
content,
structured_content,
is_error,
result_type,
..ToolResult::new(vec![])
},
)
.boxed()
}
fn any_revision() -> impl Strategy<Value = ProtocolVersion> {
prop::sample::select(vec![
ProtocolVersion::V2024_11_05,
ProtocolVersion::V2025_03_26,
ProtocolVersion::V2025_06_18,
ProtocolVersion::V2025_11_25,
ProtocolVersion::V2026_07_28,
])
}
fn expressible_pair() -> Boxed<(ProtocolVersion, ToolResult)> {
(any_revision(), any_result())
.boxed()
.prop_filter(
"the value must be expressible at the revision it is tested at",
|(version, ir)| {
check_expressible(&ir.required_features(), &session(*version), *version).is_ok()
},
)
.boxed()
}
fn expressible_at_draft() -> Boxed<ToolResult> {
any_result()
.boxed()
.prop_filter(
"the value must be expressible at the revision the draft tracks",
|ir| {
let version = DRAFT_REVISION;
check_expressible(&ir.required_features(), &session(version), version).is_ok()
},
)
.boxed()
}
proptest! {
#[test]
fn an_expressible_value_survives_its_own_revision(
(version, ir) in expressible_pair(),
) {
let (lifted, report) = round_trip_at(version, ir.clone())
.expect("the gate said this value fits, so lowering cannot refuse it");
prop_assert_eq!(report.steps().len(), 0, "a strict round trip degrades nothing");
prop_assert_eq!(lifted, canonical(ir, version));
}
#[test]
fn an_expressible_value_survives_the_draft(ir in expressible_at_draft()) {
let (lifted, report) = round_trip(&Draft, ir.clone())
.expect("the draft expresses everything 2026-07-28 does");
prop_assert_eq!(report.steps().len(), 0, "a strict round trip degrades nothing");
prop_assert_eq!(lifted, canonical(ir, DRAFT_REVISION));
}
#[test]
fn a_round_trip_reaches_a_fixed_point(
(version, ir) in expressible_pair(),
) {
let once = canonical(ir, version);
let (twice, _) = round_trip_at(version, once.clone())
.expect("a value that lowered once lowers again");
prop_assert_eq!(&twice, &once);
}
}
#[test]
fn draft_reports_the_revision_it_tracks() {
assert_eq!(Draft.version(), DRAFT_REVISION);
}
#[test]
fn every_revision_has_a_codec_named_for_it() {
let expected = [
(ProtocolVersion::V2024_11_05, V2024_11_05.version()),
(ProtocolVersion::V2025_03_26, V2025_03_26.version()),
(ProtocolVersion::V2025_06_18, V2025_06_18.version()),
(ProtocolVersion::V2025_11_25, V2025_11_25.version()),
(ProtocolVersion::V2026_07_28, V2026_07_28.version()),
];
for (revision, reported) in expected {
assert_eq!(revision, reported, "a codec must report its own revision");
}
}