Skip to main content

ag_ui_a2ui/toolkit/
streaming.rs

1//! Incremental parsing of a model's A2UI output as it streams.
2//!
3//! [`crate::toolkit::parser`] waits for the whole generation before it can hand
4//! back a surface. That is the wrong shape for A2UI, whose entire component
5//! model exists so a renderer can start painting as soon as `root` arrives.
6//! [`StreamParser`] closes that gap: feed it token chunks and it emits renderable
7//! A2UI as soon as enough of the tree has arrived to draw something.
8//!
9//! ```
10//! use ag_ui_a2ui::catalog::Catalog;
11//! use ag_ui_a2ui::toolkit::streaming::StreamParser;
12//!
13//! let catalog = Catalog::basic();
14//! let mut parser = StreamParser::new(catalog);
15//!
16//! // Conversational text comes out immediately.
17//! let parts = parser.process_chunk("Here you go. <a2ui-json>[").unwrap();
18//! assert_eq!(parts[0].text, "Here you go. ");
19//!
20//! // A message is emitted the moment it closes, mid-array.
21//! let parts = parser
22//!     .process_chunk(r#"{"version":"v0.9","createSurface":{"surfaceId":"s","catalogId":"c"}},"#)
23//!     .unwrap();
24//! assert_eq!(parts[0].a2ui.as_ref().unwrap().len(), 1);
25//! ```
26//!
27//! # What "as soon as possible" actually means
28//!
29//! Four mechanisms do the work, and they are the reason this is not simply a
30//! JSON parser fed one byte at a time:
31//!
32//! **Healing cut tokens.** A chunk boundary can land anywhere, including inside
33//! a string. The parser closes open braces and brackets to make the fragment
34//! parseable, but it will only close an open *string* for a key on the cuttable
35//! list — `text`, `label`, `hint` and friends. Cutting `"id"` or `"path"` would
36//! invent an identifier or a binding that the model never wrote, so those
37//! fragments are held back until the next chunk instead.
38//!
39//! **Placeholder synthesis.** A parent usually arrives before its children. Its
40//! child references are rewritten to `loading_<id>` and a stand-in component is
41//! emitted alongside, so the renderer can lay out the tree immediately and swap
42//! in the real component when it lands.
43//!
44//! **Reachability filtering.** Only components reachable from `root` are
45//! emitted. A component that arrives before its parent is cached, not sent — it
46//! would have nowhere to attach — and is re-sent as part of the tree once the
47//! path from the root exists.
48//!
49//! **Validation as a filter, not a failure.** Partial fragments are validated
50//! and silently dropped if they do not hold up. A placeholder of a type the
51//! catalog does not define, or a component still missing a required property,
52//! is simply not emitted yet. Structural failures that no further input can fix
53//! — a reference loop, a message that matches no envelope — are errors.
54//!
55//! # State is per-stream
56//!
57//! A parser instance carries the surface state for one generation: which
58//! surfaces exist, which components have been seen and emitted, and the data
59//! model so far. Create a new one per generation.
60
61use std::collections::{BTreeMap, BTreeSet};
62
63use serde_json::{Map, Value};
64
65use crate::catalog::Catalog;
66use crate::constants::{A2UI_CLOSE_TAG, A2UI_OPEN_TAG, PROTOCOL_VERSION, ROOT_ID};
67use crate::error::{Error, Result};
68use crate::toolkit::parser::ResponsePart;
69use crate::validate::{OPERATIONS, ValidateOptions, Validator};
70
71/// Message keys the parser recognizes, in envelope order.
72const MSG_CREATE_SURFACE: &str = "createSurface";
73const MSG_UPDATE_COMPONENTS: &str = "updateComponents";
74const MSG_UPDATE_DATA_MODEL: &str = "updateDataModel";
75const MSG_DELETE_SURFACE: &str = "deleteSurface";
76
77/// The four together, for the places that ask "is this a message, and which".
78const MESSAGE_KEYS: [&str; 4] = [
79    MSG_CREATE_SURFACE,
80    MSG_UPDATE_COMPONENTS,
81    MSG_UPDATE_DATA_MODEL,
82    MSG_DELETE_SURFACE,
83];
84
85/// Component properties that hold child references.
86///
87/// Pruning walks these by name rather than by catalog type, because a partial
88/// component may not have named its type yet.
89const CHILD_FIELDS: [&str; 6] = [
90    "children",
91    "explicitList",
92    "child",
93    "contentChild",
94    "entryPointChild",
95    "componentId",
96];
97
98/// How far the metadata sniffer rewinds between passes.
99///
100/// Comfortably longer than the keys it looks for (`"surfaceId"`, `"root"`), so a
101/// key split across two passes is still matched whole.
102const SNIFF_OVERLAP: usize = 16;
103
104/// Keys whose string values may be closed early when a chunk cuts them.
105///
106/// Everything absent from this list is structural or atomic: healing `"id"` or
107/// `"path"` mid-token would fabricate an identifier or a data binding that the
108/// model never wrote, so those fragments wait for more input instead.
109pub const DEFAULT_CUTTABLE_KEYS: [&str; 7] = [
110    "literalString",
111    "valueString",
112    "label",
113    "hint",
114    "caption",
115    "altText",
116    "text",
117];
118
119/// Parses a model's A2UI output incrementally, one chunk at a time.
120#[derive(Clone, Debug)]
121pub struct StreamParser {
122    catalog: Catalog,
123    validate: bool,
124    cuttable_keys: BTreeSet<String>,
125
126    // --- text and JSON scanning ---
127    buffer: String,
128    found_delimiter: bool,
129    json_buffer: String,
130    /// Open brackets as `(kind, byte offset into json_buffer)`. Its length is
131    /// the nesting depth and its first frame says whether the block opened with
132    /// the array of messages, so neither is tracked separately.
133    brace_stack: Vec<(char, usize)>,
134    in_string: bool,
135    string_escaped: bool,
136    found_valid_json_in_block: bool,
137    /// How much of `json_buffer` the metadata sniffer has already read.
138    sniff_cursor: usize,
139
140    // --- protocol state ---
141    seen_components: BTreeMap<String, Value>,
142    /// Data-model entries already emitted, per surface. Keyed by surface
143    /// because two surfaces may legitimately hold the same value at the same
144    /// path, and the second one still has to be sent.
145    yielded_data_model: BTreeMap<String, Map<String, Value>>,
146    deleted_surfaces: BTreeSet<String>,
147    /// Component ids already emitted, per surface.
148    yielded_ids: BTreeMap<String, BTreeSet<String>>,
149    /// Canonical content of each emitted component, for change detection.
150    yielded_contents: BTreeMap<(String, String), String>,
151    root_ids: BTreeMap<String, String>,
152    unbound_root_id: Option<String>,
153    surface_id: Option<String>,
154    yielded_start_messages: BTreeSet<String>,
155    yielded_surfaces: BTreeSet<String>,
156    active_msg_type: Option<String>,
157    buffered_start_message: Option<Value>,
158    topology_dirty: bool,
159}
160
161impl StreamParser {
162    /// A parser for a stream of components drawn from `catalog`.
163    pub fn new(catalog: Catalog) -> Self {
164        Self {
165            catalog,
166            validate: true,
167            cuttable_keys: DEFAULT_CUTTABLE_KEYS
168                .iter()
169                .map(|k| (*k).to_string())
170                .collect(),
171            buffer: String::new(),
172            found_delimiter: false,
173            json_buffer: String::new(),
174            brace_stack: Vec::new(),
175            in_string: false,
176            string_escaped: false,
177            found_valid_json_in_block: false,
178            sniff_cursor: 0,
179            seen_components: BTreeMap::new(),
180            yielded_data_model: BTreeMap::new(),
181            deleted_surfaces: BTreeSet::new(),
182            yielded_ids: BTreeMap::new(),
183            yielded_contents: BTreeMap::new(),
184            root_ids: BTreeMap::new(),
185            unbound_root_id: None,
186            surface_id: None,
187            yielded_start_messages: BTreeSet::new(),
188            yielded_surfaces: BTreeSet::new(),
189            active_msg_type: None,
190            buffered_start_message: None,
191            topology_dirty: false,
192        }
193    }
194
195    /// Overrides which keys may have their string values closed early.
196    ///
197    /// A catalog whose components carry long free-text properties under other
198    /// names can add them here to keep those streaming smoothly.
199    #[must_use]
200    pub fn with_cuttable_keys<I, S>(mut self, keys: I) -> Self
201    where
202        I: IntoIterator<Item = S>,
203        S: Into<String>,
204    {
205        self.cuttable_keys = keys.into_iter().map(Into::into).collect();
206        self
207    }
208
209    /// Turns off validation, emitting whatever parses.
210    ///
211    /// Only useful when the catalog is not known to this process; the filtering
212    /// that validation provides is most of what makes partial output safe to
213    /// render.
214    #[must_use]
215    pub fn without_validation(mut self) -> Self {
216        self.validate = false;
217        self
218    }
219
220    /// The surface the stream is currently describing.
221    pub fn surface_id(&self) -> Option<&str> {
222        self.surface_id.as_deref()
223    }
224
225    /// The root component id for the active surface.
226    pub fn root_id(&self) -> &str {
227        match &self.surface_id {
228            Some(sid) => self
229                .root_ids
230                .get(sid)
231                .map(String::as_str)
232                .unwrap_or(ROOT_ID),
233            None => self.unbound_root_id.as_deref().unwrap_or(ROOT_ID),
234        }
235    }
236
237    fn set_surface_id(&mut self, value: Option<String>) {
238        if let (Some(sid), Some(root)) = (&value, self.unbound_root_id.take()) {
239            self.root_ids.insert(sid.clone(), root);
240        }
241        self.surface_id = value;
242    }
243
244    fn set_root_id(&mut self, value: String) {
245        match &self.surface_id {
246            Some(sid) => {
247                self.root_ids.insert(sid.clone(), value);
248            }
249            None => self.unbound_root_id = Some(value),
250        }
251    }
252
253    /// Feeds the next chunk of the model's output.
254    ///
255    /// Returns the parts that became renderable because of this chunk:
256    /// conversational text, complete A2UI messages, and partial updates that are
257    /// safe to draw. An empty vector means the chunk did not complete anything.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`Error::Parse`] for a block that contained no JSON at all, and
262    /// [`Error::Validation`] for output no further input can rescue: a message
263    /// matching no envelope, a missing required envelope field, or a reference
264    /// loop.
265    pub fn process_chunk(&mut self, chunk: &str) -> Result<Vec<ResponsePart>> {
266        let mut parts: Vec<ResponsePart> = Vec::new();
267        self.buffer.push_str(chunk);
268
269        loop {
270            if !self.found_delimiter {
271                match find_open_tag(&self.buffer) {
272                    Some((start, Some(end))) => {
273                        if start > 0 {
274                            parts.push(text_part(&self.buffer[..start]));
275                        }
276                        self.buffer = self.buffer[end..].to_string();
277                        self.found_delimiter = true;
278                        continue;
279                    }
280                    // A tag has started but has not closed. Hold it back, so
281                    // `<a2u` or a half-written attribute list never escapes as
282                    // conversational text.
283                    Some((start, None)) => {
284                        if start > 0 {
285                            parts.push(text_part(&self.buffer[..start]));
286                            self.buffer = self.buffer[start..].to_string();
287                        }
288                    }
289                    None => {
290                        if !self.buffer.is_empty() {
291                            parts.push(text_part(&self.buffer));
292                            self.buffer.clear();
293                        }
294                    }
295                }
296                break;
297            }
298
299            if let Some(index) = self.buffer.find(A2UI_CLOSE_TAG) {
300                let fragment = self.buffer[..index].to_string();
301                self.process_json_chunk(&fragment, &mut parts)?;
302                if !self.found_valid_json_in_block {
303                    return Err(Error::parse(
304                        "Failed to parse JSON: No valid JSON object found in A2UI block.",
305                    ));
306                }
307                self.buffer = self.buffer[index + A2UI_CLOSE_TAG.len()..].to_string();
308                self.found_delimiter = false;
309                self.reset_json_state();
310                continue;
311            }
312
313            let keep = trailing_prefix_len(&self.buffer, A2UI_CLOSE_TAG);
314            if keep < self.buffer.len() {
315                let split = self.buffer.len() - keep;
316                let fragment = self.buffer[..split].to_string();
317                self.buffer = self.buffer[split..].to_string();
318                self.process_json_chunk(&fragment, &mut parts)?;
319            }
320            break;
321        }
322        Ok(parts)
323    }
324
325    fn reset_json_state(&mut self) {
326        self.json_buffer.clear();
327        self.brace_stack.clear();
328        self.in_string = false;
329        self.string_escaped = false;
330        self.found_valid_json_in_block = false;
331        self.sniff_cursor = 0;
332        // `active_msg_type` and the yielded-content map deliberately survive, so
333        // a second block can keep updating the surface built by the first.
334    }
335
336    /// Whether the block's outermost open container is the array of messages.
337    fn in_top_level_list(&self) -> bool {
338        matches!(self.brace_stack.first(), Some(('[', _)))
339    }
340
341    /// Scans one JSON fragment, emitting whatever it completes.
342    fn process_json_chunk(&mut self, chunk: &str, parts: &mut Vec<ResponsePart>) -> Result<()> {
343        for ch in chunk.chars() {
344            // Outside any object, only a container opener is interesting.
345            if self.brace_stack.is_empty() && ch != '[' && ch != '{' {
346                continue;
347            }
348
349            if self.in_string {
350                self.scan_string_char(ch);
351            } else {
352                match ch {
353                    '"' => {
354                        self.in_string = true;
355                        self.string_escaped = false;
356                        self.push_json(ch);
357                    }
358                    '{' | '[' => {
359                        self.brace_stack.push((ch, self.json_buffer.len()));
360                        self.json_buffer.push(ch);
361                    }
362                    '}' => self.close_object(parts)?,
363                    ']' => {
364                        if self.brace_stack.last().map(|(k, _)| *k) == Some('[') {
365                            self.brace_stack.pop();
366                            self.json_buffer.push(']');
367                        }
368                    }
369                    _ => self.push_json(ch),
370                }
371            }
372
373            // Identifiers are sniffed eagerly on delimiters so a surfaceId is
374            // known before the message carrying it finishes.
375            if !self.brace_stack.is_empty() && matches!(ch, '"' | ':' | ',' | '}' | ']') {
376                self.sniff_metadata();
377            }
378        }
379
380        if !self.brace_stack.is_empty() && !self.json_buffer.is_empty() {
381            self.sniff_partial_component();
382            self.sniff_partial_data_model(parts);
383        }
384        if self.topology_dirty {
385            self.yield_reachable(parts)?;
386            self.topology_dirty = false;
387        }
388        Ok(())
389    }
390
391    fn push_json(&mut self, ch: char) {
392        if !self.brace_stack.is_empty() {
393            self.json_buffer.push(ch);
394        }
395    }
396
397    fn scan_string_char(&mut self, ch: char) {
398        if self.string_escaped {
399            self.string_escaped = false;
400        } else if ch == '\\' {
401            self.string_escaped = true;
402        } else if ch == '"' {
403            self.in_string = false;
404        }
405        self.push_json(ch);
406    }
407
408    /// Handles a `}`: pops the frame and, if the object parses, dispatches it.
409    fn close_object(&mut self, parts: &mut Vec<ResponsePart>) -> Result<()> {
410        let Some((_, start)) = self.brace_stack.pop() else {
411            return Ok(());
412        };
413        self.json_buffer.push('}');
414
415        let fragment = self.json_buffer[start..].to_string();
416        if !fragment.starts_with('{') || !fragment.ends_with('}') {
417            return Ok(());
418        }
419        let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&fragment) else {
420            return Ok(());
421        };
422        self.found_valid_json_in_block = true;
423        let obj = Value::Object(map);
424
425        let is_protocol = self.in_top_level_list() && is_protocol_message(&obj);
426        let is_component = obj.get("id").is_some() && obj.get("component").is_some();
427        let is_top_level = self.brace_stack.is_empty()
428            || (self.in_top_level_list() && self.brace_stack.len() == 1);
429
430        if is_component {
431            self.handle_partial_component(&obj);
432        } else if (is_top_level || is_protocol) && !self.handle_complete_object(&obj, parts)? {
433            // Nothing recognized it, so validate it to surface the reason.
434            self.yield_message(obj.clone(), parts, false)?;
435        }
436
437        // Drop processed objects from the buffer so it does not grow without
438        // bound across a long generation.
439        if is_top_level {
440            if self.in_top_level_list() && self.brace_stack.len() == 1 {
441                let mut kept = self.json_buffer[..start].to_string();
442                kept.push_str(&self.json_buffer[start + fragment.len()..]);
443                self.json_buffer = kept;
444            } else {
445                self.json_buffer = self.json_buffer[fragment.len()..].to_string();
446                let shift = fragment.len();
447                for entry in &mut self.brace_stack {
448                    entry.1 = entry.1.saturating_sub(shift);
449                }
450            }
451        }
452        Ok(())
453    }
454
455    /// Reacts to a fully parsed protocol message. Returns whether it was one.
456    fn handle_complete_object(
457        &mut self,
458        obj: &Value,
459        parts: &mut Vec<ResponsePart>,
460    ) -> Result<bool> {
461        if !is_protocol_message(obj) {
462            return Ok(false);
463        }
464        if self.validate {
465            self.validate_message(obj)?;
466        }
467
468        let payload_surface = MESSAGE_KEYS
469            .iter()
470            .find_map(|key| obj.get(*key))
471            .and_then(|payload| payload.get("surfaceId"))
472            .and_then(Value::as_str)
473            .map(str::to_string);
474        if payload_surface.is_some() {
475            self.set_surface_id(payload_surface);
476        }
477        let sid = self
478            .surface_id
479            .clone()
480            .unwrap_or_else(|| "unknown".to_string());
481
482        if let Some(payload) = obj.get(MSG_CREATE_SURFACE) {
483            if let Some(root) = payload.get("root").and_then(Value::as_str) {
484                self.set_root_id(root.to_string());
485            }
486            self.active_msg_type = Some(MSG_CREATE_SURFACE.to_string());
487            self.buffered_start_message = Some(obj.clone());
488            if !self.yielded_start_messages.contains(&sid) {
489                self.yield_message(obj.clone(), parts, false)?;
490                self.yielded_start_messages.insert(sid.clone());
491                self.yielded_surfaces.insert(sid.clone());
492                self.buffered_start_message = None;
493            }
494            // A fresh surface is live again: replacing a surface is
495            // delete-then-create on the same id, and leaving it marked deleted
496            // would suppress every component that follows.
497            self.deleted_surfaces.remove(&sid);
498            self.yield_reachable(parts)?;
499            return Ok(true);
500        }
501
502        if let Some(payload) = obj.get(MSG_UPDATE_COMPONENTS) {
503            self.active_msg_type = Some(MSG_UPDATE_COMPONENTS.to_string());
504            if let Some(root) = payload.get("root").and_then(Value::as_str) {
505                self.set_root_id(root.to_string());
506            }
507            if let Some(components) = payload.get("components").and_then(Value::as_array) {
508                for component in components {
509                    if let Some(id) = component.get("id").and_then(Value::as_str) {
510                        self.seen_components
511                            .insert(id.to_string(), component.clone());
512                    }
513                }
514            }
515            self.yield_reachable(parts)?;
516            return Ok(true);
517        }
518
519        if obj.get(MSG_DELETE_SURFACE).is_some() {
520            if !self.yielded_start_messages.contains(&sid) {
521                // The surface was never created, so there is nothing to delete
522                // and nothing to forward: a renderer that never drew it would
523                // have to invent it in order to remove it.
524                return Ok(true);
525            }
526            self.delete_surface(&sid);
527            self.yield_message(obj.clone(), parts, false)?;
528            return Ok(true);
529        }
530
531        if obj.get(MSG_UPDATE_DATA_MODEL).is_some() {
532            self.yield_message(obj.clone(), parts, false)?;
533            return Ok(true);
534        }
535        Ok(false)
536    }
537
538    fn delete_surface(&mut self, sid: &str) {
539        self.yielded_ids.remove(sid);
540        self.yielded_contents
541            .retain(|(surface, _), _| surface != sid);
542        self.yielded_surfaces.remove(sid);
543        self.yielded_start_messages.remove(sid);
544        self.deleted_surfaces.insert(sid.to_string());
545    }
546
547    /// Caches a component seen before its message finished.
548    fn handle_partial_component(&mut self, comp: &Value) {
549        let Some(id) = comp.get("id").and_then(Value::as_str) else {
550            return;
551        };
552        // An empty object anywhere means a property has been opened but not
553        // filled — `"children": {`. Emitting that would violate the catalog, so
554        // the parent is left to draw a placeholder instead.
555        if has_empty_object(comp) {
556            return;
557        }
558        self.seen_components.insert(id.to_string(), comp.clone());
559        self.topology_dirty = true;
560    }
561
562    /// Emits every component currently reachable from the root.
563    fn yield_reachable(&mut self, parts: &mut Vec<ResponsePart>) -> Result<()> {
564        let Some(active_msg_type) = self.active_msg_type.clone() else {
565            return Ok(());
566        };
567        let Some(sid) = self.surface_id.clone() else {
568            return Ok(());
569        };
570        // Components mean nothing until the surface they attach to exists.
571        if !self.yielded_surfaces.contains(&sid) && self.buffered_start_message.is_none() {
572            return Ok(());
573        }
574        if self.deleted_surfaces.contains(&sid) {
575            return Ok(());
576        }
577
578        let root_id = self.root_id().to_string();
579        let reachable = self.analyze_topology(&root_id)?;
580
581        // Hoisted out of the loop: both were being rebuilt per component, and
582        // cloning the raw buffer once per component turns a large message into
583        // quadratic copying.
584        let seen: BTreeSet<&str> = self.seen_components.keys().map(String::as_str).collect();
585        let mut processed: Vec<Value> = Vec::new();
586        let mut extras: Vec<Value> = Vec::new();
587        for id in &reachable {
588            let Some(component) = self.seen_components.get(id) else {
589                continue;
590            };
591            let mut component = component.clone();
592            let comp_id = component
593                .get("id")
594                .and_then(Value::as_str)
595                .unwrap_or("unknown")
596                .to_string();
597            rewrite_children(
598                &mut component,
599                &comp_id,
600                &seen,
601                &mut extras,
602                &self.json_buffer,
603            );
604            processed.push(component);
605        }
606        processed.extend(extras);
607
608        let yielded = self.yielded_ids.entry(sid.clone()).or_default().clone();
609        let mut should_yield = reachable.difference(&yielded).next().is_some();
610        if !should_yield {
611            // Nothing new, but an already-emitted component may have grown.
612            should_yield = processed.iter().any(|component| {
613                let Some(id) = component.get("id").and_then(Value::as_str) else {
614                    return false;
615                };
616                let key = (sid.clone(), id.to_string());
617                self.yielded_contents.get(&key) != Some(&canonical_json(component))
618            });
619        }
620        if !should_yield {
621            return Ok(());
622        }
623
624        if let Some(start) = self.buffered_start_message.clone() {
625            if !self.yielded_start_messages.contains(&sid) {
626                self.yield_message(start, parts, false)?;
627                self.yielded_start_messages.insert(sid.clone());
628                self.yielded_surfaces.insert(sid.clone());
629            }
630        }
631
632        let mut payload = Map::new();
633        payload.insert("surfaceId".to_string(), Value::String(sid.clone()));
634        payload.insert("components".to_string(), Value::Array(processed.clone()));
635        let key = if active_msg_type == MSG_CREATE_SURFACE {
636            MSG_UPDATE_COMPONENTS
637        } else {
638            active_msg_type.as_str()
639        };
640        let mut message = Map::new();
641        message.insert(
642            "version".to_string(),
643            Value::String(PROTOCOL_VERSION.into()),
644        );
645        message.insert(key.to_string(), Value::Object(payload));
646
647        // A partial tree that does not hold up is dropped, not raised: the next
648        // chunk usually completes it.
649        if !self.yield_message(Value::Object(message), parts, true)? {
650            return Ok(());
651        }
652
653        self.yielded_ids
654            .entry(sid.clone())
655            .or_default()
656            .extend(reachable.iter().cloned());
657        for component in &processed {
658            if let Some(id) = component.get("id").and_then(Value::as_str) {
659                self.yielded_contents
660                    .insert((sid.clone(), id.to_string()), canonical_json(component));
661            }
662        }
663        Ok(())
664    }
665
666    /// Ids reachable from the root, erroring on loops.
667    ///
668    /// A loop is fatal because no further input can undo it, unlike a dangling
669    /// reference which the next chunk usually resolves.
670    fn analyze_topology(&self, root_id: &str) -> Result<BTreeSet<String>> {
671        let mut adjacency: BTreeMap<&str, Vec<(&str, String)>> = BTreeMap::new();
672        for (id, component) in &self.seen_components {
673            let mut edges = Vec::new();
674            let mut references = Vec::new();
675            collect_child_refs(component, &mut references);
676            for reference in references {
677                if reference.0 == *id {
678                    return Err(Error::Parse(format!(
679                        "Self-reference detected: Component '{id}' references itself in field \
680                         '{}'",
681                        reference.1
682                    )));
683                }
684                edges.push((
685                    self.seen_components
686                        .get_key_value(&reference.0)
687                        .map(|(k, _)| k.as_str())
688                        .unwrap_or_default(),
689                    reference.0,
690                ));
691            }
692            adjacency.insert(id.as_str(), edges);
693        }
694
695        let mut visited: BTreeSet<String> = BTreeSet::new();
696        let mut on_path: BTreeSet<String> = BTreeSet::new();
697        if self.seen_components.contains_key(root_id) {
698            walk(root_id, &adjacency, &mut visited, &mut on_path)?;
699        }
700        Ok(visited
701            .into_iter()
702            .filter(|id| self.seen_components.contains_key(id))
703            .collect())
704    }
705
706    /// Emits a message, returning whether it survived validation.
707    ///
708    /// `partial` selects the filter behaviour: a partial fragment that fails
709    /// validation is dropped, a complete message that fails is an error.
710    fn yield_message(
711        &mut self,
712        message: Value,
713        parts: &mut Vec<ResponsePart>,
714        partial: bool,
715    ) -> Result<bool> {
716        if self.validate {
717            if let Err(error) = self.validate_message(&message) {
718                if partial {
719                    return Ok(false);
720                }
721                return Err(error);
722            }
723        }
724        if !self.deduplicate_data_model(&message) {
725            return Ok(false);
726        }
727
728        match parts.last_mut() {
729            Some(last) if last.a2ui.is_none() => last.a2ui = Some(vec![message]),
730            Some(last) => {
731                if let Some(existing) = last.a2ui.as_mut() {
732                    existing.push(message);
733                }
734            }
735            None => parts.push(ResponsePart {
736                text: String::new(),
737                raw: None,
738                a2ui: Some(vec![message]),
739                is_final: true,
740            }),
741        }
742        Ok(true)
743    }
744
745    /// Suppresses a data-model update that repeats what that surface was
746    /// already sent.
747    fn deduplicate_data_model(&mut self, message: &Value) -> bool {
748        let Some(Value::Object(update)) = message.get(MSG_UPDATE_DATA_MODEL) else {
749            return true;
750        };
751        let sid = self.data_model_surface(update.get("surfaceId"));
752        let yielded = self.yielded_data_model.entry(sid).or_default();
753        let is_new = update
754            .iter()
755            .any(|(k, v)| k != "surfaceId" && k != "root" && yielded.get(k) != Some(v));
756        if !is_new {
757            return false;
758        }
759        for (k, v) in update {
760            if k != "surfaceId" && k != "root" {
761                yielded.insert(k.clone(), v.clone());
762            }
763        }
764        true
765    }
766
767    /// The surface a data-model update belongs to: the one it names, or the one
768    /// the stream is currently describing.
769    fn data_model_surface(&self, named: Option<&Value>) -> String {
770        named
771            .and_then(Value::as_str)
772            .map(str::to_string)
773            .or_else(|| self.surface_id.clone())
774            .unwrap_or_else(|| "default".to_string())
775    }
776
777    /// Validates one complete message: envelope shape, then components.
778    fn validate_message(&self, message: &Value) -> Result<()> {
779        validate_envelope(message)?;
780
781        let Some(components) = message
782            .get(MSG_UPDATE_COMPONENTS)
783            .and_then(|payload| payload.get("components"))
784            .and_then(Value::as_array)
785        else {
786            return Ok(());
787        };
788        // Streaming fragments legitimately reference components still on the
789        // wire and need not contain the root, so only the checks that cannot
790        // come good later are applied here.
791        let options = ValidateOptions {
792            require_root: false,
793            allow_dangling_children: true,
794            check_bindings: false,
795            check_binding_syntax: false,
796            ..ValidateOptions::full_surface()
797        };
798        Validator::with_options(&self.catalog, options)
799            .validate_json(components, None)
800            .into_result()
801    }
802
803    // --- sniffers -----------------------------------------------------------
804
805    /// Reads identifiers out of the raw buffer before their message closes.
806    fn sniff_metadata(&mut self) {
807        // Called on every delimiter character, so it reads only what has
808        // arrived since last time. Re-scanning the whole buffer each time turns
809        // a long message into quadratic work, which is a denial of service on
810        // input a model controls.
811        if self.json_buffer.len() < self.sniff_cursor {
812            // The buffer was compacted; positions no longer mean anything.
813            self.sniff_cursor = 0;
814        }
815        let start = floor_char_boundary(&self.json_buffer, self.sniff_cursor);
816        let region = &self.json_buffer[start..];
817        // Rewind far enough that a key straddling the boundary is seen whole on
818        // the next pass. Once a key *is* seen, an unfinished value pins the
819        // cursor to it, so this only has to cover the key itself.
820        let mut next_cursor = self.json_buffer.len().saturating_sub(SNIFF_OVERLAP);
821
822        let mut found: Vec<(&str, String)> = Vec::new();
823        for key in ["surfaceId", "root"] {
824            let (value, incomplete) = scan_string_values(region, key);
825            if let Some(value) = value {
826                found.push((key, value));
827            }
828            if let Some(offset) = incomplete {
829                // A key whose value has not finished arriving: leave the cursor
830                // before it so the next pass sees the whole pair.
831                next_cursor = next_cursor.min(start + offset);
832            }
833        }
834        for (key, value) in found {
835            match key {
836                "surfaceId" => self.set_surface_id(Some(value)),
837                _ => self.set_root_id(value),
838            }
839        }
840        self.sniff_cursor = next_cursor;
841
842        if self.active_msg_type.is_none() {
843            for key in [MSG_CREATE_SURFACE, MSG_UPDATE_COMPONENTS] {
844                if self.json_buffer.contains(&format!("\"{key}\":")) {
845                    self.active_msg_type = Some(key.to_string());
846                    break;
847                }
848            }
849        }
850    }
851
852    /// Looks for a component inside the still-open buffer.
853    fn sniff_partial_component(&mut self) {
854        if !self.json_buffer.contains("\"components\"") {
855            return;
856        }
857        let frames: Vec<usize> = self
858            .brace_stack
859            .iter()
860            .rev()
861            .filter(|(kind, _)| *kind == '{')
862            .map(|(_, start)| *start)
863            .collect();
864        for start in frames {
865            let Some(fragment) = self.json_buffer.get(start..) else {
866                continue;
867            };
868            let healed = self.heal_json(fragment);
869            if healed.is_empty() {
870                continue;
871            }
872            let Ok(obj) = serde_json::from_str::<Value>(&healed) else {
873                continue;
874            };
875            let has_identity =
876                obj.get("id").is_some() && obj.get("component").and_then(Value::as_str).is_some();
877            if has_identity {
878                self.handle_partial_component(&obj);
879            }
880        }
881    }
882
883    /// Looks for a data-model update inside the still-open buffer, emitting only
884    /// what changed since the last one.
885    fn sniff_partial_data_model(&mut self, parts: &mut Vec<ResponsePart>) {
886        if !self
887            .json_buffer
888            .contains(&format!("\"{MSG_UPDATE_DATA_MODEL}\""))
889        {
890            return;
891        }
892        let frames: Vec<usize> = self
893            .brace_stack
894            .iter()
895            .rev()
896            .filter(|(kind, _)| *kind == '{')
897            .map(|(_, start)| *start)
898            .collect();
899
900        for start in frames {
901            let Some(fragment) = self.json_buffer.get(start..) else {
902                continue;
903            };
904            let Some(obj) = self.parse_healed_or_trimmed(fragment) else {
905                continue;
906            };
907            let Some(update) = obj.get(MSG_UPDATE_DATA_MODEL).and_then(Value::as_object) else {
908                continue;
909            };
910            let Some(Value::Object(value)) = update.get("value") else {
911                continue;
912            };
913
914            let sid = self.data_model_surface(update.get("surfaceId"));
915            let known = self.yielded_data_model.get(&sid);
916            let mut delta = Map::new();
917            for (key, item) in value {
918                if known.and_then(|entries| entries.get(key)) != Some(item) {
919                    delta.insert(key.clone(), item.clone());
920                }
921            }
922            if delta.is_empty() {
923                continue;
924            }
925
926            let mut payload = Map::new();
927            payload.insert("surfaceId".to_string(), Value::String(sid.clone()));
928            payload.insert("value".to_string(), Value::Object(delta.clone()));
929            let mut message = Map::new();
930            message.insert(
931                "version".to_string(),
932                Value::String(PROTOCOL_VERSION.into()),
933            );
934            message.insert(MSG_UPDATE_DATA_MODEL.to_string(), Value::Object(payload));
935
936            // The delta is recorded whether or not the message survives, so the
937            // same keys are not offered again on the next chunk.
938            let _ = self.yield_message(Value::Object(message), parts, true);
939            let yielded = self.yielded_data_model.entry(sid).or_default();
940            for (key, item) in delta {
941                yielded.insert(key, item);
942            }
943        }
944    }
945
946    /// Parses a fragment, retreating to the last comma when healing is not enough.
947    fn parse_healed_or_trimmed(&self, fragment: &str) -> Option<Value> {
948        let healed = self.heal_json(fragment);
949        if let Ok(value) = serde_json::from_str::<Value>(&healed) {
950            return Some(value);
951        }
952        // `{"a": 1, "b":` heals to invalid JSON, but dropping the dangling
953        // `"b":` leaves a complete object that is still worth emitting.
954        let mut trimmed = fragment.to_string();
955        while let Some(index) = trimmed.rfind(',') {
956            trimmed.truncate(index);
957            let healed = self.heal_json(&trimmed);
958            if healed.is_empty() {
959                continue;
960            }
961            if let Ok(value) = serde_json::from_str::<Value>(&healed) {
962                return Some(value);
963            }
964        }
965        None
966    }
967
968    /// Closes a cut JSON fragment so it can be parsed.
969    ///
970    /// Returns an empty string when the cut lands inside a string whose key is
971    /// not cuttable: healing it would invent content the model never wrote.
972    fn heal_json(&self, fragment: &str) -> String {
973        let mut fixed = fragment.trim_end().to_string();
974        if fixed.is_empty() {
975            return String::new();
976        }
977
978        let mut stack: Vec<char> = Vec::new();
979        let mut in_string = false;
980        let mut escaped = false;
981        let mut last_quote = None;
982        for (index, ch) in fixed.char_indices() {
983            if escaped {
984                escaped = false;
985                continue;
986            }
987            match ch {
988                '\\' => escaped = true,
989                '"' => {
990                    in_string = !in_string;
991                    if in_string {
992                        last_quote = Some(index);
993                    }
994                }
995                '{' | '[' if !in_string => stack.push(ch),
996                '}' | ']' if !in_string => {
997                    stack.pop();
998                }
999                _ => {}
1000            }
1001        }
1002
1003        if in_string {
1004            if let Some(quote) = last_quote {
1005                let prefix = fixed[..quote].trim_end();
1006                if prefix.ends_with(':') {
1007                    match key_before_colon(prefix) {
1008                        Some(key) if self.cuttable_keys.contains(&key) => {}
1009                        // Structural or unknown key: wait for the rest.
1010                        _ => return String::new(),
1011                    }
1012                }
1013            }
1014            fixed.push('"');
1015        }
1016
1017        let trimmed = fixed.trim_end();
1018        let mut fixed = trimmed
1019            .strip_suffix(',')
1020            .unwrap_or(trimmed)
1021            .trim_end()
1022            .to_string();
1023        while let Some(open) = stack.pop() {
1024            fixed.push(if open == '{' { '}' } else { ']' });
1025        }
1026        fixed
1027    }
1028}
1029
1030/// Depth-first walk collecting reachable ids and rejecting loops.
1031fn walk<'a>(
1032    root: &'a str,
1033    adjacency: &BTreeMap<&'a str, Vec<(&'a str, String)>>,
1034    visited: &mut BTreeSet<String>,
1035    on_path: &mut BTreeSet<String>,
1036) -> Result<()> {
1037    // Iterative rather than recursive: this runs on every chunk of a model's
1038    // output, over a component graph whose depth the model chooses. A recursive
1039    // walk would take the process down on a deep enough tree, and unlike a
1040    // validation failure there is nothing to report afterwards.
1041    let mut stack: Vec<(&str, usize)> = vec![(root, 0)];
1042    visited.insert(root.to_string());
1043    on_path.insert(root.to_string());
1044
1045    while let Some(&(node, edge_index)) = stack.last() {
1046        let edges = adjacency.get(node).map(Vec::as_slice).unwrap_or_default();
1047        let Some((_, target)) = edges.get(edge_index) else {
1048            on_path.remove(node);
1049            stack.pop();
1050            continue;
1051        };
1052        if let Some(top) = stack.last_mut() {
1053            top.1 += 1;
1054        }
1055        if on_path.contains(target.as_str()) {
1056            return Err(Error::Parse(format!(
1057                "Circular reference detected involving component '{target}'"
1058            )));
1059        }
1060        if visited.insert(target.to_string()) {
1061            on_path.insert(target.to_string());
1062            // Borrow the key out of the map so the stack holds `&'a str`
1063            // rather than a reference into `adjacency`'s values.
1064            let next = adjacency
1065                .get_key_value(target.as_str())
1066                .map(|(key, _)| *key)
1067                .unwrap_or(target.as_str());
1068            stack.push((next, 0));
1069        }
1070    }
1071    Ok(())
1072}
1073
1074fn is_protocol_message(obj: &Value) -> bool {
1075    MESSAGE_KEYS.iter().any(|key| obj.get(*key).is_some())
1076}
1077
1078/// Checks the envelope of a message this parser is about to emit.
1079///
1080/// The *contract* — which operations exist and which of their fields are
1081/// required — is read from [`crate::validate::OPERATIONS`], the one table this
1082/// crate keeps the v0.9 envelope in. Only the rendering is local: the
1083/// language-agnostic conformance suite matches on these exact strings, so this
1084/// cannot simply forward the validator's report, which locates each failure by
1085/// path instead.
1086fn validate_envelope(message: &Value) -> Result<()> {
1087    let Some(map) = message.as_object() else {
1088        return Err(validation_error(
1089            "Validation failed: message must be an object",
1090        ));
1091    };
1092
1093    let Some(key) = MESSAGE_KEYS.into_iter().find(|key| map.contains_key(*key)) else {
1094        return Err(validation_error(format!(
1095            "Validation failed: {:?} is not a valid A2UI message; it must contain exactly one of \
1096             {}",
1097            map.keys().collect::<Vec<_>>(),
1098            MESSAGE_KEYS.join(", ")
1099        )));
1100    };
1101    if !map.contains_key("version") {
1102        return Err(validation_error(
1103            "Validation failed: 'version' is a required property",
1104        ));
1105    }
1106
1107    let Some(payload) = map.get(key).and_then(Value::as_object) else {
1108        return Err(validation_error(format!(
1109            "Validation failed: '{key}' must be an object"
1110        )));
1111    };
1112    let fields = OPERATIONS
1113        .iter()
1114        .find(|(name, _)| *name == key)
1115        .map_or(&[][..], |(_, fields)| *fields);
1116    for (field, _, required) in fields {
1117        if *required && !payload.contains_key(*field) {
1118            return Err(validation_error(format!(
1119                "Validation failed: '{field}' is a required property of {key}"
1120            )));
1121        }
1122    }
1123    Ok(())
1124}
1125
1126fn validation_error(message: impl Into<String>) -> Error {
1127    Error::Validation {
1128        errors: crate::ValidationErrors(vec![crate::validate::ValidationError::new(
1129            crate::validate::ErrorCode::MissingField,
1130            "message",
1131            message,
1132        )]),
1133    }
1134}
1135
1136fn text_part(text: &str) -> ResponsePart {
1137    ResponsePart {
1138        text: text.to_string(),
1139        raw: None,
1140        a2ui: None,
1141        is_final: true,
1142    }
1143}
1144
1145/// Locates an `<a2ui-json ...>` open tag, as byte offsets into `text`.
1146///
1147/// Matches what [`crate::toolkit::parser`]'s scanner accepts, which is what a
1148/// model actually writes: attributes are tolerated, the match is
1149/// case-insensitive, and the tag name must end on a word boundary so
1150/// `<a2ui-jsonx>` is not one.
1151///
1152/// `Some((start, Some(end)))` is a complete tag occupying `start..end`.
1153/// `Some((start, None))` means a tag has begun at `start` but its `>` has not
1154/// arrived, so the caller must hold everything from there back for the next
1155/// chunk rather than emitting it as conversational text.
1156fn find_open_tag(text: &str) -> Option<(usize, Option<usize>)> {
1157    // `A2UI_OPEN_TAG` without its angle brackets.
1158    let name = &A2UI_OPEN_TAG[1..A2UI_OPEN_TAG.len() - 1];
1159
1160    for (start, _) in text.match_indices('<') {
1161        let rest = &text[start + 1..];
1162        if rest.len() < name.len() {
1163            if name.as_bytes()[..rest.len()].eq_ignore_ascii_case(rest.as_bytes()) {
1164                return Some((start, None));
1165            }
1166            continue;
1167        }
1168        if !rest.as_bytes()[..name.len()].eq_ignore_ascii_case(name.as_bytes()) {
1169            continue;
1170        }
1171        let after = &rest[name.len()..];
1172        match after.chars().next() {
1173            None => return Some((start, None)),
1174            Some(c) if c.is_alphanumeric() || c == '_' => continue,
1175            Some(_) => {}
1176        }
1177        return Some((
1178            start,
1179            after
1180                .find('>')
1181                .map(|close| start + 1 + name.len() + close + 1),
1182        ));
1183    }
1184    None
1185}
1186
1187/// Length of the longest suffix of `text` that is a prefix of `tag`.
1188fn trailing_prefix_len(text: &str, tag: &str) -> usize {
1189    let max = tag.len().saturating_sub(1).min(text.len());
1190    (1..=max)
1191        .rev()
1192        .find(|len| text.is_char_boundary(text.len() - len) && text.ends_with(&tag[..*len]))
1193        .unwrap_or(0)
1194}
1195
1196/// Extracts the key from a `"key":` suffix.
1197fn key_before_colon(prefix: &str) -> Option<String> {
1198    let without_colon = prefix.strip_suffix(':')?.trim_end();
1199    let inner = without_colon.strip_suffix('"')?;
1200    let start = inner.rfind('"')?;
1201    Some(inner[start + 1..].to_string())
1202}
1203
1204/// Reads `"key": "value"` pairs out of a buffer region, front to back.
1205///
1206/// Returns the last complete value found, and the offset of the first key whose
1207/// value has not finished arriving. A caller scanning incrementally must rewind
1208/// to that offset, or it would never see the finished pair.
1209fn scan_string_values(region: &str, key: &str) -> (Option<String>, Option<usize>) {
1210    let needle = format!("\"{key}\"");
1211    let mut latest = None;
1212    let mut incomplete = None;
1213    let mut cursor = 0;
1214
1215    while let Some(offset) = region[cursor..].find(&needle) {
1216        let at = cursor + offset;
1217        let rest = region[at + needle.len()..].trim_start();
1218        match rest
1219            .strip_prefix(':')
1220            .map(str::trim_start)
1221            .and_then(|rest| rest.strip_prefix('"'))
1222        {
1223            Some(value) => match value.find('"') {
1224                Some(end) => latest = Some(value[..end].to_string()),
1225                None => {
1226                    incomplete = Some(at);
1227                    break;
1228                }
1229            },
1230            // Not a string value: `"root": 3` is not an identifier, and a key
1231            // whose colon has not arrived yet must be looked at again.
1232            None => {
1233                if rest.is_empty() || rest == ":" {
1234                    incomplete = Some(at);
1235                    break;
1236                }
1237            }
1238        }
1239        cursor = at + needle.len();
1240    }
1241    (latest, incomplete)
1242}
1243
1244/// The largest char boundary at or below `index`.
1245fn floor_char_boundary(text: &str, index: usize) -> usize {
1246    let mut index = index.min(text.len());
1247    while index > 0 && !text.is_char_boundary(index) {
1248        index -= 1;
1249    }
1250    index
1251}
1252
1253/// Whether any object nested inside the value is empty.
1254fn has_empty_object(value: &Value) -> bool {
1255    match value {
1256        Value::Object(map) => map.is_empty() || map.values().any(has_empty_object),
1257        Value::Array(items) => items.iter().any(has_empty_object),
1258        _ => false,
1259    }
1260}
1261
1262/// Collects child ids from the conventional reference fields.
1263fn collect_child_refs(value: &Value, refs: &mut Vec<(String, String)>) {
1264    match value {
1265        Value::Object(map) => {
1266            for field in CHILD_FIELDS {
1267                match map.get(field) {
1268                    Some(Value::String(id)) => refs.push((id.clone(), field.to_string())),
1269                    Some(Value::Array(items)) => {
1270                        for item in items {
1271                            if let Value::String(id) = item {
1272                                refs.push((id.clone(), field.to_string()));
1273                            }
1274                        }
1275                    }
1276                    _ => {}
1277                }
1278            }
1279            for (key, child) in map {
1280                if key == "id" || key == "component" {
1281                    continue;
1282                }
1283                collect_child_refs(child, refs);
1284            }
1285        }
1286        Value::Array(items) => {
1287            for item in items {
1288                collect_child_refs(item, refs);
1289            }
1290        }
1291        _ => {}
1292    }
1293}
1294
1295/// Replaces references to unseen components with placeholders.
1296fn rewrite_children(
1297    value: &mut Value,
1298    comp_id: &str,
1299    seen: &BTreeSet<&str>,
1300    extras: &mut Vec<Value>,
1301    buffer: &str,
1302) {
1303    match value {
1304        Value::Object(map) => {
1305            for field in CHILD_FIELDS {
1306                match map.get_mut(field) {
1307                    Some(Value::Array(items)) => {
1308                        let mut resolved: Vec<Value> = Vec::with_capacity(items.len());
1309                        for item in items.iter() {
1310                            let Some(id) = item.as_str() else { continue };
1311                            if seen.contains(id) {
1312                                resolved.push(Value::String(id.to_string()));
1313                            } else {
1314                                let placeholder = format!("loading_{id}");
1315                                push_placeholder(&placeholder, extras);
1316                                resolved.push(Value::String(placeholder));
1317                            }
1318                        }
1319                        if resolved.is_empty()
1320                            && matches!(field, "children" | "explicitList")
1321                            && list_is_still_open(buffer, field)
1322                        {
1323                            // The list has been opened but no ids have arrived;
1324                            // give the renderer something to lay out.
1325                            let placeholder = format!("loading_children_{comp_id}");
1326                            push_placeholder(&placeholder, extras);
1327                            resolved.push(Value::String(placeholder));
1328                        }
1329                        *map.get_mut(field).expect("field present") = Value::Array(resolved);
1330                    }
1331                    Some(Value::String(id)) if !seen.contains(id.as_str()) => {
1332                        let placeholder = format!("loading_{id}");
1333                        push_placeholder(&placeholder, extras);
1334                        *id = placeholder;
1335                    }
1336                    _ => {}
1337                }
1338            }
1339            for (key, child) in map.iter_mut() {
1340                if key == "id" || key == "component" {
1341                    continue;
1342                }
1343                rewrite_children(child, comp_id, seen, extras, buffer);
1344            }
1345        }
1346        Value::Array(items) => {
1347            for item in items {
1348                rewrite_children(item, comp_id, seen, extras, buffer);
1349            }
1350        }
1351        _ => {}
1352    }
1353}
1354
1355fn push_placeholder(id: &str, extras: &mut Vec<Value>) {
1356    let already = extras
1357        .iter()
1358        .any(|extra| extra.get("id").and_then(Value::as_str) == Some(id));
1359    if already {
1360        return;
1361    }
1362    let mut placeholder = Map::new();
1363    placeholder.insert("id".to_string(), Value::String(id.to_string()));
1364    placeholder.insert("component".to_string(), Value::String("Row".to_string()));
1365    placeholder.insert("children".to_string(), Value::Array(Vec::new()));
1366    extras.push(Value::Object(placeholder));
1367}
1368
1369/// Whether the raw buffer shows `"field": [` with no closing bracket yet.
1370fn list_is_still_open(buffer: &str, field: &str) -> bool {
1371    let needle = format!("\"{field}\"");
1372    let Some(index) = buffer.rfind(&needle) else {
1373        return false;
1374    };
1375    let after = &buffer[index + needle.len()..];
1376    match after.find('[') {
1377        Some(open) => !after[..open].contains(']'),
1378        None => false,
1379    }
1380}
1381
1382/// Serializes with object keys sorted, so content comparison is stable.
1383fn canonical_json(value: &Value) -> String {
1384    match value {
1385        Value::Object(map) => {
1386            let mut keys: Vec<&String> = map.keys().collect();
1387            keys.sort();
1388            let body: Vec<String> = keys
1389                .into_iter()
1390                .map(|key| {
1391                    format!(
1392                        "{}:{}",
1393                        serde_json::to_string(key).unwrap_or_default(),
1394                        canonical_json(&map[key])
1395                    )
1396                })
1397                .collect();
1398            format!("{{{}}}", body.join(","))
1399        }
1400        Value::Array(items) => {
1401            let body: Vec<String> = items.iter().map(canonical_json).collect();
1402            format!("[{}]", body.join(","))
1403        }
1404        other => serde_json::to_string(other).unwrap_or_default(),
1405    }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410    use super::*;
1411    use crate::catalog::Catalog;
1412    use serde_json::json;
1413
1414    /// A catalog with the component types the streaming tests use, including the
1415    /// `Row` the parser synthesizes placeholders from.
1416    fn catalog() -> Catalog {
1417        Catalog::from_schema(&json!({
1418            "catalogId": "test",
1419            "components": {
1420                "Text": {
1421                    "type": "object",
1422                    "properties": {"component": {"const": "Text"}, "text": {}},
1423                    "required": ["component"]
1424                },
1425                "Card": {
1426                    "type": "object",
1427                    "properties": {
1428                        "component": {"const": "Card"},
1429                        "child": {"$ref": "common_types.json#/$defs/ComponentId"}
1430                    },
1431                    "required": ["component"]
1432                },
1433                "Row": {
1434                    "type": "object",
1435                    "properties": {
1436                        "component": {"const": "Row"},
1437                        "children": {"$ref": "common_types.json#/$defs/ChildList"}
1438                    },
1439                    "required": ["component"]
1440                },
1441                "Audio": {
1442                    "type": "object",
1443                    "properties": {"component": {"const": "Audio"}, "url": {}, "label": {}},
1444                    "required": ["component", "url"]
1445                }
1446            }
1447        }))
1448        .expect("test catalog")
1449    }
1450
1451    fn parser() -> StreamParser {
1452        StreamParser::new(catalog())
1453    }
1454
1455    /// Feeds chunks and returns the A2UI messages from the final chunk.
1456    fn feed(parser: &mut StreamParser, chunks: &[&str]) -> Vec<Value> {
1457        let mut last = Vec::new();
1458        for chunk in chunks {
1459            last = parser
1460                .process_chunk(chunk)
1461                .expect("chunk should parse")
1462                .into_iter()
1463                .filter_map(|part| part.a2ui)
1464                .flatten()
1465                .collect();
1466        }
1467        last
1468    }
1469
1470    const CREATE: &str =
1471        r#"{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"test"}},"#;
1472
1473    const UPDATE_OPEN: &str =
1474        r#"{"version":"v0.9","updateComponents":{"surfaceId":"s1","components":"#;
1475
1476    #[test]
1477    fn conversational_text_streams_before_and_after_the_block() {
1478        let mut parser = parser();
1479        let parts = parser.process_chunk("Hello! ").unwrap();
1480        assert_eq!(parts[0].text, "Hello! ");
1481        assert!(parts[0].a2ui.is_none());
1482
1483        let parts = parser.process_chunk("Here you go: <a2ui-json>[").unwrap();
1484        assert_eq!(parts[0].text, "Here you go: ");
1485
1486        parser.process_chunk(CREATE).unwrap();
1487        let parts = parser
1488            .process_chunk("]</a2ui-json> Anything else?")
1489            .unwrap();
1490        assert_eq!(parts.last().unwrap().text, " Anything else?");
1491    }
1492
1493    #[test]
1494    fn a_tag_split_across_chunks_is_not_leaked_as_text() {
1495        let mut parser = parser();
1496        let parts = parser.process_chunk("Talking <a2u").unwrap();
1497        assert_eq!(parts.len(), 1);
1498        assert_eq!(
1499            parts[0].text, "Talking ",
1500            "the partial tag must be held back"
1501        );
1502
1503        let parts = parser.process_chunk("i-json>").unwrap();
1504        assert!(parts.is_empty());
1505    }
1506
1507    #[test]
1508    fn an_open_tag_carrying_attributes_still_opens_a_block() {
1509        // `parse_response` accepts these; the streaming path used to look for
1510        // the bare tag literally, so an attributed one matched nothing and the
1511        // whole surface streamed out as conversational text for the user to
1512        // read as raw JSON.
1513        for open in [
1514            r#"<a2ui-json version="v0.9">"#,
1515            "<a2ui-json >",
1516            "<A2UI-JSON>",
1517        ] {
1518            let mut parser = parser();
1519            let mut messages = Vec::new();
1520            for chunk in [open, "[", CREATE, "]</a2ui-json>"] {
1521                messages.extend(feed(&mut parser, &[chunk]));
1522            }
1523            assert!(
1524                messages.iter().any(|m| m.get("createSurface").is_some()),
1525                "{open} did not open a block"
1526            );
1527        }
1528
1529        // A lookalike is still not the tag, in either parser.
1530        let mut parser = parser();
1531        let parts = parser
1532            .process_chunk(r#"<a2ui-jsonx>[{"id":"t"}]</a2ui-jsonx>"#)
1533            .unwrap();
1534        assert_eq!(parts[0].text, r#"<a2ui-jsonx>[{"id":"t"}]</a2ui-jsonx>"#);
1535    }
1536
1537    #[test]
1538    fn a_message_is_emitted_the_moment_it_closes() {
1539        let mut parser = parser();
1540        assert!(
1541            feed(
1542                &mut parser,
1543                &["<a2ui-json>[", r#"{"version":"v0.9","createSur"#]
1544            )
1545            .is_empty()
1546        );
1547
1548        let messages = feed(
1549            &mut parser,
1550            &[r#"face":{"surfaceId":"s1","catalogId":"test"}},"#],
1551        );
1552        assert_eq!(messages.len(), 1);
1553        assert_eq!(messages[0]["createSurface"]["surfaceId"], "s1");
1554    }
1555
1556    #[test]
1557    fn cut_text_is_healed_and_extended_as_more_arrives() {
1558        let mut parser = parser();
1559        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1560
1561        let chunk = format!(r#"{UPDATE_OPEN}[{{"id":"root","component":"Text","text":"Em"#);
1562        let messages = feed(&mut parser, &[&chunk]);
1563        assert_eq!(
1564            messages[0]["updateComponents"]["components"][0]["text"],
1565            "Em"
1566        );
1567
1568        let messages = feed(&mut parser, &[r#"ail"}]}}"#]);
1569        assert_eq!(
1570            messages[0]["updateComponents"]["components"][0]["text"],
1571            "Email"
1572        );
1573    }
1574
1575    #[test]
1576    fn a_cut_identifier_is_held_back_rather_than_invented() {
1577        let mut parser = parser();
1578        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1579        // `id` is not cuttable: healing it would fabricate a component id.
1580        let chunk = format!(r#"{UPDATE_OPEN}[{{"id":"but"#);
1581        assert!(feed(&mut parser, &[&chunk]).is_empty());
1582    }
1583
1584    #[test]
1585    fn a_missing_child_becomes_a_placeholder_and_is_swapped_in_later() {
1586        let mut parser = parser();
1587        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1588
1589        let chunk = format!(r#"{UPDATE_OPEN}[{{"id":"root","component":"Card","child":"c1"}}, "#);
1590        let messages = feed(&mut parser, &[&chunk]);
1591        let components = messages[0]["updateComponents"]["components"]
1592            .as_array()
1593            .unwrap();
1594        assert_eq!(components[0]["child"], "loading_c1");
1595        assert_eq!(components[1]["id"], "loading_c1");
1596        assert_eq!(components[1]["component"], "Row");
1597
1598        let messages = feed(
1599            &mut parser,
1600            &[r#"{"id":"c1","component":"Text","text":"hi"}]}}"#],
1601        );
1602        let components = messages[0]["updateComponents"]["components"]
1603            .as_array()
1604            .unwrap();
1605        // Sorted by id, and the placeholder is gone.
1606        assert_eq!(components[0]["id"], "c1");
1607        assert_eq!(components[1]["child"], "c1");
1608        assert_eq!(components.len(), 2);
1609    }
1610
1611    #[test]
1612    fn components_wait_until_they_are_reachable_from_the_root() {
1613        let mut parser = parser();
1614        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1615
1616        // The child arrives first; nothing can be drawn from it yet.
1617        let chunk = format!(r#"{UPDATE_OPEN}[{{"id":"c1","component":"Text","text":"hi"}}"#);
1618        assert!(feed(&mut parser, &[&chunk]).is_empty());
1619
1620        let messages = feed(
1621            &mut parser,
1622            &[r#", {"id":"root","component":"Card","child":"c1"}]}}"#],
1623        );
1624        let components = messages[0]["updateComponents"]["components"]
1625            .as_array()
1626            .unwrap();
1627        assert_eq!(components.len(), 2);
1628    }
1629
1630    #[test]
1631    fn unreachable_components_are_never_emitted() {
1632        let mut parser = parser();
1633        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1634        let chunk = format!(
1635            r#"{UPDATE_OPEN}[{{"id":"root","component":"Text","text":"root"}},{{"id":"orphan","component":"Text","text":"orphan"}}]}}}}"#
1636        );
1637        let messages = feed(&mut parser, &[&chunk]);
1638        let components = messages[0]["updateComponents"]["components"]
1639            .as_array()
1640            .unwrap();
1641        assert_eq!(components.len(), 1);
1642        assert_eq!(components[0]["id"], "root");
1643    }
1644
1645    #[test]
1646    fn components_are_buffered_until_the_surface_exists() {
1647        let mut parser = parser();
1648        let chunk =
1649            format!(r#"{UPDATE_OPEN}[{{"id":"root","component":"Text","text":"hi"}}]}}}}, "#);
1650        let messages = feed(&mut parser, &["<a2ui-json>[", &chunk]);
1651        assert!(messages.is_empty(), "no surface to attach to yet");
1652
1653        let messages = feed(&mut parser, &[CREATE]);
1654        assert_eq!(
1655            messages.len(),
1656            2,
1657            "the surface and its tree arrive together"
1658        );
1659        assert!(messages[0].get("createSurface").is_some());
1660        assert!(messages[1].get("updateComponents").is_some());
1661    }
1662
1663    #[test]
1664    fn deleting_a_surface_that_was_never_created_is_dropped() {
1665        let mut parser = parser();
1666        let messages = feed(
1667            &mut parser,
1668            &[
1669                "<a2ui-json>[",
1670                r#"{"version":"v0.9","deleteSurface":{"surfaceId":"s1"}}, "#,
1671                CREATE,
1672            ],
1673        );
1674        assert_eq!(messages.len(), 1);
1675        assert!(messages[0].get("createSurface").is_some());
1676    }
1677
1678    #[test]
1679    fn a_surface_recreated_after_a_delete_renders_again() {
1680        let mut parser = parser();
1681        let messages = feed(
1682            &mut parser,
1683            &[
1684                "<a2ui-json>[",
1685                CREATE,
1686                r#"{"version":"v0.9","deleteSurface":{"surfaceId":"s1"}}, "#,
1687                CREATE,
1688                &format!(r#"{UPDATE_OPEN}[{{"id":"root","component":"Text","text":"back"}}]}}}}"#),
1689            ],
1690        );
1691        // Replacing a surface is delete-then-create on the same id, which the
1692        // create path already anticipates. The components that follow belong to
1693        // the new surface, not the deleted one.
1694        let components = &messages
1695            .iter()
1696            .rev()
1697            .find(|m| m.get("updateComponents").is_some())
1698            .expect("the recreated surface must render")["updateComponents"]["components"];
1699        assert_eq!(components[0]["text"], "back");
1700    }
1701
1702    #[test]
1703    fn a_self_reference_is_an_error_no_further_input_can_fix() {
1704        let mut parser = parser();
1705        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1706        let chunk =
1707            format!(r#"{UPDATE_OPEN}[{{"id":"root","component":"Card","child":"root"}}]}}}}"#);
1708        let error = parser.process_chunk(&chunk).unwrap_err();
1709        assert!(
1710            error.to_string().contains("Self-reference detected"),
1711            "{error}"
1712        );
1713    }
1714
1715    #[test]
1716    fn a_reference_loop_across_messages_is_an_error() {
1717        let mut parser = parser();
1718        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1719        let chunk = format!(
1720            r#"{UPDATE_OPEN}[{{"id":"root","component":"Card","child":"c1"}}]}}}},{UPDATE_OPEN}[{{"id":"c1","component":"Card","child":"root"}}]}}}}"#
1721        );
1722        let error = parser.process_chunk(&chunk).unwrap_err();
1723        assert!(
1724            error.to_string().contains("Circular reference detected"),
1725            "{error}"
1726        );
1727    }
1728
1729    #[test]
1730    fn a_message_matching_no_envelope_is_an_error() {
1731        let mut parser = parser();
1732        let error = parser
1733            .process_chunk(r#"<a2ui-json>[{"unknownMessage":"invalid"}]"#)
1734            .unwrap_err();
1735        assert!(error.to_string().contains("Validation failed"), "{error}");
1736    }
1737
1738    #[test]
1739    fn a_missing_required_envelope_field_is_an_error() {
1740        let mut first = parser();
1741        let error = first
1742            .process_chunk(r#"<a2ui-json>[{"version":"v0.9","createSurface":{"surfaceId":"s1"}}]"#)
1743            .unwrap_err();
1744        assert!(error.to_string().contains("required property"), "{error}");
1745
1746        let mut parser = parser();
1747        let error = parser
1748            .process_chunk(r#"<a2ui-json>[{"updateComponents":{"components":[]}}]"#)
1749            .unwrap_err();
1750        assert!(error.to_string().contains("Validation failed"), "{error}");
1751    }
1752
1753    /// The streaming parser renders envelope failures in the wording the
1754    /// conformance suite pins, but reads *which* fields are required from the
1755    /// same table [`crate::validate`] uses — so a change to one operation's
1756    /// contract cannot reach only one of the two paths.
1757    #[test]
1758    fn the_envelope_contract_is_the_validators_own_table() {
1759        for (key, fields) in OPERATIONS {
1760            let required: Vec<&str> = fields
1761                .iter()
1762                .filter(|(_, _, required)| *required)
1763                .map(|(field, _, _)| *field)
1764                .collect();
1765            if !MESSAGE_KEYS.contains(&key) {
1766                continue;
1767            }
1768            for omitted in &required {
1769                let payload: Map<String, Value> = required
1770                    .iter()
1771                    .filter(|field| *field != omitted)
1772                    .map(|field| ((*field).to_string(), json!("x")))
1773                    .collect();
1774                let message = json!({"version": "v0.9", key: payload});
1775                let error = validate_envelope(&message)
1776                    .expect_err("a message missing a required field must not validate");
1777                assert!(
1778                    error.to_string().contains(&format!("'{omitted}'")),
1779                    "{key} without {omitted}: {error}"
1780                );
1781            }
1782        }
1783    }
1784
1785    #[test]
1786    fn a_partial_component_missing_a_required_property_is_not_emitted_yet() {
1787        let mut parser = parser();
1788        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1789        // `Audio` requires `url`; the fragment has only `label` so far.
1790        let chunk =
1791            format!(r#"{UPDATE_OPEN}[{{"id":"root","component":"Audio","label":"almost ready""#);
1792        assert!(feed(&mut parser, &[&chunk]).is_empty());
1793
1794        let messages = feed(&mut parser, &[r#", "url":"http://a.mp3"}]}}"#]);
1795        assert_eq!(
1796            messages[0]["updateComponents"]["components"][0]["url"],
1797            "http://a.mp3"
1798        );
1799    }
1800
1801    #[test]
1802    fn a_placeholder_the_catalog_cannot_render_suppresses_the_partial_update() {
1803        // This catalog has no `Row`, so the synthesized placeholder would be
1804        // invalid; the partial tree is held back rather than sent broken.
1805        let catalog = Catalog::from_schema(&json!({
1806            "catalogId": "no-row",
1807            "components": {
1808                "Card": {
1809                    "type": "object",
1810                    "properties": {
1811                        "component": {"const": "Card"},
1812                        "child": {"$ref": "common_types.json#/$defs/ComponentId"}
1813                    },
1814                    "required": ["component"]
1815                },
1816                "Text": {"type": "object", "properties": {"component": {"const": "Text"}}}
1817            }
1818        }))
1819        .unwrap();
1820        let mut parser = StreamParser::new(catalog);
1821        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1822
1823        let chunk = format!(r#"{UPDATE_OPEN}[{{"id":"root","component":"Card","child":"c1"}}"#);
1824        assert!(feed(&mut parser, &[&chunk]).is_empty());
1825    }
1826
1827    #[test]
1828    fn the_data_model_streams_as_deltas_then_settles() {
1829        let mut parser = parser();
1830        let messages = feed(
1831            &mut parser,
1832            &[
1833                "<a2ui-json>[",
1834                CREATE,
1835                r#"{"version":"v0.9","updateDataModel":{"surfaceId":"s1","value":{"a":1,"b":"#,
1836            ],
1837        );
1838        // The complete `a` is offered; the dangling `b` is not.
1839        assert_eq!(messages[0]["updateDataModel"]["value"], json!({"a": 1}));
1840
1841        // Once the message closes it is sent whole, not as a delta, so the
1842        // renderer's model is exactly what the agent meant to send.
1843        let messages = feed(&mut parser, &["2}}}"]);
1844        assert_eq!(
1845            messages[0]["updateDataModel"]["value"],
1846            json!({"a": 1, "b": 2})
1847        );
1848    }
1849
1850    #[test]
1851    fn an_unchanged_data_model_key_is_not_offered_twice() {
1852        let mut parser = parser();
1853        feed(
1854            &mut parser,
1855            &[
1856                "<a2ui-json>[",
1857                CREATE,
1858                r#"{"version":"v0.9","updateDataModel":{"surfaceId":"s1","value":{"a":1"#,
1859            ],
1860        );
1861        let messages = feed(&mut parser, &[", "]);
1862        assert!(messages.is_empty(), "nothing changed, so nothing to send");
1863    }
1864
1865    #[test]
1866    fn two_surfaces_may_carry_the_same_data_model() {
1867        let mut parser = parser();
1868        let messages = feed(
1869            &mut parser,
1870            &[
1871                "<a2ui-json>[",
1872                CREATE,
1873                r#"{"version":"v0.9","updateDataModel":{"surfaceId":"s1","path":"/","value":{"a":1}}}, "#,
1874                r#"{"version":"v0.9","createSurface":{"surfaceId":"s2","catalogId":"test"}}, "#,
1875                r#"{"version":"v0.9","updateDataModel":{"surfaceId":"s2","path":"/","value":{"a":1}}}"#,
1876            ],
1877        );
1878        // Deduplication is per surface: the second surface's model is not a
1879        // repeat of anything it has been sent, however the first surface's
1880        // happens to be spelled.
1881        let updated: Vec<&Value> = messages
1882            .iter()
1883            .filter(|m| m.get(MSG_UPDATE_DATA_MODEL).is_some())
1884            .collect();
1885        assert_eq!(updated.len(), 1, "{messages:?}");
1886        assert_eq!(updated[0][MSG_UPDATE_DATA_MODEL]["surfaceId"], "s2");
1887    }
1888
1889    #[test]
1890    fn custom_cuttable_keys_replace_the_defaults() {
1891        let mut parser = StreamParser::new(catalog())
1892            .with_cuttable_keys(["label"])
1893            .without_validation();
1894        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1895
1896        // `label` is cuttable here, `text` no longer is.
1897        let chunk = format!(r#"{UPDATE_OPEN}[{{"id":"root","component":"Audio","label":"partial"#);
1898        let messages = feed(&mut parser, &[&chunk]);
1899        assert_eq!(
1900            messages[0]["updateComponents"]["components"][0]["label"],
1901            "partial"
1902        );
1903    }
1904
1905    #[test]
1906    fn a_block_with_no_json_at_all_is_a_parse_error() {
1907        let mut parser = parser();
1908        let error = parser
1909            .process_chunk("<a2ui-json>not json at all</a2ui-json>")
1910            .unwrap_err();
1911        assert!(matches!(error, Error::Parse(_)), "{error}");
1912    }
1913
1914    #[test]
1915    fn a_second_block_keeps_updating_the_first_surface() {
1916        let mut parser = parser();
1917        let first = format!(
1918            r#"{UPDATE_OPEN}[{{"id":"root","component":"Text","text":"first"}}]}}}}]</a2ui-json>"#
1919        );
1920        feed(&mut parser, &["<a2ui-json>[", CREATE, &first]);
1921
1922        let second = format!(
1923            r#"<a2ui-json>[{UPDATE_OPEN}[{{"id":"root","component":"Text","text":"second"}}]}}}}]</a2ui-json>"#
1924        );
1925        let messages = feed(&mut parser, &[&second]);
1926        assert_eq!(
1927            messages[0]["updateComponents"]["components"][0]["text"],
1928            "second"
1929        );
1930    }
1931
1932    #[test]
1933    fn the_root_id_and_surface_id_are_readable_while_streaming() {
1934        let mut parser = parser();
1935        assert_eq!(parser.surface_id(), None);
1936        assert_eq!(parser.root_id(), "root");
1937
1938        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1939        assert_eq!(parser.surface_id(), Some("s1"));
1940    }
1941
1942    #[test]
1943    fn healing_refuses_to_close_a_non_cuttable_string() {
1944        let parser = parser();
1945        assert_eq!(parser.heal_json(r#"{"id": "part"#), "");
1946        assert_eq!(parser.heal_json(r#"{"path": "/us"#), "");
1947        assert_eq!(parser.heal_json(r#"{"text": "par"#), r#"{"text": "par"}"#);
1948        assert_eq!(parser.heal_json(r#"{"a": 1,"#), r#"{"a": 1}"#);
1949    }
1950
1951    #[test]
1952    fn canonical_json_sorts_keys_at_every_level() {
1953        let a = json!({"b": 1, "a": {"d": 2, "c": [3, {"f": 4, "e": 5}]}});
1954        let b = json!({"a": {"c": [3, {"e": 5, "f": 4}], "d": 2}, "b": 1});
1955        assert_eq!(canonical_json(&a), canonical_json(&b));
1956        assert_ne!(canonical_json(&a), canonical_json(&json!({"b": 2})));
1957    }
1958
1959    #[test]
1960    fn a_very_deep_component_chain_does_not_blow_the_stack() {
1961        // The topology walk runs on every chunk, over a graph whose depth the
1962        // model chooses. If it recursed, this would abort the process rather
1963        // than fail a test.
1964        let mut parser = StreamParser::new(catalog()).without_validation();
1965        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1966
1967        let depth = 20_000;
1968        let mut components = String::from(r#"[{"id":"root","component":"Card","child":"n0"}"#);
1969        for i in 0..depth {
1970            let child = if i + 1 == depth {
1971                "leaf".to_string()
1972            } else {
1973                format!("n{}", i + 1)
1974            };
1975            components.push_str(&format!(
1976                r#",{{"id":"n{i}","component":"Card","child":"{child}"}}"#
1977            ));
1978        }
1979        components.push_str(r#",{"id":"leaf","component":"Text","text":"end"}]"#);
1980
1981        let chunk = format!(r#"{UPDATE_OPEN}{components}}}}}"#);
1982        let messages = feed(&mut parser, &[&chunk]);
1983        let emitted = messages[0]["updateComponents"]["components"]
1984            .as_array()
1985            .expect("components");
1986        assert_eq!(emitted.len(), depth + 2);
1987    }
1988
1989    #[test]
1990    fn a_deep_chain_with_a_loop_at_the_bottom_is_still_caught() {
1991        // Depth must not let a cycle slip past: the walk has to reach the end.
1992        let mut parser = StreamParser::new(catalog()).without_validation();
1993        feed(&mut parser, &["<a2ui-json>[", CREATE]);
1994
1995        let depth = 5_000;
1996        let mut components = String::from(r#"[{"id":"root","component":"Card","child":"n0"}"#);
1997        for i in 0..depth {
1998            let child = if i + 1 == depth {
1999                "root".to_string() // closes the loop
2000            } else {
2001                format!("n{}", i + 1)
2002            };
2003            components.push_str(&format!(
2004                r#",{{"id":"n{i}","component":"Card","child":"{child}"}}"#
2005            ));
2006        }
2007        components.push(']');
2008
2009        let chunk = format!(r#"{UPDATE_OPEN}{components}}}}}"#);
2010        let error = parser.process_chunk(&chunk).unwrap_err();
2011        assert!(
2012            error.to_string().contains("Circular reference detected"),
2013            "{error}"
2014        );
2015    }
2016
2017    #[test]
2018    fn a_surface_id_split_across_chunks_is_still_picked_up() {
2019        // The metadata sniffer reads only what is new since the last pass, so a
2020        // key landing on a chunk boundary is the case that breaks it.
2021        let mut parser = parser();
2022        feed(&mut parser, &["<a2ui-json>[", CREATE]);
2023
2024        // `"surfaceId"` is split down the middle, and its value again after.
2025        let messages = feed(
2026            &mut parser,
2027            &[
2028                r#"{"version":"v0.9","updateComponents":{"surf"#,
2029                r#"aceId":"s1","components":[{"id":"root","component":"Text","text":"hi"}"#,
2030            ],
2031        );
2032        assert_eq!(
2033            messages[0]["updateComponents"]["surfaceId"], "s1",
2034            "the split key must still be found"
2035        );
2036    }
2037
2038    #[test]
2039    fn a_later_surface_does_not_leak_into_an_earlier_one() {
2040        let mut parser = parser();
2041        feed(
2042            &mut parser,
2043            &[
2044                "<a2ui-json>[",
2045                r#"{"version":"v0.9","createSurface":{"surfaceId":"one","catalogId":"test"}},"#,
2046                r#"{"version":"v0.9","createSurface":{"surfaceId":"two","catalogId":"test"}},"#,
2047            ],
2048        );
2049        // Back to the first surface: the sniffer has to notice the switch.
2050        let messages = feed(
2051            &mut parser,
2052            &[concat!(
2053                r#"{"version":"v0.9","updateComponents":{"surfaceId":"one","components":"#,
2054                r#"[{"id":"root","component":"Text","text":"hi"}"#
2055            )],
2056        );
2057        assert_eq!(messages[0]["updateComponents"]["surfaceId"], "one");
2058    }
2059
2060    #[test]
2061    fn scanning_reads_the_last_pair_and_flags_an_unfinished_one() {
2062        let buffer = r#"{"surfaceId": "first"} {"surfaceId" : "second"}"#;
2063        let (value, incomplete) = scan_string_values(buffer, "surfaceId");
2064        assert_eq!(value, Some("second".to_string()));
2065        assert_eq!(incomplete, None);
2066
2067        assert_eq!(scan_string_values(buffer, "missing"), (None, None));
2068
2069        // A key with a non-string value is skipped rather than mis-read.
2070        assert_eq!(scan_string_values(r#"{"root": 3}"#, "root"), (None, None));
2071
2072        // A value still arriving must be looked at again next time, and the
2073        // earlier complete value is still reported.
2074        let cut = r#"{"surfaceId": "done"}, {"surfaceId": "partia"#;
2075        let (value, incomplete) = scan_string_values(cut, "surfaceId");
2076        assert_eq!(value, Some("done".to_string()));
2077        assert_eq!(incomplete, Some(cut.rfind("\"surfaceId\"").unwrap()));
2078    }
2079}