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