Skip to main content

act_sdk/
response.rs

1use crate::context::RawToolEvent;
2
3/// Trait for types that can be converted into stream events.
4///
5/// Specific impls use a by-value `self` receiver so that method resolution
6/// prefers them over the [`IntoToolResponseViaSerialize`] blanket (which uses
7/// `&self`). This is the stable "autoref specialization" pattern.
8///
9/// To return a custom content type, implement this trait on your type with a
10/// by-value receiver. Do NOT implement [`IntoToolResponseViaSerialize`] — the
11/// blanket impl already covers every [`serde::Serialize`] type (→ CBOR).
12pub trait IntoToolResponse {
13    fn into_tool_response(self, default_language: &str) -> Vec<RawToolEvent>;
14}
15
16impl IntoToolResponse for String {
17    fn into_tool_response(self, _default_language: &str) -> Vec<RawToolEvent> {
18        vec![RawToolEvent::Content {
19            data: self.into_bytes(),
20            mime_type: Some(crate::constants::MIME_TEXT.to_string()),
21            metadata: vec![],
22        }]
23    }
24}
25
26impl IntoToolResponse for &str {
27    fn into_tool_response(self, default_language: &str) -> Vec<RawToolEvent> {
28        self.to_string().into_tool_response(default_language)
29    }
30}
31
32impl IntoToolResponse for () {
33    fn into_tool_response(self, _default_language: &str) -> Vec<RawToolEvent> {
34        vec![]
35    }
36}
37
38impl IntoToolResponse for Vec<u8> {
39    fn into_tool_response(self, _default_language: &str) -> Vec<RawToolEvent> {
40        vec![RawToolEvent::Content {
41            data: self,
42            mime_type: Some(crate::constants::MIME_OCTET_STREAM.to_string()),
43            metadata: vec![],
44        }]
45    }
46}
47
48/// Wrapper that serializes the inner value as JSON with `application/json` MIME type.
49pub struct Json<T>(pub T);
50
51impl<T: serde::Serialize> IntoToolResponse for Json<T> {
52    fn into_tool_response(self, _default_language: &str) -> Vec<RawToolEvent> {
53        vec![RawToolEvent::Content {
54            data: serde_json::to_vec(&self.0).unwrap_or_default(),
55            mime_type: Some(crate::constants::MIME_JSON.to_string()),
56            metadata: vec![],
57        }]
58    }
59}
60
61/// Wrapper for returning content with an explicit MIME type.
62///
63/// The first field is the MIME type string, the second is the raw data.
64pub struct Content(pub &'static str, pub Vec<u8>);
65
66impl IntoToolResponse for Content {
67    fn into_tool_response(self, _default_language: &str) -> Vec<RawToolEvent> {
68        vec![RawToolEvent::Content {
69            data: self.1,
70            mime_type: Some(self.0.to_string()),
71            metadata: vec![],
72        }]
73    }
74}
75
76/// Autoref-specialization fallback: any `Serialize` value that has no specific
77/// `IntoToolResponse` impl is CBOR-encoded. The `&self` receiver makes method
78/// resolution prefer a by-value `IntoToolResponse` impl when one exists.
79#[doc(hidden)]
80pub trait IntoToolResponseViaSerialize {
81    #[allow(clippy::wrong_self_convention)]
82    fn into_tool_response(&self, default_language: &str) -> Vec<RawToolEvent>;
83}
84
85impl<T: serde::Serialize> IntoToolResponseViaSerialize for T {
86    fn into_tool_response(&self, default_language: &str) -> Vec<RawToolEvent> {
87        cbor_encode_response(self, default_language)
88    }
89}
90
91/// Encode a serializable value as CBOR and wrap it as a stream event.
92///
93/// Called by [`IntoToolResponseViaSerialize`]'s blanket impl and available
94/// as a direct helper for generated code.
95pub fn cbor_encode_response<T: serde::Serialize>(
96    val: &T,
97    _default_language: &str,
98) -> Vec<RawToolEvent> {
99    let mut buf = Vec::new();
100    ciborium::into_writer(val, &mut buf).expect("CBOR serialization should not fail");
101    vec![RawToolEvent::Content {
102        data: buf,
103        mime_type: Some(crate::constants::MIME_CBOR.to_string()),
104        metadata: vec![],
105    }]
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use serde_json::json;
112
113    fn extract(events: Vec<RawToolEvent>) -> (Vec<u8>, Option<String>) {
114        match events.into_iter().next().unwrap() {
115            RawToolEvent::Content {
116                data, mime_type, ..
117            } => (data, mime_type),
118            _ => panic!("expected Content event"),
119        }
120    }
121
122    #[test]
123    fn string_is_text_plain() {
124        let (data, mime) = extract("hello".to_string().into_tool_response("en"));
125        assert_eq!(mime.as_deref(), Some(crate::constants::MIME_TEXT));
126        assert_eq!(data, b"hello");
127    }
128
129    #[test]
130    fn str_ref_is_text_plain() {
131        let s: &str = "hello";
132        let (data, mime) = extract(s.into_tool_response("en"));
133        assert_eq!(mime.as_deref(), Some(crate::constants::MIME_TEXT));
134        assert_eq!(data, b"hello");
135    }
136
137    #[test]
138    fn unit_is_empty() {
139        assert!(().into_tool_response("en").is_empty());
140    }
141
142    #[test]
143    fn vec_u8_is_octet_stream() {
144        let (_d, mime) = extract(vec![1u8, 2, 3].into_tool_response("en"));
145        assert_eq!(mime.as_deref(), Some(crate::constants::MIME_OCTET_STREAM));
146    }
147
148    #[test]
149    fn content_keeps_explicit_mime() {
150        let (data, mime) = extract(Content("image/png", vec![0x89]).into_tool_response("en"));
151        assert_eq!(mime.as_deref(), Some("image/png"));
152        assert_eq!(data, vec![0x89]);
153    }
154
155    #[test]
156    fn json_wrapper_is_json_mime() {
157        let value = json!({"rows": [1, 2, 3]});
158        let (data, mime) = extract(Json(value.clone()).into_tool_response("en"));
159        assert_eq!(mime.as_deref(), Some(crate::constants::MIME_JSON));
160        let parsed: serde_json::Value = serde_json::from_slice(&data).unwrap();
161        assert_eq!(parsed, value);
162    }
163
164    #[test]
165    fn serialize_struct_falls_back_to_cbor() {
166        #[derive(serde::Serialize)]
167        struct S {
168            n: u32,
169        }
170        let (data, mime) = extract(S { n: 7 }.into_tool_response("en"));
171        assert_eq!(mime.as_deref(), Some(crate::constants::MIME_CBOR));
172        let v: ciborium::value::Value = ciborium::from_reader(&data[..]).unwrap();
173        assert_eq!(
174            v,
175            ciborium::value::Value::Map(vec![(
176                ciborium::value::Value::Text("n".into()),
177                ciborium::value::Value::Integer(7.into())
178            )])
179        );
180    }
181
182    #[test]
183    fn bytes_falls_back_to_cbor_byte_string() {
184        let (data, mime) = extract(crate::bytes::Bytes(b"hi".to_vec()).into_tool_response("en"));
185        assert_eq!(mime.as_deref(), Some(crate::constants::MIME_CBOR));
186        let v: ciborium::value::Value = ciborium::from_reader(&data[..]).unwrap();
187        assert_eq!(v, ciborium::value::Value::Bytes(b"hi".to_vec()));
188    }
189}