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 argv for SDK (JSONL) mode.
96///
97/// `thread_id`: if provided, resumes an existing thread for multi-turn.
98/// The final `-` instructs Codex to read the prompt from stdin.
99pub fn codex_sdk_args(program: &str, thread_id: Option<&str>) -> (String, Vec<String>) {
100    let mut args = vec![
101        "exec".to_string(),
102        "--json".to_string(),
103        "-m".to_string(),
104        "gpt-5.4".to_string(),
105        "--dangerously-bypass-approvals-and-sandbox".to_string(),
106    ];
107
108    if let Some(tid) = thread_id {
109        args.push("resume".to_string());
110        args.push(tid.to_string());
111    }
112
113    args.push("-".to_string());
114
115    (program.to_string(), args)
116}
117
118/// Build the Codex exec command for SDK (JSONL) mode.
119///
120/// `thread_id`: if provided, resumes an existing thread for multi-turn.
121/// `prompt`: the message/task text to send.
122pub fn codex_sdk_command(program: &str, prompt: &str, thread_id: Option<&str>) -> String {
123    let escaped_prompt = prompt.replace('\'', "'\\''");
124    match thread_id {
125        Some(tid) => {
126            let escaped_tid = tid.replace('\'', "'\\''");
127            format!(
128                "exec {program} exec --json -m gpt-5.4 --dangerously-bypass-approvals-and-sandbox resume '{escaped_tid}' '{escaped_prompt}'"
129            )
130        }
131        None => {
132            format!(
133                "exec {program} exec --json -m gpt-5.4 --dangerously-bypass-approvals-and-sandbox '{escaped_prompt}'"
134            )
135        }
136    }
137}
138
139// ---------------------------------------------------------------------------
140// Tests
141// ---------------------------------------------------------------------------
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn parse_thread_started() {
149        let line = r#"{"type":"thread.started","thread_id":"abc-123"}"#;
150        let evt: CodexEvent = serde_json::from_str(line).unwrap();
151        assert_eq!(evt.event_type, "thread.started");
152        assert_eq!(evt.thread_id.as_deref(), Some("abc-123"));
153    }
154
155    #[test]
156    fn parse_turn_started() {
157        let line = r#"{"type":"turn.started"}"#;
158        let evt: CodexEvent = serde_json::from_str(line).unwrap();
159        assert_eq!(evt.event_type, "turn.started");
160    }
161
162    #[test]
163    fn parse_turn_completed() {
164        let line = r#"{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":50,"output_tokens":30}}"#;
165        let evt: CodexEvent = serde_json::from_str(line).unwrap();
166        assert_eq!(evt.event_type, "turn.completed");
167        let usage = evt.usage.unwrap();
168        assert_eq!(usage.input_tokens, 100);
169        assert_eq!(usage.output_tokens, 30);
170    }
171
172    #[test]
173    fn parse_turn_failed() {
174        let line = r#"{"type":"turn.failed","error":{"message":"rate limit"}}"#;
175        let evt: CodexEvent = serde_json::from_str(line).unwrap();
176        assert_eq!(evt.event_type, "turn.failed");
177        assert_eq!(evt.error.unwrap().message, "rate limit");
178    }
179
180    #[test]
181    fn parse_item_agent_message() {
182        let line = r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Hello world"}}"#;
183        let evt: CodexEvent = serde_json::from_str(line).unwrap();
184        assert_eq!(evt.event_type, "item.completed");
185        let item = evt.item.unwrap();
186        assert_eq!(item.item_type, "agent_message");
187        assert_eq!(item.agent_text(), Some("Hello world"));
188    }
189
190    #[test]
191    fn parse_item_command_execution() {
192        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"}}"#;
193        let evt: CodexEvent = serde_json::from_str(line).unwrap();
194        let item = evt.item.unwrap();
195        assert_eq!(item.item_type, "command_execution");
196        assert_eq!(item.command.as_deref(), Some("ls"));
197        assert_eq!(item.exit_code, Some(0));
198        assert!(item.agent_text().is_none());
199    }
200
201    #[test]
202    fn parse_error_event() {
203        let line = r#"{"type":"error","message":"fatal"}"#;
204        // The flat struct puts message at top level for the error event type;
205        // but the Codex format uses an error object. Let's handle both.
206        let evt: CodexEvent = serde_json::from_str(line).unwrap();
207        assert_eq!(evt.event_type, "error");
208    }
209
210    #[test]
211    fn unknown_event_type_tolerated() {
212        let line = r#"{"type":"future.event","some_field":42}"#;
213        let evt: CodexEvent = serde_json::from_str(line).unwrap();
214        assert_eq!(evt.event_type, "future.event");
215    }
216
217    #[test]
218    fn codex_sdk_command_new_session() {
219        let cmd = codex_sdk_command("codex", "fix the bug", None);
220        assert!(cmd.contains("exec codex exec --json"));
221        assert!(cmd.contains("-m gpt-5.4"));
222        assert!(cmd.contains("--dangerously-bypass-approvals-and-sandbox"));
223        assert!(cmd.contains("'fix the bug'"));
224        assert!(!cmd.contains("resume"));
225    }
226
227    #[test]
228    fn codex_sdk_command_resume() {
229        let cmd = codex_sdk_command("codex", "next step", Some("tid-123"));
230        assert!(cmd.contains("-m gpt-5.4"));
231        assert!(cmd.contains("resume 'tid-123'"));
232        assert!(cmd.contains("'next step'"));
233    }
234
235    #[test]
236    fn codex_sdk_command_escapes_quotes() {
237        let cmd = codex_sdk_command("codex", "fix user's bug", None);
238        assert!(cmd.contains("user'\\''s"));
239    }
240
241    #[test]
242    fn codex_sdk_args_new_session() {
243        let (program, args) = codex_sdk_args("codex", None);
244        assert_eq!(program, "codex");
245        assert_eq!(
246            args,
247            vec![
248                "exec",
249                "--json",
250                "-m",
251                "gpt-5.4",
252                "--dangerously-bypass-approvals-and-sandbox",
253                "-"
254            ]
255        );
256    }
257
258    #[test]
259    fn codex_sdk_args_resume() {
260        let (program, args) = codex_sdk_args("codex", Some("tid-123"));
261        assert_eq!(program, "codex");
262        assert_eq!(
263            args,
264            vec![
265                "exec",
266                "--json",
267                "-m",
268                "gpt-5.4",
269                "--dangerously-bypass-approvals-and-sandbox",
270                "resume",
271                "tid-123",
272                "-"
273            ]
274        );
275    }
276}