Skip to main content

apiari_codex_sdk/
types.rs

1//! Protocol types for the Codex CLI `exec --json` JSONL output.
2//!
3//! When the CLI is invoked with `codex exec --json`, every line on stdout is a
4//! JSON object whose `"type"` field determines the variant.
5//!
6//! | `type`             | Rust variant      | Description                              |
7//! |--------------------|-------------------|------------------------------------------|
8//! | `thread.started`   | `ThreadStarted`   | Thread ID assigned for this execution.   |
9//! | `turn.started`     | `TurnStarted`     | A new turn has begun.                    |
10//! | `turn.completed`   | `TurnCompleted`   | Turn finished successfully.              |
11//! | `turn.failed`      | `TurnFailed`      | Turn failed with an error.               |
12//! | `item.started`     | `ItemStarted`     | An item (message, command, etc.) began.  |
13//! | `item.updated`     | `ItemUpdated`     | Incremental update to an in-flight item. |
14//! | `item.completed`   | `ItemCompleted`   | An item finished.                        |
15//! | `token_count`      | `TokenCount`      | Token usage statistics.                  |
16//! | `error`            | `Error`           | Execution-level error.                   |
17
18use serde::{Deserialize, Serialize};
19
20// ---------------------------------------------------------------------------
21// Top-level event envelope
22// ---------------------------------------------------------------------------
23
24/// A single JSONL event read from `codex exec --json` stdout.
25///
26/// Deserialized via `#[serde(tag = "type")]` so the `"type"` field selects
27/// the variant. Unknown event types deserialize as [`Event::Unknown`].
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type")]
30pub enum Event {
31    /// A thread has been created for this execution.
32    #[serde(rename = "thread.started")]
33    ThreadStarted {
34        /// The thread identifier.
35        thread_id: String,
36    },
37
38    /// A new turn has started.
39    #[serde(rename = "turn.started")]
40    TurnStarted,
41
42    /// A turn completed successfully.
43    #[serde(rename = "turn.completed")]
44    TurnCompleted {
45        /// Token usage for this turn.
46        #[serde(default)]
47        usage: Option<Usage>,
48    },
49
50    /// A turn failed.
51    #[serde(rename = "turn.failed")]
52    TurnFailed {
53        /// Token usage for this turn (may still be reported on failure).
54        #[serde(default)]
55        usage: Option<Usage>,
56        /// Error details.
57        #[serde(default)]
58        error: Option<ThreadError>,
59    },
60
61    /// An item has started (message, command, file change, etc.).
62    #[serde(rename = "item.started")]
63    ItemStarted {
64        /// The item being started.
65        item: Item,
66    },
67
68    /// Incremental update to an in-flight item.
69    #[serde(rename = "item.updated")]
70    ItemUpdated {
71        /// The item with updated content.
72        item: Item,
73    },
74
75    /// An item has completed.
76    #[serde(rename = "item.completed")]
77    ItemCompleted {
78        /// The completed item.
79        item: Item,
80    },
81
82    /// Token usage statistics.
83    #[serde(rename = "token_count")]
84    TokenCount {
85        /// Number of input tokens.
86        #[serde(default)]
87        input_tokens: u64,
88        /// Number of cached input tokens.
89        #[serde(default)]
90        cached_input_tokens: u64,
91        /// Number of output tokens.
92        #[serde(default)]
93        output_tokens: u64,
94    },
95
96    /// An execution-level error.
97    #[serde(rename = "error")]
98    Error {
99        /// Error message.
100        #[serde(default)]
101        message: Option<String>,
102    },
103
104    /// Forward-compatibility: any unrecognized event type.
105    #[serde(other)]
106    Unknown,
107}
108
109// ---------------------------------------------------------------------------
110// Items
111// ---------------------------------------------------------------------------
112
113/// An item within a codex execution turn.
114///
115/// Items represent the model's actions: generating text, executing commands,
116/// modifying files, etc. Each item goes through started -> updated* -> completed.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(tag = "type", rename_all = "snake_case")]
119pub enum Item {
120    /// A text message from the agent.
121    AgentMessage {
122        /// Unique item identifier.
123        #[serde(default)]
124        id: Option<String>,
125        /// The message text.
126        #[serde(default)]
127        text: Option<String>,
128    },
129
130    /// Reasoning / chain-of-thought text.
131    Reasoning {
132        /// Unique item identifier.
133        #[serde(default)]
134        id: Option<String>,
135        /// The reasoning text.
136        #[serde(default)]
137        text: Option<String>,
138    },
139
140    /// A shell command execution.
141    CommandExecution {
142        /// Unique item identifier.
143        #[serde(default)]
144        id: Option<String>,
145        /// The command that was executed.
146        #[serde(default)]
147        command: Option<String>,
148        /// Aggregated stdout/stderr output from the command.
149        #[serde(default)]
150        aggregated_output: Option<String>,
151        /// Exit code of the command.
152        #[serde(default)]
153        exit_code: Option<i32>,
154        /// Execution status (e.g. "completed", "running").
155        #[serde(default)]
156        status: Option<String>,
157    },
158
159    /// A file modification.
160    FileChange {
161        /// Unique item identifier.
162        #[serde(default)]
163        id: Option<String>,
164        /// The individual file changes.
165        #[serde(default)]
166        changes: Vec<FileUpdateChange>,
167        /// Status of the file change.
168        #[serde(default)]
169        status: Option<String>,
170    },
171
172    /// An MCP tool invocation.
173    McpToolCall {
174        /// Unique item identifier.
175        #[serde(default)]
176        id: Option<String>,
177        /// The MCP server name.
178        #[serde(default)]
179        server: Option<String>,
180        /// The tool name.
181        #[serde(default)]
182        tool: Option<String>,
183        /// Execution status.
184        #[serde(default)]
185        status: Option<String>,
186    },
187
188    /// A web search query.
189    WebSearch {
190        /// Unique item identifier.
191        #[serde(default)]
192        id: Option<String>,
193        /// The search query.
194        #[serde(default)]
195        query: Option<String>,
196    },
197
198    /// A todo/task list.
199    TodoList {
200        /// Unique item identifier.
201        #[serde(default)]
202        id: Option<String>,
203        /// The todo items.
204        #[serde(default)]
205        items: Vec<TodoItem>,
206    },
207
208    /// An item-level error.
209    Error {
210        /// Unique item identifier.
211        #[serde(default)]
212        id: Option<String>,
213        /// Error message.
214        #[serde(default)]
215        message: Option<String>,
216    },
217
218    /// Forward-compatibility: any unrecognized item type.
219    #[serde(other)]
220    Unknown,
221}
222
223// ---------------------------------------------------------------------------
224// Supporting types
225// ---------------------------------------------------------------------------
226
227/// Token usage statistics.
228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct Usage {
230    /// Number of input tokens.
231    #[serde(default)]
232    pub input_tokens: u64,
233    /// Number of output tokens.
234    #[serde(default)]
235    pub output_tokens: u64,
236    /// Number of cached input tokens.
237    #[serde(default)]
238    pub cached_input_tokens: u64,
239    /// Total tokens (input + output).
240    #[serde(default)]
241    pub total_tokens: u64,
242}
243
244/// Error information from a failed turn.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct ThreadError {
247    /// Error message.
248    #[serde(default)]
249    pub message: Option<String>,
250    /// Error code.
251    #[serde(default)]
252    pub code: Option<String>,
253}
254
255/// A single file change within a [`Item::FileChange`].
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct FileUpdateChange {
258    /// Path to the modified file.
259    #[serde(default)]
260    pub file_path: Option<String>,
261    /// Original file content (before the change).
262    #[serde(default)]
263    pub old_content: Option<String>,
264    /// New file content (after the change).
265    #[serde(default)]
266    pub new_content: Option<String>,
267}
268
269/// A single item in a [`Item::TodoList`].
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct TodoItem {
272    /// The todo item text.
273    #[serde(default)]
274    pub text: Option<String>,
275    /// Whether the item is completed.
276    #[serde(default)]
277    pub completed: bool,
278}
279
280// ---------------------------------------------------------------------------
281// Helpers
282// ---------------------------------------------------------------------------
283
284impl Event {
285    /// Returns `true` if this is a [`Event::ThreadStarted`].
286    pub fn is_thread_started(&self) -> bool {
287        matches!(self, Event::ThreadStarted { .. })
288    }
289
290    /// Returns `true` if this is a [`Event::TurnCompleted`].
291    pub fn is_turn_completed(&self) -> bool {
292        matches!(self, Event::TurnCompleted { .. })
293    }
294
295    /// Returns `true` if this is a [`Event::TurnFailed`].
296    pub fn is_turn_failed(&self) -> bool {
297        matches!(self, Event::TurnFailed { .. })
298    }
299
300    /// Returns `true` if this is an [`Event::Error`].
301    pub fn is_error(&self) -> bool {
302        matches!(self, Event::Error { .. })
303    }
304
305    /// Returns `true` if this is an [`Event::ItemCompleted`].
306    pub fn is_item_completed(&self) -> bool {
307        matches!(self, Event::ItemCompleted { .. })
308    }
309
310    /// Extract the item from an ItemStarted, ItemUpdated, or ItemCompleted event.
311    pub fn item(&self) -> Option<&Item> {
312        match self {
313            Event::ItemStarted { item }
314            | Event::ItemUpdated { item }
315            | Event::ItemCompleted { item } => Some(item),
316            _ => None,
317        }
318    }
319}
320
321impl Item {
322    /// Get the item ID, if present.
323    pub fn id(&self) -> Option<&str> {
324        match self {
325            Item::AgentMessage { id, .. }
326            | Item::Reasoning { id, .. }
327            | Item::CommandExecution { id, .. }
328            | Item::FileChange { id, .. }
329            | Item::McpToolCall { id, .. }
330            | Item::WebSearch { id, .. }
331            | Item::TodoList { id, .. }
332            | Item::Error { id, .. } => id.as_deref(),
333            Item::Unknown => None,
334        }
335    }
336
337    /// Get the text content for message/reasoning items.
338    pub fn text(&self) -> Option<&str> {
339        match self {
340            Item::AgentMessage { text, .. } | Item::Reasoning { text, .. } => text.as_deref(),
341            _ => None,
342        }
343    }
344}
345
346// ---------------------------------------------------------------------------
347// Tests
348// ---------------------------------------------------------------------------
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn deserialize_thread_started() {
356        let json = r#"{"type":"thread.started","thread_id":"thread_abc123"}"#;
357        let event: Event = serde_json::from_str(json).unwrap();
358        match event {
359            Event::ThreadStarted { thread_id } => assert_eq!(thread_id, "thread_abc123"),
360            other => panic!("expected ThreadStarted, got {other:?}"),
361        }
362    }
363
364    #[test]
365    fn deserialize_turn_started() {
366        let json = r#"{"type":"turn.started"}"#;
367        let event: Event = serde_json::from_str(json).unwrap();
368        assert!(matches!(event, Event::TurnStarted));
369    }
370
371    #[test]
372    fn deserialize_turn_started_with_extra_fields() {
373        let json = r#"{"type":"turn.started","future_field":"hello"}"#;
374        let event: Event = serde_json::from_str(json).unwrap();
375        assert!(matches!(event, Event::TurnStarted));
376    }
377
378    #[test]
379    fn deserialize_turn_completed_with_usage() {
380        let json =
381            r#"{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":50}}"#;
382        let event: Event = serde_json::from_str(json).unwrap();
383        match event {
384            Event::TurnCompleted {
385                usage: Some(usage), ..
386            } => {
387                assert_eq!(usage.input_tokens, 100);
388                assert_eq!(usage.output_tokens, 50);
389            }
390            other => panic!("expected TurnCompleted with usage, got {other:?}"),
391        }
392    }
393
394    #[test]
395    fn deserialize_turn_completed_without_usage() {
396        let json = r#"{"type":"turn.completed"}"#;
397        let event: Event = serde_json::from_str(json).unwrap();
398        match event {
399            Event::TurnCompleted { usage: None } => {}
400            other => panic!("expected TurnCompleted without usage, got {other:?}"),
401        }
402    }
403
404    #[test]
405    fn deserialize_turn_failed() {
406        let json = r#"{"type":"turn.failed","error":{"message":"rate limited","code":"rate_limit"}}"#;
407        let event: Event = serde_json::from_str(json).unwrap();
408        match event {
409            Event::TurnFailed {
410                error: Some(ref err),
411                ..
412            } => {
413                assert_eq!(err.message.as_deref(), Some("rate limited"));
414                assert_eq!(err.code.as_deref(), Some("rate_limit"));
415            }
416            other => panic!("expected TurnFailed, got {other:?}"),
417        }
418    }
419
420    #[test]
421    fn deserialize_item_completed_agent_message() {
422        let json = r#"{"type":"item.completed","item":{"type":"agent_message","id":"msg_1","text":"Hello!"}}"#;
423        let event: Event = serde_json::from_str(json).unwrap();
424        match event {
425            Event::ItemCompleted {
426                item: Item::AgentMessage { id, text },
427            } => {
428                assert_eq!(id.as_deref(), Some("msg_1"));
429                assert_eq!(text.as_deref(), Some("Hello!"));
430            }
431            other => panic!("expected ItemCompleted with AgentMessage, got {other:?}"),
432        }
433    }
434
435    #[test]
436    fn deserialize_item_reasoning() {
437        let json =
438            r#"{"type":"item.started","item":{"type":"reasoning","id":"r_1","text":"Let me think..."}}"#;
439        let event: Event = serde_json::from_str(json).unwrap();
440        match event {
441            Event::ItemStarted {
442                item: Item::Reasoning { id, text },
443            } => {
444                assert_eq!(id.as_deref(), Some("r_1"));
445                assert_eq!(text.as_deref(), Some("Let me think..."));
446            }
447            other => panic!("expected ItemStarted with Reasoning, got {other:?}"),
448        }
449    }
450
451    #[test]
452    fn deserialize_item_command_execution() {
453        let json = r#"{"type":"item.completed","item":{"type":"command_execution","id":"cmd_1","command":"ls -la","aggregated_output":"total 42\n","exit_code":0,"status":"completed"}}"#;
454        let event: Event = serde_json::from_str(json).unwrap();
455        match event {
456            Event::ItemCompleted {
457                item:
458                    Item::CommandExecution {
459                        id,
460                        command,
461                        exit_code,
462                        status,
463                        ..
464                    },
465            } => {
466                assert_eq!(id.as_deref(), Some("cmd_1"));
467                assert_eq!(command.as_deref(), Some("ls -la"));
468                assert_eq!(exit_code, Some(0));
469                assert_eq!(status.as_deref(), Some("completed"));
470            }
471            other => panic!("expected CommandExecution, got {other:?}"),
472        }
473    }
474
475    #[test]
476    fn deserialize_item_file_change() {
477        let json = r#"{"type":"item.completed","item":{"type":"file_change","id":"fc_1","changes":[{"file_path":"src/main.rs","new_content":"fn main() {}"}],"status":"completed"}}"#;
478        let event: Event = serde_json::from_str(json).unwrap();
479        match event {
480            Event::ItemCompleted {
481                item: Item::FileChange {
482                    id, changes, status,
483                },
484            } => {
485                assert_eq!(id.as_deref(), Some("fc_1"));
486                assert_eq!(changes.len(), 1);
487                assert_eq!(changes[0].file_path.as_deref(), Some("src/main.rs"));
488                assert_eq!(status.as_deref(), Some("completed"));
489            }
490            other => panic!("expected FileChange, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn deserialize_token_count() {
496        let json = r#"{"type":"token_count","input_tokens":200,"cached_input_tokens":50,"output_tokens":100}"#;
497        let event: Event = serde_json::from_str(json).unwrap();
498        match event {
499            Event::TokenCount {
500                input_tokens,
501                cached_input_tokens,
502                output_tokens,
503            } => {
504                assert_eq!(input_tokens, 200);
505                assert_eq!(cached_input_tokens, 50);
506                assert_eq!(output_tokens, 100);
507            }
508            other => panic!("expected TokenCount, got {other:?}"),
509        }
510    }
511
512    #[test]
513    fn deserialize_error_event() {
514        let json = r#"{"type":"error","message":"something went wrong"}"#;
515        let event: Event = serde_json::from_str(json).unwrap();
516        match event {
517            Event::Error { message } => {
518                assert_eq!(message.as_deref(), Some("something went wrong"))
519            }
520            other => panic!("expected Error, got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn deserialize_unknown_event_type() {
526        let json = r#"{"type":"future.event","some_field":"value"}"#;
527        let event: Event = serde_json::from_str(json).unwrap();
528        assert!(matches!(event, Event::Unknown));
529    }
530
531    #[test]
532    fn deserialize_unknown_item_type() {
533        let json = r#"{"type":"item.completed","item":{"type":"future_item","id":"x"}}"#;
534        let event: Event = serde_json::from_str(json).unwrap();
535        match event {
536            Event::ItemCompleted {
537                item: Item::Unknown,
538            } => {}
539            other => panic!("expected ItemCompleted with Unknown item, got {other:?}"),
540        }
541    }
542
543    #[test]
544    fn item_id_helper() {
545        let item = Item::AgentMessage {
546            id: Some("msg_1".into()),
547            text: Some("hi".into()),
548        };
549        assert_eq!(item.id(), Some("msg_1"));
550        assert_eq!(Item::Unknown.id(), None);
551    }
552
553    #[test]
554    fn item_text_helper() {
555        let item = Item::Reasoning {
556            id: None,
557            text: Some("thinking...".into()),
558        };
559        assert_eq!(item.text(), Some("thinking..."));
560
561        let cmd = Item::CommandExecution {
562            id: None,
563            command: None,
564            aggregated_output: None,
565            exit_code: None,
566            status: None,
567        };
568        assert_eq!(cmd.text(), None);
569    }
570
571    #[test]
572    fn event_item_helper() {
573        let event = Event::ItemCompleted {
574            item: Item::AgentMessage {
575                id: Some("m1".into()),
576                text: Some("hello".into()),
577            },
578        };
579        assert!(event.item().is_some());
580        assert_eq!(event.item().unwrap().id(), Some("m1"));
581
582        assert!(Event::TurnStarted.item().is_none());
583    }
584}