1use 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#[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 #[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#[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 pub terminal_id: TerminalId,
47 #[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 #[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#[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 #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
87 pub data: String,
88 #[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 #[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#[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 #[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 #[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 #[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 #[must_use]
159 pub fn new() -> Self {
160 Self::default()
161 }
162
163 #[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 #[must_use]
172 pub fn signal(mut self, signal: impl IntoOption<String>) -> Self {
173 self.signal = signal.into_option();
174 self
175 }
176
177 #[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#[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 #[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 #[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 #[must_use]
221 pub fn new() -> Self {
222 Self::default()
223 }
224
225 #[must_use]
227 pub fn cancel(mut self, cancel: impl IntoOption<TerminalCancelCapabilities>) -> Self {
228 self.cancel = cancel.into_option();
229 self
230 }
231
232 #[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#[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 #[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 #[must_use]
268 pub fn new() -> Self {
269 Self::default()
270 }
271
272 #[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#[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 pub terminal_id: TerminalId,
299 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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#[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 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
464 pub cols: u16,
465 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
467 pub rows: u16,
468 #[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 #[must_use]
483 pub fn new(cols: u16, rows: u16) -> Self {
484 Self {
485 cols,
486 rows,
487 meta: None,
488 }
489 }
490
491 #[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#[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 pub terminal_id: TerminalId,
513 #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
515 pub data: String,
516 #[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 #[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 #[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 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 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 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}