Skip to main content

codex_wrapper/
types.rs

1//! Domain types shared across commands: enums for CLI options, version parsing,
2//! and structured JSONL events.
3//!
4//! # JSONL schema: what is verified and what is assumed
5//!
6//! This parser was rewritten in #73 after the previous one was found to match
7//! an event shape the CLI never emits. It matched a fixture that invented the
8//! schema, so every test passed while real output produced empty results. The
9//! split below exists so the next person can check the assumptions instead of
10//! rediscovering the bug.
11//!
12//! **Verified** against `codex-cli` 0.145.0, from the compiled serde tag list
13//! and live runs of both `codex exec --json` and `codex exec review --json`:
14//!
15//! - The event vocabulary is `thread.started`, `turn.started`, `turn.completed`,
16//!   `turn.failed`, `item.started`, `item.updated`, `item.completed`. There is
17//!   no bare `completed`. Review emits the same vocabulary as exec.
18//! - `thread.started` carries `thread_id`.
19//! - A completed turn reports token counts, never a monetary cost. The `usage`
20//!   object carries `input_tokens`, `cached_input_tokens`,
21//!   `cache_write_input_tokens`, `output_tokens`, and
22//!   `reasoning_output_tokens`. It does **not** carry `total_tokens`, so
23//!   [`TokenUsage::total`] reaches a total through its input plus output
24//!   fallback on every real run.
25//! - Assistant text arrives as an `item.completed` event whose `item` has a
26//!   `type` of `agent_message` and its text in a `text` field. A review's
27//!   diff-reading steps arrive as `command_execution` items on the same event.
28//! - A review's `turn.completed` reports a usage object of all zeros.
29//! - Exhausting the native rollout budget emits an `error` event followed by
30//!   `turn.failed`, both with `shared rollout token budget exhausted`. The
31//!   terminal event carries no usage object on 0.145.0 even though Codex used
32//!   its internal meter to make the decision.
33//!
34//! - The stream carries **no incremental text**. Three captured runs, a
35//!   one-word exec, a four-sentence exec, and a review, each delivered the
36//!   whole assistant message in a single `item.completed`. No `item.updated`,
37//!   no partial or delta fields, and `codex exec --help` has no flag that
38//!   changes output granularity. There is nothing to assemble, which is why
39//!   this module has no equivalent of `claude-wrapper`'s `PartialMessageEvent`
40//!   (#84).
41//!
42//! **Assumed**, still: nothing load-bearing.
43//! [`JsonLineEvent::agent_message_text`] also accepts an `item_type`
44//! discriminator and a `content` block array, neither of which has been seen
45//! in real output. That tolerance stays because the failure mode when this
46//! parser guesses wrong is an empty result rather than an error, which is how
47//! #73 went unnoticed.
48//!
49//! To re-confirm after a CLI upgrade, capture a run and check `result` is
50//! non-empty:
51//!
52//! ```sh
53//! codex exec --json --ephemeral --skip-git-repo-check "reply with: ok" > turn.jsonl
54//! ```
55
56#[cfg(feature = "json")]
57use std::collections::HashMap;
58use std::fmt;
59use std::str::FromStr;
60
61use serde::{Deserialize, Serialize};
62
63/// Sandbox policy for model-generated shell commands.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "kebab-case")]
66pub enum SandboxMode {
67    /// Read-only filesystem access.
68    ReadOnly,
69    /// Write access limited to the workspace directory (default).
70    #[default]
71    WorkspaceWrite,
72    /// Full filesystem access -- use with extreme caution.
73    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/// When the model should ask for human approval before executing commands.
87///
88/// These are the values accepted by the `--ask-for-approval` flag on
89/// [`ForkCommand`](crate::ForkCommand) and [`ResumeCommand`](crate::ResumeCommand).
90/// The exec family sets the same setting through the `approval_policy` config
91/// key, which accepts a larger value set -- see [`ApprovalPolicyConfig`].
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum ApprovalPolicy {
95    /// Only run trusted commands without asking.
96    Untrusted,
97    /// The model decides when to ask (default).
98    #[default]
99    OnRequest,
100    /// Never ask for approval.
101    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/// Approval policy values accepted by the `approval_policy` config key.
115///
116/// `codex-cli` 0.145.0 removed `--ask-for-approval` from the exec family; the
117/// config key is the supported equivalent. It accepts two values the flag does
118/// not ([`OnFailure`](Self::OnFailure) and [`Granular`](Self::Granular)), which
119/// is why this is a separate type from [`ApprovalPolicy`] rather than an alias.
120/// The three shared values convert implicitly:
121///
122/// ```
123/// use codex_wrapper::{ApprovalPolicy, ApprovalPolicyConfig};
124///
125/// let config: ApprovalPolicyConfig = ApprovalPolicy::Never.into();
126/// assert_eq!(config, ApprovalPolicyConfig::Never);
127/// ```
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "kebab-case")]
130pub enum ApprovalPolicyConfig {
131    /// Only run trusted commands without asking.
132    Untrusted,
133    /// Ask after a command fails.
134    ///
135    /// Not accepted by `--ask-for-approval`, so it has no [`ApprovalPolicy`]
136    /// counterpart.
137    OnFailure,
138    /// The model decides when to ask (default).
139    #[default]
140    OnRequest,
141    /// Ask per-operation rather than per-command.
142    ///
143    /// Not accepted by `--ask-for-approval`, so it has no [`ApprovalPolicy`]
144    /// counterpart.
145    Granular,
146    /// Never ask for approval.
147    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    /// Narrow to the flag-accepted subset.
176    ///
177    /// Returns the original value as the error for
178    /// [`OnFailure`](ApprovalPolicyConfig::OnFailure) and
179    /// [`Granular`](ApprovalPolicyConfig::Granular), which `--ask-for-approval`
180    /// rejects.
181    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/// Web search mode, set through the `web_search` config key.
192///
193/// `codex-cli` 0.145.0 removed `--search` from the exec family. The config key
194/// replacing it is an enum rather than the flag's boolean;
195/// [`Live`](Self::Live) is what `--search` meant.
196///
197/// `--search` is still a valid flag on [`ForkCommand`](crate::ForkCommand) and
198/// [`ResumeCommand`](crate::ResumeCommand), which keep their boolean setters.
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "kebab-case")]
201pub enum WebSearchMode {
202    /// No web search.
203    #[default]
204    Disabled,
205    /// Serve results from cache only.
206    Cached,
207    /// Search a prebuilt index.
208    Indexed,
209    /// Live web search. The `--search` flag's former behavior.
210    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/// Color output mode for exec commands.
225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "lowercase")]
227pub enum Color {
228    /// Always emit color codes.
229    Always,
230    /// Never emit color codes.
231    Never,
232    /// Auto-detect terminal support (default).
233    #[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/// A single parsed JSONL event from `--json` output.
248///
249/// The `event_type` field corresponds to the `"type"` key in the JSON.
250/// All other fields are captured in `extra`.
251#[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/// A terminal `turn.failed` classification from the Codex JSONL stream.
261///
262/// Codex does not currently emit a machine-readable error code, so the one
263/// typed class here is pinned to the stable message captured from 0.145.0.
264/// Unknown failures remain distinguishable rather than being forced into the
265/// budget class.
266#[cfg(feature = "json")]
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268#[non_exhaustive]
269pub enum TurnFailureKind {
270    /// Codex stopped after exhausting its native shared rollout budget.
271    RolloutBudgetExhausted,
272    /// A terminal failure not classified by this wrapper.
273    Other,
274}
275
276#[cfg(feature = "json")]
277impl JsonLineEvent {
278    /// Returns the `session_id` field, if present and a string.
279    #[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    /// Returns the `thread_id` field, if present and a string.
285    #[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    /// Returns `true` when this is the terminal event of a successful turn.
291    #[must_use]
292    pub fn is_turn_completed(&self) -> bool {
293        self.event_type == "turn.completed"
294    }
295
296    /// Returns `true` when this is the terminal event of a failed turn.
297    #[must_use]
298    pub fn is_turn_failed(&self) -> bool {
299        self.event_type == "turn.failed"
300    }
301
302    /// The message carried by a terminal `turn.failed` event.
303    ///
304    /// The captured CLI shape nests it under `error.message`.
305    #[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    /// Classify a terminal `turn.failed` event without downstream string matching.
317    #[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    /// Token counts reported by a `turn.completed` event.
332    ///
333    /// Returns `None` on any other event, or when no `usage` object is
334    /// present. The CLI reports tokens, not money; see [`TokenUsage`].
335    #[must_use]
336    pub fn usage(&self) -> Option<TokenUsage> {
337        self.extra.get("usage").map(TokenUsage::from_json)
338    }
339
340    /// Assistant text carried by an `item.completed` agent-message item.
341    ///
342    /// Returns `None` for any other event or item type.
343    ///
344    /// Real output uses a `type` discriminator and a `text` field; see the
345    /// schema block at the top of this module. An `item_type` discriminator
346    /// and a `content` block array are also accepted, neither of them
347    /// observed. Tolerating the unobserved shapes is deliberate: the previous
348    /// parser committed to one exact layout and silently yielded empty results
349    /// when it was wrong, which is the bug this replaces.
350    #[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    /// Returns the `role` field, if present and a string.
380    #[must_use]
381    pub fn role(&self) -> Option<&str> {
382        self.extra.get("role").and_then(|v| v.as_str())
383    }
384
385    /// Extracts concatenated text from a `content` blocks array.
386    ///
387    /// Each block with `"type": "text"` contributes its `"text"` value.
388    /// Returns `None` if there is no `content` array or no text blocks.
389    #[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    /// The `item` discriminator on an `item.*` event.
402    ///
403    /// `agent_message` and `command_execution` are the observed values. This
404    /// is the field to match on when narrowing an item event, and it is
405    /// present on `item.started` as well as `item.completed`.
406    ///
407    /// Accepts `item_type` as well as `type` for the same reason
408    /// [`agent_message_text`](Self::agent_message_text) does.
409    #[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    /// A shell command the model ran, from a `command_execution` item.
416    ///
417    /// `None` for any other item. `exit_code` and `status` are only populated
418    /// on `item.completed`; an `item.started` for the same command carries the
419    /// command alone.
420    #[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/// A typed summary of a completed `codex exec` run, assembled from the JSONL
440/// event stream.
441///
442/// This mirrors the shape of `claude-wrapper`'s `QueryResult` so a downstream
443/// abstraction can treat both wrappers uniformly. The full parsed event stream
444/// is retained in [`events`](QueryResult::events) as an escape hatch for fields
445/// not surfaced here.
446#[cfg(feature = "json")]
447#[derive(Debug, Clone)]
448pub struct QueryResult {
449    /// Assistant text, concatenated from the turn's agent-message items.
450    ///
451    /// Empty when the turn produced no agent message. A failed turn can still
452    /// carry text produced before its terminal failure; native rollout-budget
453    /// exhaustion is one observed example.
454    pub result: String,
455    /// The `session_id` captured from the event stream, if any.
456    ///
457    /// Observed live runs carry only `thread_id`, so this is usually `None`.
458    /// Retained because it costs nothing and the field may exist under auth
459    /// modes not yet observed.
460    pub session_id: Option<String>,
461    /// The `thread_id` captured from the event stream, if any.
462    ///
463    /// Codex's native identifier for resuming a conversation, emitted on
464    /// `thread.started`.
465    pub thread_id: Option<String>,
466    /// Token counts from the terminal `turn.completed` event, if present.
467    ///
468    /// The CLI reports tokens, not money. There is no cost field to read; see
469    /// [`TokenUsage`].
470    pub usage: Option<TokenUsage>,
471    /// The full parsed event stream this result was assembled from.
472    ///
473    /// The escape hatch for anything not surfaced above, including whatever
474    /// this parser gets wrong.
475    pub events: Vec<JsonLineEvent>,
476}
477
478#[cfg(feature = "json")]
479impl QueryResult {
480    /// Assemble a [`QueryResult`] from a parsed JSONL event stream.
481    ///
482    /// `usage` comes from the last `turn.completed` event; `result` is every
483    /// agent-message item concatenated in order; `session_id` and `thread_id`
484    /// are the first occurrences in the stream.
485    #[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/// A shell command the model ran, from a `command_execution` item.
516///
517/// Every field is optional: an `item.started` carries the command with no
518/// outcome yet, and the CLI has added fields to this item before.
519#[cfg(feature = "json")]
520#[derive(Debug, Clone, Default, PartialEq, Eq)]
521#[non_exhaustive]
522pub struct CommandExecution {
523    /// The command line, as the CLI recorded it.
524    pub command: Option<String>,
525    /// Reported status, `completed` being the observed value.
526    pub status: Option<String>,
527    /// Exit code, once the command has finished.
528    pub exit_code: Option<i32>,
529    /// Combined stdout and stderr, when the CLI included it.
530    pub aggregated_output: Option<String>,
531}
532
533/// Token counts reported on a completed turn.
534///
535/// `codex-cli` 0.145.0 reports token usage and no monetary cost. Converting
536/// tokens to dollars needs a per-model price table the CLI does not provide,
537/// so this crate does not attempt it: a hardcoded table would go stale
538/// silently, which is the same class of bug as #73.
539///
540/// Every field is optional, and absence is normal rather than exceptional:
541/// observed runs carry five of the six and never `total_tokens`, which is why
542/// [`TokenUsage::total`] falls back to input plus output.
543#[cfg(feature = "json")]
544#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
545pub struct TokenUsage {
546    /// Input tokens for the turn.
547    pub input_tokens: Option<u64>,
548    /// Input tokens served from cache.
549    pub cached_input_tokens: Option<u64>,
550    /// Input tokens written to cache.
551    pub cache_write_input_tokens: Option<u64>,
552    /// Output tokens for the turn.
553    pub output_tokens: Option<u64>,
554    /// Output tokens spent on reasoning.
555    pub reasoning_output_tokens: Option<u64>,
556    /// Total tokens, if the CLI ever reports one. Observed runs do not.
557    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    /// Best available total for the turn.
575    ///
576    /// Prefers the CLI's own `total_tokens`, falls back to input plus output
577    /// when it is absent, and returns `None` when neither is reported, so a
578    /// missing total is never silently counted as zero.
579    #[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/// Parsed semantic version of the Codex CLI (`major.minor.patch`).
592///
593/// Supports comparison and ordering for version-gating logic.
594#[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    /// Classify this version against a tested-against range.
624    ///
625    /// ```
626    /// use codex_wrapper::{CliVersion, CliVersionStatus};
627    ///
628    /// let min = CliVersion::new(0, 145, 0);
629    /// let max = CliVersion::new(0, 146, 0);
630    ///
631    /// assert!(CliVersion::new(0, 145, 3).status_within(&min, &max).is_tested());
632    /// assert!(!CliVersion::new(0, 200, 0).status_within(&min, &max).is_tested());
633    /// ```
634    #[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/// Classification of an installed CLI version against a tested range.
653///
654/// Returned by [`CliVersion::status_within`] and
655/// [`Codex::cli_version_status`](crate::Codex::cli_version_status). Mirrors
656/// `claude-wrapper`'s enum of the same name so a downstream abstraction can
657/// treat both wrappers uniformly.
658///
659/// There is deliberately no `Unparseable` variant: unparseable output is an
660/// error from [`Codex::cli_version`](crate::Codex::cli_version), not a status,
661/// and modeling it twice would fork this shape away from the sibling crate.
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
663#[serde(tag = "status", rename_all = "snake_case")]
664pub enum CliVersionStatus {
665    /// Within the tested-against range.
666    Tested,
667    /// Newer than the highest tested version.
668    ///
669    /// The wrapper should still generally work; semantics may have drifted.
670    NewerUntested {
671        /// The installed CLI version.
672        found: CliVersion,
673        /// Highest version this wrapper is tested against.
674        tested_max: CliVersion,
675    },
676    /// Older than the lowest tested version.
677    ///
678    /// Incorrect behavior is likely rather than merely possible: the arguments
679    /// this wrapper emits target the newer CLI, and older releases reject some
680    /// of them outright.
681    OlderThanMinimum {
682        /// The installed CLI version.
683        found: CliVersion,
684        /// Lowest version this wrapper is tested against.
685        minimum: CliVersion,
686    },
687}
688
689impl CliVersionStatus {
690    /// True only for [`Tested`](Self::Tested).
691    ///
692    /// For callers branching on "should I run?" without matching every
693    /// variant.
694    #[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        // The pre-#73 parser matched this. The CLI has never emitted it.
788        let bogus: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
789        assert!(!bogus.is_turn_completed());
790    }
791
792    /// Transcribed from a paid `codex-cli` 0.145.0 run with a one-unit native
793    /// rollout budget. The CLI exposes the limit decision but no usage object.
794    #[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        // Absent from the observed payload, so absent here rather than zero.
838        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    /// A missing total must not read as zero, which would silently understate
854    /// a session's usage.
855    #[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    /// The shape a real run emits: `type` on the item, text in `text`.
862    #[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    /// `item_type` and content blocks are not shapes the CLI has been seen to
873    /// emit. The accessor still tolerates them, because the cost of being
874    /// wrong here is a silently empty result rather than a loud failure, which
875    /// is how #73 stayed hidden. These cases keep that tolerance covered.
876    #[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        // The item type a review's diff-reading steps arrive as.
896        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    /// A failed turn has no agent message and no usage. Both must come back
987    /// empty rather than fabricated.
988    #[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    /// Both values transcribed from captured runs.
1006    #[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    /// Transcribed from a captured `codex exec review` run, where the reviewer
1026    /// reads the diff before commenting.
1027    #[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    /// An `item.started` has no outcome yet, and must not invent one.
1042    #[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}