Skip to main content

rustant_core/canvas/
protocol.rs

1//! Canvas protocol — A2UI-inspired message types for agent-to-UI communication.
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Messages exchanged between the agent and UI canvas.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(tag = "type")]
9pub enum CanvasMessage {
10    /// Push new content to the canvas.
11    Push {
12        target: CanvasTarget,
13        content_type: ContentType,
14        content: String,
15    },
16    /// Clear all content from a canvas target.
17    Clear { target: CanvasTarget },
18    /// Update existing content (partial replacement).
19    Update {
20        target: CanvasTarget,
21        content_type: ContentType,
22        content: String,
23    },
24    /// User interaction event from the UI.
25    Interact {
26        action: String,
27        selector: String,
28        data: serde_json::Value,
29    },
30    /// Event notification from UI to agent.
31    Event {
32        event_type: String,
33        target: CanvasTarget,
34        data: serde_json::Value,
35    },
36    /// Request the current canvas state.
37    State { target: CanvasTarget },
38    /// Full snapshot of canvas state.
39    Snapshot {
40        target: CanvasTarget,
41        items: Vec<CanvasItem>,
42    },
43}
44
45/// A content type for canvas items.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum ContentType {
49    Html,
50    Markdown,
51    Code,
52    Chart,
53    Table,
54    Form,
55    Image,
56    Diagram,
57}
58
59impl ContentType {
60    /// Parse a content type from a string.
61    pub fn from_str_loose(s: &str) -> Option<ContentType> {
62        match s.to_lowercase().as_str() {
63            "html" => Some(ContentType::Html),
64            "markdown" | "md" => Some(ContentType::Markdown),
65            "code" => Some(ContentType::Code),
66            "chart" => Some(ContentType::Chart),
67            "table" => Some(ContentType::Table),
68            "form" => Some(ContentType::Form),
69            "image" | "img" => Some(ContentType::Image),
70            "diagram" | "mermaid" => Some(ContentType::Diagram),
71            _ => None,
72        }
73    }
74}
75
76/// Target for canvas operations.
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(untagged)]
79pub enum CanvasTarget {
80    /// Broadcast to all connected canvases.
81    #[default]
82    Broadcast,
83    /// Target a specific canvas by name.
84    Named(String),
85}
86
87/// An item stored in the canvas state.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CanvasItem {
90    pub id: Uuid,
91    pub content_type: ContentType,
92    pub content: String,
93    pub created_at: chrono::DateTime<chrono::Utc>,
94}
95
96impl CanvasItem {
97    pub fn new(content_type: ContentType, content: String) -> Self {
98        Self {
99            id: Uuid::new_v4(),
100            content_type,
101            content,
102            created_at: chrono::Utc::now(),
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_canvas_message_push_serialization() {
113        let msg = CanvasMessage::Push {
114            target: CanvasTarget::Broadcast,
115            content_type: ContentType::Html,
116            content: "<h1>Hello</h1>".into(),
117        };
118        let json = serde_json::to_string(&msg).unwrap();
119        assert!(json.contains("Push"));
120        let restored: CanvasMessage = serde_json::from_str(&json).unwrap();
121        match restored {
122            CanvasMessage::Push {
123                content_type,
124                content,
125                ..
126            } => {
127                assert_eq!(content_type, ContentType::Html);
128                assert_eq!(content, "<h1>Hello</h1>");
129            }
130            _ => panic!("Wrong variant"),
131        }
132    }
133
134    #[test]
135    fn test_canvas_message_clear_serialization() {
136        let msg = CanvasMessage::Clear {
137            target: CanvasTarget::Named("main".into()),
138        };
139        let json = serde_json::to_string(&msg).unwrap();
140        let restored: CanvasMessage = serde_json::from_str(&json).unwrap();
141        match restored {
142            CanvasMessage::Clear { target } => {
143                assert_eq!(target, CanvasTarget::Named("main".into()));
144            }
145            _ => panic!("Wrong variant"),
146        }
147    }
148
149    #[test]
150    fn test_canvas_message_update_serialization() {
151        let msg = CanvasMessage::Update {
152            target: CanvasTarget::Broadcast,
153            content_type: ContentType::Markdown,
154            content: "# Updated".into(),
155        };
156        let json = serde_json::to_string(&msg).unwrap();
157        let _: CanvasMessage = serde_json::from_str(&json).unwrap();
158    }
159
160    #[test]
161    fn test_canvas_message_interact_serialization() {
162        let msg = CanvasMessage::Interact {
163            action: "click".into(),
164            selector: "#submit".into(),
165            data: serde_json::json!({"value": "ok"}),
166        };
167        let json = serde_json::to_string(&msg).unwrap();
168        let _: CanvasMessage = serde_json::from_str(&json).unwrap();
169    }
170
171    #[test]
172    fn test_canvas_message_event_serialization() {
173        let msg = CanvasMessage::Event {
174            event_type: "form_submit".into(),
175            target: CanvasTarget::Broadcast,
176            data: serde_json::json!({"field": "value"}),
177        };
178        let json = serde_json::to_string(&msg).unwrap();
179        let _: CanvasMessage = serde_json::from_str(&json).unwrap();
180    }
181
182    #[test]
183    fn test_canvas_message_state_serialization() {
184        let msg = CanvasMessage::State {
185            target: CanvasTarget::Broadcast,
186        };
187        let json = serde_json::to_string(&msg).unwrap();
188        let _: CanvasMessage = serde_json::from_str(&json).unwrap();
189    }
190
191    #[test]
192    fn test_canvas_message_snapshot_serialization() {
193        let msg = CanvasMessage::Snapshot {
194            target: CanvasTarget::Broadcast,
195            items: vec![CanvasItem::new(ContentType::Html, "<p>Test</p>".into())],
196        };
197        let json = serde_json::to_string(&msg).unwrap();
198        let restored: CanvasMessage = serde_json::from_str(&json).unwrap();
199        match restored {
200            CanvasMessage::Snapshot { items, .. } => {
201                assert_eq!(items.len(), 1);
202                assert_eq!(items[0].content_type, ContentType::Html);
203            }
204            _ => panic!("Wrong variant"),
205        }
206    }
207
208    #[test]
209    fn test_content_type_parsing() {
210        assert_eq!(ContentType::from_str_loose("html"), Some(ContentType::Html));
211        assert_eq!(
212            ContentType::from_str_loose("markdown"),
213            Some(ContentType::Markdown)
214        );
215        assert_eq!(
216            ContentType::from_str_loose("md"),
217            Some(ContentType::Markdown)
218        );
219        assert_eq!(ContentType::from_str_loose("code"), Some(ContentType::Code));
220        assert_eq!(
221            ContentType::from_str_loose("chart"),
222            Some(ContentType::Chart)
223        );
224        assert_eq!(
225            ContentType::from_str_loose("table"),
226            Some(ContentType::Table)
227        );
228        assert_eq!(ContentType::from_str_loose("form"), Some(ContentType::Form));
229        assert_eq!(
230            ContentType::from_str_loose("image"),
231            Some(ContentType::Image)
232        );
233        assert_eq!(ContentType::from_str_loose("img"), Some(ContentType::Image));
234        assert_eq!(
235            ContentType::from_str_loose("diagram"),
236            Some(ContentType::Diagram)
237        );
238        assert_eq!(
239            ContentType::from_str_loose("mermaid"),
240            Some(ContentType::Diagram)
241        );
242        assert_eq!(ContentType::from_str_loose("invalid"), None);
243    }
244
245    #[test]
246    fn test_content_type_case_insensitive() {
247        assert_eq!(ContentType::from_str_loose("HTML"), Some(ContentType::Html));
248        assert_eq!(
249            ContentType::from_str_loose("Chart"),
250            Some(ContentType::Chart)
251        );
252    }
253
254    #[test]
255    fn test_canvas_target_default() {
256        let target = CanvasTarget::default();
257        assert_eq!(target, CanvasTarget::Broadcast);
258    }
259}