Skip to main content

codex_codes/io/
items.rs

1//! Thread item types shared between the exec and app-server protocols.
2//!
3//! A [`ThreadItem`] represents a single unit of work within a turn — an agent
4//! message, a command execution, a file change, etc. Items are emitted via
5//! `item/started` and `item/completed` notifications and included in the
6//! final [`Turn`](crate::Turn) when a turn completes.
7//!
8//! Both snake_case (exec protocol) and camelCase (app-server protocol) type
9//! tags are accepted via serde aliases.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14/// Status of a command execution within a [`CommandExecutionItem`].
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum CommandExecutionStatus {
18    /// The command is currently running.
19    #[serde(alias = "inProgress")]
20    InProgress,
21    /// The command finished successfully.
22    #[serde(alias = "completed")]
23    Completed,
24    /// The command failed (non-zero exit code or error).
25    #[serde(alias = "failed")]
26    Failed,
27    /// The user declined the approval request for this command.
28    #[serde(alias = "declined")]
29    Declined,
30}
31
32/// A command execution item — a shell command the agent ran.
33///
34/// The exec JSONL protocol uses snake_case (`aggregated_output`, `exit_code`)
35/// while the app-server protocol uses camelCase (`aggregatedOutput`, `exitCode`)
36/// and may emit `null` for missing output. Fields below carry serde aliases so
37/// both formats deserialize cleanly.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct CommandExecutionItem {
41    pub id: String,
42    /// The shell command that was executed.
43    pub command: String,
44    /// Combined stdout/stderr output from the command. `None` while still in
45    /// progress on the app-server protocol; the exec protocol uses an empty
46    /// string for the same state.
47    #[serde(alias = "aggregated_output", default)]
48    pub aggregated_output: Option<String>,
49    /// Exit code, if the command has finished.
50    #[serde(alias = "exit_code", default, skip_serializing_if = "Option::is_none")]
51    pub exit_code: Option<i32>,
52    pub status: CommandExecutionStatus,
53}
54
55/// Kind of patch change applied to a file.
56///
57/// Internally-tagged on the wire under the `type` discriminator
58/// (`{"type":"add"}`, `{"type":"delete"}`, `{"type":"update","move_path":...}`).
59/// Older codex versions emitted bare strings (`"update"`); those are no longer
60/// accepted — regenerate test fixtures against a current codex CLI if needed.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(tag = "type", rename_all = "snake_case")]
63pub enum PatchChangeKind {
64    Add,
65    Delete,
66    Update {
67        /// Set when the patch renames the file to this path.
68        #[serde(default, skip_serializing_if = "Option::is_none")]
69        move_path: Option<String>,
70    },
71}
72
73/// A single file update within a file change item.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct FileUpdateChange {
76    pub path: String,
77    pub kind: PatchChangeKind,
78    /// Unified-diff snippet describing the change.
79    #[serde(default)]
80    pub diff: String,
81}
82
83/// Status of a patch apply operation.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub enum PatchApplyStatus {
87    /// Patch is currently being applied. Surfaced on `item/started`.
88    #[serde(alias = "in_progress")]
89    InProgress,
90    #[serde(alias = "completed")]
91    Completed,
92    #[serde(alias = "failed")]
93    Failed,
94    /// The user declined the file-change approval. Surfaced on
95    /// `item/completed` after a rejected approval.
96    #[serde(alias = "declined")]
97    Declined,
98}
99
100/// A file change item representing one or more file modifications.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct FileChangeItem {
103    pub id: String,
104    pub changes: Vec<FileUpdateChange>,
105    pub status: PatchApplyStatus,
106}
107
108/// Status of an MCP tool call.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum McpToolCallStatus {
112    #[serde(alias = "inProgress")]
113    InProgress,
114    #[serde(alias = "completed")]
115    Completed,
116    #[serde(alias = "failed")]
117    Failed,
118}
119
120/// Result of an MCP tool call.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct McpToolCallResult {
123    pub content: Vec<Value>,
124    pub structured_content: Value,
125}
126
127/// Error from an MCP tool call.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct McpToolCallError {
130    pub message: String,
131}
132
133/// An MCP tool call item.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct McpToolCallItem {
136    pub id: String,
137    pub server: String,
138    pub tool: String,
139    pub arguments: Value,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub result: Option<McpToolCallResult>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub error: Option<McpToolCallError>,
144    pub status: McpToolCallStatus,
145}
146
147/// An agent message item containing text output.
148///
149/// `text` may be empty (or absent on the wire) for `item/started` events on
150/// the app-server protocol — codex emits the message envelope before any
151/// tokens have been generated.
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct AgentMessageItem {
154    pub id: String,
155    #[serde(default)]
156    pub text: String,
157}
158
159/// A single content block within a [`UserMessageItem`].
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct UserMessageContent {
162    /// Block kind tag (e.g. `"text"`).
163    #[serde(rename = "type")]
164    pub kind: String,
165    /// The text content.
166    #[serde(default)]
167    pub text: String,
168    /// Tokenized/structured representation of the text. Shape varies by
169    /// codex version, so it's preserved as raw JSON.
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub text_elements: Vec<Value>,
172}
173
174/// A user message item — the prompt the user sent for the current turn.
175///
176/// Emitted by the app-server as the first item in a turn. The exec JSONL
177/// protocol does not typically emit this item kind.
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct UserMessageItem {
180    pub id: String,
181    pub content: Vec<UserMessageContent>,
182}
183
184/// A reasoning item containing the model's chain-of-thought.
185///
186/// `text` may be empty on `item/started` events; populated by the time
187/// `item/completed` arrives.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct ReasoningItem {
190    pub id: String,
191    #[serde(default)]
192    pub text: String,
193}
194
195/// A web search item.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct WebSearchItem {
198    pub id: String,
199    pub query: String,
200}
201
202/// An error item.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct ErrorItem {
205    pub id: String,
206    pub message: String,
207}
208
209/// A single todo entry within a todo list.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct TodoItem {
212    pub text: String,
213    pub completed: bool,
214}
215
216/// A todo list item.
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct TodoListItem {
219    pub id: String,
220    pub items: Vec<TodoItem>,
221}
222
223/// All possible thread item types emitted by the Codex CLI.
224///
225/// Items are the core building blocks of a turn. Each variant represents
226/// a different kind of work the agent performed. Items arrive via
227/// `item/started` and `item/completed` notifications and are collected
228/// in the final [`Turn`](crate::Turn).
229///
230/// Accepts both snake_case (`agent_message`) and camelCase (`agentMessage`)
231/// type tags for compatibility with the exec and app-server protocols.
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[serde(tag = "type", rename_all = "snake_case")]
234pub enum ThreadItem {
235    /// The user's prompt for the turn (app-server protocol only).
236    #[serde(alias = "userMessage")]
237    UserMessage(UserMessageItem),
238    /// Text output from the agent.
239    #[serde(alias = "agentMessage")]
240    AgentMessage(AgentMessageItem),
241    /// Chain-of-thought reasoning from the model.
242    #[serde(alias = "reasoning")]
243    Reasoning(ReasoningItem),
244    /// A shell command the agent executed.
245    #[serde(alias = "commandExecution")]
246    CommandExecution(CommandExecutionItem),
247    /// File modifications the agent applied.
248    #[serde(alias = "fileChange")]
249    FileChange(FileChangeItem),
250    /// An MCP tool call to an external server.
251    #[serde(alias = "mcpToolCall")]
252    McpToolCall(McpToolCallItem),
253    /// A web search the agent performed.
254    #[serde(alias = "webSearch")]
255    WebSearch(WebSearchItem),
256    /// A todo list the agent maintains.
257    #[serde(alias = "todoList")]
258    TodoList(TodoListItem),
259    /// An error that occurred during processing.
260    #[serde(alias = "error")]
261    Error(ErrorItem),
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_deserialize_agent_message() {
270        let json = r#"{"type":"agent_message","id":"msg_1","text":"Hello world"}"#;
271        let item: ThreadItem = serde_json::from_str(json).unwrap();
272        assert!(matches!(item, ThreadItem::AgentMessage(ref m) if m.text == "Hello world"));
273    }
274
275    #[test]
276    fn test_deserialize_command_execution() {
277        let json = r#"{"type":"command_execution","id":"cmd_1","command":"ls -la","aggregated_output":"total 0","exit_code":0,"status":"completed"}"#;
278        let item: ThreadItem = serde_json::from_str(json).unwrap();
279        assert!(matches!(item, ThreadItem::CommandExecution(ref c) if c.exit_code == Some(0)));
280    }
281
282    #[test]
283    fn test_deserialize_file_change() {
284        let json = r#"{"type":"file_change","id":"fc_1","changes":[{"path":"src/main.rs","kind":{"type":"update"},"diff":"@@ -1 +1 @@\n-a\n+b\n"}],"status":"completed"}"#;
285        let item: ThreadItem = serde_json::from_str(json).unwrap();
286        assert!(matches!(
287            item,
288            ThreadItem::FileChange(ref f) if matches!(f.changes[0].kind, PatchChangeKind::Update { .. })
289        ));
290    }
291
292    #[test]
293    fn test_deserialize_todo_list() {
294        let json = r#"{"type":"todo_list","id":"td_1","items":[{"text":"Fix bug","completed":false},{"text":"Write tests","completed":true}]}"#;
295        let item: ThreadItem = serde_json::from_str(json).unwrap();
296        assert!(matches!(item, ThreadItem::TodoList(ref t) if t.items.len() == 2));
297    }
298
299    #[test]
300    fn test_deserialize_error() {
301        let json = r#"{"type":"error","id":"err_1","message":"something went wrong"}"#;
302        let item: ThreadItem = serde_json::from_str(json).unwrap();
303        assert!(matches!(item, ThreadItem::Error(ref e) if e.message == "something went wrong"));
304    }
305
306    #[test]
307    fn test_deserialize_reasoning() {
308        let json = r#"{"type":"reasoning","id":"r_1","text":"Let me think about this..."}"#;
309        let item: ThreadItem = serde_json::from_str(json).unwrap();
310        assert!(matches!(item, ThreadItem::Reasoning(ref r) if r.text.contains("think")));
311    }
312
313    #[test]
314    fn test_deserialize_web_search() {
315        let json = r#"{"type":"web_search","id":"ws_1","query":"rust serde tutorial"}"#;
316        let item: ThreadItem = serde_json::from_str(json).unwrap();
317        assert!(matches!(item, ThreadItem::WebSearch(ref w) if w.query == "rust serde tutorial"));
318    }
319
320    #[test]
321    fn test_deserialize_mcp_tool_call() {
322        let json = r#"{"type":"mcp_tool_call","id":"mcp_1","server":"my-server","tool":"my-tool","arguments":{"key":"value"},"status":"completed","result":{"content":[],"structured_content":null}}"#;
323        let item: ThreadItem = serde_json::from_str(json).unwrap();
324        assert!(matches!(item, ThreadItem::McpToolCall(ref m) if m.tool == "my-tool"));
325    }
326
327    #[test]
328    fn test_deserialize_camel_case_agent_message() {
329        let json = r#"{"type":"agentMessage","id":"msg_1","text":"Hello"}"#;
330        let item: ThreadItem = serde_json::from_str(json).unwrap();
331        assert!(matches!(item, ThreadItem::AgentMessage(ref m) if m.text == "Hello"));
332    }
333
334    #[test]
335    fn test_deserialize_camel_case_command_execution() {
336        let json = r#"{"type":"commandExecution","id":"cmd_1","command":"ls","aggregated_output":"","status":"completed"}"#;
337        let item: ThreadItem = serde_json::from_str(json).unwrap();
338        assert!(matches!(item, ThreadItem::CommandExecution(_)));
339    }
340
341    #[test]
342    fn test_deserialize_camel_case_file_change() {
343        let json = r#"{"type":"fileChange","id":"fc_1","changes":[],"status":"completed"}"#;
344        let item: ThreadItem = serde_json::from_str(json).unwrap();
345        assert!(matches!(item, ThreadItem::FileChange(_)));
346    }
347
348    #[test]
349    fn test_command_execution_status_declined() {
350        let json = r#"{"type":"command_execution","id":"cmd_1","command":"rm -rf /","aggregated_output":"","status":"declined"}"#;
351        let item: ThreadItem = serde_json::from_str(json).unwrap();
352        assert!(
353            matches!(item, ThreadItem::CommandExecution(ref c) if c.status == CommandExecutionStatus::Declined)
354        );
355    }
356}