Skip to main content

ag_ui_a2ui/toolkit/
history.rs

1//! Recovering a previously rendered surface from conversation history.
2//!
3//! `intent = "update"` is only useful if the agent knows what it is updating.
4//! The surface it rendered a few turns ago is not stored anywhere — it went out
5//! over the transport and the renderer holds it. What the agent *does* still
6//! have is the conversation, and the operations it emitted are in there.
7//!
8//! [`find_prior_surface`] walks the messages newest-first, finds the most
9//! recently rendered surface, and replays every operation for that surface in
10//! order to reconstruct its components, data model, and `catalogId`. That
11//! reconstruction becomes the "here is what is on screen now" section of the
12//! next generation prompt.
13//!
14//! # Where the operations are found
15//!
16//! Two encodings are recognized, because both occur in practice: the
17//! [`A2UI_OPERATIONS_KEY`](crate::constants::A2UI_OPERATIONS_KEY) transport
18//! envelope, and raw `<a2ui-json>` blocks in an assistant turn. A message may
19//! also be the envelope itself rather than text containing one.
20//!
21//! A generation that failed is deliberately none of those — see
22//! [`wrap_error_envelope`](crate::toolkit::envelope::wrap_error_envelope) — so
23//! it contributes no operations and cannot be recovered as a surface that was
24//! never on screen.
25
26use std::collections::BTreeMap;
27
28use serde_json::Value;
29
30use crate::constants::DEFAULT_SURFACE_ID;
31use crate::message::{AgentMessage, AgentPayload, Component};
32use crate::toolkit::envelope::{is_operations_envelope, unwrap_operations_envelope};
33use crate::toolkit::parser::{has_a2ui_parts, unwrap_response};
34
35/// One entry of conversation history, oldest first in a slice.
36///
37/// Deliberately minimal: this crate does not depend on any particular chat
38/// message type, so callers map their own history onto this.
39#[derive(Debug, Clone, Default, PartialEq)]
40pub struct HistoryMessage {
41    /// Who produced the message. Only used for reporting.
42    pub role: String,
43    /// The textual content, which may embed an envelope or `<a2ui-json>` blocks.
44    pub content: String,
45    /// Structured payload, when the transport carried one (a tool result, an
46    /// A2A data part). Checked before `content`.
47    pub data: Option<Value>,
48}
49
50impl HistoryMessage {
51    /// A message with textual content only.
52    pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
53        Self {
54            role: role.into(),
55            content: content.into(),
56            data: None,
57        }
58    }
59
60    /// A message carrying a structured payload.
61    pub fn data(role: impl Into<String>, data: Value) -> Self {
62        Self {
63            role: role.into(),
64            content: String::new(),
65            data: Some(data),
66        }
67    }
68}
69
70/// A surface reconstructed from history.
71#[derive(Debug, Clone, PartialEq)]
72pub struct PriorSurface {
73    /// The surface's id, to reuse on update.
74    pub surface_id: String,
75    /// The catalog it was created with. `None` if no `createSurface` was found,
76    /// which happens when history only retains later incremental updates.
77    pub catalog_id: Option<String>,
78    /// Components as they stood after the last operation, in first-definition
79    /// order, with later definitions of the same id replacing earlier ones.
80    pub components: Vec<Component>,
81    /// The data model after replaying every `updateDataModel`.
82    pub data_model: Value,
83    /// Whether the surface was deleted after being rendered.
84    pub deleted: bool,
85}
86
87impl PriorSurface {
88    /// A component by id.
89    pub fn component(&self, id: &str) -> Option<&Component> {
90        self.components.iter().find(|c| c.id == id)
91    }
92}
93
94/// Finds the most recently rendered surface in the conversation.
95///
96/// Messages are newest-last, as they would be in a chat transcript. The scan
97/// runs backwards to pick the surface, then forwards to replay it, so an update
98/// targets whatever the user is actually looking at.
99///
100/// Returns `None` when history holds no A2UI at all.
101pub fn find_prior_surface(messages: &[HistoryMessage]) -> Option<PriorSurface> {
102    find_prior_surface_by_id(messages, None)
103}
104
105/// [`find_prior_surface`] restricted to one `surfaceId`.
106///
107/// Use this when several surfaces are live and the caller knows which one the
108/// user means.
109pub fn find_prior_surface_by_id(
110    messages: &[HistoryMessage],
111    surface_id: Option<&str>,
112) -> Option<PriorSurface> {
113    let per_message: Vec<Vec<AgentMessage>> = messages.iter().map(extract_operations).collect();
114
115    // Newest-first, so the surface the user last saw wins.
116    let target = match surface_id {
117        Some(id) => id.to_string(),
118        None => per_message
119            .iter()
120            .rev()
121            .flat_map(|ops| ops.iter().rev())
122            .find_map(|op| op.surface_id().map(str::to_string))?,
123    };
124
125    let mut components: BTreeMap<String, (usize, Component)> = BTreeMap::new();
126    let mut order = 0usize;
127    let mut data_model = Value::Null;
128    let mut catalog_id = None;
129    let mut deleted = false;
130    let mut seen = false;
131
132    // Oldest-first, so the reconstruction ends where the renderer is now.
133    for op in per_message.iter().flatten() {
134        if op.surface_id() != Some(target.as_str()) {
135            continue;
136        }
137        seen = true;
138        match &op.payload {
139            AgentPayload::CreateSurface(create) => {
140                catalog_id = Some(create.catalog_id.clone());
141                // Re-creating a surface id starts it over.
142                components.clear();
143                data_model = Value::Null;
144                deleted = false;
145            }
146            AgentPayload::UpdateComponents(update) => {
147                deleted = false;
148                for component in &update.components {
149                    match components.get_mut(&component.id) {
150                        // Keep the original position; a redefinition replaces
151                        // the body, not the ordering.
152                        Some((_, existing)) => *existing = component.clone(),
153                        None => {
154                            components.insert(component.id.clone(), (order, component.clone()));
155                            order += 1;
156                        }
157                    }
158                }
159            }
160            AgentPayload::UpdateDataModel(update) => {
161                let _ = update.apply(&mut data_model);
162            }
163            AgentPayload::DeleteSurface(_) => {
164                deleted = true;
165                components.clear();
166                data_model = Value::Null;
167            }
168            _ => {}
169        }
170    }
171
172    if !seen {
173        return None;
174    }
175
176    let mut ordered: Vec<(usize, Component)> = components.into_values().collect();
177    ordered.sort_by_key(|(position, _)| *position);
178
179    Some(PriorSurface {
180        surface_id: target,
181        catalog_id,
182        components: ordered.into_iter().map(|(_, c)| c).collect(),
183        data_model,
184        deleted,
185    })
186}
187
188/// A surface id that will not collide with anything already in history.
189///
190/// `createSurface` requires a globally unique id for the renderer's lifetime, so
191/// creating a second surface in the same conversation needs a fresh one.
192pub fn next_surface_id(messages: &[HistoryMessage], base: &str) -> String {
193    let base = if base.is_empty() {
194        DEFAULT_SURFACE_ID
195    } else {
196        base
197    };
198    let used: Vec<String> = messages
199        .iter()
200        .flat_map(extract_operations)
201        .filter_map(|op| op.surface_id().map(str::to_string))
202        .collect();
203    if !used.iter().any(|id| id == base) {
204        return base.to_string();
205    }
206    (2..)
207        .map(|n| format!("{base}-{n}"))
208        .find(|candidate| !used.contains(candidate))
209        .unwrap_or_else(|| format!("{base}-{}", used.len() + 1))
210}
211
212/// Pulls every A2UI operation out of one history message.
213fn extract_operations(message: &HistoryMessage) -> Vec<AgentMessage> {
214    let mut out = Vec::new();
215
216    if let Some(data) = &message.data {
217        collect_from_value(data, &mut out);
218    }
219
220    let content = message.content.trim();
221    if content.is_empty() {
222        return out;
223    }
224    if let Ok(value) = serde_json::from_str::<Value>(content) {
225        collect_from_value(&value, &mut out);
226    }
227    if has_a2ui_parts(content) {
228        if let Ok(parts) = unwrap_response(content) {
229            for part in parts {
230                let Some(raw) = part.raw else { continue };
231                if let Ok(value) = serde_json::from_str::<Value>(&raw) {
232                    collect_from_value(&value, &mut out);
233                }
234            }
235        }
236    }
237    out
238}
239
240fn collect_from_value(value: &Value, out: &mut Vec<AgentMessage>) {
241    // `error` first, the way upstream's part converter reads it: a payload that
242    // reports a failure describes a surface that never reached the renderer,
243    // whether or not it also carries the operations key. This crate's own
244    // `wrap_error_envelope` no longer sends both, but producers that predate
245    // that split — and the other toolkits — still do. No agent → renderer
246    // message has a top-level `error`, so nothing legitimate is skipped here.
247    if value.get("error").is_some() {
248        return;
249    }
250    if is_operations_envelope(value) {
251        if let Ok(operations) = unwrap_operations_envelope(value) {
252            out.extend(operations);
253        }
254        return;
255    }
256    match value {
257        Value::Array(items) => {
258            for item in items {
259                if let Ok(message) = serde_json::from_value::<AgentMessage>(item.clone()) {
260                    out.push(message);
261                }
262            }
263        }
264        Value::Object(_) => {
265            if let Ok(message) = serde_json::from_value::<AgentMessage>(value.clone()) {
266                out.push(message);
267            }
268        }
269        _ => {}
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::toolkit::envelope::{wrap_as_operations_envelope, wrap_error_envelope};
277    use crate::toolkit::ops::{Intent, SurfaceSpec, assemble_ops};
278    use serde_json::json;
279
280    fn rendered(surface: &str, text: &str) -> HistoryMessage {
281        let spec = SurfaceSpec::new(surface)
282            .with_components(vec![
283                Component::new("root", "Column").with("children", json!(["label"])),
284                Component::new("label", "Text").with("text", json!({"path": "/title"})),
285            ])
286            .with_data_model(json!({"title": text}));
287        let envelope = wrap_as_operations_envelope(&assemble_ops(Intent::Create, &spec)).unwrap();
288        HistoryMessage::text("assistant", envelope)
289    }
290
291    #[test]
292    fn a_surface_is_recovered_from_an_operations_envelope() {
293        let history = vec![
294            HistoryMessage::text("user", "show me the cart"),
295            rendered("cart", "Your cart"),
296        ];
297        let prior = find_prior_surface(&history).unwrap();
298        assert_eq!(prior.surface_id, "cart");
299        assert_eq!(
300            prior.catalog_id.as_deref(),
301            Some(crate::constants::BASIC_CATALOG_ID)
302        );
303        assert_eq!(prior.components.len(), 2);
304        assert_eq!(
305            prior.component("label").unwrap().prop("text"),
306            Some(&json!({"path": "/title"}))
307        );
308        assert_eq!(prior.data_model, json!({"title": "Your cart"}));
309        assert!(!prior.deleted);
310    }
311
312    #[test]
313    fn the_newest_surface_wins() {
314        let history = vec![
315            rendered("first", "one"),
316            HistoryMessage::text("user", "now show the other one"),
317            rendered("second", "two"),
318        ];
319        assert_eq!(find_prior_surface(&history).unwrap().surface_id, "second");
320        assert_eq!(
321            find_prior_surface_by_id(&history, Some("first"))
322                .unwrap()
323                .data_model,
324            json!({"title": "one"})
325        );
326    }
327
328    #[test]
329    fn later_updates_are_folded_onto_the_original() {
330        let mut history = vec![rendered("cart", "Your cart")];
331        let update = SurfaceSpec::new("cart")
332            .with_components(vec![
333                Component::new("label", "Text").with("text", json!("Checkout")),
334                Component::new("extra", "Text").with("text", json!("free shipping")),
335            ])
336            .with_data_model(json!("Updated"))
337            .with_data_path("/title");
338        history.push(HistoryMessage::text(
339            "assistant",
340            wrap_as_operations_envelope(&assemble_ops(Intent::Update, &update)).unwrap(),
341        ));
342
343        let prior = find_prior_surface(&history).unwrap();
344        // Redefinition replaces in place; a new id appends.
345        let ids: Vec<&str> = prior.components.iter().map(|c| c.id.as_str()).collect();
346        assert_eq!(ids, vec!["root", "label", "extra"]);
347        assert_eq!(
348            prior.component("label").unwrap().prop("text"),
349            Some(&json!("Checkout"))
350        );
351        assert_eq!(prior.data_model, json!({"title": "Updated"}));
352        assert_eq!(
353            prior.catalog_id.as_deref(),
354            Some(crate::constants::BASIC_CATALOG_ID)
355        );
356    }
357
358    #[test]
359    fn raw_a2ui_json_blocks_are_recognized_too() {
360        let block = format!(
361            "Here you go\n<a2ui-json>{}</a2ui-json>",
362            serde_json::to_string(&assemble_ops(
363                Intent::Create,
364                &SurfaceSpec::new("inline").with_components(vec![
365                    Component::new("root", "Text").with("text", json!("x"))
366                ])
367            ))
368            .unwrap()
369        );
370        let prior = find_prior_surface(&[HistoryMessage::text("assistant", block)]).unwrap();
371        assert_eq!(prior.surface_id, "inline");
372        assert_eq!(prior.components.len(), 1);
373    }
374
375    #[test]
376    fn structured_payloads_are_read_before_text() {
377        let envelope: Value = serde_json::from_str(
378            &wrap_as_operations_envelope(&assemble_ops(
379                Intent::Create,
380                &SurfaceSpec::new("tool-result").with_components(vec![
381                    Component::new("root", "Text").with("text", json!("x")),
382                ]),
383            ))
384            .unwrap(),
385        )
386        .unwrap();
387        let prior = find_prior_surface(&[HistoryMessage::data("tool", envelope)]).unwrap();
388        assert_eq!(prior.surface_id, "tool-result");
389    }
390
391    #[test]
392    fn a_deleted_surface_is_reported_as_deleted() {
393        let mut history = vec![rendered("cart", "Your cart")];
394        history.push(HistoryMessage::text(
395            "assistant",
396            wrap_as_operations_envelope(&[AgentMessage::delete_surface("cart")]).unwrap(),
397        ));
398        let prior = find_prior_surface(&history).unwrap();
399        assert!(prior.deleted);
400        assert!(prior.components.is_empty());
401    }
402
403    #[test]
404    fn recreating_a_surface_id_starts_it_over() {
405        let history = vec![rendered("cart", "old"), rendered("cart", "new")];
406        let prior = find_prior_surface(&history).unwrap();
407        assert_eq!(prior.data_model, json!({"title": "new"}));
408        assert_eq!(prior.components.len(), 2);
409    }
410
411    #[test]
412    fn a_failed_surface_is_never_read_back_as_a_prior_one() {
413        let failure = wrap_error_envelope("cart", "could not build the surface", &[]).unwrap();
414        let as_text = HistoryMessage::text("assistant", failure.as_str());
415        let as_data = HistoryMessage::data("tool", serde_json::from_str(&failure).unwrap());
416
417        // Whichever way the transport carried it, it describes a surface that
418        // was never rendered, so there is nothing to recover.
419        assert!(find_prior_surface(std::slice::from_ref(&as_text)).is_none());
420        assert!(find_prior_surface(&[as_data]).is_none());
421        assert!(find_prior_surface_by_id(std::slice::from_ref(&as_text), Some("cart")).is_none());
422
423        // And arriving after a real surface, it leaves that surface alone —
424        // including its id, which the failure names too.
425        let rendered_only = vec![rendered("cart", "Your cart")];
426        let then_failed = vec![rendered("cart", "Your cart"), as_text];
427        assert_eq!(
428            find_prior_surface(&then_failed),
429            find_prior_surface(&rendered_only)
430        );
431        assert_eq!(next_surface_id(&then_failed, "cart"), "cart-2");
432    }
433
434    #[test]
435    fn a_failure_that_also_carries_operations_is_still_not_a_surface() {
436        // Not this crate's own shape any more, but three producers still send
437        // it: this crate before the envelope split, and the TypeScript and .NET
438        // toolkits, whose part converters check `error` first and emit no A2UI
439        // at all. Reading the operations back would put a surface the user
440        // never saw into the next generation prompt.
441        let mut failed = serde_json::from_str::<Value>(
442            &wrap_as_operations_envelope(&crate::toolkit::ops::assemble_ops(
443                Intent::Create,
444                &SurfaceSpec::new("cart").with_components(vec![
445                    Component::new("root", "Text").with("text", json!("x")),
446                ]),
447            ))
448            .unwrap(),
449        )
450        .unwrap();
451
452        for error in [json!("could not build the surface"), json!({"code": "X"})] {
453            failed["error"] = error;
454            let as_data = HistoryMessage::data("tool", failed.clone());
455            let as_text = HistoryMessage::text("assistant", failed.to_string());
456            assert!(find_prior_surface(&[as_data]).is_none(), "{failed}");
457            assert!(find_prior_surface(&[as_text]).is_none(), "{failed}");
458        }
459    }
460
461    #[test]
462    fn history_without_a2ui_yields_nothing() {
463        let history = vec![
464            HistoryMessage::text("user", "hello"),
465            HistoryMessage::text("assistant", "hi there"),
466            HistoryMessage::text("assistant", r#"{"some": "unrelated json"}"#),
467        ];
468        assert!(find_prior_surface(&history).is_none());
469        assert!(find_prior_surface_by_id(&history, Some("cart")).is_none());
470    }
471
472    #[test]
473    fn next_surface_id_avoids_ids_already_used() {
474        let history = vec![rendered("cart", "one")];
475        assert_eq!(next_surface_id(&history, "cart"), "cart-2");
476        assert_eq!(next_surface_id(&history, "other"), "other");
477        assert_eq!(next_surface_id(&[], ""), DEFAULT_SURFACE_ID);
478    }
479}