Skip to main content

ag_ui_a2ui/toolkit/
ops.rs

1//! Building the operation stream that renders a surface.
2//!
3//! Three builders plus [`assemble_ops`], which puts them in the order a
4//! renderer expects: create the surface, define its components, then supply the
5//! data.
6//!
7//! # `intent = "update"` must not re-create the surface
8//!
9//! `createSurface` allocates a `surfaceId` and fixes its catalog. Sending it
10//! again for a surface that already exists is an error per spec — the renderer
11//! rejects it, and the frontend surfaces that failure to the user. Editing an
12//! existing surface therefore means sending only `updateComponents` and
13//! `updateDataModel`. [`Intent::Update`] encodes exactly that.
14
15use serde_json::Value;
16
17use crate::constants::{BASIC_CATALOG_ID, DEFAULT_SURFACE_ID};
18use crate::message::{AgentMessage, Component};
19
20/// Whether the caller is building a new surface or editing one that exists.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum Intent {
23    /// Build a surface that does not exist yet.
24    #[default]
25    Create,
26    /// Edit a surface the renderer already holds.
27    Update,
28}
29
30impl Intent {
31    /// Parses the wire spelling, case-insensitively.
32    ///
33    /// Anything unrecognized is `None` rather than a silent default: guessing
34    /// wrong in the "update" direction re-creates a live surface.
35    pub fn from_wire(value: &str) -> Option<Self> {
36        match value.trim().to_ascii_lowercase().as_str() {
37            "create" | "new" => Some(Intent::Create),
38            "update" | "edit" => Some(Intent::Update),
39            _ => None,
40        }
41    }
42
43    /// The wire spelling.
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Intent::Create => "create",
47            Intent::Update => "update",
48        }
49    }
50
51    /// Whether a `createSurface` operation belongs in this stream.
52    pub fn needs_create_surface(self) -> bool {
53        matches!(self, Intent::Create)
54    }
55}
56
57impl std::fmt::Display for Intent {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.write_str(self.as_str())
60    }
61}
62
63/// What a surface should look like, as the authoring layer sees it.
64#[derive(Debug, Clone, PartialEq)]
65pub struct SurfaceSpec {
66    /// Target surface id. Defaults to [`DEFAULT_SURFACE_ID`].
67    pub surface_id: String,
68    /// Catalog the components are drawn from. Defaults to [`BASIC_CATALOG_ID`].
69    pub catalog_id: String,
70    /// The flat component adjacency list.
71    pub components: Vec<Component>,
72    /// Initial or updated data model. `None` sends no data operation.
73    pub data_model: Option<Value>,
74    /// Where the data model is written.
75    ///
76    /// Defaults to `/`, which replaces the whole model. When updating a live
77    /// surface, prefer a narrower pointer: the renderer's two-way bindings write
78    /// user input straight into this model, and replacing the root discards it.
79    pub data_path: String,
80    /// Optional catalog-defined theme for `createSurface`.
81    pub theme: Option<Value>,
82    /// Ask the renderer to echo the data model back with every message.
83    pub send_data_model: Option<bool>,
84}
85
86impl Default for SurfaceSpec {
87    fn default() -> Self {
88        Self {
89            surface_id: DEFAULT_SURFACE_ID.to_string(),
90            catalog_id: BASIC_CATALOG_ID.to_string(),
91            components: Vec::new(),
92            data_model: None,
93            data_path: "/".to_string(),
94            theme: None,
95            send_data_model: None,
96        }
97    }
98}
99
100impl SurfaceSpec {
101    /// A spec for the given surface with default catalog and no content.
102    pub fn new(surface_id: impl Into<String>) -> Self {
103        Self {
104            surface_id: surface_id.into(),
105            ..Self::default()
106        }
107    }
108
109    /// Sets the component list.
110    #[must_use]
111    pub fn with_components(mut self, components: Vec<Component>) -> Self {
112        self.components = components;
113        self
114    }
115
116    /// Sets the data model, written at [`SurfaceSpec::data_path`].
117    #[must_use]
118    pub fn with_data_model(mut self, data_model: Value) -> Self {
119        self.data_model = Some(data_model);
120        self
121    }
122
123    /// Sets the catalog id.
124    #[must_use]
125    pub fn with_catalog_id(mut self, catalog_id: impl Into<String>) -> Self {
126        self.catalog_id = catalog_id.into();
127        self
128    }
129
130    /// Sets the pointer the data model is written at.
131    #[must_use]
132    pub fn with_data_path(mut self, data_path: impl Into<String>) -> Self {
133        self.data_path = data_path.into();
134        self
135    }
136}
137
138/// Builds a `createSurface` operation.
139pub fn create_surface(
140    surface_id: impl Into<String>,
141    catalog_id: impl Into<String>,
142    theme: Option<Value>,
143    send_data_model: Option<bool>,
144) -> AgentMessage {
145    let mut message = AgentMessage::create_surface(surface_id, catalog_id);
146    if let crate::message::AgentPayload::CreateSurface(payload) = &mut message.payload {
147        payload.theme = theme;
148        payload.send_data_model = send_data_model;
149    }
150    message
151}
152
153/// Builds an `updateComponents` operation.
154pub fn update_components(
155    surface_id: impl Into<String>,
156    components: Vec<Component>,
157) -> AgentMessage {
158    AgentMessage::update_components(surface_id, components)
159}
160
161/// Builds an `updateDataModel` operation.
162///
163/// A `null` value deletes the key at `path`; a `path` of `/` replaces the whole
164/// data model.
165pub fn update_data_model(
166    surface_id: impl Into<String>,
167    path: impl Into<String>,
168    value: Value,
169) -> AgentMessage {
170    AgentMessage::update_data_model(surface_id, path, value)
171}
172
173/// Assembles the full operation stream for a surface.
174///
175/// Order is `createSurface` (create intent only) → `updateComponents` →
176/// `updateDataModel`, so the renderer has a surface to attach to, then a tree to
177/// draw, then data to fill it with. Empty components or an absent data model
178/// simply omit that operation.
179///
180/// ```
181/// use ag_ui_a2ui::toolkit::ops::{assemble_ops, Intent, SurfaceSpec};
182/// use ag_ui_a2ui::message::Component;
183/// use serde_json::json;
184///
185/// let spec = SurfaceSpec::new("cart")
186///     .with_components(vec![Component::new("root", "Text").with("text", json!("hi"))]);
187///
188/// assert_eq!(assemble_ops(Intent::Create, &spec).len(), 2);
189/// // Updating an existing surface must not re-create it.
190/// assert_eq!(assemble_ops(Intent::Update, &spec).len(), 1);
191/// ```
192pub fn assemble_ops(intent: Intent, spec: &SurfaceSpec) -> Vec<AgentMessage> {
193    let mut ops = Vec::with_capacity(3);
194
195    if intent.needs_create_surface() {
196        ops.push(create_surface(
197            &spec.surface_id,
198            &spec.catalog_id,
199            spec.theme.clone(),
200            spec.send_data_model,
201        ));
202    }
203    if !spec.components.is_empty() {
204        ops.push(update_components(&spec.surface_id, spec.components.clone()));
205    }
206    if let Some(data_model) = &spec.data_model {
207        ops.push(update_data_model(
208            &spec.surface_id,
209            &spec.data_path,
210            data_model.clone(),
211        ));
212    }
213    ops
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::message::AgentPayload;
220    use serde_json::json;
221
222    fn spec() -> SurfaceSpec {
223        SurfaceSpec::new("s1")
224            .with_components(vec![
225                Component::new("root", "Text").with("text", json!("hello")),
226            ])
227            .with_data_model(json!({"a": 1}))
228    }
229
230    #[test]
231    fn create_emits_all_three_operations_in_order() {
232        let ops = assemble_ops(Intent::Create, &spec());
233        let kinds: Vec<&str> = ops
234            .iter()
235            .map(|op| match op.payload {
236                AgentPayload::CreateSurface(_) => "create",
237                AgentPayload::UpdateComponents(_) => "components",
238                AgentPayload::UpdateDataModel(_) => "data",
239                _ => "other",
240            })
241            .collect();
242        assert_eq!(kinds, vec!["create", "components", "data"]);
243    }
244
245    #[test]
246    fn update_never_emits_create_surface() {
247        let ops = assemble_ops(Intent::Update, &spec());
248        assert!(
249            !ops.iter()
250                .any(|op| matches!(op.payload, AgentPayload::CreateSurface(_))),
251            "updating an existing surface must not re-create it"
252        );
253        assert_eq!(ops.len(), 2);
254    }
255
256    #[test]
257    fn empty_content_omits_its_operation() {
258        let bare = SurfaceSpec::new("s1");
259        assert_eq!(assemble_ops(Intent::Update, &bare).len(), 0);
260        assert_eq!(assemble_ops(Intent::Create, &bare).len(), 1);
261    }
262
263    #[test]
264    fn defaults_come_from_the_wire_constants() {
265        let spec = SurfaceSpec::default();
266        assert_eq!(spec.surface_id, DEFAULT_SURFACE_ID);
267        assert_eq!(spec.catalog_id, BASIC_CATALOG_ID);
268        assert_eq!(spec.data_path, "/");
269    }
270
271    #[test]
272    fn theme_and_send_data_model_ride_on_create_surface() {
273        let mut spec = spec();
274        spec.theme = Some(json!({"primaryColor": "#00BFFF"}));
275        spec.send_data_model = Some(true);
276        let ops = assemble_ops(Intent::Create, &spec);
277        let AgentPayload::CreateSurface(payload) = &ops[0].payload else {
278            panic!("expected createSurface");
279        };
280        assert_eq!(payload.theme, Some(json!({"primaryColor": "#00BFFF"})));
281        assert_eq!(payload.send_data_model, Some(true));
282    }
283
284    #[test]
285    fn a_narrower_data_path_is_preserved() {
286        let spec = SurfaceSpec::new("s1")
287            .with_data_model(json!("Ada"))
288            .with_data_path("/user/name");
289        let ops = assemble_ops(Intent::Update, &spec);
290        let AgentPayload::UpdateDataModel(payload) = &ops[0].payload else {
291            panic!("expected updateDataModel");
292        };
293        assert_eq!(payload.path, "/user/name");
294    }
295
296    #[test]
297    fn intent_parses_the_wire_spellings_and_rejects_the_rest() {
298        assert_eq!(Intent::from_wire("create"), Some(Intent::Create));
299        assert_eq!(Intent::from_wire("  UPDATE "), Some(Intent::Update));
300        assert_eq!(Intent::from_wire("edit"), Some(Intent::Update));
301        assert_eq!(Intent::from_wire("replace"), None);
302        assert_eq!(Intent::default(), Intent::Create);
303    }
304}