Skip to main content

ag_ui_a2ui/
agui.rs

1//! Interop with [`ag_ui`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/index.html) (feature `ag-ui`).
2//!
3//! A2UI is transport-agnostic and this crate keeps it that way — nothing below
4//! this module knows what AG-UI is. What lives here is the small amount of
5//! glue an agent hosted on AG-UI would otherwise write by hand, twice:
6//!
7//! - [`HistoryMessage`] from an [`ag_ui::Message`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/message/enum.Message.html), so the surface-recovery
8//!   scan can read an AG-UI thread directly.
9//! - [`find_prior_surface_in`], the same scan without the mapping step.
10//! - [`ag_ui::Tool`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/tool/struct.Tool.html) from a [`ToolDefinition`], so the toolkit's two tool
11//!   definitions can be offered on a run.
12//!
13//! Turn the feature off to use A2UI standalone over A2A or MCP; the dependency
14//! on `ag-ui-core` goes with it.
15
16use ag_ui::{Message, Tool};
17use serde_json::Value;
18
19use crate::toolkit::history::{HistoryMessage, PriorSurface, find_prior_surface};
20use crate::toolkit::tools::ToolDefinition;
21
22/// Maps an AG-UI message onto the toolkit's transport-neutral history entry.
23///
24/// Text goes to `content` and structured payloads to `data`, which is the split
25/// [`crate::toolkit::history`] scans on. A user's multimodal parts are flattened
26/// to their text: an A2UI envelope is never an image.
27impl From<&Message> for HistoryMessage {
28    fn from(message: &Message) -> Self {
29        let role = message.role().as_str();
30        match message {
31            Message::Developer(m) => Self::text(role, m.content.clone()),
32            Message::System(m) => Self::text(role, m.content.clone()),
33            Message::Assistant(m) => Self::text(role, m.content.clone().unwrap_or_default()),
34            Message::User(m) => Self::text(role, m.content.to_text()),
35            Message::Tool(m) => Self::text(role, m.content.clone()),
36            Message::Reasoning(m) => Self::text(role, m.content.clone()),
37            Message::Activity(m) => Self::data(role, Value::Object(m.content.clone())),
38        }
39    }
40}
41
42/// Offers a toolkit tool definition on an AG-UI run.
43impl From<ToolDefinition> for Tool {
44    fn from(definition: ToolDefinition) -> Self {
45        Self::new(
46            definition.name,
47            definition.description,
48            definition.parameters,
49        )
50    }
51}
52
53/// [`find_prior_surface`] over an AG-UI conversation.
54///
55/// The agent stores nothing between runs, so "what is the user looking at" comes
56/// from the thread the client sent: this replays the A2UI operations already in
57/// it and reports the surface they built.
58///
59/// ```
60/// use ag_ui_a2ui::agui::find_prior_surface_in;
61/// use ag_ui_a2ui::toolkit::envelope::wrap_as_operations_envelope;
62/// use ag_ui_a2ui::{AgentMessage, Component};
63/// use ag_ui::Message;
64/// use serde_json::json;
65///
66/// let rendered = wrap_as_operations_envelope(&[
67///     AgentMessage::create_surface("board", "basic"),
68///     AgentMessage::update_components(
69///         "board",
70///         vec![Component::new("root", "Text").with("text", json!("hello"))],
71///     ),
72/// ])?;
73///
74/// let thread = [
75///     Message::user("m-1", "show me the board"),
76///     Message::tool("m-2", "call-1", rendered),
77/// ];
78///
79/// let prior = find_prior_surface_in(&thread).expect("the thread rendered a surface");
80/// assert_eq!(prior.surface_id, "board");
81/// assert_eq!(prior.catalog_id.as_deref(), Some("basic"));
82/// assert!(prior.component("root").is_some());
83/// # Ok::<(), ag_ui_a2ui::Error>(())
84/// ```
85pub fn find_prior_surface_in(messages: &[Message]) -> Option<PriorSurface> {
86    let history: Vec<HistoryMessage> = messages.iter().map(HistoryMessage::from).collect();
87    find_prior_surface(&history)
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::toolkit::envelope::wrap_as_operations_envelope;
94    use crate::toolkit::tools::{generate_a2ui_tool, render_a2ui_tool};
95    use crate::{AgentMessage, Component};
96    use ag_ui::{ActivityMessage, InputContent, JsonObject, UserContent};
97    use serde_json::json;
98
99    #[test]
100    fn every_role_maps_to_the_field_the_scan_reads() {
101        let mut activity = JsonObject::new();
102        activity.insert("step".into(), json!("searching"));
103
104        let cases: Vec<(Message, &str, &str, bool)> = vec![
105            (
106                Message::system("m", "be brief"),
107                "system",
108                "be brief",
109                false,
110            ),
111            (
112                Message::developer("m", "internal"),
113                "developer",
114                "internal",
115                false,
116            ),
117            (
118                Message::assistant("m", "on it"),
119                "assistant",
120                "on it",
121                false,
122            ),
123            (Message::user("m", "hello"), "user", "hello", false),
124            (
125                Message::tool("m", "call-1", "{\"ok\":true}"),
126                "tool",
127                "{\"ok\":true}",
128                false,
129            ),
130            (
131                Message::Activity(ActivityMessage {
132                    id: "m".into(),
133                    activity_type: "web_search".into(),
134                    content: activity,
135                    ..Default::default()
136                }),
137                "activity",
138                "",
139                true,
140            ),
141        ];
142
143        for (message, role, content, has_data) in cases {
144            let entry = HistoryMessage::from(&message);
145            assert_eq!(entry.role, role);
146            assert_eq!(entry.content, content, "content of {role}");
147            assert_eq!(entry.data.is_some(), has_data, "data of {role}");
148        }
149    }
150
151    #[test]
152    fn a_multimodal_turn_contributes_its_text() {
153        let message = Message::user(
154            "m",
155            UserContent::from(vec![
156                InputContent::text("look at this"),
157                InputContent::text("and this"),
158            ]),
159        );
160        assert_eq!(
161            HistoryMessage::from(&message).content,
162            "look at this\nand this"
163        );
164    }
165
166    #[test]
167    fn a_thread_with_no_a2ui_recovers_nothing() {
168        let thread = [
169            Message::user("m-1", "hello"),
170            Message::assistant("m-2", "hi there"),
171        ];
172        assert!(find_prior_surface_in(&thread).is_none());
173    }
174
175    #[test]
176    fn a_deleted_surface_is_recovered_as_deleted() {
177        let envelope = wrap_as_operations_envelope(&[
178            AgentMessage::create_surface("board", "basic"),
179            AgentMessage::update_components(
180                "board",
181                vec![Component::new("root", "Text").with("text", json!("hi"))],
182            ),
183            AgentMessage::delete_surface("board"),
184        ])
185        .expect("operations serialize");
186
187        let thread = [Message::tool("m-1", "call-1", envelope)];
188        let prior = find_prior_surface_in(&thread).expect("the surface was rendered");
189        assert!(prior.deleted);
190    }
191
192    #[test]
193    fn both_toolkit_tools_convert_into_offerable_tools() {
194        for definition in [generate_a2ui_tool(), render_a2ui_tool(None)] {
195            let name = definition.name;
196            let tool = Tool::from(definition);
197            assert_eq!(tool.name, name);
198            assert!(!tool.description.is_empty());
199            assert_eq!(tool.parameters["type"], "object");
200        }
201    }
202}