agent_client_protocol_schema/v2/
terminal.rs1use 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#[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 #[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#[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 pub terminal_id: TerminalId,
46 #[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 #[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 #[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#[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 #[schemars(extend("contentEncoding" = "base64"))]
86 pub data: String,
87 #[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 #[must_use]
103 pub fn new(data: impl Into<String>) -> Self {
104 Self {
105 data: data.into(),
106 meta: None,
107 }
108 }
109
110 #[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#[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 #[serde_as(deserialize_as = "DefaultOnError")]
130 #[schemars(extend("x-deserialize-default-on-error" = true))]
131 #[serde(default)]
132 pub exit_code: Option<u32>,
133 #[serde_as(deserialize_as = "DefaultOnError")]
139 #[schemars(extend("x-deserialize-default-on-error" = true))]
140 #[serde(default)]
141 pub signal: Option<String>,
142 #[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 #[must_use]
158 pub fn new() -> Self {
159 Self::default()
160 }
161
162 #[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 #[must_use]
171 pub fn signal(mut self, signal: impl IntoOption<String>) -> Self {
172 self.signal = signal.into_option();
173 self
174 }
175
176 #[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#[serde_as]
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
192#[serde(rename_all = "camelCase")]
193#[non_exhaustive]
194pub struct TerminalUpdate {
195 pub terminal_id: TerminalId,
197 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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#[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 pub terminal_id: TerminalId,
314 #[schemars(extend("contentEncoding" = "base64"))]
316 pub data: String,
317 #[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 #[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 #[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}