agent_client_protocol_schema/v2/
terminal.rs1use 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#[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, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43#[non_exhaustive]
44pub struct Terminal {
45 pub terminal_id: TerminalId,
47 #[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 #[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 #[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#[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 #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
88 pub data: String,
89 #[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 #[must_use]
105 pub fn new(data: impl Into<String>) -> Self {
106 Self {
107 data: data.into(),
108 meta: None,
109 }
110 }
111
112 #[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#[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 #[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 #[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 #[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 #[must_use]
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 #[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 #[must_use]
174 pub fn signal(mut self, signal: impl IntoOption<String>) -> Self {
175 self.signal = signal.into_option();
176 self
177 }
178
179 #[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#[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 pub terminal_id: TerminalId,
201 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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#[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 pub terminal_id: TerminalId,
319 #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
321 pub data: String,
322 #[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 #[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 #[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}