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