1#[cfg(feature = "json")]
57use std::collections::HashMap;
58use std::fmt;
59use std::str::FromStr;
60
61use serde::{Deserialize, Serialize};
62
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "kebab-case")]
66pub enum SandboxMode {
67 ReadOnly,
69 #[default]
71 WorkspaceWrite,
72 DangerFullAccess,
74}
75
76impl SandboxMode {
77 pub(crate) fn as_arg(self) -> &'static str {
78 match self {
79 Self::ReadOnly => "read-only",
80 Self::WorkspaceWrite => "workspace-write",
81 Self::DangerFullAccess => "danger-full-access",
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum ApprovalPolicy {
95 Untrusted,
97 #[default]
99 OnRequest,
100 Never,
102}
103
104impl ApprovalPolicy {
105 pub(crate) fn as_arg(self) -> &'static str {
106 match self {
107 Self::Untrusted => "untrusted",
108 Self::OnRequest => "on-request",
109 Self::Never => "never",
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "kebab-case")]
130pub enum ApprovalPolicyConfig {
131 Untrusted,
133 OnFailure,
138 #[default]
140 OnRequest,
141 Granular,
146 Never,
148}
149
150impl ApprovalPolicyConfig {
151 pub(crate) fn as_config_value(self) -> &'static str {
152 match self {
153 Self::Untrusted => "untrusted",
154 Self::OnFailure => "on-failure",
155 Self::OnRequest => "on-request",
156 Self::Granular => "granular",
157 Self::Never => "never",
158 }
159 }
160}
161
162impl From<ApprovalPolicy> for ApprovalPolicyConfig {
163 fn from(policy: ApprovalPolicy) -> Self {
164 match policy {
165 ApprovalPolicy::Untrusted => Self::Untrusted,
166 ApprovalPolicy::OnRequest => Self::OnRequest,
167 ApprovalPolicy::Never => Self::Never,
168 }
169 }
170}
171
172impl TryFrom<ApprovalPolicyConfig> for ApprovalPolicy {
173 type Error = ApprovalPolicyConfig;
174
175 fn try_from(config: ApprovalPolicyConfig) -> std::result::Result<Self, Self::Error> {
182 match config {
183 ApprovalPolicyConfig::Untrusted => Ok(Self::Untrusted),
184 ApprovalPolicyConfig::OnRequest => Ok(Self::OnRequest),
185 ApprovalPolicyConfig::Never => Ok(Self::Never),
186 other => Err(other),
187 }
188 }
189}
190
191#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "kebab-case")]
201pub enum WebSearchMode {
202 #[default]
204 Disabled,
205 Cached,
207 Indexed,
209 Live,
211}
212
213impl WebSearchMode {
214 pub(crate) fn as_config_value(self) -> &'static str {
215 match self {
216 Self::Disabled => "disabled",
217 Self::Cached => "cached",
218 Self::Indexed => "indexed",
219 Self::Live => "live",
220 }
221 }
222}
223
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "lowercase")]
227pub enum Color {
228 Always,
230 Never,
232 #[default]
234 Auto,
235}
236
237impl Color {
238 pub(crate) fn as_arg(self) -> &'static str {
239 match self {
240 Self::Always => "always",
241 Self::Never => "never",
242 Self::Auto => "auto",
243 }
244 }
245}
246
247#[cfg(feature = "json")]
252#[derive(Debug, Clone, Deserialize, Serialize)]
253pub struct JsonLineEvent {
254 #[serde(rename = "type", default)]
255 pub event_type: String,
256 #[serde(flatten)]
257 pub extra: HashMap<String, serde_json::Value>,
258}
259
260#[cfg(feature = "json")]
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268#[non_exhaustive]
269pub enum TurnFailureKind {
270 RolloutBudgetExhausted,
272 Other,
274}
275
276#[cfg(feature = "json")]
277impl JsonLineEvent {
278 #[must_use]
280 pub fn session_id(&self) -> Option<&str> {
281 self.extra.get("session_id").and_then(|v| v.as_str())
282 }
283
284 #[must_use]
286 pub fn thread_id(&self) -> Option<&str> {
287 self.extra.get("thread_id").and_then(|v| v.as_str())
288 }
289
290 #[must_use]
292 pub fn is_turn_completed(&self) -> bool {
293 self.event_type == "turn.completed"
294 }
295
296 #[must_use]
298 pub fn is_turn_failed(&self) -> bool {
299 self.event_type == "turn.failed"
300 }
301
302 #[must_use]
306 pub fn turn_failure_message(&self) -> Option<&str> {
307 if !self.is_turn_failed() {
308 return None;
309 }
310 self.extra
311 .get("error")
312 .and_then(|error| error.get("message"))
313 .and_then(serde_json::Value::as_str)
314 }
315
316 #[must_use]
318 pub fn turn_failure_kind(&self) -> Option<TurnFailureKind> {
319 self.is_turn_failed().then(|| {
320 if self
321 .turn_failure_message()
322 .is_some_and(|message| message.contains("shared rollout token budget exhausted"))
323 {
324 TurnFailureKind::RolloutBudgetExhausted
325 } else {
326 TurnFailureKind::Other
327 }
328 })
329 }
330
331 #[must_use]
336 pub fn usage(&self) -> Option<TokenUsage> {
337 self.extra.get("usage").map(TokenUsage::from_json)
338 }
339
340 #[must_use]
351 pub fn agent_message_text(&self) -> Option<String> {
352 if self.event_type != "item.completed" {
353 return None;
354 }
355 let item = self.extra.get("item")?;
356 let kind = item
357 .get("item_type")
358 .or_else(|| item.get("type"))
359 .and_then(|v| v.as_str())?;
360 if kind != "agent_message" {
361 return None;
362 }
363
364 if let Some(text) = item.get("text").and_then(|v| v.as_str())
365 && !text.is_empty()
366 {
367 return Some(text.to_string());
368 }
369
370 let blocks = item.get("content").and_then(|v| v.as_array())?;
371 let text: String = blocks
372 .iter()
373 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
374 .collect::<Vec<_>>()
375 .join("");
376 if text.is_empty() { None } else { Some(text) }
377 }
378
379 #[must_use]
381 pub fn role(&self) -> Option<&str> {
382 self.extra.get("role").and_then(|v| v.as_str())
383 }
384
385 #[must_use]
390 pub fn content_text(&self) -> Option<String> {
391 let blocks = self.extra.get("content").and_then(|v| v.as_array())?;
392 let text: String = blocks
393 .iter()
394 .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
395 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
396 .collect::<Vec<_>>()
397 .join("");
398 if text.is_empty() { None } else { Some(text) }
399 }
400
401 #[must_use]
410 pub fn item_type(&self) -> Option<&str> {
411 let item = self.extra.get("item")?;
412 item.get("type").or_else(|| item.get("item_type"))?.as_str()
413 }
414
415 #[must_use]
421 pub fn command_execution(&self) -> Option<CommandExecution> {
422 if self.item_type()? != "command_execution" {
423 return None;
424 }
425 let item = self.extra.get("item")?;
426 let string = |key: &str| item.get(key).and_then(|v| v.as_str()).map(str::to_string);
427 Some(CommandExecution {
428 command: string("command"),
429 status: string("status"),
430 exit_code: item
431 .get("exit_code")
432 .and_then(serde_json::Value::as_i64)
433 .and_then(|code| i32::try_from(code).ok()),
434 aggregated_output: string("aggregated_output"),
435 })
436 }
437}
438
439#[cfg(feature = "json")]
447#[derive(Debug, Clone)]
448pub struct QueryResult {
449 pub result: String,
455 pub session_id: Option<String>,
461 pub thread_id: Option<String>,
466 pub usage: Option<TokenUsage>,
471 pub events: Vec<JsonLineEvent>,
476}
477
478#[cfg(feature = "json")]
479impl QueryResult {
480 #[must_use]
486 pub fn from_events(events: Vec<JsonLineEvent>) -> Self {
487 let usage = events
488 .iter()
489 .rev()
490 .find(|e| e.is_turn_completed())
491 .and_then(JsonLineEvent::usage);
492 let result = events
493 .iter()
494 .filter_map(JsonLineEvent::agent_message_text)
495 .collect::<Vec<_>>()
496 .join("");
497 let session_id = events
498 .iter()
499 .find_map(JsonLineEvent::session_id)
500 .map(str::to_string);
501 let thread_id = events
502 .iter()
503 .find_map(JsonLineEvent::thread_id)
504 .map(str::to_string);
505 Self {
506 result,
507 session_id,
508 thread_id,
509 usage,
510 events,
511 }
512 }
513}
514
515#[cfg(feature = "json")]
520#[derive(Debug, Clone, Default, PartialEq, Eq)]
521#[non_exhaustive]
522pub struct CommandExecution {
523 pub command: Option<String>,
525 pub status: Option<String>,
527 pub exit_code: Option<i32>,
529 pub aggregated_output: Option<String>,
531}
532
533#[cfg(feature = "json")]
544#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
545pub struct TokenUsage {
546 pub input_tokens: Option<u64>,
548 pub cached_input_tokens: Option<u64>,
550 pub cache_write_input_tokens: Option<u64>,
552 pub output_tokens: Option<u64>,
554 pub reasoning_output_tokens: Option<u64>,
556 pub total_tokens: Option<u64>,
558}
559
560#[cfg(feature = "json")]
561impl TokenUsage {
562 fn from_json(value: &serde_json::Value) -> Self {
563 let field = |name: &str| value.get(name).and_then(serde_json::Value::as_u64);
564 Self {
565 input_tokens: field("input_tokens"),
566 cached_input_tokens: field("cached_input_tokens"),
567 cache_write_input_tokens: field("cache_write_input_tokens"),
568 output_tokens: field("output_tokens"),
569 reasoning_output_tokens: field("reasoning_output_tokens"),
570 total_tokens: field("total_tokens"),
571 }
572 }
573
574 #[must_use]
580 pub fn total(&self) -> Option<u64> {
581 if let Some(total) = self.total_tokens {
582 return Some(total);
583 }
584 match (self.input_tokens, self.output_tokens) {
585 (None, None) => None,
586 (input, output) => Some(input.unwrap_or(0) + output.unwrap_or(0)),
587 }
588 }
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
595pub struct CliVersion {
596 pub major: u32,
597 pub minor: u32,
598 pub patch: u32,
599}
600
601impl CliVersion {
602 #[must_use]
603 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
604 Self {
605 major,
606 minor,
607 patch,
608 }
609 }
610
611 pub fn parse_version_output(output: &str) -> Result<Self, VersionParseError> {
612 output
613 .split_whitespace()
614 .find_map(|token| token.parse().ok())
615 .ok_or_else(|| VersionParseError(output.trim().to_string()))
616 }
617
618 #[must_use]
619 pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
620 self >= minimum
621 }
622
623 #[must_use]
635 pub fn status_within(&self, min: &CliVersion, max: &CliVersion) -> CliVersionStatus {
636 if self < min {
637 CliVersionStatus::OlderThanMinimum {
638 found: *self,
639 minimum: *min,
640 }
641 } else if self > max {
642 CliVersionStatus::NewerUntested {
643 found: *self,
644 tested_max: *max,
645 }
646 } else {
647 CliVersionStatus::Tested
648 }
649 }
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
663#[serde(tag = "status", rename_all = "snake_case")]
664pub enum CliVersionStatus {
665 Tested,
667 NewerUntested {
671 found: CliVersion,
673 tested_max: CliVersion,
675 },
676 OlderThanMinimum {
682 found: CliVersion,
684 minimum: CliVersion,
686 },
687}
688
689impl CliVersionStatus {
690 #[must_use]
695 pub fn is_tested(self) -> bool {
696 matches!(self, Self::Tested)
697 }
698}
699
700impl PartialOrd for CliVersion {
701 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
702 Some(self.cmp(other))
703 }
704}
705
706impl Ord for CliVersion {
707 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
708 self.major
709 .cmp(&other.major)
710 .then(self.minor.cmp(&other.minor))
711 .then(self.patch.cmp(&other.patch))
712 }
713}
714
715impl fmt::Display for CliVersion {
716 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
717 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
718 }
719}
720
721impl FromStr for CliVersion {
722 type Err = VersionParseError;
723
724 fn from_str(s: &str) -> Result<Self, Self::Err> {
725 let parts: Vec<&str> = s.split('.').collect();
726 if parts.len() != 3 {
727 return Err(VersionParseError(s.to_string()));
728 }
729
730 Ok(Self {
731 major: parts[0]
732 .parse()
733 .map_err(|_| VersionParseError(s.to_string()))?,
734 minor: parts[1]
735 .parse()
736 .map_err(|_| VersionParseError(s.to_string()))?,
737 patch: parts[2]
738 .parse()
739 .map_err(|_| VersionParseError(s.to_string()))?,
740 })
741 }
742}
743
744#[derive(Debug, Clone, thiserror::Error)]
745#[error("invalid version string: {0:?}")]
746pub struct VersionParseError(pub String);
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751
752 #[test]
753 fn parses_codex_version_output() {
754 let version = CliVersion::parse_version_output("codex-cli 0.145.0").unwrap();
755 assert_eq!(version, CliVersion::new(0, 145, 0));
756 }
757
758 #[test]
759 fn parses_plain_version_output() {
760 let version = CliVersion::parse_version_output("0.145.0").unwrap();
761 assert_eq!(version, CliVersion::new(0, 145, 0));
762 }
763
764 #[cfg(feature = "json")]
765 #[test]
766 fn json_line_event_session_and_thread_id() {
767 let event: JsonLineEvent = serde_json::from_str(
768 r#"{"type":"message.created","session_id":"sess_abc","thread_id":"thread_123"}"#,
769 )
770 .unwrap();
771 assert_eq!(event.session_id(), Some("sess_abc"));
772 assert_eq!(event.thread_id(), Some("thread_123"));
773 }
774
775 #[cfg(feature = "json")]
776 #[test]
777 fn json_line_event_turn_terminal_types() {
778 let completed: JsonLineEvent =
779 serde_json::from_str(r#"{"type":"turn.completed"}"#).unwrap();
780 assert!(completed.is_turn_completed());
781 assert!(!completed.is_turn_failed());
782
783 let failed: JsonLineEvent = serde_json::from_str(r#"{"type":"turn.failed"}"#).unwrap();
784 assert!(failed.is_turn_failed());
785 assert!(!failed.is_turn_completed());
786
787 let bogus: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
789 assert!(!bogus.is_turn_completed());
790 }
791
792 #[cfg(feature = "json")]
795 #[test]
796 fn classifies_captured_rollout_budget_terminal_without_inventing_usage() {
797 let failed: JsonLineEvent = serde_json::from_str(
798 r#"{"type":"turn.failed","error":{"message":"shared rollout token budget exhausted"}}"#,
799 )
800 .unwrap();
801
802 assert_eq!(
803 failed.turn_failure_kind(),
804 Some(TurnFailureKind::RolloutBudgetExhausted)
805 );
806 assert_eq!(failed.usage(), None);
807
808 let other: JsonLineEvent = serde_json::from_str(
809 r#"{"type":"turn.failed","error":{"message":"tool policy rejected"}}"#,
810 )
811 .unwrap();
812 assert_eq!(other.turn_failure_kind(), Some(TurnFailureKind::Other));
813
814 let missing_message: JsonLineEvent =
815 serde_json::from_str(r#"{"type":"turn.failed"}"#).unwrap();
816 assert_eq!(
817 missing_message.turn_failure_kind(),
818 Some(TurnFailureKind::Other)
819 );
820
821 let nonterminal: JsonLineEvent =
822 serde_json::from_str(r#"{"type":"turn.started"}"#).unwrap();
823 assert_eq!(nonterminal.turn_failure_kind(), None);
824 }
825
826 #[cfg(feature = "json")]
827 #[test]
828 fn json_line_event_usage() {
829 let event: JsonLineEvent = serde_json::from_str(
830 r#"{"type":"turn.completed","usage":{"input_tokens":120,"output_tokens":45,"total_tokens":165}}"#,
831 )
832 .unwrap();
833 let usage = event.usage().unwrap();
834 assert_eq!(usage.input_tokens, Some(120));
835 assert_eq!(usage.output_tokens, Some(45));
836 assert_eq!(usage.total_tokens, Some(165));
837 assert_eq!(usage.cache_write_input_tokens, None);
839 assert_eq!(usage.total(), Some(165));
840 }
841
842 #[cfg(feature = "json")]
843 #[test]
844 fn token_usage_total_falls_back_to_input_plus_output() {
845 let usage = TokenUsage {
846 input_tokens: Some(10),
847 output_tokens: Some(5),
848 ..TokenUsage::default()
849 };
850 assert_eq!(usage.total(), Some(15));
851 }
852
853 #[cfg(feature = "json")]
856 #[test]
857 fn token_usage_total_is_none_when_nothing_reported() {
858 assert_eq!(TokenUsage::default().total(), None);
859 }
860
861 #[cfg(feature = "json")]
863 #[test]
864 fn agent_message_text_from_item_completed() {
865 let event: JsonLineEvent = serde_json::from_str(
866 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello"}}"#,
867 )
868 .unwrap();
869 assert_eq!(event.agent_message_text().as_deref(), Some("hello"));
870 }
871
872 #[cfg(feature = "json")]
877 #[test]
878 fn agent_message_text_tolerates_layout_variants() {
879 let item_type_key: JsonLineEvent = serde_json::from_str(
880 r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"a"}}"#,
881 )
882 .unwrap();
883 assert_eq!(item_type_key.agent_message_text().as_deref(), Some("a"));
884
885 let content_blocks: JsonLineEvent = serde_json::from_str(
886 r#"{"type":"item.completed","item":{"item_type":"agent_message","content":[{"text":"b"},{"text":"c"}]}}"#,
887 )
888 .unwrap();
889 assert_eq!(content_blocks.agent_message_text().as_deref(), Some("bc"));
890 }
891
892 #[cfg(feature = "json")]
893 #[test]
894 fn agent_message_text_ignores_other_items_and_events() {
895 let other_item: JsonLineEvent = serde_json::from_str(
897 r#"{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"git diff","exit_code":0}}"#,
898 )
899 .unwrap();
900 assert_eq!(other_item.agent_message_text(), None);
901
902 let other_event: JsonLineEvent = serde_json::from_str(
903 r#"{"type":"item.started","item":{"id":"item_0","type":"agent_message","text":"x"}}"#,
904 )
905 .unwrap();
906 assert_eq!(other_event.agent_message_text(), None);
907 }
908
909 #[cfg(feature = "json")]
910 #[test]
911 fn json_line_event_role() {
912 let event: JsonLineEvent =
913 serde_json::from_str(r#"{"type":"message.created","role":"assistant"}"#).unwrap();
914 assert_eq!(event.role(), Some("assistant"));
915 }
916
917 #[cfg(feature = "json")]
918 #[test]
919 fn json_line_event_content_text() {
920 let event: JsonLineEvent = serde_json::from_str(
921 r#"{"type":"message.delta","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]}"#,
922 )
923 .unwrap();
924 assert_eq!(event.content_text(), Some("Hello world".to_string()));
925 }
926
927 #[cfg(feature = "json")]
928 #[test]
929 fn json_line_event_content_text_skips_non_text_blocks() {
930 let event: JsonLineEvent = serde_json::from_str(
931 r#"{"type":"message.delta","content":[{"type":"image","url":"x"},{"type":"text","text":"only this"}]}"#,
932 )
933 .unwrap();
934 assert_eq!(event.content_text(), Some("only this".to_string()));
935 }
936
937 #[cfg(feature = "json")]
938 #[test]
939 fn json_line_event_content_text_none_when_empty() {
940 let event: JsonLineEvent =
941 serde_json::from_str(r#"{"type":"message.delta","content":[]}"#).unwrap();
942 assert_eq!(event.content_text(), None);
943 }
944
945 #[cfg(feature = "json")]
946 #[test]
947 fn json_line_event_content_text_none_when_missing() {
948 let event: JsonLineEvent = serde_json::from_str(r#"{"type":"message.delta"}"#).unwrap();
949 assert_eq!(event.content_text(), None);
950 }
951
952 #[cfg(feature = "json")]
953 #[test]
954 fn query_result_from_events() {
955 let events: Vec<JsonLineEvent> = [
956 r#"{"type":"thread.started","thread_id":"thread_1"}"#,
957 r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"the answer"}}"#,
958 r#"{"type":"turn.completed","usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10}}"#,
959 ]
960 .iter()
961 .map(|l| serde_json::from_str(l).unwrap())
962 .collect();
963
964 let result = QueryResult::from_events(events);
965 assert_eq!(result.result, "the answer");
966 assert_eq!(result.thread_id.as_deref(), Some("thread_1"));
967 assert_eq!(result.usage.unwrap().total(), Some(10));
968 assert_eq!(result.events.len(), 3);
969 }
970
971 #[cfg(feature = "json")]
972 #[test]
973 fn query_result_concatenates_multiple_agent_messages() {
974 let events: Vec<JsonLineEvent> = [
975 r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"one "}}"#,
976 r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"two"}}"#,
977 r#"{"type":"turn.completed","usage":{"total_tokens":4}}"#,
978 ]
979 .iter()
980 .map(|l| serde_json::from_str(l).unwrap())
981 .collect();
982
983 assert_eq!(QueryResult::from_events(events).result, "one two");
984 }
985
986 #[cfg(feature = "json")]
989 #[test]
990 fn query_result_from_a_failed_turn() {
991 let events: Vec<JsonLineEvent> = [
992 r#"{"type":"thread.started","thread_id":"thread_2"}"#,
993 r#"{"type":"turn.failed","error":{"message":"usage limit"}}"#,
994 ]
995 .iter()
996 .map(|l| serde_json::from_str(l).unwrap())
997 .collect();
998
999 let result = QueryResult::from_events(events);
1000 assert_eq!(result.result, "");
1001 assert_eq!(result.usage, None);
1002 assert_eq!(result.thread_id.as_deref(), Some("thread_2"));
1003 }
1004
1005 #[cfg(feature = "json")]
1007 #[test]
1008 fn item_type_reads_the_discriminator() {
1009 let message: JsonLineEvent = serde_json::from_str(
1010 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}}"#,
1011 )
1012 .unwrap();
1013 assert_eq!(message.item_type(), Some("agent_message"));
1014
1015 let command: JsonLineEvent = serde_json::from_str(
1016 r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"git diff"}}"#,
1017 )
1018 .unwrap();
1019 assert_eq!(command.item_type(), Some("command_execution"));
1020
1021 let turn: JsonLineEvent = serde_json::from_str(r#"{"type":"turn.completed"}"#).unwrap();
1022 assert_eq!(turn.item_type(), None);
1023 }
1024
1025 #[cfg(feature = "json")]
1028 #[test]
1029 fn command_execution_reads_a_finished_command() {
1030 let event: JsonLineEvent = serde_json::from_str(
1031 r#"{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"git diff","aggregated_output":"","exit_code":0,"status":"completed"}}"#,
1032 )
1033 .unwrap();
1034
1035 let command = event.command_execution().unwrap();
1036 assert_eq!(command.command.as_deref(), Some("git diff"));
1037 assert_eq!(command.exit_code, Some(0));
1038 assert_eq!(command.status.as_deref(), Some("completed"));
1039 }
1040
1041 #[cfg(feature = "json")]
1043 #[test]
1044 fn command_execution_tolerates_a_command_still_running() {
1045 let event: JsonLineEvent = serde_json::from_str(
1046 r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"git diff"}}"#,
1047 )
1048 .unwrap();
1049
1050 let command = event.command_execution().unwrap();
1051 assert_eq!(command.command.as_deref(), Some("git diff"));
1052 assert_eq!(command.exit_code, None);
1053 assert_eq!(command.status, None);
1054 }
1055
1056 #[cfg(feature = "json")]
1057 #[test]
1058 fn command_execution_is_none_for_other_items() {
1059 let event: JsonLineEvent = serde_json::from_str(
1060 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}}"#,
1061 )
1062 .unwrap();
1063 assert!(event.command_execution().is_none());
1064 }
1065}