Skip to main content

vct_core/models/
codex.rs

1//! Serde models for the OpenAI Codex CLI `*.jsonl` session format.
2//!
3//! Every line is a [`CodexLog`] wrapping a `type` discriminator and a
4//! [`CodexPayload`]. The payload is a wide union of optional fields because the
5//! same struct deserializes session-meta, turn-context, event-message, and
6//! response-item records; only the fields relevant to each record type are
7//! populated.
8
9use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
10use serde_json::Value;
11
12/// Normalizes JSON-encoded or structured tool arguments into the legacy string field.
13fn 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
30/// Normalizes string, object, and content-block output shapes into plain text.
31fn 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/// A single line of a Codex/OpenAI session log.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct CodexLog {
89    /// ISO-8601 timestamp string for the record.
90    pub timestamp: String,
91    /// Top-level record discriminator (`session_meta`, `event_msg`, …).
92    #[serde(rename = "type")]
93    pub log_type: String,
94    /// Event-specific payload.
95    pub payload: CodexPayload,
96}
97
98/// Event-specific payload of a [`CodexLog`].
99///
100/// A single flat union covering every Codex record type; each field is `Option`
101/// and only the subset meaningful to the record's `payload_type` is present.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct CodexPayload {
104    /// Inner payload discriminator (e.g. `message`, `function_call`).
105    #[serde(rename = "type")]
106    pub payload_type: Option<String>,
107    /// Message role (`user`, `assistant`, …) for message payloads.
108    pub role: Option<String>,
109    /// Message content blocks for message payloads.
110    pub content: Option<Vec<CodexContent>>,
111    /// Function name for function-call payloads (e.g. `shell`, `exec_command`).
112    pub name: Option<String>,
113    /// Raw JSON-encoded arguments or custom-tool input.
114    #[serde(
115        default,
116        alias = "input",
117        deserialize_with = "deserialize_tool_arguments"
118    )]
119    pub arguments: Option<String>,
120    /// Correlation id linking a function call to its output.
121    pub call_id: Option<String>,
122    /// Normalized function/custom-tool output body; text blocks are flattened.
123    #[serde(default, deserialize_with = "deserialize_tool_output")]
124    pub output: Option<String>,
125    /// Free-form message text for event-message payloads.
126    pub message: Option<String>,
127    /// Token-usage / event info blob, shape depends on the event.
128    pub info: Option<Value>,
129    /// Working directory recorded in session-meta / turn-context payloads.
130    pub cwd: Option<String>,
131    /// Approval policy in effect for the turn.
132    pub approval_policy: Option<String>,
133    /// Sandbox policy blob in effect for the turn.
134    pub sandbox_policy: Option<Value>,
135    /// Model name driving the turn.
136    pub model: Option<String>,
137    /// Reasoning-effort setting for the turn.
138    pub effort: Option<String>,
139    /// Reasoning summary text, when present.
140    pub summary: Option<String>,
141    /// Record identifier.
142    pub id: Option<String>,
143    /// Originator label (which client produced the session).
144    pub originator: Option<String>,
145    /// Git repository metadata captured at session start.
146    pub git: Option<CodexGitInfo>,
147    /// Whether a `patch_apply_end` event reported a successful apply.
148    #[serde(default)]
149    pub success: Option<bool>,
150    /// Per-file changes of a `patch_apply_end` event, keyed by absolute path.
151    #[serde(default)]
152    pub changes: Option<Value>,
153}
154
155/// One content block of a Codex message payload.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct CodexContent {
158    /// Block type (e.g. `text`, `input_text`, `output_text`).
159    #[serde(rename = "type")]
160    pub content_type: String,
161    /// Block text, when the block carries text.
162    pub text: Option<String>,
163}
164
165/// Git repository metadata captured at the start of a Codex session.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct CodexGitInfo {
168    /// Current commit hash.
169    pub commit_hash: Option<String>,
170    /// Current branch name.
171    pub branch: Option<String>,
172    /// Remote repository URL.
173    pub repository_url: Option<String>,
174}
175
176/// Arguments of the legacy `name == "shell"` function call.
177///
178/// The command is an argv array, typically `["bash", "-lc", "<script>"]`.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct CodexShellArguments {
181    /// Command argv vector.
182    pub command: Vec<String>,
183}
184
185/// Arguments for the current `name == "exec_command"` function call.
186///
187/// Codex CLI replaced the legacy `shell` function (whose arguments were a
188/// `["bash", "-lc", "<script>"]` array) with a flat `{cmd, workdir, ...}`
189/// object. The analyzer normalises both into the same `CodexShellCall`
190/// downstream so the patch / sed / cat detection can stay shared.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct CodexExecCommandArguments {
193    /// Full command string to execute.
194    pub cmd: String,
195    /// Working directory for the command (empty when unset).
196    #[serde(default)]
197    pub workdir: String,
198}
199
200/// Result of a Codex shell command: captured output plus optional metadata.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct CodexShellOutput {
203    /// Combined stdout/stderr captured from the command.
204    pub output: String,
205    /// Exit-code and timing metadata, when the CLI recorded it.
206    pub metadata: Option<CodexShellMetadata>,
207}
208
209/// Exit-code and timing metadata for a Codex shell command.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct CodexShellMetadata {
212    /// Process exit code.
213    pub exit_code: i32,
214    /// Wall-clock duration of the command in seconds.
215    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}