codex-cli-sdk 0.0.1

Rust SDK for the OpenAI Codex CLI
Documentation
use crate::types::events::{ApprovalRequestEvent, PatchApprovalRequestEvent};
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;

/// Decision for an approval request.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalDecision {
    /// Approve the action.
    Approved,
    /// Approve this and all identical future instances in this session.
    ApprovedForSession,
    /// Deny — agent should try an alternative approach.
    #[default]
    Denied,
    /// Abort — stop the session until next user command.
    Abort,
}

/// Wrapper around [`ApprovalDecision`] with forward-compatible fields.
#[derive(Debug, Clone)]
pub struct ApprovalOutcome {
    /// The approval decision.
    pub decision: ApprovalDecision,

    /// Reserved: command override.
    ///
    /// **This field is a no-op.** It is gated behind the `unstable` feature flag
    /// because the Codex CLI protocol does not yet support input mutation.
    /// Enable the `unstable` feature only if you are experimenting with future
    /// protocol changes.
    #[cfg(feature = "unstable")]
    pub updated_command: Option<String>,
}

impl ApprovalOutcome {
    /// Create a new outcome with no command override.
    pub fn new(decision: ApprovalDecision) -> Self {
        Self {
            decision,
            #[cfg(feature = "unstable")]
            updated_command: None,
        }
    }

    /// Set a command override (forward-compat, currently no-op).
    ///
    /// Requires the `unstable` feature. The value is not forwarded to the CLI
    /// until the CLI protocol supports input mutation.
    #[cfg(feature = "unstable")]
    pub fn with_updated_command(mut self, command: impl Into<String>) -> Self {
        self.updated_command = Some(command.into());
        self
    }
}

impl From<ApprovalDecision> for ApprovalOutcome {
    fn from(decision: ApprovalDecision) -> Self {
        Self {
            decision,
            #[cfg(feature = "unstable")]
            updated_command: None,
        }
    }
}

/// Context provided to the approval callback.
#[derive(Debug, Clone)]
pub struct ApprovalContext {
    /// The approval request event from the CLI.
    pub request: ApprovalRequestEvent,
    /// The thread ID this approval belongs to.
    pub thread_id: Option<String>,
}

/// Callback type for handling approval requests.
///
/// Returns [`ApprovalOutcome`] which wraps a decision and an optional command override.
/// For simple cases, return `ApprovalDecision::Approved.into()`.
pub type ApprovalCallback = Arc<
    dyn Fn(ApprovalContext) -> Pin<Box<dyn Future<Output = ApprovalOutcome> + Send>> + Send + Sync,
>;

/// Context provided to the patch approval callback.
#[derive(Debug, Clone)]
pub struct PatchApprovalContext {
    /// The patch approval request event from the CLI.
    pub request: PatchApprovalRequestEvent,
    /// The thread ID this approval belongs to.
    pub thread_id: Option<String>,
}

/// Callback type for handling patch approval requests.
///
/// Returns [`ApprovalOutcome`] for forward-compatibility (the `updated_command`
/// field is ignored for patch approvals).
pub type PatchApprovalCallback = Arc<
    dyn Fn(PatchApprovalContext) -> Pin<Box<dyn Future<Output = ApprovalOutcome> + Send>>
        + Send
        + Sync,
>;

/// The JSON message written to stdin to respond to an approval request.
#[derive(Debug, Serialize)]
pub(crate) struct ApprovalResponse {
    pub op: String,
    pub id: String,
    pub decision: ApprovalDecision,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub turn_id: Option<String>,
}

impl ApprovalResponse {
    pub fn new(request_id: String, decision: ApprovalDecision) -> Self {
        Self {
            op: "ExecApproval".to_string(),
            id: request_id,
            decision,
            turn_id: None,
        }
    }
}

/// The JSON message for patch approval responses.
#[derive(Debug, Serialize)]
pub(crate) struct PatchApprovalResponse {
    pub op: String,
    pub id: String,
    pub decision: ApprovalDecision,
}

impl PatchApprovalResponse {
    pub fn new(request_id: String, decision: ApprovalDecision) -> Self {
        Self {
            op: "PatchApproval".to_string(),
            id: request_id,
            decision,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn approval_response_serializes() {
        let response = ApprovalResponse::new("req-1".into(), ApprovalDecision::Approved);
        let json = serde_json::to_string(&response).unwrap();
        assert!(json.contains("ExecApproval"));
        assert!(json.contains("approved"));
        assert!(!json.contains("turn_id")); // None should be skipped
    }

    #[test]
    fn approval_decision_round_trip() {
        let decision = ApprovalDecision::ApprovedForSession;
        let json = serde_json::to_string(&decision).unwrap();
        let parsed: ApprovalDecision = serde_json::from_str(&json).unwrap();
        assert!(matches!(parsed, ApprovalDecision::ApprovedForSession));
    }

    #[test]
    fn patch_approval_response_serializes() {
        let response = PatchApprovalResponse::new("req-2".into(), ApprovalDecision::Denied);
        let json = serde_json::to_string(&response).unwrap();
        assert!(json.contains("PatchApproval"));
        assert!(json.contains("denied"));
    }

    #[test]
    fn default_decision_is_denied() {
        assert!(matches!(
            ApprovalDecision::default(),
            ApprovalDecision::Denied
        ));
    }

    #[test]
    fn approval_outcome_from_decision() {
        let outcome: ApprovalOutcome = ApprovalDecision::Approved.into();
        assert!(matches!(outcome.decision, ApprovalDecision::Approved));
    }

    #[cfg(feature = "unstable")]
    #[test]
    fn approval_outcome_with_updated_command() {
        let outcome = ApprovalOutcome::new(ApprovalDecision::Approved)
            .with_updated_command("safe-command --flag");
        assert!(matches!(outcome.decision, ApprovalDecision::Approved));
        assert_eq!(
            outcome.updated_command,
            Some("safe-command --flag".to_string())
        );
    }

    #[cfg(feature = "unstable")]
    #[test]
    fn approval_outcome_wire_compatible() {
        // The ApprovalResponse only uses the decision — updated_command is not serialized.
        let outcome =
            ApprovalOutcome::new(ApprovalDecision::Approved).with_updated_command("overridden");
        let response = ApprovalResponse::new("req-1".into(), outcome.decision);
        let json = serde_json::to_string(&response).unwrap();
        assert!(!json.contains("overridden"));
        assert!(json.contains("approved"));
    }
}