Skip to main content

ag_ui_a2ui/toolkit/
envelope.rs

1//! The transport envelope: `{"a2ui_operations": [...]}`.
2//!
3//! A2UI itself says nothing about how messages reach the renderer. In practice
4//! every toolkit wraps a batch of operations in a single JSON object keyed by
5//! [`A2UI_OPERATIONS_KEY`], and the frontend sniffs for exactly that key to
6//! decide whether a payload is A2UI. The envelope is emitted as a JSON *string*
7//! because that is what fits in a tool result, an assistant message, or an A2A
8//! data part without further wrapping.
9//!
10//! Failure is the other shape, and it is a different object rather than an empty
11//! envelope: see [`wrap_error_envelope`] for why a surface that could not be
12//! built must not answer the sniff.
13
14use serde_json::{Map, Value, json};
15
16use crate::constants::A2UI_OPERATIONS_KEY;
17use crate::error::Result;
18use crate::message::AgentMessage;
19use crate::validate::ValidationError;
20
21/// Wraps operations in the envelope, as a JSON string ready for transport.
22///
23/// ```
24/// use ag_ui_a2ui::message::AgentMessage;
25/// use ag_ui_a2ui::toolkit::envelope::wrap_as_operations_envelope;
26///
27/// let json = wrap_as_operations_envelope(&[AgentMessage::delete_surface("s1")]).unwrap();
28/// assert!(json.starts_with(r#"{"a2ui_operations":["#));
29/// ```
30///
31/// # Errors
32///
33/// Returns [`Error::Json`](crate::Error::Json) if an operation cannot be
34/// serialized.
35pub fn wrap_as_operations_envelope(operations: &[AgentMessage]) -> Result<String> {
36    Ok(serde_json::to_string(&operations_envelope(operations)?)?)
37}
38
39/// The envelope as a [`Value`], for callers that embed it in a larger payload.
40///
41/// # Errors
42///
43/// Returns [`Error::Json`](crate::Error::Json) if an operation cannot be
44/// serialized.
45pub fn operations_envelope(operations: &[AgentMessage]) -> Result<Value> {
46    let operations = serde_json::to_value(operations)?;
47    let mut envelope = Map::new();
48    envelope.insert(A2UI_OPERATIONS_KEY.to_string(), operations);
49    Ok(Value::Object(envelope))
50}
51
52/// Builds the payload reporting that a surface could not be produced.
53///
54/// Deliberately *not* an operations envelope. [`A2UI_OPERATIONS_KEY`] is the
55/// content sniff, so carrying it — even with an empty list — leaves a failed
56/// generation indistinguishable from a rendered one to every consumer that keys
57/// on it, [`history`](crate::toolkit::history) included. Upstream draws the same
58/// line: its tool returns the validated operations under one key on success and
59/// `error` on failure, never both, and its part converter checks `error` first
60/// and emits no A2UI at all.
61///
62/// `error` is therefore the human-readable message, the way upstream sends it.
63/// The specification's structured validation fields sit *alongside* it rather
64/// than nested under it, since `error` is already the spec's `message`, with the
65/// full validation list under `details` for callers that route on the codes.
66///
67/// ```
68/// use ag_ui_a2ui::toolkit::envelope::{is_operations_envelope, wrap_error_envelope};
69///
70/// let json = wrap_error_envelope("s1", "could not build the surface", &[]).unwrap();
71/// let value: serde_json::Value = serde_json::from_str(&json).unwrap();
72/// assert_eq!(value["error"], "could not build the surface");
73/// assert!(!is_operations_envelope(&value));
74/// ```
75///
76/// # Errors
77///
78/// Returns [`Error::Json`](crate::Error::Json) if the error list cannot be
79/// serialized.
80pub fn wrap_error_envelope(
81    surface_id: &str,
82    message: &str,
83    errors: &[ValidationError],
84) -> Result<String> {
85    let mut envelope = Map::new();
86    envelope.insert("error".to_string(), json!(message));
87    envelope.insert("code".to_string(), json!("VALIDATION_FAILED"));
88    envelope.insert("surfaceId".to_string(), json!(surface_id));
89    // The spec's `path` is a single locator; use the first failure's, which is
90    // the one a reader should look at first.
91    envelope.insert(
92        "path".to_string(),
93        json!(errors.first().map_or("components", |e| e.path.as_str())),
94    );
95    envelope.insert("details".to_string(), serde_json::to_value(errors)?);
96    Ok(serde_json::to_string(&Value::Object(envelope))?)
97}
98
99/// Whether a value is an A2UI operations envelope.
100///
101/// This is the frontend's content sniff: presence of the key, carrying an array.
102/// A payload from [`wrap_error_envelope`] does not have the key and so does not
103/// match, which is what keeps a failure from being mistaken for a surface.
104pub fn is_operations_envelope(value: &Value) -> bool {
105    value.get(A2UI_OPERATIONS_KEY).is_some_and(Value::is_array)
106}
107
108/// Reads operations back out of an envelope.
109///
110/// # Errors
111///
112/// Returns [`Error::Parse`](crate::Error::Parse) if the value is not an
113/// envelope, or [`Error::Json`](crate::Error::Json) if its operations do not
114/// deserialize.
115pub fn unwrap_operations_envelope(value: &Value) -> Result<Vec<AgentMessage>> {
116    let operations = value.get(A2UI_OPERATIONS_KEY).ok_or_else(|| {
117        crate::Error::parse(format!("payload has no '{A2UI_OPERATIONS_KEY}' key"))
118    })?;
119    Ok(serde_json::from_value(operations.clone())?)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::message::Component;
126    use crate::validate::ErrorCode;
127
128    fn ops() -> Vec<AgentMessage> {
129        vec![
130            AgentMessage::create_surface("s1", "cat"),
131            AgentMessage::update_components(
132                "s1",
133                vec![Component::new("root", "Text").with("text", json!("hi"))],
134            ),
135        ]
136    }
137
138    #[test]
139    fn the_envelope_round_trips() {
140        let json = wrap_as_operations_envelope(&ops()).unwrap();
141        let value: Value = serde_json::from_str(&json).unwrap();
142        assert!(is_operations_envelope(&value));
143        assert_eq!(unwrap_operations_envelope(&value).unwrap(), ops());
144    }
145
146    #[test]
147    fn the_envelope_key_is_the_frontend_sniff() {
148        let value: Value =
149            serde_json::from_str(&wrap_as_operations_envelope(&ops()).unwrap()).unwrap();
150        assert!(value.get("a2ui_operations").is_some());
151        assert!(!is_operations_envelope(&json!({"operations": []})));
152        assert!(!is_operations_envelope(&json!({"a2ui_operations": "nope"})));
153    }
154
155    #[test]
156    fn an_empty_batch_is_still_a_valid_envelope() {
157        let json = wrap_as_operations_envelope(&[]).unwrap();
158        assert_eq!(json, r#"{"a2ui_operations":[]}"#);
159    }
160
161    fn errors() -> Vec<ValidationError> {
162        vec![
163            ValidationError::new(
164                ErrorCode::NoRoot,
165                "components",
166                "No component has id 'root'.",
167            ),
168            ValidationError::new(
169                ErrorCode::UnresolvedChild,
170                "components[1].child",
171                "'gone' is not defined.",
172            ),
173        ]
174    }
175
176    #[test]
177    fn the_error_payload_is_a_message_with_the_codes_beside_it() {
178        let json = wrap_error_envelope("s1", "could not build the surface", &errors()).unwrap();
179        let value: Value = serde_json::from_str(&json).unwrap();
180
181        assert_eq!(value["error"], "could not build the surface");
182        assert_eq!(value["code"], "VALIDATION_FAILED");
183        assert_eq!(value["surfaceId"], "s1");
184        assert_eq!(value["path"], "components");
185        assert_eq!(value["details"][1]["code"], "unresolved_child");
186    }
187
188    #[test]
189    fn a_failed_surface_does_not_satisfy_the_frontend_sniff() {
190        let json = wrap_error_envelope("s1", "could not build the surface", &errors()).unwrap();
191        let value: Value = serde_json::from_str(&json).unwrap();
192
193        // The whole point: a failure that carried the key would clear pending
194        // state *and* be replayed later as a surface that was never rendered.
195        assert!(!is_operations_envelope(&value), "{value}");
196        assert!(value.get(A2UI_OPERATIONS_KEY).is_none(), "{value}");
197        assert!(unwrap_operations_envelope(&value).is_err());
198    }
199
200    #[test]
201    fn an_error_payload_with_no_errors_still_has_a_path() {
202        let json = wrap_error_envelope("s1", "model returned nothing", &[]).unwrap();
203        let value: Value = serde_json::from_str(&json).unwrap();
204        assert_eq!(value["path"], "components");
205    }
206
207    #[test]
208    fn unwrapping_a_non_envelope_is_an_error() {
209        assert!(unwrap_operations_envelope(&json!({"nope": []})).is_err());
210    }
211}