Skip to main content

agent_client_protocol_schema/v2/
terminal.rs

1//! Agent-owned terminal output reported for display by clients.
2
3use std::sync::Arc;
4
5use derive_more::{Display, From};
6use serde::{Deserialize, Serialize};
7use serde_with::{DefaultOnError, serde_as, skip_serializing_none};
8
9use super::{AbsolutePath, Meta, apply_meta_update};
10use crate::{IntoMaybeUndefined, IntoOption, MaybeUndefined};
11
12/// Unique identifier for an agent-owned terminal within a session.
13#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Display, From)]
15#[serde(transparent)]
16#[from(forward)]
17#[non_exhaustive]
18pub struct TerminalId(pub Arc<str>);
19
20impl TerminalId {
21    /// Wraps a protocol string as a typed [`TerminalId`].
22    #[must_use]
23    pub fn new(id: impl Into<Self>) -> Self {
24        id.into()
25    }
26}
27
28impl IntoOption<TerminalId> for &str {
29    fn into_option(self) -> Option<TerminalId> {
30        Some(TerminalId::new(self))
31    }
32}
33
34/// A display-only reference to an agent-owned terminal.
35///
36/// Terminal state and output are delivered separately through
37/// `terminal_update` and `terminal_output_chunk` session updates.
38#[serde_as]
39#[skip_serializing_none]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43#[non_exhaustive]
44pub struct Terminal {
45    /// The ID of the terminal to display.
46    pub terminal_id: TerminalId,
47    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
48    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
49    /// Implementations MUST NOT make assumptions about values at these keys.
50    ///
51    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
52    #[serde_as(deserialize_as = "DefaultOnError")]
53    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
54    #[serde(default)]
55    #[serde(rename = "_meta")]
56    pub meta: Option<Meta>,
57}
58
59impl Terminal {
60    /// Builds a terminal reference for the given terminal ID.
61    #[must_use]
62    pub fn new(terminal_id: impl Into<TerminalId>) -> Self {
63        Self {
64            terminal_id: terminal_id.into(),
65            meta: None,
66        }
67    }
68
69    /// Sets or clears metadata scoped to this content reference.
70    #[must_use]
71    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
72        self.meta = meta.into_option();
73        self
74    }
75}
76
77/// An authoritative replacement snapshot of terminal output bytes.
78#[serde_as]
79#[skip_serializing_none]
80#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83#[non_exhaustive]
84pub struct TerminalOutput {
85    /// Base64-encoded replacement terminal output bytes.
86    #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
87    pub data: String,
88    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
89    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
90    /// Implementations MUST NOT make assumptions about values at these keys.
91    ///
92    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
93    #[serde_as(deserialize_as = "DefaultOnError")]
94    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
95    #[serde(default)]
96    #[serde(rename = "_meta")]
97    pub meta: Option<Meta>,
98}
99
100impl TerminalOutput {
101    /// Builds an authoritative terminal output replacement.
102    #[must_use]
103    pub fn new(data: impl Into<String>) -> Self {
104        Self {
105            data: data.into(),
106            meta: None,
107        }
108    }
109
110    /// Sets or clears metadata scoped to this replacement snapshot.
111    #[must_use]
112    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
113        self.meta = meta.into_option();
114        self
115    }
116}
117
118/// Exit information for an agent-owned terminal.
119///
120/// The presence of this object marks the terminal as exited, even when neither
121/// an exit code nor a signal is known.
122#[serde_as]
123#[skip_serializing_none]
124#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127#[non_exhaustive]
128pub struct TerminalExitStatus {
129    /// Process exit code as the platform reports it, when known. Negative values are
130    /// allowed, for example signed Windows exit codes. Omitted and `null` are equivalent.
131    #[serde_as(deserialize_as = "DefaultOnError")]
132    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
133    #[serde(default)]
134    pub exit_code: Option<i64>,
135    /// Signal that terminated the process, when known.
136    ///
137    /// Agents should use the conventional platform signal name. POSIX examples
138    /// include `SIGTERM`, `SIGKILL`, and `SIGINT`. Other platforms may use a
139    /// platform-specific name. Omitted and `null` are equivalent.
140    #[serde_as(deserialize_as = "DefaultOnError")]
141    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
142    #[serde(default)]
143    pub signal: Option<String>,
144    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
145    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
146    /// Implementations MUST NOT make assumptions about values at these keys.
147    ///
148    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
149    #[serde_as(deserialize_as = "DefaultOnError")]
150    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
151    #[serde(default)]
152    #[serde(rename = "_meta")]
153    pub meta: Option<Meta>,
154}
155
156impl TerminalExitStatus {
157    /// Builds terminal exit information with no known exit code or signal.
158    #[must_use]
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    /// Sets or clears the optional exit code.
164    #[must_use]
165    pub fn exit_code(mut self, exit_code: impl IntoOption<i64>) -> Self {
166        self.exit_code = exit_code.into_option();
167        self
168    }
169
170    /// Sets or clears the optional terminating signal.
171    #[must_use]
172    pub fn signal(mut self, signal: impl IntoOption<String>) -> Self {
173        self.signal = signal.into_option();
174        self
175    }
176
177    /// Sets or clears metadata scoped to this exit information.
178    #[must_use]
179    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
180        self.meta = meta.into_option();
181        self
182    }
183}
184
185/// An upsert for the stored state of an agent-owned terminal.
186///
187/// Only `terminalId` is required. Other fields have patch
188/// semantics: omitted fields leave the stored value unchanged, `null` clears
189/// it, and concrete values replace it. A concrete `_meta` object instead
190/// patches the stored metadata by top-level key. When the terminal ID is new,
191/// omitted fields start unknown.
192#[serde_as]
193#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196#[non_exhaustive]
197pub struct TerminalUpdate {
198    /// Unique identifier for this terminal within the session.
199    pub terminal_id: TerminalId,
200    /// The command being run.
201    #[serde_as(deserialize_as = "DefaultOnError")]
202    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
203    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
204    pub command: MaybeUndefined<String>,
205    /// The absolute working directory of the command.
206    #[serde_as(deserialize_as = "DefaultOnError")]
207    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
208    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
209    pub cwd: MaybeUndefined<AbsolutePath>,
210    /// An authoritative replacement snapshot of terminal output bytes.
211    #[serde_as(deserialize_as = "DefaultOnError")]
212    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
213    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
214    pub output: MaybeUndefined<TerminalOutput>,
215    /// Exit information. A concrete object marks the terminal as exited.
216    #[serde_as(deserialize_as = "DefaultOnError")]
217    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
218    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
219    pub exit_status: MaybeUndefined<TerminalExitStatus>,
220    /// The terminal size the command runs with, when known.
221    ///
222    /// Clients can render output at this size so that column layouts, progress bars,
223    /// and cursor-positioned output display as the program wrote them.
224    #[serde_as(deserialize_as = "DefaultOnError")]
225    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
226    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
227    pub size: MaybeUndefined<TerminalSize>,
228    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
229    /// metadata to their interactions. Omitted leaves the stored metadata unchanged; `null`
230    /// clears it. An object patches the stored metadata by top-level key: keys it omits are
231    /// unchanged, a key set to `null` is removed, and any other value replaces that key's
232    /// stored value without merging nested objects. Beyond this patch rule, implementations
233    /// MUST NOT make assumptions about values at these keys.
234    ///
235    /// See protocol docs: [Metadata Patches](https://agentclientprotocol.com/protocol/v2/extensibility#metadata-patches)
236    #[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<_>>")]
237    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
238    #[serde(
239        rename = "_meta",
240        default,
241        skip_serializing_if = "MaybeUndefined::is_undefined"
242    )]
243    pub meta: MaybeUndefined<Meta>,
244}
245
246impl TerminalUpdate {
247    /// Builds a terminal upsert with only its required ID set.
248    #[must_use]
249    pub fn new(terminal_id: impl Into<TerminalId>) -> Self {
250        Self {
251            terminal_id: terminal_id.into(),
252            command: MaybeUndefined::Undefined,
253            cwd: MaybeUndefined::Undefined,
254            output: MaybeUndefined::Undefined,
255            exit_status: MaybeUndefined::Undefined,
256            size: MaybeUndefined::Undefined,
257            meta: MaybeUndefined::Undefined,
258        }
259    }
260
261    /// Sets, clears, or leaves unchanged the command being run.
262    #[must_use]
263    pub fn command(mut self, command: impl IntoMaybeUndefined<String>) -> Self {
264        self.command = command.into_maybe_undefined();
265        self
266    }
267
268    /// Sets, clears, or leaves unchanged the absolute working directory.
269    #[must_use]
270    pub fn cwd(mut self, cwd: impl IntoMaybeUndefined<AbsolutePath>) -> Self {
271        self.cwd = cwd.into_maybe_undefined();
272        self
273    }
274
275    /// Sets, clears, or leaves unchanged the authoritative output snapshot.
276    #[must_use]
277    pub fn output(mut self, output: impl IntoMaybeUndefined<TerminalOutput>) -> Self {
278        self.output = output.into_maybe_undefined();
279        self
280    }
281
282    /// Sets, clears, or leaves unchanged the terminal exit information.
283    #[must_use]
284    pub fn exit_status(mut self, exit_status: impl IntoMaybeUndefined<TerminalExitStatus>) -> Self {
285        self.exit_status = exit_status.into_maybe_undefined();
286        self
287    }
288
289    /// Sets, clears, or leaves unchanged the terminal size.
290    #[must_use]
291    pub fn size(mut self, size: impl IntoMaybeUndefined<TerminalSize>) -> Self {
292        self.size = size.into_maybe_undefined();
293        self
294    }
295
296    /// Patches, clears, or leaves unchanged terminal metadata.
297    ///
298    /// A concrete object patches stored metadata by top-level key; see
299    /// [`apply_meta_update`].
300    #[must_use]
301    pub fn meta(mut self, meta: impl IntoMaybeUndefined<Meta>) -> Self {
302        self.meta = meta.into_maybe_undefined();
303        self
304    }
305
306    /// Applies a later terminal patch to this stored terminal state.
307    ///
308    /// Fields set to `null` remain `null` so callers can distinguish explicit
309    /// clearing from an update that did not mention the field. A concrete
310    /// `_meta` object patches the stored metadata by top-level key, as described
311    /// by [`apply_meta_update`].
312    pub fn apply_update(&mut self, update: TerminalUpdate) {
313        debug_assert_eq!(self.terminal_id, update.terminal_id);
314        if !update.command.is_undefined() {
315            self.command = update.command;
316        }
317        if !update.cwd.is_undefined() {
318            self.cwd = update.cwd;
319        }
320        if !update.output.is_undefined() {
321            self.output = update.output;
322        }
323        if !update.exit_status.is_undefined() {
324            self.exit_status = update.exit_status;
325        }
326        if !update.size.is_undefined() {
327            self.size = update.size;
328        }
329        apply_meta_update(&mut self.meta, update.meta);
330    }
331}
332
333/// The size of an agent-owned terminal in character cells.
334#[serde_as]
335#[skip_serializing_none]
336#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase")]
339#[non_exhaustive]
340pub struct TerminalSize {
341    /// Number of columns.
342    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
343    pub cols: u16,
344    /// Number of rows.
345    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
346    pub rows: u16,
347    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
348    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
349    /// Implementations MUST NOT make assumptions about values at these keys.
350    ///
351    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
352    #[serde_as(deserialize_as = "DefaultOnError")]
353    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
354    #[serde(default)]
355    #[serde(rename = "_meta")]
356    pub meta: Option<Meta>,
357}
358
359impl TerminalSize {
360    /// Builds a terminal size from its columns and rows.
361    #[must_use]
362    pub fn new(cols: u16, rows: u16) -> Self {
363        Self {
364            cols,
365            rows,
366            meta: None,
367        }
368    }
369
370    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
371    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
372    /// these keys.
373    ///
374    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
375    #[must_use]
376    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
377        self.meta = meta.into_option();
378        self
379    }
380}
381
382/// A chunk of bytes appended to an agent-owned terminal's output.
383#[serde_as]
384#[skip_serializing_none]
385#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(rename_all = "camelCase")]
388#[non_exhaustive]
389pub struct TerminalOutputChunk {
390    /// The terminal receiving these bytes.
391    pub terminal_id: TerminalId,
392    /// Independently base64-encoded terminal output bytes.
393    #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
394    pub data: String,
395    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
396    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
397    /// Implementations MUST NOT make assumptions about values at these keys.
398    /// This metadata is scoped to the chunk and does not patch stored metadata.
399    ///
400    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
401    #[serde_as(deserialize_as = "DefaultOnError")]
402    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
403    #[serde(default)]
404    #[serde(rename = "_meta")]
405    pub meta: Option<Meta>,
406}
407
408impl TerminalOutputChunk {
409    /// Builds a terminal output chunk with the required fields set.
410    #[must_use]
411    pub fn new(terminal_id: impl Into<TerminalId>, data: impl Into<String>) -> Self {
412        Self {
413            terminal_id: terminal_id.into(),
414            data: data.into(),
415            meta: None,
416        }
417    }
418
419    /// Sets or clears chunk-scoped metadata.
420    #[must_use]
421    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
422        self.meta = meta.into_option();
423        self
424    }
425}
426
427#[cfg(test)]
428mod tests {
429
430    #[test]
431    fn terminal_size_is_an_optional_patch_field() {
432        let update = TerminalUpdate::new("term_1").size(TerminalSize::new(120, 40));
433        assert_eq!(
434            serde_json::to_value(&update).unwrap(),
435            serde_json::json!({ "terminalId": "term_1", "size": { "cols": 120, "rows": 40 } })
436        );
437
438        let mut stored = update;
439        stored.apply_update(TerminalUpdate::new("term_1").command("ls"));
440        assert_eq!(
441            stored.size,
442            MaybeUndefined::Value(TerminalSize::new(120, 40))
443        );
444        stored.apply_update(TerminalUpdate::new("term_1").size(None));
445        assert_eq!(stored.size, MaybeUndefined::Null);
446    }
447
448    #[test]
449    fn exit_code_accepts_negative_values() {
450        let status: TerminalExitStatus =
451            serde_json::from_value(serde_json::json!({ "exitCode": -1_073_741_819_i64 })).unwrap();
452        assert_eq!(status.exit_code, Some(-1_073_741_819));
453    }
454
455    use super::*;
456    use serde_json::{from_value, json, to_value};
457
458    #[test]
459    fn terminal_reference_meta_is_optional_and_content_scoped() {
460        let omitted = Terminal::new("term_1");
461        assert_eq!(
462            serde_json::to_value(&omitted).unwrap(),
463            serde_json::json!({
464                "terminalId": "term_1"
465            })
466        );
467
468        let null: Terminal = serde_json::from_value(serde_json::json!({
469            "terminalId": "term_1",
470            "_meta": null
471        }))
472        .unwrap();
473        assert_eq!(null.meta, None);
474
475        let mut meta = Meta::new();
476        meta.insert("view".to_string(), serde_json::json!("expanded"));
477        assert_eq!(
478            serde_json::to_value(Terminal::new("term_1").meta(meta)).unwrap(),
479            serde_json::json!({
480                "terminalId": "term_1",
481                "_meta": {
482                    "view": "expanded"
483                }
484            })
485        );
486    }
487
488    #[test]
489    fn terminal_update_distinguishes_omitted_null_and_value() {
490        let update = TerminalUpdate::new("term_1")
491            .command("cargo test")
492            .cwd("/workspace/project")
493            .output(None::<TerminalOutput>)
494            .exit_status(TerminalExitStatus::new().exit_code(0));
495
496        assert_eq!(
497            serde_json::to_value(update).unwrap(),
498            serde_json::json!({
499                "terminalId": "term_1",
500                "command": "cargo test",
501                "cwd": "/workspace/project",
502                "output": null,
503                "exitStatus": {
504                    "exitCode": 0
505                }
506            })
507        );
508
509        let parsed: TerminalUpdate = serde_json::from_value(serde_json::json!({
510            "terminalId": "term_1",
511            "command": null,
512            "cwd": "/workspace/project"
513        }))
514        .unwrap();
515        assert_eq!(parsed.command, MaybeUndefined::Null);
516        assert_eq!(
517            parsed.cwd,
518            MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
519        );
520        assert_eq!(parsed.output, MaybeUndefined::Undefined);
521        assert_eq!(parsed.exit_status, MaybeUndefined::Undefined);
522        assert_eq!(parsed.meta, MaybeUndefined::Undefined);
523    }
524
525    #[test]
526    fn terminal_update_applies_patch_fields() {
527        let mut stored = TerminalUpdate::new("term_1")
528            .command("cargo test")
529            .cwd("/workspace/project")
530            .output(TerminalOutput::new("b2xk"));
531
532        stored.apply_update(
533            TerminalUpdate::new("term_1")
534                .command(None::<String>)
535                .output(TerminalOutput::new("bmV3"))
536                .exit_status(TerminalExitStatus::new().signal("SIGTERM")),
537        );
538
539        assert_eq!(stored.command, MaybeUndefined::Null);
540        assert_eq!(
541            stored.cwd,
542            MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
543        );
544        assert_eq!(
545            stored.output,
546            MaybeUndefined::Value(TerminalOutput::new("bmV3"))
547        );
548        assert_eq!(
549            stored.exit_status,
550            MaybeUndefined::Value(TerminalExitStatus::new().signal("SIGTERM"))
551        );
552    }
553
554    #[test]
555    fn terminal_wire_patches_preserve_omitted_fields_and_replace_values() {
556        let initial = json!({
557            "terminalId": "term_1",
558            "_meta": {"source": "replay", "opaque": {"sequence": 1}}
559        });
560        let mut stored: TerminalUpdate = from_value(initial.clone()).unwrap();
561        // A metadata-only first update leaves output and exit status unknown.
562        assert_eq!(to_value(&stored).unwrap(), initial);
563
564        let populated = json!({
565            "terminalId": "term_1",
566            "command": "cargo check",
567            "cwd": "/workspace/project",
568            "output": {"data": "b2xk", "_meta": {"source": "snapshot"}},
569            "exitStatus": {"exitCode": 0, "_meta": {"observed": true}}
570        });
571        stored.apply_update(from_value(populated.clone()).unwrap());
572        let mut expected = populated;
573        expected["_meta"] = initial["_meta"].clone();
574        assert_eq!(to_value(&stored).unwrap(), expected);
575
576        for (field, empty) in [
577            ("command", json!("")),
578            ("output", json!({"data": ""})),
579            ("exitStatus", json!({})),
580        ] {
581            let original = expected[field].clone();
582            // Nested objects are replacements too: an empty output snapshot
583            // must not inherit snapshot metadata, and an empty exit-status
584            // object means exited with unknown details, not exit code zero.
585            for replacement in [empty, json!(null), original] {
586                stored.apply_update(
587                    from_value(json!({"terminalId": "term_1", (field): replacement})).unwrap(),
588                );
589                expected[field] = replacement;
590                assert_eq!(to_value(&stored).unwrap(), expected, "patching {field}");
591
592                stored.apply_update(from_value(json!({"terminalId": "term_1"})).unwrap());
593                assert_eq!(to_value(&stored).unwrap(), expected, "omitting {field}");
594            }
595        }
596
597        // Top-level `_meta` patches individual keys instead of replacing the object.
598        stored.apply_update(
599            from_value(json!({
600                "terminalId": "term_1",
601                "_meta": {"source": null, "opaque": {"other": 2}, "extra": true}
602            }))
603            .unwrap(),
604        );
605        expected["_meta"] = json!({"opaque": {"other": 2}, "extra": true});
606        assert_eq!(to_value(&stored).unwrap(), expected, "patching _meta keys");
607
608        stored.apply_update(from_value(json!({"terminalId": "term_1", "_meta": null})).unwrap());
609        expected["_meta"] = json!(null);
610        assert_eq!(to_value(&stored).unwrap(), expected, "clearing _meta");
611    }
612
613    #[test]
614    fn terminal_output_chunk_serializes_base64_data_and_meta() {
615        let mut meta = Meta::new();
616        meta.insert("source".to_string(), serde_json::json!("pty"));
617
618        assert_eq!(
619            serde_json::to_value(TerminalOutputChunk::new("term_1", "8J+agA==").meta(meta))
620                .unwrap(),
621            serde_json::json!({
622                "terminalId": "term_1",
623                "data": "8J+agA==",
624                "_meta": {
625                    "source": "pty"
626                }
627            })
628        );
629    }
630
631    #[test]
632    fn terminal_output_meta_is_optional_and_snapshot_scoped() {
633        let omitted = TerminalOutput::new("b2s=");
634        assert_eq!(
635            serde_json::to_value(&omitted).unwrap(),
636            serde_json::json!({ "data": "b2s=" })
637        );
638
639        let null: TerminalOutput = serde_json::from_value(serde_json::json!({
640            "data": "b2s=",
641            "_meta": null
642        }))
643        .unwrap();
644        assert_eq!(null, omitted);
645
646        let mut meta = Meta::new();
647        meta.insert("source".to_string(), serde_json::json!("replay"));
648        assert_eq!(
649            serde_json::to_value(TerminalOutput::new("b2s=").meta(meta)).unwrap(),
650            serde_json::json!({
651                "data": "b2s=",
652                "_meta": {
653                    "source": "replay"
654                }
655            })
656        );
657    }
658
659    #[cfg(feature = "schemars")]
660    #[test]
661    fn terminal_output_fields_use_base64_content_encoding() {
662        let output = serde_json::to_value(schemars::schema_for!(TerminalOutput)).unwrap();
663        assert_eq!(output["properties"]["data"]["contentEncoding"], "base64");
664        assert!(output["properties"]["data"].get("format").is_none());
665        assert_eq!(output["required"], serde_json::json!(["data"]));
666        assert!(output["properties"].get("_meta").is_some());
667        assert!(output["properties"].get("truncated").is_none());
668
669        let chunk = serde_json::to_value(schemars::schema_for!(TerminalOutputChunk)).unwrap();
670        assert_eq!(chunk["properties"]["data"]["contentEncoding"], "base64");
671        assert!(chunk["properties"]["data"].get("format").is_none());
672    }
673
674    #[test]
675    fn terminal_exit_status_treats_omitted_and_null_as_unknown() {
676        let omitted: TerminalExitStatus = serde_json::from_value(serde_json::json!({})).unwrap();
677        let null: TerminalExitStatus = serde_json::from_value(serde_json::json!({
678            "exitCode": null,
679            "signal": null,
680            "_meta": null
681        }))
682        .unwrap();
683
684        assert_eq!(omitted, TerminalExitStatus::new());
685        assert_eq!(null, TerminalExitStatus::new());
686        assert_eq!(serde_json::to_value(null).unwrap(), serde_json::json!({}));
687    }
688
689    #[test]
690    fn terminal_exit_status_meta_is_exit_scoped() {
691        let mut meta = Meta::new();
692        meta.insert("reason".to_string(), serde_json::json!("timeout"));
693
694        assert_eq!(
695            serde_json::to_value(TerminalExitStatus::new().signal("SIGTERM").meta(meta)).unwrap(),
696            serde_json::json!({
697                "signal": "SIGTERM",
698                "_meta": {
699                    "reason": "timeout"
700                }
701            })
702        );
703    }
704}