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 assign meaning to keys they do not recognize.
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 assign meaning to keys they do not recognize.
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 assign meaning to keys they do not recognize.
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/// Client-initiated controls a terminal currently supports.
186///
187/// A malformed value falls back to no controls, so the Client never sends a request the
188/// Agent did not advertise.
189///
190/// See protocol docs: [Cancelling Terminals](https://agentclientprotocol.com/protocol/v2/tool-calls#cancelling-terminals)
191#[serde_as]
192#[skip_serializing_none]
193#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
194#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
195#[serde(rename_all = "camelCase")]
196#[non_exhaustive]
197pub struct TerminalCapabilities {
198    /// Permits the Client to cancel this terminal with `terminal/cancel`.
199    ///
200    /// Omitted and `null` are equivalent and mean unsupported. An object (including `{}`)
201    /// means supported. Agents SHOULD stop advertising it once the terminal reports `exitStatus`.
202    #[serde_as(deserialize_as = "DefaultOnError")]
203    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
204    #[serde(default)]
205    pub cancel: Option<TerminalCancelCapabilities>,
206    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
207    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
208    /// Implementations MUST NOT assign meaning to keys they do not recognize.
209    ///
210    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
211    #[serde_as(deserialize_as = "DefaultOnError")]
212    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
213    #[serde(default)]
214    #[serde(rename = "_meta")]
215    pub meta: Option<Meta>,
216}
217
218impl TerminalCapabilities {
219    /// Builds an empty capability set. Cancellation is not advertised.
220    #[must_use]
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Sets or removes permission to cancel this terminal.
226    #[must_use]
227    pub fn cancel(mut self, cancel: impl IntoOption<TerminalCancelCapabilities>) -> Self {
228        self.cancel = cancel.into_option();
229        self
230    }
231
232    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
233    /// metadata to their interactions. Implementations MUST NOT assign meaning to keys they
234    /// do not recognize.
235    ///
236    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
237    #[must_use]
238    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
239        self.meta = meta.into_option();
240        self
241    }
242}
243
244/// Capability to cancel a terminal with `terminal/cancel`.
245///
246/// Supplying `{}` advertises support. An omitted or `null` `cancel` does not.
247#[serde_as]
248#[skip_serializing_none]
249#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
250#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
251#[non_exhaustive]
252pub struct TerminalCancelCapabilities {
253    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
254    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
255    /// Implementations MUST NOT assign meaning to keys they do not recognize.
256    ///
257    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
258    #[serde_as(deserialize_as = "DefaultOnError")]
259    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
260    #[serde(default)]
261    #[serde(rename = "_meta")]
262    pub meta: Option<Meta>,
263}
264
265impl TerminalCancelCapabilities {
266    /// Builds an empty capability object advertising cancellation support.
267    #[must_use]
268    pub fn new() -> Self {
269        Self::default()
270    }
271
272    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
273    /// metadata to their interactions. Implementations MUST NOT assign meaning to keys they
274    /// do not recognize.
275    ///
276    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
277    #[must_use]
278    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
279        self.meta = meta.into_option();
280        self
281    }
282}
283
284/// An upsert for the stored state of an agent-owned terminal.
285///
286/// Only `terminalId` is required. Other fields have patch
287/// semantics: omitted fields leave the stored value unchanged, `null` clears
288/// it, and concrete values replace it. A concrete `_meta` object instead
289/// patches the stored metadata by top-level key. When the terminal ID is new,
290/// omitted fields start unknown.
291#[serde_as]
292#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295#[non_exhaustive]
296pub struct TerminalUpdate {
297    /// Unique identifier for this terminal within the session.
298    pub terminal_id: TerminalId,
299    /// The command being run.
300    #[serde_as(deserialize_as = "DefaultOnError")]
301    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
302    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
303    pub command: MaybeUndefined<String>,
304    /// The absolute working directory of the command.
305    #[serde_as(deserialize_as = "DefaultOnError")]
306    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
307    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
308    pub cwd: MaybeUndefined<AbsolutePath>,
309    /// An authoritative replacement snapshot of terminal output bytes.
310    #[serde_as(deserialize_as = "DefaultOnError")]
311    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
312    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
313    pub output: MaybeUndefined<TerminalOutput>,
314    /// Exit information. A concrete object marks the terminal as exited.
315    #[serde_as(deserialize_as = "DefaultOnError")]
316    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
317    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
318    pub exit_status: MaybeUndefined<TerminalExitStatus>,
319    /// The terminal size the command runs with, when known.
320    ///
321    /// Clients can render output at this size so that column layouts, progress bars,
322    /// and cursor-positioned output display as the program wrote them.
323    #[serde_as(deserialize_as = "DefaultOnError")]
324    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
325    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
326    pub size: MaybeUndefined<TerminalSize>,
327    /// Client-initiated controls this terminal currently supports, such as cancellation.
328    ///
329    /// Omission leaves the capabilities unchanged, `null` clears them, and an object replaces
330    /// the whole previous value. A new terminal starts with none.
331    #[serde_as(deserialize_as = "DefaultOnError")]
332    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
333    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
334    pub capabilities: MaybeUndefined<TerminalCapabilities>,
335    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
336    /// metadata to their interactions. Omitted leaves the stored metadata unchanged. `null`
337    /// clears it. An object patches the stored metadata by top-level key: keys it omits are
338    /// unchanged, a key set to `null` is removed, and any other value replaces that key's
339    /// stored value without merging nested objects. Beyond this patch rule, implementations
340    /// MUST NOT assign meaning to keys they do not recognize.
341    ///
342    /// See protocol docs: [Metadata Patches](https://agentclientprotocol.com/protocol/v2/extensibility#metadata-patches)
343    #[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<_>>")]
344    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
345    #[serde(
346        rename = "_meta",
347        default,
348        skip_serializing_if = "MaybeUndefined::is_undefined"
349    )]
350    pub meta: MaybeUndefined<Meta>,
351}
352
353impl TerminalUpdate {
354    /// Builds a terminal upsert with only its required ID set.
355    #[must_use]
356    pub fn new(terminal_id: impl Into<TerminalId>) -> Self {
357        Self {
358            terminal_id: terminal_id.into(),
359            command: MaybeUndefined::Undefined,
360            cwd: MaybeUndefined::Undefined,
361            output: MaybeUndefined::Undefined,
362            exit_status: MaybeUndefined::Undefined,
363            size: MaybeUndefined::Undefined,
364            capabilities: MaybeUndefined::Undefined,
365            meta: MaybeUndefined::Undefined,
366        }
367    }
368
369    /// Sets, clears, or leaves unchanged the command being run.
370    #[must_use]
371    pub fn command(mut self, command: impl IntoMaybeUndefined<String>) -> Self {
372        self.command = command.into_maybe_undefined();
373        self
374    }
375
376    /// Sets, clears, or leaves unchanged the absolute working directory.
377    #[must_use]
378    pub fn cwd(mut self, cwd: impl IntoMaybeUndefined<AbsolutePath>) -> Self {
379        self.cwd = cwd.into_maybe_undefined();
380        self
381    }
382
383    /// Sets, clears, or leaves unchanged the authoritative output snapshot.
384    #[must_use]
385    pub fn output(mut self, output: impl IntoMaybeUndefined<TerminalOutput>) -> Self {
386        self.output = output.into_maybe_undefined();
387        self
388    }
389
390    /// Sets, clears, or leaves unchanged the terminal exit information.
391    #[must_use]
392    pub fn exit_status(mut self, exit_status: impl IntoMaybeUndefined<TerminalExitStatus>) -> Self {
393        self.exit_status = exit_status.into_maybe_undefined();
394        self
395    }
396
397    /// Sets, clears, or leaves unchanged the terminal size.
398    #[must_use]
399    pub fn size(mut self, size: impl IntoMaybeUndefined<TerminalSize>) -> Self {
400        self.size = size.into_maybe_undefined();
401        self
402    }
403
404    /// Sets, clears, or leaves unchanged the Client-initiated controls.
405    #[must_use]
406    pub fn capabilities(
407        mut self,
408        capabilities: impl IntoMaybeUndefined<TerminalCapabilities>,
409    ) -> Self {
410        self.capabilities = capabilities.into_maybe_undefined();
411        self
412    }
413
414    /// Patches, clears, or leaves unchanged terminal metadata.
415    ///
416    /// A concrete object patches stored metadata by top-level key. See
417    /// [`apply_meta_update`].
418    #[must_use]
419    pub fn meta(mut self, meta: impl IntoMaybeUndefined<Meta>) -> Self {
420        self.meta = meta.into_maybe_undefined();
421        self
422    }
423
424    /// Applies a later terminal patch to this stored terminal state.
425    ///
426    /// Fields set to `null` remain `null` so callers can distinguish explicit
427    /// clearing from an update that did not mention the field. A concrete
428    /// `_meta` object patches the stored metadata by top-level key, as described
429    /// by [`apply_meta_update`].
430    pub fn apply_update(&mut self, update: TerminalUpdate) {
431        debug_assert_eq!(self.terminal_id, update.terminal_id);
432        if !update.command.is_undefined() {
433            self.command = update.command;
434        }
435        if !update.cwd.is_undefined() {
436            self.cwd = update.cwd;
437        }
438        if !update.output.is_undefined() {
439            self.output = update.output;
440        }
441        if !update.exit_status.is_undefined() {
442            self.exit_status = update.exit_status;
443        }
444        if !update.size.is_undefined() {
445            self.size = update.size;
446        }
447        if !update.capabilities.is_undefined() {
448            self.capabilities = update.capabilities;
449        }
450        apply_meta_update(&mut self.meta, update.meta);
451    }
452}
453
454/// The size of an agent-owned terminal in character cells.
455#[serde_as]
456#[skip_serializing_none]
457#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459#[serde(rename_all = "camelCase")]
460#[non_exhaustive]
461pub struct TerminalSize {
462    /// Number of columns.
463    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
464    pub cols: u16,
465    /// Number of rows.
466    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
467    pub rows: u16,
468    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
469    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
470    /// Implementations MUST NOT assign meaning to keys they do not recognize.
471    ///
472    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
473    #[serde_as(deserialize_as = "DefaultOnError")]
474    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
475    #[serde(default)]
476    #[serde(rename = "_meta")]
477    pub meta: Option<Meta>,
478}
479
480impl TerminalSize {
481    /// Builds a terminal size from its columns and rows.
482    #[must_use]
483    pub fn new(cols: u16, rows: u16) -> Self {
484        Self {
485            cols,
486            rows,
487            meta: None,
488        }
489    }
490
491    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
492    /// metadata to their interactions. Implementations MUST NOT assign meaning to keys they
493    /// do not recognize.
494    ///
495    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
496    #[must_use]
497    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
498        self.meta = meta.into_option();
499        self
500    }
501}
502
503/// A chunk of bytes appended to an agent-owned terminal's output.
504#[serde_as]
505#[skip_serializing_none]
506#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase")]
509#[non_exhaustive]
510pub struct TerminalOutputChunk {
511    /// The terminal receiving these bytes.
512    pub terminal_id: TerminalId,
513    /// Independently base64-encoded terminal output bytes.
514    #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
515    pub data: String,
516    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
517    /// metadata to their interactions. Omitted and `null` are equivalent and mean no metadata.
518    /// Implementations MUST NOT assign meaning to keys they do not recognize.
519    /// This metadata is scoped to the chunk and does not patch stored metadata.
520    ///
521    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility)
522    #[serde_as(deserialize_as = "DefaultOnError")]
523    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
524    #[serde(default)]
525    #[serde(rename = "_meta")]
526    pub meta: Option<Meta>,
527}
528
529impl TerminalOutputChunk {
530    /// Builds a terminal output chunk with the required fields set.
531    #[must_use]
532    pub fn new(terminal_id: impl Into<TerminalId>, data: impl Into<String>) -> Self {
533        Self {
534            terminal_id: terminal_id.into(),
535            data: data.into(),
536            meta: None,
537        }
538    }
539
540    /// Sets or clears chunk-scoped metadata.
541    #[must_use]
542    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
543        self.meta = meta.into_option();
544        self
545    }
546}
547
548#[cfg(test)]
549mod tests {
550
551    #[test]
552    fn terminal_capabilities_patch_and_tolerate_malformed_values() {
553        use serde_json::json;
554        let update = TerminalUpdate::new("term_003")
555            .capabilities(TerminalCapabilities::new().cancel(TerminalCancelCapabilities::new()));
556        assert_eq!(
557            serde_json::to_value(&update).unwrap(),
558            json!({ "terminalId": "term_003", "capabilities": { "cancel": {} } })
559        );
560
561        let mut stored = update;
562        stored.apply_update(TerminalUpdate::new("term_003"));
563        assert!(stored.capabilities.is_value());
564        stored.apply_update(TerminalUpdate::new("term_003").capabilities(MaybeUndefined::Null));
565        assert!(stored.capabilities.is_null());
566
567        for bad in [json!("yes"), json!(1)] {
568            let parsed: TerminalUpdate =
569                serde_json::from_value(json!({ "terminalId": "term_003", "capabilities": bad }))
570                    .unwrap();
571            assert!(parsed.capabilities.is_undefined());
572        }
573        let parsed: TerminalCapabilities =
574            serde_json::from_value(json!({ "cancel": "yes" })).unwrap();
575        assert_eq!(parsed.cancel, None);
576    }
577
578    #[test]
579    fn terminal_size_is_an_optional_patch_field() {
580        let update = TerminalUpdate::new("term_1").size(TerminalSize::new(120, 40));
581        assert_eq!(
582            serde_json::to_value(&update).unwrap(),
583            serde_json::json!({ "terminalId": "term_1", "size": { "cols": 120, "rows": 40 } })
584        );
585
586        let mut stored = update;
587        stored.apply_update(TerminalUpdate::new("term_1").command("ls"));
588        assert_eq!(
589            stored.size,
590            MaybeUndefined::Value(TerminalSize::new(120, 40))
591        );
592        stored.apply_update(TerminalUpdate::new("term_1").size(None));
593        assert_eq!(stored.size, MaybeUndefined::Null);
594    }
595
596    #[test]
597    fn exit_code_accepts_negative_values() {
598        let status: TerminalExitStatus =
599            serde_json::from_value(serde_json::json!({ "exitCode": -1_073_741_819_i64 })).unwrap();
600        assert_eq!(status.exit_code, Some(-1_073_741_819));
601    }
602
603    use super::*;
604    use serde_json::{from_value, json, to_value};
605
606    #[test]
607    fn terminal_reference_meta_is_optional_and_content_scoped() {
608        let omitted = Terminal::new("term_1");
609        assert_eq!(
610            serde_json::to_value(&omitted).unwrap(),
611            serde_json::json!({
612                "terminalId": "term_1"
613            })
614        );
615
616        let null: Terminal = serde_json::from_value(serde_json::json!({
617            "terminalId": "term_1",
618            "_meta": null
619        }))
620        .unwrap();
621        assert_eq!(null.meta, None);
622
623        let mut meta = Meta::new();
624        meta.insert("view".to_string(), serde_json::json!("expanded"));
625        assert_eq!(
626            serde_json::to_value(Terminal::new("term_1").meta(meta)).unwrap(),
627            serde_json::json!({
628                "terminalId": "term_1",
629                "_meta": {
630                    "view": "expanded"
631                }
632            })
633        );
634    }
635
636    #[test]
637    fn terminal_update_distinguishes_omitted_null_and_value() {
638        let update = TerminalUpdate::new("term_1")
639            .command("cargo test")
640            .cwd("/workspace/project")
641            .output(None::<TerminalOutput>)
642            .exit_status(TerminalExitStatus::new().exit_code(0));
643
644        assert_eq!(
645            serde_json::to_value(update).unwrap(),
646            serde_json::json!({
647                "terminalId": "term_1",
648                "command": "cargo test",
649                "cwd": "/workspace/project",
650                "output": null,
651                "exitStatus": {
652                    "exitCode": 0
653                }
654            })
655        );
656
657        let parsed: TerminalUpdate = serde_json::from_value(serde_json::json!({
658            "terminalId": "term_1",
659            "command": null,
660            "cwd": "/workspace/project"
661        }))
662        .unwrap();
663        assert_eq!(parsed.command, MaybeUndefined::Null);
664        assert_eq!(
665            parsed.cwd,
666            MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
667        );
668        assert_eq!(parsed.output, MaybeUndefined::Undefined);
669        assert_eq!(parsed.exit_status, MaybeUndefined::Undefined);
670        assert_eq!(parsed.meta, MaybeUndefined::Undefined);
671    }
672
673    #[test]
674    fn terminal_update_applies_patch_fields() {
675        let mut stored = TerminalUpdate::new("term_1")
676            .command("cargo test")
677            .cwd("/workspace/project")
678            .output(TerminalOutput::new("b2xk"));
679
680        stored.apply_update(
681            TerminalUpdate::new("term_1")
682                .command(None::<String>)
683                .output(TerminalOutput::new("bmV3"))
684                .exit_status(TerminalExitStatus::new().signal("SIGTERM")),
685        );
686
687        assert_eq!(stored.command, MaybeUndefined::Null);
688        assert_eq!(
689            stored.cwd,
690            MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
691        );
692        assert_eq!(
693            stored.output,
694            MaybeUndefined::Value(TerminalOutput::new("bmV3"))
695        );
696        assert_eq!(
697            stored.exit_status,
698            MaybeUndefined::Value(TerminalExitStatus::new().signal("SIGTERM"))
699        );
700    }
701
702    #[test]
703    fn terminal_wire_patches_preserve_omitted_fields_and_replace_values() {
704        let initial = json!({
705            "terminalId": "term_1",
706            "_meta": {"source": "replay", "opaque": {"sequence": 1}}
707        });
708        let mut stored: TerminalUpdate = from_value(initial.clone()).unwrap();
709        // A metadata-only first update leaves output and exit status unknown.
710        assert_eq!(to_value(&stored).unwrap(), initial);
711
712        let populated = json!({
713            "terminalId": "term_1",
714            "command": "cargo check",
715            "cwd": "/workspace/project",
716            "output": {"data": "b2xk", "_meta": {"source": "snapshot"}},
717            "exitStatus": {"exitCode": 0, "_meta": {"observed": true}}
718        });
719        stored.apply_update(from_value(populated.clone()).unwrap());
720        let mut expected = populated;
721        expected["_meta"] = initial["_meta"].clone();
722        assert_eq!(to_value(&stored).unwrap(), expected);
723
724        for (field, empty) in [
725            ("command", json!("")),
726            ("output", json!({"data": ""})),
727            ("exitStatus", json!({})),
728        ] {
729            let original = expected[field].clone();
730            // Nested objects are replacements too: an empty output snapshot
731            // must not inherit snapshot metadata, and an empty exit-status
732            // object means exited with unknown details, not exit code zero.
733            for replacement in [empty, json!(null), original] {
734                stored.apply_update(
735                    from_value(json!({"terminalId": "term_1", (field): replacement})).unwrap(),
736                );
737                expected[field] = replacement;
738                assert_eq!(to_value(&stored).unwrap(), expected, "patching {field}");
739
740                stored.apply_update(from_value(json!({"terminalId": "term_1"})).unwrap());
741                assert_eq!(to_value(&stored).unwrap(), expected, "omitting {field}");
742            }
743        }
744
745        // Top-level `_meta` patches individual keys instead of replacing the object.
746        stored.apply_update(
747            from_value(json!({
748                "terminalId": "term_1",
749                "_meta": {"source": null, "opaque": {"other": 2}, "extra": true}
750            }))
751            .unwrap(),
752        );
753        expected["_meta"] = json!({"opaque": {"other": 2}, "extra": true});
754        assert_eq!(to_value(&stored).unwrap(), expected, "patching _meta keys");
755
756        stored.apply_update(from_value(json!({"terminalId": "term_1", "_meta": null})).unwrap());
757        expected["_meta"] = json!(null);
758        assert_eq!(to_value(&stored).unwrap(), expected, "clearing _meta");
759    }
760
761    #[test]
762    fn terminal_output_chunk_serializes_base64_data_and_meta() {
763        let mut meta = Meta::new();
764        meta.insert("source".to_string(), serde_json::json!("pty"));
765
766        assert_eq!(
767            serde_json::to_value(TerminalOutputChunk::new("term_1", "8J+agA==").meta(meta))
768                .unwrap(),
769            serde_json::json!({
770                "terminalId": "term_1",
771                "data": "8J+agA==",
772                "_meta": {
773                    "source": "pty"
774                }
775            })
776        );
777    }
778
779    #[test]
780    fn terminal_output_meta_is_optional_and_snapshot_scoped() {
781        let omitted = TerminalOutput::new("b2s=");
782        assert_eq!(
783            serde_json::to_value(&omitted).unwrap(),
784            serde_json::json!({ "data": "b2s=" })
785        );
786
787        let null: TerminalOutput = serde_json::from_value(serde_json::json!({
788            "data": "b2s=",
789            "_meta": null
790        }))
791        .unwrap();
792        assert_eq!(null, omitted);
793
794        let mut meta = Meta::new();
795        meta.insert("source".to_string(), serde_json::json!("replay"));
796        assert_eq!(
797            serde_json::to_value(TerminalOutput::new("b2s=").meta(meta)).unwrap(),
798            serde_json::json!({
799                "data": "b2s=",
800                "_meta": {
801                    "source": "replay"
802                }
803            })
804        );
805    }
806
807    #[cfg(feature = "schemars")]
808    #[test]
809    fn terminal_output_fields_use_base64_content_encoding() {
810        let output = serde_json::to_value(schemars::schema_for!(TerminalOutput)).unwrap();
811        assert_eq!(output["properties"]["data"]["contentEncoding"], "base64");
812        assert!(output["properties"]["data"].get("format").is_none());
813        assert_eq!(output["required"], serde_json::json!(["data"]));
814        assert!(output["properties"].get("_meta").is_some());
815        assert!(output["properties"].get("truncated").is_none());
816
817        let chunk = serde_json::to_value(schemars::schema_for!(TerminalOutputChunk)).unwrap();
818        assert_eq!(chunk["properties"]["data"]["contentEncoding"], "base64");
819        assert!(chunk["properties"]["data"].get("format").is_none());
820    }
821
822    #[test]
823    fn terminal_exit_status_treats_omitted_and_null_as_unknown() {
824        let omitted: TerminalExitStatus = serde_json::from_value(serde_json::json!({})).unwrap();
825        let null: TerminalExitStatus = serde_json::from_value(serde_json::json!({
826            "exitCode": null,
827            "signal": null,
828            "_meta": null
829        }))
830        .unwrap();
831
832        assert_eq!(omitted, TerminalExitStatus::new());
833        assert_eq!(null, TerminalExitStatus::new());
834        assert_eq!(serde_json::to_value(null).unwrap(), serde_json::json!({}));
835    }
836
837    #[test]
838    fn terminal_exit_status_meta_is_exit_scoped() {
839        let mut meta = Meta::new();
840        meta.insert("reason".to_string(), serde_json::json!("timeout"));
841
842        assert_eq!(
843            serde_json::to_value(TerminalExitStatus::new().signal("SIGTERM").meta(meta)).unwrap(),
844            serde_json::json!({
845                "signal": "SIGTERM",
846                "_meta": {
847                    "reason": "timeout"
848                }
849            })
850        );
851    }
852}