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};
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, 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. When the terminal ID is new, omitted
192/// fields start unknown.
193#[serde_as]
194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197#[non_exhaustive]
198pub struct TerminalUpdate {
199    /// Unique identifier for this terminal within the session.
200    pub terminal_id: TerminalId,
201    /// The command being run.
202    #[serde_as(deserialize_as = "DefaultOnError")]
203    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
204    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
205    pub command: MaybeUndefined<String>,
206    /// The absolute working directory of the command.
207    #[serde_as(deserialize_as = "DefaultOnError")]
208    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
209    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
210    pub cwd: MaybeUndefined<AbsolutePath>,
211    /// An authoritative replacement snapshot of terminal output bytes.
212    #[serde_as(deserialize_as = "DefaultOnError")]
213    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
214    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
215    pub output: MaybeUndefined<TerminalOutput>,
216    /// Exit information. A concrete object marks the terminal as exited.
217    #[serde_as(deserialize_as = "DefaultOnError")]
218    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
219    #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
220    pub exit_status: MaybeUndefined<TerminalExitStatus>,
221    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
222    /// metadata to their interactions. Omitted means no metadata update; `null` is an
223    /// explicit clear signal. Implementations MUST NOT make assumptions about values at these keys.
224    ///
225    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
226    #[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<_>>")]
227    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
228    #[serde(
229        rename = "_meta",
230        default,
231        skip_serializing_if = "MaybeUndefined::is_undefined"
232    )]
233    pub meta: MaybeUndefined<Meta>,
234}
235
236impl TerminalUpdate {
237    /// Builds a terminal upsert with only its required ID set.
238    #[must_use]
239    pub fn new(terminal_id: impl Into<TerminalId>) -> Self {
240        Self {
241            terminal_id: terminal_id.into(),
242            command: MaybeUndefined::Undefined,
243            cwd: MaybeUndefined::Undefined,
244            output: MaybeUndefined::Undefined,
245            exit_status: MaybeUndefined::Undefined,
246            meta: MaybeUndefined::Undefined,
247        }
248    }
249
250    /// Sets, clears, or leaves unchanged the command being run.
251    #[must_use]
252    pub fn command(mut self, command: impl IntoMaybeUndefined<String>) -> Self {
253        self.command = command.into_maybe_undefined();
254        self
255    }
256
257    /// Sets, clears, or leaves unchanged the absolute working directory.
258    #[must_use]
259    pub fn cwd(mut self, cwd: impl IntoMaybeUndefined<AbsolutePath>) -> Self {
260        self.cwd = cwd.into_maybe_undefined();
261        self
262    }
263
264    /// Sets, clears, or leaves unchanged the authoritative output snapshot.
265    #[must_use]
266    pub fn output(mut self, output: impl IntoMaybeUndefined<TerminalOutput>) -> Self {
267        self.output = output.into_maybe_undefined();
268        self
269    }
270
271    /// Sets, clears, or leaves unchanged the terminal exit information.
272    #[must_use]
273    pub fn exit_status(mut self, exit_status: impl IntoMaybeUndefined<TerminalExitStatus>) -> Self {
274        self.exit_status = exit_status.into_maybe_undefined();
275        self
276    }
277
278    /// Sets, clears, or leaves unchanged terminal metadata.
279    #[must_use]
280    pub fn meta(mut self, meta: impl IntoMaybeUndefined<Meta>) -> Self {
281        self.meta = meta.into_maybe_undefined();
282        self
283    }
284
285    /// Applies a later terminal patch to this stored terminal state.
286    ///
287    /// Fields set to `null` remain `null` so callers can distinguish explicit
288    /// clearing from an update that did not mention the field.
289    pub fn apply_update(&mut self, update: TerminalUpdate) {
290        debug_assert_eq!(self.terminal_id, update.terminal_id);
291        if !update.command.is_undefined() {
292            self.command = update.command;
293        }
294        if !update.cwd.is_undefined() {
295            self.cwd = update.cwd;
296        }
297        if !update.output.is_undefined() {
298            self.output = update.output;
299        }
300        if !update.exit_status.is_undefined() {
301            self.exit_status = update.exit_status;
302        }
303        if !update.meta.is_undefined() {
304            self.meta = update.meta;
305        }
306    }
307}
308
309/// A chunk of bytes appended to an agent-owned terminal's output.
310#[serde_as]
311#[skip_serializing_none]
312#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
314#[serde(rename_all = "camelCase")]
315#[non_exhaustive]
316pub struct TerminalOutputChunk {
317    /// The terminal receiving these bytes.
318    pub terminal_id: TerminalId,
319    /// Independently base64-encoded terminal output bytes.
320    #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
321    pub data: String,
322    /// The _meta property is reserved by ACP to allow clients and agents to attach additional
323    /// metadata to their interactions. Implementations MUST NOT make assumptions about values at
324    /// these keys. This field is chunk-scoped. Omitted and `null` are
325    /// equivalent and mean no chunk metadata was provided.
326    ///
327    /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
328    #[serde_as(deserialize_as = "DefaultOnError")]
329    #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
330    #[serde(default)]
331    #[serde(rename = "_meta")]
332    pub meta: Option<Meta>,
333}
334
335impl TerminalOutputChunk {
336    /// Builds a terminal output chunk with the required fields set.
337    #[must_use]
338    pub fn new(terminal_id: impl Into<TerminalId>, data: impl Into<String>) -> Self {
339        Self {
340            terminal_id: terminal_id.into(),
341            data: data.into(),
342            meta: None,
343        }
344    }
345
346    /// Sets or clears chunk-scoped metadata.
347    #[must_use]
348    pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
349        self.meta = meta.into_option();
350        self
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn terminal_reference_meta_is_optional_and_content_scoped() {
360        let omitted = Terminal::new("term_1");
361        assert_eq!(
362            serde_json::to_value(&omitted).unwrap(),
363            serde_json::json!({
364                "terminalId": "term_1"
365            })
366        );
367
368        let null: Terminal = serde_json::from_value(serde_json::json!({
369            "terminalId": "term_1",
370            "_meta": null
371        }))
372        .unwrap();
373        assert_eq!(null.meta, None);
374
375        let mut meta = Meta::new();
376        meta.insert("view".to_string(), serde_json::json!("expanded"));
377        assert_eq!(
378            serde_json::to_value(Terminal::new("term_1").meta(meta)).unwrap(),
379            serde_json::json!({
380                "terminalId": "term_1",
381                "_meta": {
382                    "view": "expanded"
383                }
384            })
385        );
386    }
387
388    #[test]
389    fn terminal_update_distinguishes_omitted_null_and_value() {
390        let update = TerminalUpdate::new("term_1")
391            .command("cargo test")
392            .cwd("/workspace/project")
393            .output(None::<TerminalOutput>)
394            .exit_status(TerminalExitStatus::new().exit_code(0));
395
396        assert_eq!(
397            serde_json::to_value(update).unwrap(),
398            serde_json::json!({
399                "terminalId": "term_1",
400                "command": "cargo test",
401                "cwd": "/workspace/project",
402                "output": null,
403                "exitStatus": {
404                    "exitCode": 0
405                }
406            })
407        );
408
409        let parsed: TerminalUpdate = serde_json::from_value(serde_json::json!({
410            "terminalId": "term_1",
411            "command": null,
412            "cwd": "/workspace/project"
413        }))
414        .unwrap();
415        assert_eq!(parsed.command, MaybeUndefined::Null);
416        assert_eq!(
417            parsed.cwd,
418            MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
419        );
420        assert_eq!(parsed.output, MaybeUndefined::Undefined);
421        assert_eq!(parsed.exit_status, MaybeUndefined::Undefined);
422        assert_eq!(parsed.meta, MaybeUndefined::Undefined);
423    }
424
425    #[test]
426    fn terminal_update_applies_patch_fields() {
427        let mut stored = TerminalUpdate::new("term_1")
428            .command("cargo test")
429            .cwd("/workspace/project")
430            .output(TerminalOutput::new("b2xk"));
431
432        stored.apply_update(
433            TerminalUpdate::new("term_1")
434                .command(None::<String>)
435                .output(TerminalOutput::new("bmV3"))
436                .exit_status(TerminalExitStatus::new().signal("SIGTERM")),
437        );
438
439        assert_eq!(stored.command, MaybeUndefined::Null);
440        assert_eq!(
441            stored.cwd,
442            MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
443        );
444        assert_eq!(
445            stored.output,
446            MaybeUndefined::Value(TerminalOutput::new("bmV3"))
447        );
448        assert_eq!(
449            stored.exit_status,
450            MaybeUndefined::Value(TerminalExitStatus::new().signal("SIGTERM"))
451        );
452    }
453
454    #[test]
455    fn terminal_output_chunk_serializes_base64_data_and_meta() {
456        let mut meta = Meta::new();
457        meta.insert("source".to_string(), serde_json::json!("pty"));
458
459        assert_eq!(
460            serde_json::to_value(TerminalOutputChunk::new("term_1", "8J+agA==").meta(meta))
461                .unwrap(),
462            serde_json::json!({
463                "terminalId": "term_1",
464                "data": "8J+agA==",
465                "_meta": {
466                    "source": "pty"
467                }
468            })
469        );
470    }
471
472    #[test]
473    fn terminal_output_meta_is_optional_and_snapshot_scoped() {
474        let omitted = TerminalOutput::new("b2s=");
475        assert_eq!(
476            serde_json::to_value(&omitted).unwrap(),
477            serde_json::json!({ "data": "b2s=" })
478        );
479
480        let null: TerminalOutput = serde_json::from_value(serde_json::json!({
481            "data": "b2s=",
482            "_meta": null
483        }))
484        .unwrap();
485        assert_eq!(null, omitted);
486
487        let mut meta = Meta::new();
488        meta.insert("source".to_string(), serde_json::json!("replay"));
489        assert_eq!(
490            serde_json::to_value(TerminalOutput::new("b2s=").meta(meta)).unwrap(),
491            serde_json::json!({
492                "data": "b2s=",
493                "_meta": {
494                    "source": "replay"
495                }
496            })
497        );
498    }
499
500    #[cfg(feature = "schemars")]
501    #[test]
502    fn terminal_output_fields_use_base64_content_encoding() {
503        let output = serde_json::to_value(schemars::schema_for!(TerminalOutput)).unwrap();
504        assert_eq!(output["properties"]["data"]["contentEncoding"], "base64");
505        assert!(output["properties"]["data"].get("format").is_none());
506        assert_eq!(output["required"], serde_json::json!(["data"]));
507        assert!(output["properties"].get("_meta").is_some());
508        assert!(output["properties"].get("truncated").is_none());
509
510        let chunk = serde_json::to_value(schemars::schema_for!(TerminalOutputChunk)).unwrap();
511        assert_eq!(chunk["properties"]["data"]["contentEncoding"], "base64");
512        assert!(chunk["properties"]["data"].get("format").is_none());
513    }
514
515    #[test]
516    fn terminal_exit_status_treats_omitted_and_null_as_unknown() {
517        let omitted: TerminalExitStatus = serde_json::from_value(serde_json::json!({})).unwrap();
518        let null: TerminalExitStatus = serde_json::from_value(serde_json::json!({
519            "exitCode": null,
520            "signal": null,
521            "_meta": null
522        }))
523        .unwrap();
524
525        assert_eq!(omitted, TerminalExitStatus::new());
526        assert_eq!(null, TerminalExitStatus::new());
527        assert_eq!(serde_json::to_value(null).unwrap(), serde_json::json!({}));
528    }
529
530    #[test]
531    fn terminal_exit_status_meta_is_exit_scoped() {
532        let mut meta = Meta::new();
533        meta.insert("reason".to_string(), serde_json::json!("timeout"));
534
535        assert_eq!(
536            serde_json::to_value(TerminalExitStatus::new().signal("SIGTERM").meta(meta)).unwrap(),
537            serde_json::json!({
538                "signal": "SIGTERM",
539                "_meta": {
540                    "reason": "timeout"
541                }
542            })
543        );
544    }
545}