1use serde::{Deserialize, Serialize};
36use serde_json::{Map, Value};
37
38use crate::constants::PROTOCOL_VERSION;
39use crate::error::{Error, Result};
40
41fn default_version() -> String {
42 PROTOCOL_VERSION.to_string()
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct AgentMessage {
48 #[serde(default = "default_version")]
51 pub version: String,
52 #[serde(flatten)]
54 pub payload: AgentPayload,
55}
56
57impl AgentMessage {
58 pub fn new(payload: AgentPayload) -> Self {
60 Self {
61 version: default_version(),
62 payload,
63 }
64 }
65
66 #[cfg_attr(
70 feature = "toolkit",
71 doc = "[`crate::toolkit::ops::assemble_ops`], which omits this message when the"
72 )]
73 #[cfg_attr(
74 not(feature = "toolkit"),
75 doc = "`toolkit::ops::assemble_ops` (behind the `toolkit` feature), which omits this message when the"
76 )]
77 pub fn create_surface(surface_id: impl Into<String>, catalog_id: impl Into<String>) -> Self {
79 Self::new(AgentPayload::CreateSurface(CreateSurface {
80 surface_id: surface_id.into(),
81 catalog_id: catalog_id.into(),
82 theme: None,
83 send_data_model: None,
84 }))
85 }
86
87 pub fn update_components(surface_id: impl Into<String>, components: Vec<Component>) -> Self {
89 Self::new(AgentPayload::UpdateComponents(UpdateComponents {
90 surface_id: surface_id.into(),
91 components,
92 }))
93 }
94
95 pub fn update_data_model(
97 surface_id: impl Into<String>,
98 path: impl Into<String>,
99 value: Value,
100 ) -> Self {
101 Self::new(AgentPayload::UpdateDataModel(UpdateDataModel {
102 surface_id: surface_id.into(),
103 path: path.into(),
104 value,
105 }))
106 }
107
108 pub fn delete_surface(surface_id: impl Into<String>) -> Self {
110 Self::new(AgentPayload::DeleteSurface(DeleteSurface {
111 surface_id: surface_id.into(),
112 }))
113 }
114
115 pub fn surface_id(&self) -> Option<&str> {
120 match &self.payload {
121 AgentPayload::CreateSurface(m) => Some(&m.surface_id),
122 AgentPayload::UpdateComponents(m) => Some(&m.surface_id),
123 AgentPayload::UpdateDataModel(m) => Some(&m.surface_id),
124 AgentPayload::DeleteSurface(m) => Some(&m.surface_id),
125 AgentPayload::CallRendererFunction(_) | AgentPayload::AgentFunctionResponse(_) => None,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub enum AgentPayload {
134 CreateSurface(CreateSurface),
136 UpdateComponents(UpdateComponents),
138 UpdateDataModel(UpdateDataModel),
140 DeleteSurface(DeleteSurface),
142 CallRendererFunction(CallRendererFunction),
144 AgentFunctionResponse(FunctionResponse),
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct CreateSurface {
152 pub surface_id: String,
154 pub catalog_id: String,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub theme: Option<Value>,
162 #[serde(skip_serializing_if = "Option::is_none")]
165 pub send_data_model: Option<bool>,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct UpdateComponents {
172 pub surface_id: String,
174 pub components: Vec<Component>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct UpdateDataModel {
185 pub surface_id: String,
187 #[serde(default = "root_pointer")]
189 pub path: String,
190 #[serde(default)]
192 pub value: Value,
193}
194
195fn root_pointer() -> String {
196 "/".to_string()
197}
198
199impl UpdateDataModel {
200 pub fn apply(&self, model: &mut Value) -> Result<()> {
212 apply_data_model_update(model, &self.path, &self.value)
213 }
214}
215
216pub fn apply_data_model_update(model: &mut Value, path: &str, value: &Value) -> Result<()> {
225 let trimmed = path.trim();
226 if trimmed.is_empty() || trimmed == "/" {
227 *model = value.clone();
228 return Ok(());
229 }
230 if !trimmed.starts_with('/') {
231 return Err(Error::pointer(
232 trimmed,
233 "updateDataModel path must be an absolute JSON Pointer starting with '/'",
234 ));
235 }
236
237 let tokens = crate::binding::pointer_tokens(trimmed);
238 let Some((last, parents)) = tokens.split_last() else {
239 *model = value.clone();
240 return Ok(());
241 };
242
243 let mut cursor = model;
244 for token in parents {
245 cursor = descend_or_create(cursor, token, trimmed)?;
246 }
247
248 match cursor {
249 Value::Object(map) => {
250 if value.is_null() {
251 map.remove(last.as_str());
252 } else {
253 map.insert(last.clone(), value.clone());
254 }
255 }
256 Value::Array(items) => {
257 let idx = parse_index(last, trimmed)?;
258 if idx < items.len() {
259 items[idx] = value.clone();
261 } else if idx == items.len() {
262 items.push(value.clone());
263 } else {
264 return Err(Error::pointer(
265 trimmed,
266 format!("array index {idx} is out of bounds (len {})", items.len()),
267 ));
268 }
269 }
270 Value::Null => {
271 let mut map = Map::new();
272 if !value.is_null() {
273 map.insert(last.clone(), value.clone());
274 }
275 *cursor = Value::Object(map);
276 }
277 _ => {
278 return Err(Error::pointer(trimmed, "path walks through a scalar value"));
279 }
280 }
281 Ok(())
282}
283
284fn descend_or_create<'a>(
285 cursor: &'a mut Value,
286 token: &str,
287 full_path: &str,
288) -> Result<&'a mut Value> {
289 if cursor.is_null() {
290 *cursor = Value::Object(Map::new());
291 }
292 match cursor {
293 Value::Object(map) => Ok(map.entry(token.to_string()).or_insert(Value::Null)),
294 Value::Array(items) => {
295 let idx = parse_index(token, full_path)?;
296 let len = items.len();
297 if idx == len {
298 items.push(Value::Null);
299 }
300 items.get_mut(idx).ok_or_else(|| {
301 Error::pointer(
302 full_path,
303 format!("array index {idx} is out of bounds (len {len})"),
304 )
305 })
306 }
307 _ => Err(Error::pointer(
308 full_path,
309 "path walks through a scalar value",
310 )),
311 }
312}
313
314fn parse_index(token: &str, full_path: &str) -> Result<usize> {
315 token.parse::<usize>().map_err(|_| {
316 Error::pointer(
317 full_path,
318 format!("expected an array index, found segment {token:?}"),
319 )
320 })
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325#[serde(rename_all = "camelCase")]
326pub struct DeleteSurface {
327 pub surface_id: String,
329}
330
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase")]
334pub struct CallRendererFunction {
335 pub function_call_id: String,
337 pub call_function: FunctionCall,
339}
340
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase")]
344pub struct CallAgentFunction {
345 pub surface_id: String,
347 pub function_call_id: String,
349 pub call_function: FunctionCall,
351}
352
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct FunctionCall {
358 pub call: String,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub args: Option<Map<String, Value>>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub catalog_id: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub return_type: Option<String>,
369}
370
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
373#[serde(rename_all = "camelCase")]
374pub struct FunctionResponse {
375 pub function_call_id: String,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub result: Option<Value>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub error: Option<String>,
383}
384
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387pub struct RendererMessage {
388 #[serde(default = "default_version")]
390 pub version: String,
391 #[serde(flatten)]
393 pub payload: RendererPayload,
394}
395
396impl RendererMessage {
397 pub fn new(payload: RendererPayload) -> Self {
399 Self {
400 version: default_version(),
401 payload,
402 }
403 }
404
405 pub fn surface_id(&self) -> Option<&str> {
407 match &self.payload {
408 RendererPayload::Action(a) => Some(&a.surface_id),
409 RendererPayload::CallAgentFunction(c) => Some(&c.surface_id),
410 RendererPayload::RendererFunctionResponse(_) => None,
411 RendererPayload::Error(e) => e.surface_id.as_deref(),
412 }
413 }
414}
415
416#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
418#[serde(rename_all = "camelCase")]
419pub enum RendererPayload {
420 Action(Action),
422 CallAgentFunction(CallAgentFunction),
424 RendererFunctionResponse(FunctionResponse),
426 Error(RendererError),
428}
429
430#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase")]
433pub struct Action {
434 pub name: String,
436 pub surface_id: String,
438 pub source_component_id: String,
440 pub timestamp: String,
442 #[serde(default)]
444 pub context: Map<String, Value>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub user_message: Option<String>,
449}
450
451#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453#[serde(rename_all = "camelCase")]
454pub struct RendererError {
455 pub code: String,
457 pub message: String,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub surface_id: Option<String>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub path: Option<String>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub function_call_id: Option<String>,
468}
469
470#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476pub struct Component {
477 pub id: String,
479 pub component: String,
481 #[serde(flatten)]
483 pub props: Map<String, Value>,
484}
485
486impl Component {
487 pub fn new(id: impl Into<String>, component: impl Into<String>) -> Self {
489 Self {
490 id: id.into(),
491 component: component.into(),
492 props: Map::new(),
493 }
494 }
495
496 #[must_use]
498 pub fn with(mut self, key: impl Into<String>, value: Value) -> Self {
499 self.props.insert(key.into(), value);
500 self
501 }
502
503 pub fn prop(&self, key: &str) -> Option<&Value> {
505 self.props.get(key)
506 }
507}
508
509#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
515#[serde(untagged)]
516pub enum ChildList {
517 Ids(Vec<String>),
519 Template(ChildTemplate),
521}
522
523#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
525#[serde(rename_all = "camelCase")]
526pub struct ChildTemplate {
527 pub component_id: String,
529 pub path: String,
531}
532
533impl ChildList {
534 pub fn from_value(value: &Value) -> Option<Self> {
536 serde_json::from_value(value.clone()).ok()
537 }
538
539 pub fn referenced_ids(&self) -> Vec<&str> {
541 match self {
542 ChildList::Ids(ids) => ids.iter().map(String::as_str).collect(),
543 ChildList::Template(t) => vec![t.component_id.as_str()],
544 }
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use serde_json::json;
552
553 #[test]
554 fn agent_message_round_trips_through_the_wire_shape() {
555 let msg = AgentMessage::create_surface("s1", "cat");
556 let wire = serde_json::to_value(&msg).unwrap();
557 assert_eq!(
558 wire,
559 json!({"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "cat"}})
560 );
561 let back: AgentMessage = serde_json::from_value(wire).unwrap();
562 assert_eq!(back, msg);
563 }
564
565 #[test]
566 fn components_keep_catalog_props_flat() {
567 let wire = json!({"id": "t", "component": "Text", "text": "hi", "variant": "h1"});
568 let c: Component = serde_json::from_value(wire.clone()).unwrap();
569 assert_eq!(c.prop("variant"), Some(&json!("h1")));
570 assert_eq!(serde_json::to_value(&c).unwrap(), wire);
571 }
572
573 #[test]
574 fn update_data_model_defaults_path_to_root() {
575 let msg: AgentMessage = serde_json::from_value(json!({
576 "version": "v0.9",
577 "updateDataModel": {"surfaceId": "s", "value": {"a": 1}}
578 }))
579 .unwrap();
580 let AgentPayload::UpdateDataModel(m) = msg.payload else {
581 panic!("expected updateDataModel");
582 };
583 assert_eq!(m.path, "/");
584 }
585
586 #[test]
587 fn upsert_creates_missing_intermediates_and_null_deletes() {
588 let mut model = json!({});
589 apply_data_model_update(&mut model, "/user/name", &json!("Ada")).unwrap();
590 assert_eq!(model, json!({"user": {"name": "Ada"}}));
591
592 apply_data_model_update(&mut model, "/user/name", &Value::Null).unwrap();
593 assert_eq!(model, json!({"user": {}}));
594
595 apply_data_model_update(&mut model, "/", &json!({"replaced": true})).unwrap();
596 assert_eq!(model, json!({"replaced": true}));
597 }
598
599 #[test]
600 fn upsert_into_arrays_preserves_length_on_delete() {
601 let mut model = json!({"items": [1, 2, 3]});
602 apply_data_model_update(&mut model, "/items/1", &Value::Null).unwrap();
603 assert_eq!(model, json!({"items": [1, null, 3]}));
604
605 apply_data_model_update(&mut model, "/items/3", &json!(4)).unwrap();
606 assert_eq!(model, json!({"items": [1, null, 3, 4]}));
607
608 let err = apply_data_model_update(&mut model, "/items/9", &json!(0)).unwrap_err();
609 assert!(matches!(err, Error::Pointer { .. }));
610 }
611
612 #[test]
613 fn escaped_pointer_tokens_are_decoded() {
614 let mut model = json!({});
615 apply_data_model_update(&mut model, "/a~1b", &json!(1)).unwrap();
616 assert_eq!(model, json!({"a/b": 1}));
617 }
618
619 #[test]
620 fn child_list_parses_both_forms() {
621 assert_eq!(
622 ChildList::from_value(&json!(["a", "b"]))
623 .unwrap()
624 .referenced_ids(),
625 vec!["a", "b"]
626 );
627 assert_eq!(
628 ChildList::from_value(&json!({"componentId": "tpl", "path": "/items"}))
629 .unwrap()
630 .referenced_ids(),
631 vec!["tpl"]
632 );
633 assert!(ChildList::from_value(&json!("nope")).is_none());
634 }
635
636 #[test]
637 fn renderer_messages_round_trip() {
638 let wire = json!({
639 "version": "v0.9",
640 "action": {
641 "name": "submit",
642 "surfaceId": "s1",
643 "sourceComponentId": "btn",
644 "timestamp": "2026-01-01T00:00:00Z",
645 "context": {"email": "a@b.c"}
646 }
647 });
648 let msg: RendererMessage = serde_json::from_value(wire.clone()).unwrap();
649 assert_eq!(msg.surface_id(), Some("s1"));
650 assert_eq!(serde_json::to_value(&msg).unwrap(), wire);
651 }
652}