Skip to main content

forge_primitives/
browser.rs

1//! What a browser tool call hands back from the server's browser.
2//!
3//! A call answers the parts the tool returned, in order: text, or an image
4//! whose bytes the MCP host hands to the CLI as their own content block. **The
5//! browser is the server's**, so none of this crosses the socket; and the
6//! image's bytes are not in this type's JSON form either, because a screenshot
7//! is megabytes and base64 in JSON would pay a third again for it.
8
9use serde::{Deserialize, Serialize};
10
11/// One part of a browser tool's answer.
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum BrowserPart {
15    /// Text the tool returns as its content.
16    Text { text: String },
17    /// An image, whose bytes follow in their own binary frame under the same
18    /// answer id. Empty until the frame arrives.
19    Image {
20        mime_type: String,
21        #[serde(skip)]
22        bytes: Vec<u8>,
23    },
24}
25
26/// One org's browser profile, `<org>/<name>`: two path-safe segments, and the
27/// unit every profile lookup is keyed by.
28#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
29pub struct ProfileKey(String);
30
31impl ProfileKey {
32    pub fn of(org: &str, name: &str) -> Result<Self, String> {
33        Self::parse(&format!("{org}/{name}"))
34    }
35
36    /// Parse `<org>/<name>`; both halves become directory names.
37    pub fn parse(key: &str) -> Result<Self, String> {
38        let Some((org, name)) = key.split_once('/') else {
39            return Err(format!("'{key}' is not a browser profile key: the shape is <org>/<name>"));
40        };
41        segment_refusal(org, "org")?;
42        segment_refusal(name, "name")?;
43        Ok(Self(key.to_owned()))
44    }
45
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49
50    pub fn org(&self) -> &str {
51        self.0.split_once('/').map(|(org, _)| org).unwrap_or_default()
52    }
53
54    pub fn name(&self) -> &str {
55        self.0.split_once('/').map(|(_, name)| name).unwrap_or_default()
56    }
57
58    /// A fixture's key, from parts the fixture chose and so already knows
59    /// are safe.
60    #[cfg(feature = "test-helpers")]
61    pub fn from_parts_for_test(org: &str, name: &str) -> Self {
62        Self(format!("{org}/{name}"))
63    }
64}
65
66/// One segment of a profile key: what can be a directory name and nothing
67/// else. A name is the profile charset; an org may carry any character a
68/// directory holds, but never a separator or a dot traversal.
69fn segment_refusal(segment: &str, what: &str) -> Result<(), String> {
70    let refused = segment.is_empty()
71        || segment == "."
72        || segment == ".."
73        || segment.contains(['/', '\\', '\0']);
74    if refused {
75        return Err(format!("'{segment}' cannot be a browser profile {what}"));
76    }
77    if what == "name" {
78        let allowed = segment.len() <= 64
79            && segment.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
80        if !allowed {
81            return Err(format!(
82                "'{segment}' cannot be a browser profile name: 1 to 64 characters of letters, \
83                 digits, dot, dash or underscore"
84            ));
85        }
86    }
87    Ok(())
88}
89
90/// A browser hand-off: a session asking the person at a client to act in a
91/// browser tab before it can carry on.
92///
93/// The parked shape crosses the socket as `BrowserHandOffPending` and the
94/// dock draws it; `id` is what the answer comes back under, the same id the
95/// registry is keyed by, and `tab` is the tab the person is asked to look at.
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct HandOff {
98    pub id: uuid::Uuid,
99    /// What the session needs done, in its own words.
100    pub reason: String,
101    /// The tab the person should see, from the asking session's own tabs.
102    pub tab: u16,
103    /// The page's CDP target id. **A number names a different tab after a
104    /// host replacement**; this is the tab's identity, and the attach
105    /// resolves it.
106    pub target: String,
107}
108
109/// The modifier keys a person held for one input event.
110#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
111pub struct InputModifiers {
112    pub alt: bool,
113    pub control: bool,
114    pub meta: bool,
115    pub shift: bool,
116}
117
118/// One pointer gesture's kind.
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum MouseAction {
122    Move,
123    Press,
124    Release,
125}
126
127/// One input event a controlling viewer sends, in CSS pixels for the page's
128/// viewport.
129///
130/// **The wire carries the person's gesture, not the browser's protocol.** A
131/// press and a wheel reach CEF's own mouse calls and a key or text reach
132/// CDP's `Input.*`; a client speaks about a click, and the protocol's method
133/// names stay on the server.
134#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
135#[serde(tag = "kind", rename_all = "snake_case")]
136pub enum BrowserInputEvent {
137    Mouse {
138        action: MouseAction,
139        x: f64,
140        y: f64,
141        /// 0 left, 1 right, 2 middle.
142        button: i32,
143        clicks: i32,
144        modifiers: InputModifiers,
145    },
146    /// A wheel's deltas in the page's own sign: positive scrolls down and
147    /// right. The server turns the sign back for CEF.
148    Wheel { x: f64, y: f64, delta_x: f64, delta_y: f64, modifiers: InputModifiers },
149    /// A key with no text of its own: an arrow, Enter, Escape, a modifier.
150    Key {
151        down: bool,
152        key: String,
153        code: String,
154        /// The Windows virtual key code a page's widgets read.
155        windows_vk: i32,
156        modifiers: InputModifiers,
157    },
158    /// The person's own text, inserted as text rather than replayed keycodes.
159    Text { text: String },
160}
161
162/// One encoded browser frame as the socket relays it.
163///
164/// **The bytes stay encoded end to end.** The host is the only encoder; the
165/// server relays `encoded` untouched as one binary frame, and every client
166/// decodes the same host frame the server read. `encoded` is the host's own
167/// frame wire: the profile id, the tab, sequence, view size, format, key
168/// flag, the page's own viewport and the WebP payload.
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub struct BrowserFrame {
171    /// The service's profile id: which surface the frame belongs to.
172    pub profile: u16,
173    /// The profile's tab this frame paints.
174    pub tab: u16,
175    /// Monotonic per tab; a newer frame supersedes an older one.
176    pub seq: u32,
177    pub encoded: Vec<u8>,
178}
179
180/// One tab as a client's browser strip draws it.
181#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
182pub struct BrowserTabRow {
183    /// The host's tab number, which frames and input already speak.
184    pub number: u16,
185    pub title: String,
186    pub url: String,
187    /// The session whose calls drive it.
188    pub owner: crate::SessionSlot,
189    /// The tab whose page opened this one, when this is a popup. **The
190    /// opener is a tab number, not a page id**: the surface follows a popup
191    /// in and hands back on the tab that opened it.
192    pub opener: Option<u16>,
193    pub state: BrowserTabState,
194    /// The page dialog holding this tab, when one is open.
195    pub dialog: Option<BrowserTabDialog>,
196}
197
198/// A page dialog holding a tab, as the strip names it.
199#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
200pub struct BrowserTabDialog {
201    /// `alert`, `confirm`, `prompt` or `beforeunload`.
202    pub kind: String,
203    pub message: String,
204}
205
206/// What a tab is doing, as the strip's mark says it.
207#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum BrowserTabState {
210    /// A call is driving it now.
211    InUse,
212    Idle,
213    /// A parked hand-off is waiting on the person in it.
214    Held,
215}
216
217/// One profile's tabs and how the profile itself is doing.
218#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
219pub struct BrowserTabList {
220    pub profile: String,
221    /// The id the binary frames carry for the profile.
222    pub id: u16,
223    /// Whether its browser host is up.
224    pub up: bool,
225    /// Whether a driver is attached and running.
226    pub connected: bool,
227    /// When the idle clock closes it, as Unix epoch milliseconds.
228    pub closing_at_ms: Option<u64>,
229    pub rows: Vec<BrowserTabRow>,
230}
231
232/// One tab's viewer state, as every attached client sees it.
233#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
234pub struct BrowserViewState {
235    /// The controller's viewer id, when one holds the role on this tab.
236    pub controller: Option<u64>,
237    /// How many clients are attached to this tab.
238    pub viewers: u32,
239}
240
241/// Where the page's history sits: what the takeover bar's two arrows may do.
242#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
243pub struct BrowserNavState {
244    pub can_back: bool,
245    pub can_forward: bool,
246}
247
248/// A page's file chooser, as the client's picker reads it.
249#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
250pub struct BrowserFileAsk {
251    /// The dialog's id; the answer comes back under it.
252    pub id: u64,
253    /// The tab the dialog was raised on. **A viewer is asked only for its
254    /// own tab**, and an answer from another tab is refused.
255    pub tab: u16,
256    /// Whether the page accepts more than one file.
257    pub multiple: bool,
258}
259
260/// One file a person chose, on its way to the server's host as a path.
261#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
262pub struct BrowserUpload {
263    pub name: String,
264    pub bytes: Vec<u8>,
265}
266
267/// The most one file answer may carry across all its files, and the most
268/// files it may name.
269///
270/// **The answer is base64 JSON in one socket message**, and the socket's
271/// inbound cap is 16 MiB: 8 MiB of bytes encodes to about 10.7 MiB, so this
272/// is a cap the connection really carries rather than a promise that tears
273/// it. The client refuses past it before reading, and the server refuses
274/// again before writing.
275pub const MAX_UPLOAD_BYTES: u64 = 8 * 1024 * 1024;
276pub const MAX_UPLOAD_FILES: usize = 16;
277
278/// How many finished downloads one profile's broadcast keeps for a viewer
279/// that is not reading.
280///
281/// **The backlog is memory, and a download is bytes**: a finished offer can
282/// carry 32 MiB, so this is the memory a profile holds for a page that saved
283/// files while nobody looked. A viewer that lags past it loses the oldest
284/// offers, which the server reports as a dropped download.
285pub const DOWNLOAD_BACKLOG: usize = 4;
286
287/// One finished download, on its way to the person's save door.
288#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
289pub struct BrowserDownload {
290    /// The tab the download was started from, so only its viewer is offered
291    /// the save. **`None` is a profile-level notice**: the backlog dropped
292    /// offers before a viewer read them, and the lost ones may belong to any
293    /// tab of the profile.
294    pub tab: Option<u16>,
295    pub name: String,
296    /// The file's bytes, empty when it could not be relayed.
297    pub bytes: Vec<u8>,
298    /// Why this download cannot be saved, when it cannot.
299    pub error: Option<String>,
300}
301
302/// One packet of the page's audio, as the controlling client plays it.
303#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
304pub struct BrowserAudio {
305    pub profile: u16,
306    pub tab: u16,
307    pub seq: u32,
308    pub sample_rate: u32,
309    pub channels: u8,
310    /// Interleaved 16-bit samples.
311    pub samples: Vec<i16>,
312}
313
314/// Why a hand-off left the registry.
315#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(tag = "type", rename_all = "snake_case")]
317pub enum HandOffEnding {
318    /// The person acted; the session carries on.
319    Done,
320    /// The person said not now; the browser is unchanged, and the session
321    /// asks again later if it still needs them on the page.
322    NotNow,
323    /// The person says something is off: their note is the message beside
324    /// the answer, and the session addresses it and hands the page off again.
325    Feedback,
326    /// The asking session went away before anyone answered.
327    Abandoned,
328}
329
330/// Which door in a client produced a hand-off's answer.
331///
332/// **The ending says what it means; this says who said it.** A hand-off that
333/// ends with nobody named is the defect two live rounds were spent on
334/// (2026-10-07, 2026-10-26): the answer has to carry the hand that sent it.
335#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337pub enum HandOffSource {
338    /// The dock's Done or Not now, before the takeover opened.
339    Dock,
340    /// The dock's own Escape, which is its refusal.
341    DockEscape,
342    /// The takeover bar's Done or Not now.
343    Takeover,
344    /// The takeover bar's note line: the person's words go to the session as
345    /// a prompt, and the ending says the person says something is off.
346    Feedback,
347    /// The asking session's waiter went away; the core's own ending.
348    Abandoned,
349    /// A client that did not say which door it used.
350    #[default]
351    Unstated,
352}
353
354/// One answer to a parked browser hand-off: what it means, which door sent it,
355/// and the connection that carried it.
356///
357/// `client` is stamped by the server at dispatch - a client cannot know its own
358/// connection number - so every ending names the hand that sent it.
359#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
360pub struct HandOffAnswer {
361    pub ending: HandOffEnding,
362    #[serde(default)]
363    pub source: HandOffSource,
364    #[serde(default)]
365    pub client: Option<u64>,
366}
367
368/// Every browser tool the spec table advertises, by name.
369///
370/// The table itself lives in `forge-workspace`'s `mcp::browser::specs`, which
371/// this crate may not depend on: a unit test there asserts the two lists
372/// agree, and the live check in `forge-server`'s `surface_parity_live` test
373/// asserts every name is answered by the pinned driver or by a host arm.
374pub const ADVERTISED_TOOLS: [&str; 32] = [
375    "browser_close",
376    "browser_resize",
377    "browser_console_messages",
378    "browser_handle_dialog",
379    "browser_emulate_media",
380    "browser_evaluate",
381    "browser_file_upload",
382    "browser_drop",
383    "browser_find",
384    "browser_fill_form",
385    "browser_press_key",
386    "browser_type",
387    "browser_navigate",
388    "browser_navigate_back",
389    "browser_network_requests",
390    "browser_network_request",
391    "browser_run_code_unsafe",
392    "browser_take_screenshot",
393    "browser_snapshot",
394    "browser_click",
395    "browser_drag",
396    "browser_hover",
397    "browser_select_option",
398    "browser_tabs",
399    "browser_wait_for",
400    "browser_click_and_capture",
401    "browser_form_state",
402    "browser_challenge_state",
403    "browser_hand_off",
404    "browser_record_start",
405    "browser_record_stop",
406    "browser_end_host",
407];
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::SessionSlot;
413
414    fn key_of_default() -> ProfileKey {
415        ProfileKey::of("Busytools", "default").expect("a key")
416    }
417
418    #[test]
419    fn a_profile_key_is_two_safe_segments() {
420        let key = ProfileKey::of("Busytools", "work").expect("a valid key");
421        assert_eq!(key.as_str(), "Busytools/work");
422        assert_eq!(key.org(), "Busytools");
423        assert_eq!(key.name(), "work");
424        assert_eq!(ProfileKey::parse("Busytools/default").expect("a parsed key"), key_of_default());
425
426        for (key, why) in [
427            ("Busytools", "one segment is not a key"),
428            ("/work", "an empty org is not a key"),
429            ("Busytools/", "an empty name is not a key"),
430            ("Busy/tools/work", "a third segment is not a key"),
431            ("Busytools/..", "a dot-dot name is not a key"),
432            ("Busytools/.", "a dot name is not a key"),
433            ("Busytools/two words", "a space is outside the name charset"),
434            ("Busytools/a/b", "a slash is outside the name charset"),
435        ] {
436            assert!(ProfileKey::parse(key).is_err(), "{why}: {key}");
437        }
438        assert!(
439            ProfileKey::of("Busy/tools", "work").is_err(),
440            "an org carrying a slash is refused"
441        );
442    }
443
444    /// A hand-off crosses as its own words plus the tab the person should
445    /// look at.
446    #[test]
447    fn a_hand_off_names_the_tab_the_person_should_see() {
448        let handoff = HandOff {
449            id: uuid::Uuid::from_u128(7),
450            reason: "CAPTCHA".to_owned(),
451            tab: 3,
452            target: "page-7".to_owned(),
453        };
454        let encoded = serde_json::to_value(&handoff).expect("a hand-off encodes");
455        assert_eq!(
456            encoded,
457            serde_json::json!({ "id": "00000000-0000-0000-0000-000000000007", "reason": "CAPTCHA", "tab": 3, "target": "page-7" }),
458        );
459        assert_eq!(serde_json::from_value::<HandOff>(encoded).expect("decodes"), handoff);
460    }
461
462    /// A tab row crosses with the session that drives it and what it is
463    /// doing, so a strip can mark a held tab without a second read. **The
464    /// opener crosses too**: a client follows a popup in and hands back on
465    /// the tab that opened it, and a row that dropped the field would leave
466    /// the surface on a closed tab.
467    #[test]
468    fn a_tab_row_crosses_with_its_owner_and_state() {
469        let row = BrowserTabRow {
470            number: 2,
471            title: "portal".to_owned(),
472            url: "https://x/".to_owned(),
473            owner: SessionSlot::worker("O", "p", "w"),
474            opener: Some(1),
475            state: BrowserTabState::Held,
476            dialog: Some(BrowserTabDialog {
477                kind: "confirm".to_owned(),
478                message: "Leave this page?".to_owned(),
479            }),
480        };
481        let encoded = serde_json::to_value(&row).expect("a row encodes");
482        assert_eq!(encoded["number"], 2);
483        assert_eq!(encoded["opener"], 1);
484        assert_eq!(encoded["state"], "held");
485        assert_eq!(encoded["dialog"]["kind"], "confirm");
486        assert_eq!(serde_json::from_value::<BrowserTabRow>(encoded).expect("decodes"), row);
487    }
488
489    /// An ending crosses as its tag: the resolved update carries it, and the
490    /// view that did not answer says what happened with the same word the
491    /// answer had.
492    #[test]
493    fn an_ending_crosses_as_its_tag() {
494        assert_eq!(
495            serde_json::to_value(HandOffEnding::Done).expect("an ending encodes"),
496            serde_json::json!({ "type": "done" }),
497        );
498        assert_eq!(
499            serde_json::from_value::<HandOffEnding>(serde_json::json!({ "type": "not_now" }))
500                .expect("an ending decodes"),
501            HandOffEnding::NotNow,
502        );
503        assert_eq!(
504            serde_json::to_value(HandOffEnding::Abandoned).expect("an ending encodes"),
505            serde_json::json!({ "type": "abandoned" }),
506        );
507    }
508
509    /// An answer crosses as the ending, the door that sent it and the
510    /// connection that carried it, so a reader of the wire can name the hand
511    /// rather than only the outcome.
512    #[test]
513    fn an_answer_crosses_with_its_source_and_client() {
514        let answer = HandOffAnswer {
515            ending: HandOffEnding::Feedback,
516            source: HandOffSource::Feedback,
517            client: Some(7),
518        };
519        let encoded = serde_json::to_value(answer).expect("an answer encodes");
520        assert_eq!(
521            encoded,
522            serde_json::json!({
523                "ending": { "type": "feedback" },
524                "source": "feedback",
525                "client": 7,
526            }),
527        );
528        assert_eq!(serde_json::from_value::<HandOffAnswer>(encoded).expect("decodes"), answer);
529        // A client cannot know its own connection number, so the field is
530        // absent on the way in rather than a zero that would name a stranger -
531        // and an answer that names no door still resolves the park, recorded
532        // as unattributed rather than lost.
533        assert_eq!(
534            serde_json::from_value::<HandOffAnswer>(serde_json::json!({
535                "ending": { "type": "done" },
536            }))
537            .expect("decodes without a source or a client"),
538            HandOffAnswer {
539                ending: HandOffEnding::Done,
540                source: HandOffSource::Unstated,
541                client: None,
542            },
543        );
544    }
545
546    /// **Every door crosses as the word the server writes, and the client's
547    /// own tests assert the same four literals it writes.** The page builds
548    /// them by hand: `dock` and `dock_escape` in `Dock.svelte`, `takeover`
549    /// and `feedback` in `Composer.svelte`. The socket record walks keys and
550    /// never values, so it cannot catch a rename on either side; this test
551    /// and the client tests that click each door are what do, and a rename
552    /// here without one there would leave the answer undecodable.
553    #[test]
554    fn every_client_door_crosses_as_the_word_the_client_writes() {
555        for (source, word) in [
556            (HandOffSource::Dock, "dock"),
557            (HandOffSource::DockEscape, "dock_escape"),
558            (HandOffSource::Takeover, "takeover"),
559            (HandOffSource::Feedback, "feedback"),
560        ] {
561            assert_eq!(
562                serde_json::to_value(source).expect("a source encodes"),
563                serde_json::json!(word),
564                "the client writes {word} for {source:?}",
565            );
566            assert_eq!(
567                serde_json::from_value::<HandOffSource>(serde_json::json!(word))
568                    .expect("the word decodes"),
569                source,
570                "{word} is the door it names",
571            );
572        }
573    }
574
575    /// The bytes are the part's, not its JSON form: a carrier that base64'd
576    /// them into JSON would pay a third again for a screenshot.
577    #[test]
578    fn an_image_part_crosses_with_its_mime_and_not_its_bytes() {
579        let part = BrowserPart::Image { mime_type: "image/png".to_owned(), bytes: vec![1, 2, 3] };
580        let encoded = serde_json::to_value(&part).expect("a part encodes");
581        assert_eq!(
582            encoded,
583            serde_json::json!({ "type": "image", "mime_type": "image/png" }),
584            "the JSON form carries the tag and the mime, and never the bytes",
585        );
586        let BrowserPart::Image { mime_type, bytes } =
587            serde_json::from_value(encoded).expect("a part decodes")
588        else {
589            panic!("an image part decodes into another part")
590        };
591        assert_eq!(mime_type, "image/png");
592        assert!(bytes.is_empty(), "and a decoded JSON part carries no bytes");
593    }
594
595    /// **An input event crosses as the gesture, not as CDP.** A wire that
596    /// named `Input.dispatchMouseEvent` would put the browser's protocol on
597    /// every client; the server is what maps a gesture to it.
598    #[test]
599    fn an_input_event_crosses_as_the_gesture() {
600        let click = BrowserInputEvent::Mouse {
601            action: MouseAction::Press,
602            x: 12.5,
603            y: 30.0,
604            button: 1,
605            clicks: 1,
606            modifiers: InputModifiers { meta: true, ..Default::default() },
607        };
608        let encoded = serde_json::to_value(&click).expect("an event encodes");
609        assert_eq!(encoded["kind"], "mouse");
610        assert_eq!(encoded["action"], "press");
611        assert_eq!(encoded["modifiers"]["meta"], true);
612        assert_eq!(
613            serde_json::from_value::<BrowserInputEvent>(encoded).expect("an event decodes"),
614            click,
615        );
616        let text = BrowserInputEvent::Text { text: "hi".to_owned() };
617        assert_eq!(serde_json::to_value(&text).expect("text encodes")["kind"], "text");
618        let key = BrowserInputEvent::Key {
619            down: true,
620            key: "Enter".to_owned(),
621            code: "Enter".to_owned(),
622            windows_vk: 13,
623            modifiers: InputModifiers::default(),
624        };
625        assert_eq!(serde_json::to_value(&key).expect("a key encodes")["kind"], "key");
626    }
627
628    #[test]
629    fn a_text_part_crosses_as_its_text() {
630        let encoded = serde_json::to_value(BrowserPart::Text { text: "done".to_owned() })
631            .expect("a part encodes");
632        assert_eq!(encoded, serde_json::json!({ "type": "text", "text": "done" }));
633    }
634}