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#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CommandExecutionItem {
35    pub id: String,
36    /// The shell command that was executed.
37    pub command: String,
38    /// Combined stdout/stderr output from the command.
39    pub aggregated_output: String,
40    /// Exit code, if the command has finished.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub exit_code: Option<i32>,
43    pub status: CommandExecutionStatus,
44}
45
46/// Kind of patch change applied to a file.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum PatchChangeKind {
50    #[serde(alias = "add")]
51    Add,
52    #[serde(alias = "delete")]
53    Delete,
54    #[serde(alias = "update")]
55    Update,
56}
57
58/// A single file update within a file change item.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct FileUpdateChange {
61    pub path: String,
62    pub kind: PatchChangeKind,
63}
64
65/// Status of a patch apply operation.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum PatchApplyStatus {
69    #[serde(alias = "completed")]
70    Completed,
71    #[serde(alias = "failed")]
72    Failed,
73}
74
75/// A file change item representing one or more file modifications.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct FileChangeItem {
78    pub id: String,
79    pub changes: Vec<FileUpdateChange>,
80    pub status: PatchApplyStatus,
81}
82
83/// Status of an MCP tool call.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum McpToolCallStatus {
87    #[serde(alias = "inProgress")]
88    InProgress,
89    #[serde(alias = "completed")]
90    Completed,
91    #[serde(alias = "failed")]
92    Failed,
93}
94
95/// Result of an MCP tool call.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct McpToolCallResult {
98    pub content: Vec<Value>,
99    pub structured_content: Value,
100}
101
102/// Error from an MCP tool call.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct McpToolCallError {
105    pub message: String,
106}
107
108/// An MCP tool call item.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct McpToolCallItem {
111    pub id: String,
112    pub server: String,
113    pub tool: String,
114    pub arguments: Value,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub result: Option<McpToolCallResult>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub error: Option<McpToolCallError>,
119    pub status: McpToolCallStatus,
120}
121
122/// An agent message item containing text output.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct AgentMessageItem {
125    pub id: String,
126    pub text: String,
127}
128
129/// A reasoning item containing the model's chain-of-thought.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ReasoningItem {
132    pub id: String,
133    pub text: String,
134}
135
136/// A web search item.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct WebSearchItem {
139    pub id: String,
140    pub query: String,
141}
142
143/// An error item.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ErrorItem {
146    pub id: String,
147    pub message: String,
148}
149
150/// A single todo entry within a todo list.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct TodoItem {
153    pub text: String,
154    pub completed: bool,
155}
156
157/// A todo list item.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct TodoListItem {
160    pub id: String,
161    pub items: Vec<TodoItem>,
162}
163
164/// All possible thread item types emitted by the Codex CLI.
165///
166/// Items are the core building blocks of a turn. Each variant represents
167/// a different kind of work the agent performed. Items arrive via
168/// `item/started` and `item/completed` notifications and are collected
169/// in the final [`Turn`](crate::Turn).
170///
171/// Accepts both snake_case (`agent_message`) and camelCase (`agentMessage`)
172/// type tags for compatibility with the exec and app-server protocols.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(tag = "type", rename_all = "snake_case")]
175pub enum ThreadItem {
176    /// Text output from the agent.
177    #[serde(alias = "agentMessage")]
178    AgentMessage(AgentMessageItem),
179    /// Chain-of-thought reasoning from the model.
180    #[serde(alias = "reasoning")]
181    Reasoning(ReasoningItem),
182    /// A shell command the agent executed.
183    #[serde(alias = "commandExecution")]
184    CommandExecution(CommandExecutionItem),
185    /// File modifications the agent applied.
186    #[serde(alias = "fileChange")]
187    FileChange(FileChangeItem),
188    /// An MCP tool call to an external server.
189    #[serde(alias = "mcpToolCall")]
190    McpToolCall(McpToolCallItem),
191    /// A web search the agent performed.
192    #[serde(alias = "webSearch")]
193    WebSearch(WebSearchItem),
194    /// A todo list the agent maintains.
195    #[serde(alias = "todoList")]
196    TodoList(TodoListItem),
197    /// An error that occurred during processing.
198    #[serde(alias = "error")]
199    Error(ErrorItem),
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_deserialize_agent_message() {
208        let json = r#"{"type":"agent_message","id":"msg_1","text":"Hello world"}"#;
209        let item: ThreadItem = serde_json::from_str(json).unwrap();
210        assert!(matches!(item, ThreadItem::AgentMessage(ref m) if m.text == "Hello world"));
211    }
212
213    #[test]
214    fn test_deserialize_command_execution() {
215        let json = r#"{"type":"command_execution","id":"cmd_1","command":"ls -la","aggregated_output":"total 0","exit_code":0,"status":"completed"}"#;
216        let item: ThreadItem = serde_json::from_str(json).unwrap();
217        assert!(matches!(item, ThreadItem::CommandExecution(ref c) if c.exit_code == Some(0)));
218    }
219
220    #[test]
221    fn test_deserialize_file_change() {
222        let json = r#"{"type":"file_change","id":"fc_1","changes":[{"path":"src/main.rs","kind":"update"}],"status":"completed"}"#;
223        let item: ThreadItem = serde_json::from_str(json).unwrap();
224        assert!(
225            matches!(item, ThreadItem::FileChange(ref f) if f.changes[0].kind == PatchChangeKind::Update)
226        );
227    }
228
229    #[test]
230    fn test_deserialize_todo_list() {
231        let json = r#"{"type":"todo_list","id":"td_1","items":[{"text":"Fix bug","completed":false},{"text":"Write tests","completed":true}]}"#;
232        let item: ThreadItem = serde_json::from_str(json).unwrap();
233        assert!(matches!(item, ThreadItem::TodoList(ref t) if t.items.len() == 2));
234    }
235
236    #[test]
237    fn test_deserialize_error() {
238        let json = r#"{"type":"error","id":"err_1","message":"something went wrong"}"#;
239        let item: ThreadItem = serde_json::from_str(json).unwrap();
240        assert!(matches!(item, ThreadItem::Error(ref e) if e.message == "something went wrong"));
241    }
242
243    #[test]
244    fn test_deserialize_reasoning() {
245        let json = r#"{"type":"reasoning","id":"r_1","text":"Let me think about this..."}"#;
246        let item: ThreadItem = serde_json::from_str(json).unwrap();
247        assert!(matches!(item, ThreadItem::Reasoning(ref r) if r.text.contains("think")));
248    }
249
250    #[test]
251    fn test_deserialize_web_search() {
252        let json = r#"{"type":"web_search","id":"ws_1","query":"rust serde tutorial"}"#;
253        let item: ThreadItem = serde_json::from_str(json).unwrap();
254        assert!(matches!(item, ThreadItem::WebSearch(ref w) if w.query == "rust serde tutorial"));
255    }
256
257    #[test]
258    fn test_deserialize_mcp_tool_call() {
259        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}}"#;
260        let item: ThreadItem = serde_json::from_str(json).unwrap();
261        assert!(matches!(item, ThreadItem::McpToolCall(ref m) if m.tool == "my-tool"));
262    }
263
264    #[test]
265    fn test_deserialize_camel_case_agent_message() {
266        let json = r#"{"type":"agentMessage","id":"msg_1","text":"Hello"}"#;
267        let item: ThreadItem = serde_json::from_str(json).unwrap();
268        assert!(matches!(item, ThreadItem::AgentMessage(ref m) if m.text == "Hello"));
269    }
270
271    #[test]
272    fn test_deserialize_camel_case_command_execution() {
273        let json = r#"{"type":"commandExecution","id":"cmd_1","command":"ls","aggregated_output":"","status":"completed"}"#;
274        let item: ThreadItem = serde_json::from_str(json).unwrap();
275        assert!(matches!(item, ThreadItem::CommandExecution(_)));
276    }
277
278    #[test]
279    fn test_deserialize_camel_case_file_change() {
280        let json = r#"{"type":"fileChange","id":"fc_1","changes":[],"status":"completed"}"#;
281        let item: ThreadItem = serde_json::from_str(json).unwrap();
282        assert!(matches!(item, ThreadItem::FileChange(_)));
283    }
284
285    #[test]
286    fn test_command_execution_status_declined() {
287        let json = r#"{"type":"command_execution","id":"cmd_1","command":"rm -rf /","aggregated_output":"","status":"declined"}"#;
288        let item: ThreadItem = serde_json::from_str(json).unwrap();
289        assert!(
290            matches!(item, ThreadItem::CommandExecution(ref c) if c.status == CommandExecutionStatus::Declined)
291        );
292    }
293}