use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
use serde_json::Value;
fn deserialize_tool_arguments<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::String(text)) => Ok(Some(text)),
Some(value @ (Value::Object(_) | Value::Array(_))) => serde_json::to_string(&value)
.map(Some)
.map_err(|_| D::Error::custom("failed to normalize tool arguments")),
Some(_) => Err(D::Error::custom(
"tool arguments must be a string, object, or array",
)),
}
}
fn deserialize_tool_output<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::String(text)) => Ok(Some(text)),
Some(value @ Value::Object(_)) => serde_json::to_string(&value)
.map(Some)
.map_err(|_| D::Error::custom("failed to normalize tool output")),
Some(Value::Array(blocks)) => normalize_output_blocks(blocks).map(Some),
Some(_) => Err(D::Error::custom(
"tool output must be a string, object, or content-block array",
)),
}
}
fn normalize_output_blocks<E>(blocks: Vec<Value>) -> Result<String, E>
where
E: serde::de::Error,
{
let mut combined = String::new();
for block in blocks {
let text = match block {
Value::String(text) => Some(text),
Value::Object(object) => match object.get("text") {
Some(Value::String(text)) => Some(text.clone()),
Some(_) => {
return Err(E::custom("tool output block text must be a string"));
}
None if object.get("type").and_then(Value::as_str) == Some("input_image") => None,
None => {
return Err(E::custom(
"tool output array contains an unsupported content block",
));
}
},
_ => {
return Err(E::custom(
"tool output array contains an unsupported content block",
));
}
};
if let Some(text) = text {
if !combined.is_empty() && !combined.ends_with('\n') && !text.starts_with('\n') {
combined.push('\n');
}
combined.push_str(&text);
}
}
Ok(combined)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexLog {
pub timestamp: String,
#[serde(rename = "type")]
pub log_type: String,
pub payload: CodexPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexPayload {
#[serde(rename = "type")]
pub payload_type: Option<String>,
pub role: Option<String>,
pub content: Option<Vec<CodexContent>>,
pub name: Option<String>,
#[serde(
default,
alias = "input",
deserialize_with = "deserialize_tool_arguments"
)]
pub arguments: Option<String>,
pub call_id: Option<String>,
#[serde(default, deserialize_with = "deserialize_tool_output")]
pub output: Option<String>,
pub message: Option<String>,
pub info: Option<Value>,
pub cwd: Option<String>,
pub approval_policy: Option<String>,
pub sandbox_policy: Option<Value>,
pub model: Option<String>,
pub effort: Option<String>,
pub summary: Option<String>,
pub id: Option<String>,
pub originator: Option<String>,
pub git: Option<CodexGitInfo>,
#[serde(default)]
pub success: Option<bool>,
#[serde(default)]
pub changes: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexContent {
#[serde(rename = "type")]
pub content_type: String,
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexGitInfo {
pub commit_hash: Option<String>,
pub branch: Option<String>,
pub repository_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexShellArguments {
pub command: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexExecCommandArguments {
pub cmd: String,
#[serde(default)]
pub workdir: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexShellOutput {
pub output: String,
pub metadata: Option<CodexShellMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexShellMetadata {
pub exit_code: i32,
pub duration_seconds: f64,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn payload(value: Value) -> CodexPayload {
serde_json::from_value(value).unwrap()
}
#[test]
fn original_public_field_list_remains_constructible() {
let payload = CodexPayload {
payload_type: None,
role: None,
content: None,
name: None,
arguments: Some("{}".to_string()),
call_id: None,
output: Some("text".to_string()),
message: None,
info: None,
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
id: None,
originator: None,
git: None,
success: None,
changes: None,
};
assert_eq!(payload.arguments.as_deref(), Some("{}"));
assert_eq!(payload.output.as_deref(), Some("text"));
}
#[test]
fn arguments_accept_legacy_strings_and_structured_json() {
let string = payload(json!({ "arguments": "{\"cmd\":\"pwd\"}" }));
assert_eq!(string.arguments.as_deref(), Some("{\"cmd\":\"pwd\"}"));
let object = payload(json!({ "arguments": { "cmd": "pwd" } }));
assert_eq!(
serde_json::from_str::<Value>(object.arguments.as_deref().unwrap()).unwrap(),
json!({ "cmd": "pwd" })
);
let array = payload(json!({ "arguments": ["bash", "-lc", "pwd"] }));
assert_eq!(
serde_json::from_str::<Value>(array.arguments.as_deref().unwrap()).unwrap(),
json!(["bash", "-lc", "pwd"])
);
}
#[test]
fn custom_input_alias_populates_the_original_arguments_field() {
let parsed = payload(json!({ "input": "await tools.exec_command({});" }));
assert_eq!(
parsed.arguments.as_deref(),
Some("await tools.exec_command({});")
);
let serialized = serde_json::to_value(parsed).unwrap();
assert_eq!(
serialized["arguments"],
json!("await tools.exec_command({});")
);
assert!(serialized.get("input").is_none());
}
#[test]
fn output_accepts_missing_null_string_and_object_shapes() {
assert_eq!(payload(json!({})).output, None);
assert_eq!(payload(json!({ "output": null })).output, None);
assert_eq!(
payload(json!({ "output": "plain text" })).output.as_deref(),
Some("plain text")
);
let object = payload(json!({
"output": {
"output": "command text",
"metadata": { "exit_code": 0, "duration_seconds": 0.1 }
}
}));
assert_eq!(
serde_json::from_str::<Value>(object.output.as_deref().unwrap()).unwrap(),
json!({
"output": "command text",
"metadata": { "exit_code": 0, "duration_seconds": 0.1 }
})
);
}
#[test]
fn output_flattens_text_blocks_and_ignores_known_image_blocks() {
let parsed = payload(json!({
"output": [
{ "type": "input_text", "text": "first" },
{ "type": "input_text", "text": "second\n" },
"third",
{ "type": "input_image", "image_url": "data:image/png;base64,redacted" }
]
}));
assert_eq!(parsed.output.as_deref(), Some("first\nsecond\nthird"));
}
#[test]
fn unsupported_argument_and_output_shapes_are_rejected() {
assert!(serde_json::from_value::<CodexPayload>(json!({ "arguments": 7 })).is_err());
assert!(serde_json::from_value::<CodexPayload>(json!({ "output": true })).is_err());
assert!(
serde_json::from_value::<CodexPayload>(json!({
"output": [{ "type": "future_block" }]
}))
.is_err()
);
}
}