#[cfg(feature = "json")]
use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SandboxMode {
ReadOnly,
#[default]
WorkspaceWrite,
DangerFullAccess,
}
impl SandboxMode {
pub(crate) fn as_arg(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovalPolicy {
Untrusted,
#[default]
OnRequest,
Never,
}
impl ApprovalPolicy {
pub(crate) fn as_arg(self) -> &'static str {
match self {
Self::Untrusted => "untrusted",
Self::OnRequest => "on-request",
Self::Never => "never",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovalPolicyConfig {
Untrusted,
OnFailure,
#[default]
OnRequest,
Granular,
Never,
}
impl ApprovalPolicyConfig {
pub(crate) fn as_config_value(self) -> &'static str {
match self {
Self::Untrusted => "untrusted",
Self::OnFailure => "on-failure",
Self::OnRequest => "on-request",
Self::Granular => "granular",
Self::Never => "never",
}
}
}
impl From<ApprovalPolicy> for ApprovalPolicyConfig {
fn from(policy: ApprovalPolicy) -> Self {
match policy {
ApprovalPolicy::Untrusted => Self::Untrusted,
ApprovalPolicy::OnRequest => Self::OnRequest,
ApprovalPolicy::Never => Self::Never,
}
}
}
impl TryFrom<ApprovalPolicyConfig> for ApprovalPolicy {
type Error = ApprovalPolicyConfig;
fn try_from(config: ApprovalPolicyConfig) -> std::result::Result<Self, Self::Error> {
match config {
ApprovalPolicyConfig::Untrusted => Ok(Self::Untrusted),
ApprovalPolicyConfig::OnRequest => Ok(Self::OnRequest),
ApprovalPolicyConfig::Never => Ok(Self::Never),
other => Err(other),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WebSearchMode {
#[default]
Disabled,
Cached,
Indexed,
Live,
}
impl WebSearchMode {
pub(crate) fn as_config_value(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Cached => "cached",
Self::Indexed => "indexed",
Self::Live => "live",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Color {
Always,
Never,
#[default]
Auto,
}
impl Color {
pub(crate) fn as_arg(self) -> &'static str {
match self {
Self::Always => "always",
Self::Never => "never",
Self::Auto => "auto",
}
}
}
#[cfg(feature = "json")]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct JsonLineEvent {
#[serde(rename = "type", default)]
pub event_type: String,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[cfg(feature = "json")]
impl JsonLineEvent {
#[must_use]
pub fn session_id(&self) -> Option<&str> {
self.extra.get("session_id").and_then(|v| v.as_str())
}
#[must_use]
pub fn thread_id(&self) -> Option<&str> {
self.extra.get("thread_id").and_then(|v| v.as_str())
}
#[must_use]
pub fn is_turn_completed(&self) -> bool {
self.event_type == "turn.completed"
}
#[must_use]
pub fn is_turn_failed(&self) -> bool {
self.event_type == "turn.failed"
}
#[must_use]
pub fn usage(&self) -> Option<TokenUsage> {
self.extra.get("usage").map(TokenUsage::from_json)
}
#[must_use]
pub fn agent_message_text(&self) -> Option<String> {
if self.event_type != "item.completed" {
return None;
}
let item = self.extra.get("item")?;
let kind = item
.get("item_type")
.or_else(|| item.get("type"))
.and_then(|v| v.as_str())?;
if kind != "agent_message" {
return None;
}
if let Some(text) = item.get("text").and_then(|v| v.as_str())
&& !text.is_empty()
{
return Some(text.to_string());
}
let blocks = item.get("content").and_then(|v| v.as_array())?;
let text: String = blocks
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("");
if text.is_empty() { None } else { Some(text) }
}
#[must_use]
pub fn role(&self) -> Option<&str> {
self.extra.get("role").and_then(|v| v.as_str())
}
#[must_use]
pub fn content_text(&self) -> Option<String> {
let blocks = self.extra.get("content").and_then(|v| v.as_array())?;
let text: String = blocks
.iter()
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("");
if text.is_empty() { None } else { Some(text) }
}
#[must_use]
pub fn item_type(&self) -> Option<&str> {
let item = self.extra.get("item")?;
item.get("type").or_else(|| item.get("item_type"))?.as_str()
}
#[must_use]
pub fn command_execution(&self) -> Option<CommandExecution> {
if self.item_type()? != "command_execution" {
return None;
}
let item = self.extra.get("item")?;
let string = |key: &str| item.get(key).and_then(|v| v.as_str()).map(str::to_string);
Some(CommandExecution {
command: string("command"),
status: string("status"),
exit_code: item
.get("exit_code")
.and_then(serde_json::Value::as_i64)
.and_then(|code| i32::try_from(code).ok()),
aggregated_output: string("aggregated_output"),
})
}
}
#[cfg(feature = "json")]
#[derive(Debug, Clone)]
pub struct QueryResult {
pub result: String,
pub session_id: Option<String>,
pub thread_id: Option<String>,
pub usage: Option<TokenUsage>,
pub events: Vec<JsonLineEvent>,
}
#[cfg(feature = "json")]
impl QueryResult {
#[must_use]
pub fn from_events(events: Vec<JsonLineEvent>) -> Self {
let usage = events
.iter()
.rev()
.find(|e| e.is_turn_completed())
.and_then(JsonLineEvent::usage);
let result = events
.iter()
.filter_map(JsonLineEvent::agent_message_text)
.collect::<Vec<_>>()
.join("");
let session_id = events
.iter()
.find_map(JsonLineEvent::session_id)
.map(str::to_string);
let thread_id = events
.iter()
.find_map(JsonLineEvent::thread_id)
.map(str::to_string);
Self {
result,
session_id,
thread_id,
usage,
events,
}
}
}
#[cfg(feature = "json")]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct CommandExecution {
pub command: Option<String>,
pub status: Option<String>,
pub exit_code: Option<i32>,
pub aggregated_output: Option<String>,
}
#[cfg(feature = "json")]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub input_tokens: Option<u64>,
pub cached_input_tokens: Option<u64>,
pub cache_write_input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub reasoning_output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
}
#[cfg(feature = "json")]
impl TokenUsage {
fn from_json(value: &serde_json::Value) -> Self {
let field = |name: &str| value.get(name).and_then(serde_json::Value::as_u64);
Self {
input_tokens: field("input_tokens"),
cached_input_tokens: field("cached_input_tokens"),
cache_write_input_tokens: field("cache_write_input_tokens"),
output_tokens: field("output_tokens"),
reasoning_output_tokens: field("reasoning_output_tokens"),
total_tokens: field("total_tokens"),
}
}
#[must_use]
pub fn total(&self) -> Option<u64> {
if let Some(total) = self.total_tokens {
return Some(total);
}
match (self.input_tokens, self.output_tokens) {
(None, None) => None,
(input, output) => Some(input.unwrap_or(0) + output.unwrap_or(0)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl CliVersion {
#[must_use]
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
pub fn parse_version_output(output: &str) -> Result<Self, VersionParseError> {
output
.split_whitespace()
.find_map(|token| token.parse().ok())
.ok_or_else(|| VersionParseError(output.trim().to_string()))
}
#[must_use]
pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
self >= minimum
}
#[must_use]
pub fn status_within(&self, min: &CliVersion, max: &CliVersion) -> CliVersionStatus {
if self < min {
CliVersionStatus::OlderThanMinimum {
found: *self,
minimum: *min,
}
} else if self > max {
CliVersionStatus::NewerUntested {
found: *self,
tested_max: *max,
}
} else {
CliVersionStatus::Tested
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum CliVersionStatus {
Tested,
NewerUntested {
found: CliVersion,
tested_max: CliVersion,
},
OlderThanMinimum {
found: CliVersion,
minimum: CliVersion,
},
}
impl CliVersionStatus {
#[must_use]
pub fn is_tested(self) -> bool {
matches!(self, Self::Tested)
}
}
impl PartialOrd for CliVersion {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for CliVersion {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.major
.cmp(&other.major)
.then(self.minor.cmp(&other.minor))
.then(self.patch.cmp(&other.patch))
}
}
impl fmt::Display for CliVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl FromStr for CliVersion {
type Err = VersionParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return Err(VersionParseError(s.to_string()));
}
Ok(Self {
major: parts[0]
.parse()
.map_err(|_| VersionParseError(s.to_string()))?,
minor: parts[1]
.parse()
.map_err(|_| VersionParseError(s.to_string()))?,
patch: parts[2]
.parse()
.map_err(|_| VersionParseError(s.to_string()))?,
})
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("invalid version string: {0:?}")]
pub struct VersionParseError(pub String);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_codex_version_output() {
let version = CliVersion::parse_version_output("codex-cli 0.145.0").unwrap();
assert_eq!(version, CliVersion::new(0, 145, 0));
}
#[test]
fn parses_plain_version_output() {
let version = CliVersion::parse_version_output("0.145.0").unwrap();
assert_eq!(version, CliVersion::new(0, 145, 0));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_session_and_thread_id() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"message.created","session_id":"sess_abc","thread_id":"thread_123"}"#,
)
.unwrap();
assert_eq!(event.session_id(), Some("sess_abc"));
assert_eq!(event.thread_id(), Some("thread_123"));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_turn_terminal_types() {
let completed: JsonLineEvent =
serde_json::from_str(r#"{"type":"turn.completed"}"#).unwrap();
assert!(completed.is_turn_completed());
assert!(!completed.is_turn_failed());
let failed: JsonLineEvent = serde_json::from_str(r#"{"type":"turn.failed"}"#).unwrap();
assert!(failed.is_turn_failed());
assert!(!failed.is_turn_completed());
let bogus: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
assert!(!bogus.is_turn_completed());
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_usage() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"turn.completed","usage":{"input_tokens":120,"output_tokens":45,"total_tokens":165}}"#,
)
.unwrap();
let usage = event.usage().unwrap();
assert_eq!(usage.input_tokens, Some(120));
assert_eq!(usage.output_tokens, Some(45));
assert_eq!(usage.total_tokens, Some(165));
assert_eq!(usage.cache_write_input_tokens, None);
assert_eq!(usage.total(), Some(165));
}
#[cfg(feature = "json")]
#[test]
fn token_usage_total_falls_back_to_input_plus_output() {
let usage = TokenUsage {
input_tokens: Some(10),
output_tokens: Some(5),
..TokenUsage::default()
};
assert_eq!(usage.total(), Some(15));
}
#[cfg(feature = "json")]
#[test]
fn token_usage_total_is_none_when_nothing_reported() {
assert_eq!(TokenUsage::default().total(), None);
}
#[cfg(feature = "json")]
#[test]
fn agent_message_text_from_item_completed() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello"}}"#,
)
.unwrap();
assert_eq!(event.agent_message_text().as_deref(), Some("hello"));
}
#[cfg(feature = "json")]
#[test]
fn agent_message_text_tolerates_layout_variants() {
let item_type_key: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"a"}}"#,
)
.unwrap();
assert_eq!(item_type_key.agent_message_text().as_deref(), Some("a"));
let content_blocks: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.completed","item":{"item_type":"agent_message","content":[{"text":"b"},{"text":"c"}]}}"#,
)
.unwrap();
assert_eq!(content_blocks.agent_message_text().as_deref(), Some("bc"));
}
#[cfg(feature = "json")]
#[test]
fn agent_message_text_ignores_other_items_and_events() {
let other_item: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"git diff","exit_code":0}}"#,
)
.unwrap();
assert_eq!(other_item.agent_message_text(), None);
let other_event: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.started","item":{"id":"item_0","type":"agent_message","text":"x"}}"#,
)
.unwrap();
assert_eq!(other_event.agent_message_text(), None);
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_role() {
let event: JsonLineEvent =
serde_json::from_str(r#"{"type":"message.created","role":"assistant"}"#).unwrap();
assert_eq!(event.role(), Some("assistant"));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_content_text() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"message.delta","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]}"#,
)
.unwrap();
assert_eq!(event.content_text(), Some("Hello world".to_string()));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_content_text_skips_non_text_blocks() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"message.delta","content":[{"type":"image","url":"x"},{"type":"text","text":"only this"}]}"#,
)
.unwrap();
assert_eq!(event.content_text(), Some("only this".to_string()));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_content_text_none_when_empty() {
let event: JsonLineEvent =
serde_json::from_str(r#"{"type":"message.delta","content":[]}"#).unwrap();
assert_eq!(event.content_text(), None);
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_content_text_none_when_missing() {
let event: JsonLineEvent = serde_json::from_str(r#"{"type":"message.delta"}"#).unwrap();
assert_eq!(event.content_text(), None);
}
#[cfg(feature = "json")]
#[test]
fn query_result_from_events() {
let events: Vec<JsonLineEvent> = [
r#"{"type":"thread.started","thread_id":"thread_1"}"#,
r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"the answer"}}"#,
r#"{"type":"turn.completed","usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10}}"#,
]
.iter()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let result = QueryResult::from_events(events);
assert_eq!(result.result, "the answer");
assert_eq!(result.thread_id.as_deref(), Some("thread_1"));
assert_eq!(result.usage.unwrap().total(), Some(10));
assert_eq!(result.events.len(), 3);
}
#[cfg(feature = "json")]
#[test]
fn query_result_concatenates_multiple_agent_messages() {
let events: Vec<JsonLineEvent> = [
r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"one "}}"#,
r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"two"}}"#,
r#"{"type":"turn.completed","usage":{"total_tokens":4}}"#,
]
.iter()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(QueryResult::from_events(events).result, "one two");
}
#[cfg(feature = "json")]
#[test]
fn query_result_from_a_failed_turn() {
let events: Vec<JsonLineEvent> = [
r#"{"type":"thread.started","thread_id":"thread_2"}"#,
r#"{"type":"turn.failed","error":{"message":"usage limit"}}"#,
]
.iter()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let result = QueryResult::from_events(events);
assert_eq!(result.result, "");
assert_eq!(result.usage, None);
assert_eq!(result.thread_id.as_deref(), Some("thread_2"));
}
#[cfg(feature = "json")]
#[test]
fn item_type_reads_the_discriminator() {
let message: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}}"#,
)
.unwrap();
assert_eq!(message.item_type(), Some("agent_message"));
let command: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"git diff"}}"#,
)
.unwrap();
assert_eq!(command.item_type(), Some("command_execution"));
let turn: JsonLineEvent = serde_json::from_str(r#"{"type":"turn.completed"}"#).unwrap();
assert_eq!(turn.item_type(), None);
}
#[cfg(feature = "json")]
#[test]
fn command_execution_reads_a_finished_command() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"git diff","aggregated_output":"","exit_code":0,"status":"completed"}}"#,
)
.unwrap();
let command = event.command_execution().unwrap();
assert_eq!(command.command.as_deref(), Some("git diff"));
assert_eq!(command.exit_code, Some(0));
assert_eq!(command.status.as_deref(), Some("completed"));
}
#[cfg(feature = "json")]
#[test]
fn command_execution_tolerates_a_command_still_running() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"git diff"}}"#,
)
.unwrap();
let command = event.command_execution().unwrap();
assert_eq!(command.command.as_deref(), Some("git diff"));
assert_eq!(command.exit_code, None);
assert_eq!(command.status, None);
}
#[cfg(feature = "json")]
#[test]
fn command_execution_is_none_for_other_items() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}}"#,
)
.unwrap();
assert!(event.command_execution().is_none());
}
}