1use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
10use serde_json::Value;
11
12fn deserialize_tool_arguments<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
14where
15 D: Deserializer<'de>,
16{
17 let value = Option::<Value>::deserialize(deserializer)?;
18 match value {
19 None | Some(Value::Null) => Ok(None),
20 Some(Value::String(text)) => Ok(Some(text)),
21 Some(value @ (Value::Object(_) | Value::Array(_))) => serde_json::to_string(&value)
22 .map(Some)
23 .map_err(|_| D::Error::custom("failed to normalize tool arguments")),
24 Some(_) => Err(D::Error::custom(
25 "tool arguments must be a string, object, or array",
26 )),
27 }
28}
29
30fn deserialize_tool_output<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
32where
33 D: Deserializer<'de>,
34{
35 let value = Option::<Value>::deserialize(deserializer)?;
36 match value {
37 None | Some(Value::Null) => Ok(None),
38 Some(Value::String(text)) => Ok(Some(text)),
39 Some(value @ Value::Object(_)) => serde_json::to_string(&value)
40 .map(Some)
41 .map_err(|_| D::Error::custom("failed to normalize tool output")),
42 Some(Value::Array(blocks)) => normalize_output_blocks(blocks).map(Some),
43 Some(_) => Err(D::Error::custom(
44 "tool output must be a string, object, or content-block array",
45 )),
46 }
47}
48
49fn normalize_output_blocks<E>(blocks: Vec<Value>) -> Result<String, E>
50where
51 E: serde::de::Error,
52{
53 let mut combined = String::new();
54 for block in blocks {
55 let text = match block {
56 Value::String(text) => Some(text),
57 Value::Object(object) => match object.get("text") {
58 Some(Value::String(text)) => Some(text.clone()),
59 Some(_) => {
60 return Err(E::custom("tool output block text must be a string"));
61 }
62 None if object.get("type").and_then(Value::as_str) == Some("input_image") => None,
63 None => {
64 return Err(E::custom(
65 "tool output array contains an unsupported content block",
66 ));
67 }
68 },
69 _ => {
70 return Err(E::custom(
71 "tool output array contains an unsupported content block",
72 ));
73 }
74 };
75
76 if let Some(text) = text {
77 if !combined.is_empty() && !combined.ends_with('\n') && !text.starts_with('\n') {
78 combined.push('\n');
79 }
80 combined.push_str(&text);
81 }
82 }
83 Ok(combined)
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct CodexLog {
89 pub timestamp: String,
91 #[serde(rename = "type")]
93 pub log_type: String,
94 pub payload: CodexPayload,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct CodexPayload {
104 #[serde(rename = "type")]
106 pub payload_type: Option<String>,
107 pub role: Option<String>,
109 pub content: Option<Vec<CodexContent>>,
111 pub name: Option<String>,
113 #[serde(
115 default,
116 alias = "input",
117 deserialize_with = "deserialize_tool_arguments"
118 )]
119 pub arguments: Option<String>,
120 pub call_id: Option<String>,
122 #[serde(default, deserialize_with = "deserialize_tool_output")]
124 pub output: Option<String>,
125 pub message: Option<String>,
127 pub info: Option<Value>,
129 pub cwd: Option<String>,
131 pub approval_policy: Option<String>,
133 pub sandbox_policy: Option<Value>,
135 pub model: Option<String>,
137 pub effort: Option<String>,
139 pub summary: Option<String>,
141 pub id: Option<String>,
143 pub originator: Option<String>,
145 pub git: Option<CodexGitInfo>,
147 #[serde(default)]
149 pub success: Option<bool>,
150 #[serde(default)]
152 pub changes: Option<Value>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct CodexContent {
158 #[serde(rename = "type")]
160 pub content_type: String,
161 pub text: Option<String>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct CodexGitInfo {
168 pub commit_hash: Option<String>,
170 pub branch: Option<String>,
172 pub repository_url: Option<String>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct CodexShellArguments {
181 pub command: Vec<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct CodexExecCommandArguments {
193 pub cmd: String,
195 #[serde(default)]
197 pub workdir: String,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct CodexShellOutput {
203 pub output: String,
205 pub metadata: Option<CodexShellMetadata>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct CodexShellMetadata {
212 pub exit_code: i32,
214 pub duration_seconds: f64,
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use serde_json::json;
222
223 fn payload(value: Value) -> CodexPayload {
224 serde_json::from_value(value).unwrap()
225 }
226
227 #[test]
228 fn original_public_field_list_remains_constructible() {
229 let payload = CodexPayload {
230 payload_type: None,
231 role: None,
232 content: None,
233 name: None,
234 arguments: Some("{}".to_string()),
235 call_id: None,
236 output: Some("text".to_string()),
237 message: None,
238 info: None,
239 cwd: None,
240 approval_policy: None,
241 sandbox_policy: None,
242 model: None,
243 effort: None,
244 summary: None,
245 id: None,
246 originator: None,
247 git: None,
248 success: None,
249 changes: None,
250 };
251
252 assert_eq!(payload.arguments.as_deref(), Some("{}"));
253 assert_eq!(payload.output.as_deref(), Some("text"));
254 }
255
256 #[test]
257 fn arguments_accept_legacy_strings_and_structured_json() {
258 let string = payload(json!({ "arguments": "{\"cmd\":\"pwd\"}" }));
259 assert_eq!(string.arguments.as_deref(), Some("{\"cmd\":\"pwd\"}"));
260
261 let object = payload(json!({ "arguments": { "cmd": "pwd" } }));
262 assert_eq!(
263 serde_json::from_str::<Value>(object.arguments.as_deref().unwrap()).unwrap(),
264 json!({ "cmd": "pwd" })
265 );
266
267 let array = payload(json!({ "arguments": ["bash", "-lc", "pwd"] }));
268 assert_eq!(
269 serde_json::from_str::<Value>(array.arguments.as_deref().unwrap()).unwrap(),
270 json!(["bash", "-lc", "pwd"])
271 );
272 }
273
274 #[test]
275 fn custom_input_alias_populates_the_original_arguments_field() {
276 let parsed = payload(json!({ "input": "await tools.exec_command({});" }));
277 assert_eq!(
278 parsed.arguments.as_deref(),
279 Some("await tools.exec_command({});")
280 );
281
282 let serialized = serde_json::to_value(parsed).unwrap();
283 assert_eq!(
284 serialized["arguments"],
285 json!("await tools.exec_command({});")
286 );
287 assert!(serialized.get("input").is_none());
288 }
289
290 #[test]
291 fn output_accepts_missing_null_string_and_object_shapes() {
292 assert_eq!(payload(json!({})).output, None);
293 assert_eq!(payload(json!({ "output": null })).output, None);
294 assert_eq!(
295 payload(json!({ "output": "plain text" })).output.as_deref(),
296 Some("plain text")
297 );
298
299 let object = payload(json!({
300 "output": {
301 "output": "command text",
302 "metadata": { "exit_code": 0, "duration_seconds": 0.1 }
303 }
304 }));
305 assert_eq!(
306 serde_json::from_str::<Value>(object.output.as_deref().unwrap()).unwrap(),
307 json!({
308 "output": "command text",
309 "metadata": { "exit_code": 0, "duration_seconds": 0.1 }
310 })
311 );
312 }
313
314 #[test]
315 fn output_flattens_text_blocks_and_ignores_known_image_blocks() {
316 let parsed = payload(json!({
317 "output": [
318 { "type": "input_text", "text": "first" },
319 { "type": "input_text", "text": "second\n" },
320 "third",
321 { "type": "input_image", "image_url": "data:image/png;base64,redacted" }
322 ]
323 }));
324 assert_eq!(parsed.output.as_deref(), Some("first\nsecond\nthird"));
325 }
326
327 #[test]
328 fn unsupported_argument_and_output_shapes_are_rejected() {
329 assert!(serde_json::from_value::<CodexPayload>(json!({ "arguments": 7 })).is_err());
330 assert!(serde_json::from_value::<CodexPayload>(json!({ "output": true })).is_err());
331 assert!(
332 serde_json::from_value::<CodexPayload>(json!({
333 "output": [{ "type": "future_block" }]
334 }))
335 .is_err()
336 );
337 }
338}