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