Skip to main content

adk_ui/a2ui/
messages.rs

1//! A2UI agent→renderer and renderer→agent message envelopes (v0.9 / v0.9.1).
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5
6/// Wire protocol version written on every A2UI envelope.
7pub const A2UI_PROTOCOL_VERSION: &str = "v0.9.1";
8
9/// Official MIME type for A2UI payloads (v0.9.1 standardization).
10pub const A2UI_MIME_TYPE: &str = "application/a2ui+json";
11
12/// Legacy MIME type still accepted by some clients.
13pub const A2UI_MIME_TYPE_LEGACY: &str = "application/json+a2ui";
14
15/// Basic catalog id used by A2UI v0.9.1 examples (string identifier, not a download).
16pub const A2UI_BASIC_CATALOG_ID: &str =
17    "https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json";
18
19/// Basic catalog id for the A2UI v1.0 release candidate.
20pub const A2UI_BASIC_CATALOG_ID_V1: &str =
21    "https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json";
22
23fn default_a2ui_version() -> String {
24    A2UI_PROTOCOL_VERSION.to_string()
25}
26
27/// A2UI createSurface payload.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct CreateSurface {
31    pub surface_id: String,
32    pub catalog_id: String,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub theme: Option<Value>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub send_data_model: Option<bool>,
37    /// Optional bulk components (A2UI v1.0 candidate; ignored by pure v0.9 clients).
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub components: Option<Vec<Value>>,
40    /// Optional initial data model (A2UI v1.0 candidate).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub data_model: Option<Value>,
43}
44
45/// A2UI updateComponents payload.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct UpdateComponents {
49    pub surface_id: String,
50    pub components: Vec<Value>,
51}
52
53/// A2UI updateDataModel payload.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct UpdateDataModel {
57    pub surface_id: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub path: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub value: Option<Value>,
62}
63
64/// A2UI deleteSurface payload.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct DeleteSurface {
68    pub surface_id: String,
69}
70
71/// Envelope: createSurface message with protocol version.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CreateSurfaceMessage {
74    #[serde(default = "default_a2ui_version")]
75    pub version: String,
76    #[serde(rename = "createSurface")]
77    pub create_surface: CreateSurface,
78}
79
80/// Envelope: updateComponents message.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct UpdateComponentsMessage {
83    #[serde(default = "default_a2ui_version")]
84    pub version: String,
85    #[serde(rename = "updateComponents")]
86    pub update_components: UpdateComponents,
87}
88
89/// Envelope: updateDataModel message.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct UpdateDataModelMessage {
92    #[serde(default = "default_a2ui_version")]
93    pub version: String,
94    #[serde(rename = "updateDataModel")]
95    pub update_data_model: UpdateDataModel,
96}
97
98/// Envelope: deleteSurface message.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct DeleteSurfaceMessage {
101    #[serde(default = "default_a2ui_version")]
102    pub version: String,
103    #[serde(rename = "deleteSurface")]
104    pub delete_surface: DeleteSurface,
105}
106
107/// Agent→renderer response to a renderer action that set `wantResponse: true` (A2UI v1.0).
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ActionResponseBody {
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub value: Option<Value>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub error: Option<A2uiActionResponseError>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct A2uiActionResponseError {
120    pub code: String,
121    pub message: String,
122}
123
124/// Envelope: actionResponse message (A2UI v1.0).
125///
126/// Wire shape:
127/// `{ "version": "v1.0", "actionId": "...", "actionResponse": { "value" | "error" } }`
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct ActionResponseMessage {
131    #[serde(default = "default_a2ui_version")]
132    pub version: String,
133    pub action_id: String,
134    pub action_response: ActionResponseBody,
135}
136
137impl ActionResponseMessage {
138    pub fn success(action_id: impl Into<String>, value: Value) -> Self {
139        Self {
140            version: "v1.0".to_string(),
141            action_id: action_id.into(),
142            action_response: ActionResponseBody {
143                value: Some(value),
144                error: None,
145            },
146        }
147    }
148
149    pub fn failure(
150        action_id: impl Into<String>,
151        code: impl Into<String>,
152        message: impl Into<String>,
153    ) -> Self {
154        Self {
155            version: "v1.0".to_string(),
156            action_id: action_id.into(),
157            action_response: ActionResponseBody {
158                value: None,
159                error: Some(A2uiActionResponseError {
160                    code: code.into(),
161                    message: message.into(),
162                }),
163            },
164        }
165    }
166}
167
168/// Agent-initiated function call (A2UI v1.0).
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct CallFunctionBody {
172    pub call: String,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub args: Option<Value>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct CallFunctionMessage {
180    #[serde(default = "default_a2ui_version")]
181    pub version: String,
182    pub function_call_id: String,
183    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
184    pub want_response: bool,
185    pub call_function: CallFunctionBody,
186}
187
188/// Renderer→agent function result (A2UI v1.0).
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub struct FunctionResponseBody {
192    pub function_call_id: String,
193    pub call: String,
194    pub value: Value,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct FunctionResponseMessage {
200    #[serde(default = "default_a2ui_version")]
201    pub version: String,
202    pub function_response: FunctionResponseBody,
203}
204
205/// A2UI agent→renderer message envelope (exactly one of the variants).
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[serde(untagged)]
208pub enum A2uiMessage {
209    CreateSurface(CreateSurfaceMessage),
210    UpdateComponents(UpdateComponentsMessage),
211    UpdateDataModel(UpdateDataModelMessage),
212    DeleteSurface(DeleteSurfaceMessage),
213    ActionResponse(ActionResponseMessage),
214    CallFunction(CallFunctionMessage),
215}
216
217impl A2uiMessage {
218    pub fn version(&self) -> &str {
219        match self {
220            Self::CreateSurface(m) => &m.version,
221            Self::UpdateComponents(m) => &m.version,
222            Self::UpdateDataModel(m) => &m.version,
223            Self::DeleteSurface(m) => &m.version,
224            Self::ActionResponse(m) => &m.version,
225            Self::CallFunction(m) => &m.version,
226        }
227    }
228
229    pub fn with_version(mut self, version: impl Into<String>) -> Self {
230        let version = version.into();
231        match &mut self {
232            Self::CreateSurface(m) => m.version = version,
233            Self::UpdateComponents(m) => m.version = version,
234            Self::UpdateDataModel(m) => m.version = version,
235            Self::DeleteSurface(m) => m.version = version,
236            Self::ActionResponse(m) => m.version = version,
237            Self::CallFunction(m) => m.version = version,
238        }
239        self
240    }
241}
242
243/// Convenience constructors that stamp the current production version.
244impl CreateSurfaceMessage {
245    pub fn new(create_surface: CreateSurface) -> Self {
246        Self {
247            version: default_a2ui_version(),
248            create_surface,
249        }
250    }
251}
252
253impl UpdateComponentsMessage {
254    pub fn new(update_components: UpdateComponents) -> Self {
255        Self {
256            version: default_a2ui_version(),
257            update_components,
258        }
259    }
260}
261
262impl UpdateDataModelMessage {
263    pub fn new(update_data_model: UpdateDataModel) -> Self {
264        Self {
265            version: default_a2ui_version(),
266            update_data_model,
267        }
268    }
269}
270
271impl DeleteSurfaceMessage {
272    pub fn new(delete_surface: DeleteSurface) -> Self {
273        Self {
274            version: default_a2ui_version(),
275            delete_surface,
276        }
277    }
278}
279
280// ── Renderer → agent ──────────────────────────────────────────────────
281
282/// Client/renderer action message (button, form submit, etc.).
283#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct A2uiClientAction {
286    pub name: String,
287    pub surface_id: String,
288    pub source_component_id: String,
289    pub timestamp: String,
290    pub context: Value,
291    /// When true, the renderer expects an `actionResponse` correlated by `action_id` (v1.0).
292    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
293    pub want_response: bool,
294    /// Optional correlation id when the renderer expects an actionResponse (v1.0 path).
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub action_id: Option<String>,
297}
298
299/// Envelope for a renderer→agent action.
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct A2uiClientActionMessage {
302    #[serde(default = "default_a2ui_version")]
303    pub version: String,
304    pub action: A2uiClientAction,
305}
306
307/// Validation / runtime error reported by the renderer.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct A2uiValidationFailedError {
311    pub code: String,
312    pub surface_id: String,
313    pub path: String,
314    pub message: String,
315}
316
317impl A2uiValidationFailedError {
318    pub fn new(
319        surface_id: impl Into<String>,
320        path: impl Into<String>,
321        message: impl Into<String>,
322    ) -> Self {
323        Self {
324            code: "VALIDATION_FAILED".to_string(),
325            surface_id: surface_id.into(),
326            path: path.into(),
327            message: message.into(),
328        }
329    }
330}
331
332/// Envelope for a renderer error.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct A2uiErrorMessage {
335    #[serde(default = "default_a2ui_version")]
336    pub version: String,
337    pub error: A2uiValidationFailedError,
338}
339
340/// Renderer→agent message union.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(untagged)]
343pub enum A2uiClientMessage {
344    Action(A2uiClientActionMessage),
345    FunctionResponse(FunctionResponseMessage),
346    Error(A2uiErrorMessage),
347}
348
349/// Write an action response value into a data model using a JSON Pointer path.
350pub fn apply_action_response_value(
351    data_model: &mut Value,
352    response_path: Option<&str>,
353    value: Value,
354) {
355    let path = response_path.unwrap_or("/__a2ui/lastActionResponse");
356    if path.is_empty() || path == "/" {
357        *data_model = value;
358        return;
359    }
360    let tokens: Vec<String> = path
361        .trim_start_matches('/')
362        .split('/')
363        .filter(|t| !t.is_empty())
364        .map(|t| t.replace("~1", "/").replace("~0", "~"))
365        .collect();
366    if tokens.is_empty() {
367        *data_model = value;
368        return;
369    }
370
371    // Rebuild as a nested object tree, merging into existing data_model.
372    let mut leaf = value;
373    for token in tokens.iter().rev() {
374        leaf = json!({ token: leaf });
375    }
376    merge_json(data_model, leaf);
377}
378
379fn merge_json(target: &mut Value, source: Value) {
380    match (target, source) {
381        (Value::Object(target_map), Value::Object(source_map)) => {
382            for (key, value) in source_map {
383                match target_map.get_mut(&key) {
384                    Some(existing) => merge_json(existing, value),
385                    None => {
386                        target_map.insert(key, value);
387                    }
388                }
389            }
390        }
391        (target, source) => {
392            *target = source;
393        }
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use serde_json::json;
401
402    #[test]
403    fn create_surface_includes_version_and_mime_constants() {
404        let msg = CreateSurfaceMessage::new(CreateSurface {
405            surface_id: "main".into(),
406            catalog_id: A2UI_BASIC_CATALOG_ID.into(),
407            theme: None,
408            send_data_model: Some(true),
409            components: None,
410            data_model: None,
411        });
412        let value = serde_json::to_value(&msg).unwrap();
413        assert_eq!(value["version"], "v0.9.1");
414        assert_eq!(value["createSurface"]["surfaceId"], "main");
415        assert_eq!(A2UI_MIME_TYPE, "application/a2ui+json");
416    }
417
418    #[test]
419    fn validation_failed_error_uses_standard_code() {
420        let err = A2uiValidationFailedError::new("s1", "/components/0/text", "bad type");
421        let value = serde_json::to_value(A2uiErrorMessage {
422            version: default_a2ui_version(),
423            error: err,
424        })
425        .unwrap();
426        assert_eq!(value["error"]["code"], "VALIDATION_FAILED");
427        assert_eq!(value["error"]["path"], "/components/0/text");
428    }
429
430    #[test]
431    fn client_action_round_trips() {
432        let msg = A2uiClientActionMessage {
433            version: default_a2ui_version(),
434            action: A2uiClientAction {
435                name: "submit".into(),
436                surface_id: "form".into(),
437                source_component_id: "btn".into(),
438                timestamp: "2026-08-09T00:00:00Z".into(),
439                context: json!({"ok": true}),
440                want_response: true,
441                action_id: Some("act-1".into()),
442            },
443        };
444        let raw = serde_json::to_string(&msg).unwrap();
445        let parsed: A2uiClientActionMessage = serde_json::from_str(&raw).unwrap();
446        assert_eq!(parsed.action.name, "submit");
447        assert_eq!(parsed.action.action_id.as_deref(), Some("act-1"));
448        assert!(parsed.action.want_response);
449        assert!(!raw.contains("responsePath"));
450    }
451
452    #[test]
453    fn action_response_success_serializes_v1() {
454        let msg = ActionResponseMessage::success("act-1", json!(["apple", "application"]));
455        let value = serde_json::to_value(&msg).unwrap();
456        assert_eq!(value["version"], "v1.0");
457        assert_eq!(value["actionId"], "act-1");
458        assert_eq!(value["actionResponse"]["value"][0], "apple");
459    }
460
461    #[test]
462    fn apply_action_response_writes_nested_path() {
463        let mut model = json!({"form": {"email": "a@b.c"}});
464        apply_action_response_value(
465            &mut model,
466            Some("/form/suggestions"),
467            json!(["alice", "alex"]),
468        );
469        assert_eq!(model["form"]["email"], "a@b.c");
470        assert_eq!(model["form"]["suggestions"][0], "alice");
471    }
472}