use crate::domain::Feature;
use crate::domain::ir::content::{Content, TextContent};
use crate::domain::ir::extensions::Extensions;
use crate::domain::ir::features::FeatureSet;
use crate::domain::ir::json::JsonObject;
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ToolResult {
pub content: Vec<Content>,
pub structured_content: Option<JsonObject>,
pub is_error: bool,
pub result_type: Option<String>,
pub extensions: Extensions,
}
impl ToolResult {
#[must_use]
pub const fn new(content: Vec<Content>) -> Self {
Self {
content,
structured_content: None,
is_error: false,
result_type: None,
extensions: Extensions::empty(),
}
}
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self::new(vec![Content::Text(TextContent::new(text))])
}
pub fn with_structured(
mut self,
json: impl Into<String>,
) -> Result<Self, crate::domain::ir::JsonObjectError> {
self.structured_content = Some(JsonObject::new(json)?);
Ok(self)
}
#[must_use]
pub const fn error(mut self) -> Self {
self.is_error = true;
self
}
#[must_use]
pub fn required_features(&self) -> FeatureSet {
let mut required = FeatureSet::empty();
for block in &self.content {
required = required.union(block.required_features());
}
if self.structured_content.is_some() {
required = required.union(FeatureSet::empty().with(Feature::ToolStructuredOutput));
}
if self.result_type.is_some() {
required = required.union(FeatureSet::empty().with(Feature::ToolResultType));
}
required
}
#[must_use]
pub fn is_lossless_anywhere(&self) -> bool {
self.required_features().is_empty()
}
#[must_use]
pub fn downgrade_structured_output(&mut self) -> Option<Downgrade> {
let structured = self.structured_content.take()?;
let json = structured.into_string();
self.content.push(Content::Text(TextContent::new(json)));
Some(Downgrade {
lost: Feature::ToolStructuredOutput,
note: "structuredContent serialised into a text content block",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Downgrade {
pub lost: Feature,
pub note: &'static str,
}
#[cfg(test)]
mod tests {
#![expect(
clippy::panic,
clippy::unwrap_used,
reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
)]
use super::ToolResult;
use crate::domain::Feature;
use crate::domain::ir::content::{AudioContent, Content, ImageContent, TextContent};
#[test]
fn a_plain_text_result_needs_nothing() {
let result = ToolResult::text("hello");
assert!(result.required_features().is_empty());
assert!(result.is_lossless_anywhere());
assert!(!result.is_error);
}
#[test]
fn structured_output_needs_exactly_one_feature() {
let result = ToolResult::text("x").with_structured(r#"{"a":1}"#).unwrap();
let required = result.required_features();
assert!(required.contains(Feature::ToolStructuredOutput));
assert_eq!(required.len(), 1);
assert!(!result.is_lossless_anywhere());
}
#[test]
fn requirements_union_across_blocks_and_fields() {
let result = ToolResult::new(vec![
Content::Text(TextContent::new("x")),
Content::Image(ImageContent::new("A", "image/png")),
Content::Audio(AudioContent::new("A", "audio/wav")),
])
.with_structured(r#"{"a":1}"#)
.unwrap();
let required = result.required_features();
assert_eq!(required.len(), 2);
assert!(required.contains(Feature::ToolAudioContent));
assert!(required.contains(Feature::ToolStructuredOutput));
}
#[test]
fn duplicates_of_the_same_block_need_only_one_feature() {
let result = ToolResult::new(vec![
Content::Audio(AudioContent::new("A", "audio/wav")),
Content::Audio(AudioContent::new("B", "audio/wav")),
Content::Audio(AudioContent::new("C", "audio/wav")),
]);
assert_eq!(result.required_features().len(), 1);
}
#[test]
fn structured_output_downlevel_scenario() {
let source = ToolResult::new(vec![Content::Text(TextContent::new("ok"))])
.with_structured(r#"{"temperature":0.7,"unit":"c"}"#)
.unwrap();
assert!(
source
.required_features()
.contains(Feature::ToolStructuredOutput)
);
let mut result = source;
let downgrade = result.downgrade_structured_output().unwrap();
assert_eq!(downgrade.lost, Feature::ToolStructuredOutput);
assert!(!downgrade.note.is_empty());
assert!(result.structured_content.is_none());
assert_eq!(result.content.len(), 2);
let Some(Content::Text(added)) = result.content.get(1) else {
panic!("expected an appended text block");
};
assert_eq!(added.text, r#"{"temperature":0.7,"unit":"c"}"#);
assert!(result.is_lossless_anywhere());
}
#[test]
fn a_result_with_nothing_to_downgrade_reports_nothing() {
assert!(
ToolResult::text("x")
.downgrade_structured_output()
.is_none()
);
}
#[test]
fn downgrading_twice_appends_once() {
let mut once = ToolResult::text("x").with_structured(r#"{"a":1}"#).unwrap();
assert!(once.downgrade_structured_output().is_some());
assert!(once.downgrade_structured_output().is_none());
assert_eq!(once.content.len(), 2);
}
#[test]
fn the_error_flag_survives_a_downgrade() {
let mut failed = ToolResult::text("boom")
.error()
.with_structured(r#"{"e":1}"#)
.unwrap();
assert!(failed.downgrade_structured_output().is_some());
assert!(failed.is_error);
}
#[test]
fn the_appended_text_is_byte_identical() {
let json = r#"{ "unit" : "c" , "n" : 1.0 }"#;
let mut downgraded = ToolResult::text("x").with_structured(json).unwrap();
assert!(downgraded.downgrade_structured_output().is_some());
let Some(Content::Text(added)) = downgraded.content.get(1) else {
panic!("expected an appended text block");
};
assert_eq!(added.text, json);
}
#[test]
fn a_non_object_is_refused_at_construction() {
assert!(ToolResult::text("x").with_structured("[]").is_err());
assert!(ToolResult::text("x").with_structured("{oops").is_err());
}
}
#[test]
fn a_result_type_raises_the_requirement() {
let mut result = ToolResult::text("done");
assert!(!result.required_features().contains(Feature::ToolResultType));
result.result_type = Some("text".to_owned());
assert!(result.required_features().contains(Feature::ToolResultType));
}
#[test]
fn result_type_is_optional_in_the_ir_though_required_on_2026_07_28() {
assert!(ToolResult::text("done").result_type.is_none());
let mut result = ToolResult::text("done");
result.result_type = Some("json".to_owned());
assert_eq!(result.result_type.as_deref(), Some("json"));
}