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, 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum PatchChangeKind {
59    #[serde(alias = "add")]
60    Add,
61    #[serde(alias = "delete")]
62    Delete,
63    #[serde(alias = "update")]
64    Update,
65}
66
67/// A single file update within a file change item.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct FileUpdateChange {
70    pub path: String,
71    pub kind: PatchChangeKind,
72}
73
74/// Status of a patch apply operation.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum PatchApplyStatus {
78    #[serde(alias = "completed")]
79    Completed,
80    #[serde(alias = "failed")]
81    Failed,
82}
83
84/// A file change item representing one or more file modifications.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct FileChangeItem {
87    pub id: String,
88    pub changes: Vec<FileUpdateChange>,
89    pub status: PatchApplyStatus,
90}
91
92/// Status of an MCP tool call.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum McpToolCallStatus {
96    #[serde(alias = "inProgress")]
97    InProgress,
98    #[serde(alias = "completed")]
99    Completed,
100    #[serde(alias = "failed")]
101    Failed,
102}
103
104/// Result of an MCP tool call.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct McpToolCallResult {
107    pub content: Vec<Value>,
108    pub structured_content: Value,
109}
110
111/// Error from an MCP tool call.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct McpToolCallError {
114    pub message: String,
115}
116
117/// An MCP tool call item.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct McpToolCallItem {
120    pub id: String,
121    pub server: String,
122    pub tool: String,
123    pub arguments: Value,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub result: Option<McpToolCallResult>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub error: Option<McpToolCallError>,
128    pub status: McpToolCallStatus,
129}
130
131/// An agent message item containing text output.
132///
133/// `text` may be empty (or absent on the wire) for `item/started` events on
134/// the app-server protocol — codex emits the message envelope before any
135/// tokens have been generated.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct AgentMessageItem {
138    pub id: String,
139    #[serde(default)]
140    pub text: String,
141}
142
143/// A single content block within a [`UserMessageItem`].
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct UserMessageContent {
146    /// Block kind tag (e.g. `"text"`).
147    #[serde(rename = "type")]
148    pub kind: String,
149    /// The text content.
150    #[serde(default)]
151    pub text: String,
152    /// Tokenized/structured representation of the text. Shape varies by
153    /// codex version, so it's preserved as raw JSON.
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub text_elements: Vec<Value>,
156}
157
158/// A user message item — the prompt the user sent for the current turn.
159///
160/// Emitted by the app-server as the first item in a turn. The exec JSONL
161/// protocol does not typically emit this item kind.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct UserMessageItem {
164    pub id: String,
165    pub content: Vec<UserMessageContent>,
166}
167
168/// A reasoning item containing the model's chain-of-thought.
169///
170/// `text` may be empty on `item/started` events; populated by the time
171/// `item/completed` arrives.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ReasoningItem {
174    pub id: String,
175    #[serde(default)]
176    pub text: String,
177}
178
179/// A web search item.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct WebSearchItem {
182    pub id: String,
183    pub query: String,
184}
185
186/// An error item.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct ErrorItem {
189    pub id: String,
190    pub message: String,
191}
192
193/// A single todo entry within a todo list.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct TodoItem {
196    pub text: String,
197    pub completed: bool,
198}
199
200/// A todo list item.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct TodoListItem {
203    pub id: String,
204    pub items: Vec<TodoItem>,
205}
206
207/// All possible thread item types emitted by the Codex CLI.
208///
209/// Items are the core building blocks of a turn. Each variant represents
210/// a different kind of work the agent performed. Items arrive via
211/// `item/started` and `item/completed` notifications and are collected
212/// in the final [`Turn`](crate::Turn).
213///
214/// Accepts both snake_case (`agent_message`) and camelCase (`agentMessage`)
215/// type tags for compatibility with the exec and app-server protocols.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(tag = "type", rename_all = "snake_case")]
218pub enum ThreadItem {
219    /// The user's prompt for the turn (app-server protocol only).
220    #[serde(alias = "userMessage")]
221    UserMessage(UserMessageItem),
222    /// Text output from the agent.
223    #[serde(alias = "agentMessage")]
224    AgentMessage(AgentMessageItem),
225    /// Chain-of-thought reasoning from the model.
226    #[serde(alias = "reasoning")]
227    Reasoning(ReasoningItem),
228    /// A shell command the agent executed.
229    #[serde(alias = "commandExecution")]
230    CommandExecution(CommandExecutionItem),
231    /// File modifications the agent applied.
232    #[serde(alias = "fileChange")]
233    FileChange(FileChangeItem),
234    /// An MCP tool call to an external server.
235    #[serde(alias = "mcpToolCall")]
236    McpToolCall(McpToolCallItem),
237    /// A web search the agent performed.
238    #[serde(alias = "webSearch")]
239    WebSearch(WebSearchItem),
240    /// A todo list the agent maintains.
241    #[serde(alias = "todoList")]
242    TodoList(TodoListItem),
243    /// An error that occurred during processing.
244    #[serde(alias = "error")]
245    Error(ErrorItem),
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_deserialize_agent_message() {
254        let json = r#"{"type":"agent_message","id":"msg_1","text":"Hello world"}"#;
255        let item: ThreadItem = serde_json::from_str(json).unwrap();
256        assert!(matches!(item, ThreadItem::AgentMessage(ref m) if m.text == "Hello world"));
257    }
258
259    #[test]
260    fn test_deserialize_command_execution() {
261        let json = r#"{"type":"command_execution","id":"cmd_1","command":"ls -la","aggregated_output":"total 0","exit_code":0,"status":"completed"}"#;
262        let item: ThreadItem = serde_json::from_str(json).unwrap();
263        assert!(matches!(item, ThreadItem::CommandExecution(ref c) if c.exit_code == Some(0)));
264    }
265
266    #[test]
267    fn test_deserialize_file_change() {
268        let json = r#"{"type":"file_change","id":"fc_1","changes":[{"path":"src/main.rs","kind":"update"}],"status":"completed"}"#;
269        let item: ThreadItem = serde_json::from_str(json).unwrap();
270        assert!(
271            matches!(item, ThreadItem::FileChange(ref f) if f.changes[0].kind == PatchChangeKind::Update)
272        );
273    }
274
275    #[test]
276    fn test_deserialize_todo_list() {
277        let json = r#"{"type":"todo_list","id":"td_1","items":[{"text":"Fix bug","completed":false},{"text":"Write tests","completed":true}]}"#;
278        let item: ThreadItem = serde_json::from_str(json).unwrap();
279        assert!(matches!(item, ThreadItem::TodoList(ref t) if t.items.len() == 2));
280    }
281
282    #[test]
283    fn test_deserialize_error() {
284        let json = r#"{"type":"error","id":"err_1","message":"something went wrong"}"#;
285        let item: ThreadItem = serde_json::from_str(json).unwrap();
286        assert!(matches!(item, ThreadItem::Error(ref e) if e.message == "something went wrong"));
287    }
288
289    #[test]
290    fn test_deserialize_reasoning() {
291        let json = r#"{"type":"reasoning","id":"r_1","text":"Let me think about this..."}"#;
292        let item: ThreadItem = serde_json::from_str(json).unwrap();
293        assert!(matches!(item, ThreadItem::Reasoning(ref r) if r.text.contains("think")));
294    }
295
296    #[test]
297    fn test_deserialize_web_search() {
298        let json = r#"{"type":"web_search","id":"ws_1","query":"rust serde tutorial"}"#;
299        let item: ThreadItem = serde_json::from_str(json).unwrap();
300        assert!(matches!(item, ThreadItem::WebSearch(ref w) if w.query == "rust serde tutorial"));
301    }
302
303    #[test]
304    fn test_deserialize_mcp_tool_call() {
305        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}}"#;
306        let item: ThreadItem = serde_json::from_str(json).unwrap();
307        assert!(matches!(item, ThreadItem::McpToolCall(ref m) if m.tool == "my-tool"));
308    }
309
310    #[test]
311    fn test_deserialize_camel_case_agent_message() {
312        let json = r#"{"type":"agentMessage","id":"msg_1","text":"Hello"}"#;
313        let item: ThreadItem = serde_json::from_str(json).unwrap();
314        assert!(matches!(item, ThreadItem::AgentMessage(ref m) if m.text == "Hello"));
315    }
316
317    #[test]
318    fn test_deserialize_camel_case_command_execution() {
319        let json = r#"{"type":"commandExecution","id":"cmd_1","command":"ls","aggregated_output":"","status":"completed"}"#;
320        let item: ThreadItem = serde_json::from_str(json).unwrap();
321        assert!(matches!(item, ThreadItem::CommandExecution(_)));
322    }
323
324    #[test]
325    fn test_deserialize_camel_case_file_change() {
326        let json = r#"{"type":"fileChange","id":"fc_1","changes":[],"status":"completed"}"#;
327        let item: ThreadItem = serde_json::from_str(json).unwrap();
328        assert!(matches!(item, ThreadItem::FileChange(_)));
329    }
330
331    #[test]
332    fn test_command_execution_status_declined() {
333        let json = r#"{"type":"command_execution","id":"cmd_1","command":"rm -rf /","aggregated_output":"","status":"declined"}"#;
334        let item: ThreadItem = serde_json::from_str(json).unwrap();
335        assert!(
336            matches!(item, ThreadItem::CommandExecution(ref c) if c.status == CommandExecutionStatus::Declined)
337        );
338    }
339}