Skip to main content

batty_cli/shim/
codex_types.rs

1//! JSONL event types for Codex CLI `exec --json` mode.
2//!
3//! These types model the events emitted on stdout when Codex runs in
4//! `codex exec --json` mode. Each line is a complete JSON object with
5//! a `type` tag discriminating the event kind.
6
7use serde::Deserialize;
8
9// ---------------------------------------------------------------------------
10// Top-level thread events (one per JSONL line)
11// ---------------------------------------------------------------------------
12
13/// A single JSONL event from `codex exec --json` stdout.
14///
15/// Uses a flat struct with `event_type` string + optional fields so that
16/// unknown/future event types are silently tolerated.
17#[derive(Debug, Deserialize)]
18pub struct CodexEvent {
19    #[serde(rename = "type")]
20    pub event_type: String,
21
22    // thread.started
23    #[serde(default)]
24    pub thread_id: Option<String>,
25
26    // turn.completed
27    #[serde(default)]
28    pub usage: Option<CodexUsage>,
29
30    // turn.failed / error
31    #[serde(default)]
32    pub error: Option<CodexError>,
33
34    // item.started / item.updated / item.completed
35    #[serde(default)]
36    pub item: Option<CodexItem>,
37}
38
39#[derive(Debug, Deserialize)]
40pub struct CodexUsage {
41    #[serde(default)]
42    pub input_tokens: i64,
43    #[serde(default)]
44    pub cached_input_tokens: i64,
45    #[serde(default)]
46    pub output_tokens: i64,
47}
48
49#[derive(Debug, Deserialize)]
50pub struct CodexError {
51    pub message: String,
52}
53
54// ---------------------------------------------------------------------------
55// Thread items
56// ---------------------------------------------------------------------------
57
58/// A thread item carried by item.started / item.updated / item.completed events.
59#[derive(Debug, Deserialize)]
60pub struct CodexItem {
61    pub id: String,
62    #[serde(rename = "type")]
63    pub item_type: String,
64
65    // agent_message
66    #[serde(default)]
67    pub text: Option<String>,
68
69    // command_execution
70    #[serde(default)]
71    pub command: Option<String>,
72    #[serde(default)]
73    pub aggregated_output: Option<String>,
74    #[serde(default)]
75    pub exit_code: Option<i32>,
76    #[serde(default)]
77    pub status: Option<String>,
78}
79
80impl CodexItem {
81    /// Extract text from an agent_message or reasoning item.
82    pub fn agent_text(&self) -> Option<&str> {
83        if self.item_type == "agent_message" || self.item_type == "reasoning" {
84            self.text.as_deref()
85        } else {
86            None
87        }
88    }
89}
90
91// ---------------------------------------------------------------------------
92// Launch command builder
93// ---------------------------------------------------------------------------
94
95/// Build the Codex exec command for SDK (JSONL) mode.
96///
97/// `thread_id`: if provided, resumes an existing thread for multi-turn.
98/// `prompt`: the message/task text to send.
99pub fn codex_sdk_command(program: &str, prompt: &str, thread_id: Option<&str>) -> String {
100    let escaped_prompt = prompt.replace('\'', "'\\''");
101    match thread_id {
102        Some(tid) => {
103            let escaped_tid = tid.replace('\'', "'\\''");
104            format!(
105                "exec {program} exec --json --dangerously-bypass-approvals-and-sandbox resume '{escaped_tid}' '{escaped_prompt}'"
106            )
107        }
108        None => {
109            format!(
110                "exec {program} exec --json --dangerously-bypass-approvals-and-sandbox '{escaped_prompt}'"
111            )
112        }
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Tests
118// ---------------------------------------------------------------------------
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn parse_thread_started() {
126        let line = r#"{"type":"thread.started","thread_id":"abc-123"}"#;
127        let evt: CodexEvent = serde_json::from_str(line).unwrap();
128        assert_eq!(evt.event_type, "thread.started");
129        assert_eq!(evt.thread_id.as_deref(), Some("abc-123"));
130    }
131
132    #[test]
133    fn parse_turn_started() {
134        let line = r#"{"type":"turn.started"}"#;
135        let evt: CodexEvent = serde_json::from_str(line).unwrap();
136        assert_eq!(evt.event_type, "turn.started");
137    }
138
139    #[test]
140    fn parse_turn_completed() {
141        let line = r#"{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":50,"output_tokens":30}}"#;
142        let evt: CodexEvent = serde_json::from_str(line).unwrap();
143        assert_eq!(evt.event_type, "turn.completed");
144        let usage = evt.usage.unwrap();
145        assert_eq!(usage.input_tokens, 100);
146        assert_eq!(usage.output_tokens, 30);
147    }
148
149    #[test]
150    fn parse_turn_failed() {
151        let line = r#"{"type":"turn.failed","error":{"message":"rate limit"}}"#;
152        let evt: CodexEvent = serde_json::from_str(line).unwrap();
153        assert_eq!(evt.event_type, "turn.failed");
154        assert_eq!(evt.error.unwrap().message, "rate limit");
155    }
156
157    #[test]
158    fn parse_item_agent_message() {
159        let line = r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Hello world"}}"#;
160        let evt: CodexEvent = serde_json::from_str(line).unwrap();
161        assert_eq!(evt.event_type, "item.completed");
162        let item = evt.item.unwrap();
163        assert_eq!(item.item_type, "agent_message");
164        assert_eq!(item.agent_text(), Some("Hello world"));
165    }
166
167    #[test]
168    fn parse_item_command_execution() {
169        let line = r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"ls","aggregated_output":"file.txt\n","exit_code":0,"status":"completed"}}"#;
170        let evt: CodexEvent = serde_json::from_str(line).unwrap();
171        let item = evt.item.unwrap();
172        assert_eq!(item.item_type, "command_execution");
173        assert_eq!(item.command.as_deref(), Some("ls"));
174        assert_eq!(item.exit_code, Some(0));
175        assert!(item.agent_text().is_none());
176    }
177
178    #[test]
179    fn parse_error_event() {
180        let line = r#"{"type":"error","message":"fatal"}"#;
181        // The flat struct puts message at top level for the error event type;
182        // but the Codex format uses an error object. Let's handle both.
183        let evt: CodexEvent = serde_json::from_str(line).unwrap();
184        assert_eq!(evt.event_type, "error");
185    }
186
187    #[test]
188    fn unknown_event_type_tolerated() {
189        let line = r#"{"type":"future.event","some_field":42}"#;
190        let evt: CodexEvent = serde_json::from_str(line).unwrap();
191        assert_eq!(evt.event_type, "future.event");
192    }
193
194    #[test]
195    fn codex_sdk_command_new_session() {
196        let cmd = codex_sdk_command("codex", "fix the bug", None);
197        assert!(cmd.contains("exec codex exec --json"));
198        assert!(cmd.contains("--dangerously-bypass-approvals-and-sandbox"));
199        assert!(cmd.contains("'fix the bug'"));
200        assert!(!cmd.contains("resume"));
201    }
202
203    #[test]
204    fn codex_sdk_command_resume() {
205        let cmd = codex_sdk_command("codex", "next step", Some("tid-123"));
206        assert!(cmd.contains("resume 'tid-123'"));
207        assert!(cmd.contains("'next step'"));
208    }
209
210    #[test]
211    fn codex_sdk_command_escapes_quotes() {
212        let cmd = codex_sdk_command("codex", "fix user's bug", None);
213        assert!(cmd.contains("user'\\''s"));
214    }
215}