Skip to main content

vct_core/session/
codex.rs

1//! Parser for OpenAI Codex rollout logs (`~/.codex/sessions/**/*.jsonl`).
2//!
3//! Codex records carry a `type` discriminator (`session_meta`,
4//! `turn_context`, `event_msg`, `response_item`). File operations are not
5//! first-class: Codex records them as legacy `function_call` pairs or current
6//! `custom_tool_call` pairs. This parser joins each call to its output by
7//! `call_id`. Legacy shell calls retain command-level analysis. A top-level
8//! custom `exec` is counted as one run command because its JavaScript source
9//! does not prove which nested tools actually ran. Direct custom patches are
10//! applied only when their paired output explicitly reports success.
11use crate::constants::FastHashMap;
12use crate::models::*;
13use crate::pricing::{TierClassifier, TierThresholds};
14use crate::session::diagnostics::{ParseDiagnostics, ParsedAnalysis};
15use crate::session::state::{ParseMode, SessionParseState};
16use crate::utils::{
17    CodexTokenTotals, get_git_remote_url, parse_iso_timestamp, process_codex_usage,
18};
19use anyhow::Result;
20use regex::Regex;
21use serde_json::Value;
22use std::borrow::Borrow;
23use std::collections::HashSet;
24
25/// Parse Codex session records from a slice of pre-typed logs.
26///
27/// Walks the records in order, threading function and custom-tool calls to
28/// their outputs and folding token usage from `token_count` events. Returns a
29/// single-record [`CodeAnalysis`] with the runtime metadata fields blank
30/// (the caller's `finalize` step fills them).
31///
32/// # Errors
33///
34/// Returns `anyhow::Result` for parity with the other provider parsers, but
35/// has no fallible step: malformed arguments and outputs are tolerated
36/// (skipped or substituted), so it returns `Ok` for any slice.
37pub fn parse_codex_logs(logs: &[CodexLog], mode: ParseMode) -> Result<CodeAnalysis> {
38    parse_codex_log_iter(logs, mode)
39}
40
41/// Streaming Codex parser used by JSONL readers that already own an iterator.
42pub(crate) fn parse_codex_log_iter<I>(logs: I, mode: ParseMode) -> Result<CodeAnalysis>
43where
44    I: IntoIterator,
45    I::Item: Borrow<CodexLog>,
46{
47    Ok(parse_codex_log_iter_with_diagnostics(logs, mode, None)?.analysis)
48}
49
50/// Streaming Codex parser with parser-only schema diagnostics.
51pub(crate) fn parse_codex_log_iter_with_diagnostics<I>(
52    logs: I,
53    mode: ParseMode,
54    tiers: Option<&TierThresholds>,
55) -> Result<ParsedAnalysis>
56where
57    I: IntoIterator,
58    I::Item: Borrow<CodexLog>,
59{
60    let mut classifier = tiers.map(TierClassifier::new);
61    let mut state = SessionParseState::with_mode(mode);
62    let mut conversation_usage: FastHashMap<String, Value> = FastHashMap::with_capacity(5);
63    let mut current_model = String::new();
64    // Codex publishes whole-session cumulative counters on every token_count
65    // event; each model is billed only the delta since the previous snapshot.
66    // Pre-context snapshots (a resumed session's replayed totals) advance the
67    // snapshot without attribution, replacing the old replay-baseline hack.
68    let mut prev_totals: Option<CodexTokenTotals> = None;
69    let mut shell_calls: FastHashMap<String, PendingCodexShellCall> =
70        FastHashMap::with_capacity(50);
71    let mut custom_calls: FastHashMap<String, CodexCustomCall> = FastHashMap::with_capacity(32);
72    // Call ids of direct `apply_patch` custom_tool_calls, so a paired
73    // `patch_apply_end` event is not double counted (see the event_msg arm).
74    // Call ids whose file ops have already been counted, by either the direct
75    // apply_patch output or its authoritative patch_apply_end event. The first
76    // to actually count wins; the other is skipped. A direct patch whose output
77    // never confirmed success does not register here, so it can't suppress a
78    // successful event.
79    let mut patch_counted_ids: HashSet<String> = HashSet::new();
80    let mut diagnostics = ParseDiagnostics::default();
81
82    for entry in logs {
83        let entry = entry.borrow();
84        let recognized = matches!(
85            entry.log_type.as_str(),
86            "session_meta"
87                | "turn_context"
88                | "event_msg"
89                | "response_item"
90                | "inter_agent_communication_metadata"
91                | "world_state"
92                | "compacted"
93        );
94        if recognized {
95            diagnostics.record_recognized_source();
96        } else {
97            diagnostics.record_unrecognized();
98        }
99        let ts = parse_iso_timestamp(&entry.timestamp);
100        if ts > state.last_ts {
101            state.last_ts = ts;
102        }
103
104        match entry.log_type.as_str() {
105            "session_meta" => {
106                if state.folder_path.is_empty()
107                    && let Some(cwd) = &entry.payload.cwd
108                {
109                    state.folder_path.clone_from(cwd); // More efficient than clone()
110                }
111                if state.task_id.is_empty()
112                    && let Some(id) = &entry.payload.id
113                {
114                    state.task_id.clone_from(id);
115                }
116                if state.git_remote.is_empty()
117                    && let Some(git) = &entry.payload.git
118                    && let Some(url) = &git.repository_url
119                {
120                    state.git_remote.clone_from(url);
121                }
122            }
123            "turn_context" => {
124                if state.folder_path.is_empty()
125                    && let Some(cwd) = &entry.payload.cwd
126                {
127                    state.folder_path.clone_from(cwd);
128                }
129                if let Some(model) = entry
130                    .payload
131                    .model
132                    .as_ref()
133                    .filter(|model| !model.is_empty())
134                {
135                    current_model.clone_from(model); // Reuse existing allocation
136                }
137            }
138            "event_msg" => {
139                if let Some(payload_type) = &entry.payload.payload_type
140                    && payload_type == "token_count"
141                    && let Some(info) = entry.payload.info.as_ref().filter(|info| !info.is_null())
142                {
143                    if !is_supported_codex_usage(info) {
144                        diagnostics.record_relevant(false);
145                    } else {
146                        diagnostics.record_relevant(true);
147                        let total = info.get("total_token_usage").and_then(Value::as_object);
148                        if current_model.is_empty() {
149                            // Replayed pre-context totals: advance the snapshot
150                            // without attributing tokens to a guessed model.
151                            if let Some(total) = total {
152                                prev_totals = Some(CodexTokenTotals::from_total_object(total));
153                            }
154                        } else {
155                            let delta = total
156                                .map(|total| {
157                                    CodexTokenTotals::delta_fields(total, prev_totals.as_ref())
158                                })
159                                .unwrap_or_default();
160                            // One token_count is one turn; its request context
161                            // is the turn's own full prompt (cached included),
162                            // published as last_token_usage.input_tokens. Fall
163                            // back to the delta input when absent.
164                            let above = classifier.as_mut().is_some_and(|classifier| {
165                                let request_context = info
166                                    .get("last_token_usage")
167                                    .and_then(|last| last.get("input_tokens"))
168                                    .and_then(Value::as_i64)
169                                    .filter(|tokens| *tokens > 0)
170                                    .or_else(|| delta.get("input_tokens").and_then(Value::as_i64))
171                                    .unwrap_or(0);
172                                classifier.is_above(&current_model, request_context)
173                            });
174                            process_codex_usage(
175                                &mut conversation_usage,
176                                &current_model,
177                                &delta,
178                                info,
179                                above,
180                            );
181                            if let Some(total) = total {
182                                prev_totals = Some(CodexTokenTotals::from_total_object(total));
183                            }
184                        }
185                    }
186                }
187
188                // Modern Codex applies most edits inside opaque `exec` cells and
189                // records the resulting file changes in a `patch_apply_end` event.
190                // A successful one is the authoritative source of those file ops.
191                if entry.payload.payload_type.as_deref() == Some("patch_apply_end") {
192                    // Skip only when the paired direct apply_patch already
193                    // counted this call_id; otherwise this event is the
194                    // authoritative record of the change and must be folded in.
195                    let already_counted = entry
196                        .payload
197                        .call_id
198                        .as_deref()
199                        .is_some_and(|id| patch_counted_ids.contains(id));
200                    if matches!(entry.payload.success, Some(true)) && !already_counted {
201                        match parse_patch_changes(entry.payload.changes.as_ref()) {
202                            Some(patches) => {
203                                for patch in patches {
204                                    state.handle_patch(patch, ts);
205                                }
206                                if let Some(id) = entry.payload.call_id.as_deref() {
207                                    patch_counted_ids.insert(id.to_string());
208                                }
209                                diagnostics.record_relevant(true);
210                            }
211                            None => diagnostics.record_relevant(false),
212                        }
213                    }
214                    // A failed or deduped event is recognized but creates no file ops.
215                }
216            }
217            "response_item" => {
218                if let Some(payload_type) = &entry.payload.payload_type {
219                    match payload_type.as_str() {
220                        "function_call" => {
221                            if let Some(name) = entry.payload.name.as_deref()
222                                && matches!(name, "shell" | "exec_command")
223                            {
224                                let call = entry
225                                    .payload
226                                    .arguments
227                                    .as_deref()
228                                    .and_then(|args| parse_function_call(name, args, ts));
229                                if let Some(call_id) = entry.payload.call_id.as_deref() {
230                                    if let Some(call) = call {
231                                        diagnostics.record_relevant(true);
232                                        shell_calls.insert(
233                                            call_id.to_string(),
234                                            PendingCodexShellCall::Parsed(call),
235                                        );
236                                    } else {
237                                        // Defer the schema verdict until the paired output. Codex
238                                        // persists model-generated argument errors as ordinary
239                                        // lifecycle records even though no command ran.
240                                        shell_calls.insert(
241                                            call_id.to_string(),
242                                            PendingCodexShellCall::InvalidArguments,
243                                        );
244                                    }
245                                } else {
246                                    diagnostics.record_relevant(false);
247                                }
248                            }
249                        }
250                        "function_call_output" => {
251                            if let Some(call_id) = &entry.payload.call_id
252                                && let Some(call) = shell_calls.remove(call_id)
253                            {
254                                match call {
255                                    PendingCodexShellCall::Parsed(call) => {
256                                        diagnostics.record_relevant(true);
257                                        let output = shell_output(entry.payload.output.as_deref());
258                                        state.handle_shell_call(call, output);
259                                    }
260                                    PendingCodexShellCall::InvalidArguments => {
261                                        diagnostics.record_relevant(output_reports_argument_error(
262                                            entry.payload.output.as_deref(),
263                                        ));
264                                    }
265                                }
266                            }
267                        }
268                        "custom_tool_call" => {
269                            if let Some(name) = entry.payload.name.as_deref()
270                                && matches!(name, "exec" | "apply_patch")
271                            {
272                                let call = entry
273                                    .payload
274                                    .arguments
275                                    .as_deref()
276                                    .and_then(|input| parse_custom_call(name, input, ts, mode));
277                                let normalized = call.is_some() && entry.payload.call_id.is_some();
278                                diagnostics.record_relevant(normalized);
279                                if let (Some(call), Some(call_id)) =
280                                    (call, entry.payload.call_id.as_deref())
281                                {
282                                    custom_calls.insert(call_id.to_string(), call);
283                                }
284                            }
285                        }
286                        "custom_tool_call_output" => {
287                            if let Some(call_id) = entry.payload.call_id.as_deref()
288                                && let Some(call) = custom_calls.remove(call_id)
289                            {
290                                let normalized = dispatch_custom_call(
291                                    &mut state,
292                                    call,
293                                    entry.payload.output.as_deref(),
294                                    call_id,
295                                    &mut patch_counted_ids,
296                                );
297                                diagnostics.record_relevant(normalized);
298                            }
299                        }
300                        _ => {}
301                    }
302                }
303            }
304            _ => {}
305        }
306    }
307
308    for call in shell_calls.into_values() {
309        if matches!(call, PendingCodexShellCall::InvalidArguments) {
310            diagnostics.record_relevant(false);
311        }
312    }
313    if state.git_remote.is_empty() {
314        state.git_remote = get_git_remote_url(&state.folder_path);
315    }
316
317    let record = state.into_record(conversation_usage);
318
319    let analysis = CodeAnalysis {
320        user: String::new(),
321        extension_name: String::new(),
322        insights_version: String::new(),
323        machine_id: String::new(),
324        records: vec![record],
325    };
326    Ok(ParsedAnalysis::new(analysis, diagnostics))
327}
328
329fn is_supported_codex_usage(info: &Value) -> bool {
330    let Some(info) = info.as_object() else {
331        return false;
332    };
333    let mut recognized = false;
334    for key in ["total_token_usage", "last_token_usage"] {
335        let Some(usage) = info.get(key) else {
336            continue;
337        };
338        if !is_supported_codex_token_usage(usage) {
339            return false;
340        }
341        recognized = true;
342    }
343    if let Some(context_window) = info.get("model_context_window") {
344        if context_window.is_null() {
345            // Older Codex records retain the field with a null value while
346            // still carrying valid token objects.
347        } else if context_window.as_i64().is_none() {
348            return false;
349        } else {
350            recognized = true;
351        }
352    }
353    recognized
354}
355
356fn is_supported_codex_token_usage(usage: &Value) -> bool {
357    let Some(usage) = usage.as_object() else {
358        return false;
359    };
360    if usage.is_empty() {
361        return true;
362    }
363
364    let mut recognized = false;
365    for key in [
366        "input_tokens",
367        "cached_input_tokens",
368        "output_tokens",
369        "reasoning_output_tokens",
370        "total_tokens",
371    ] {
372        if let Some(value) = usage.get(key) {
373            if value.as_i64().is_none() {
374                return false;
375            }
376            recognized = true;
377        }
378    }
379    recognized
380}
381
382enum PendingCodexShellCall {
383    Parsed(CodexShellCall),
384    InvalidArguments,
385}
386
387fn output_reports_argument_error(output: Option<&str>) -> bool {
388    let output = shell_output(output).output;
389    let output = strip_exec_command_metadata_prefix(&output)
390        .trim_start()
391        .to_ascii_lowercase();
392    (output.starts_with("error") || output.starts_with("failed"))
393        && (output.contains("failed to parse")
394            || output.contains("missing field")
395            || output.contains("missing required")
396            || output.contains("invalid argument"))
397}
398
399/// Strip the metadata header that current Codex (`exec_command`) prepends
400/// to every shell output. Format observed in the wild:
401///
402/// ```text
403/// Chunk ID: <hex>
404/// Wall time: <num> seconds
405/// Process exited with code <num>
406/// Original token count: <num>
407/// Output:
408/// <actual stdout>
409/// ```
410///
411/// Without stripping, sed/cat read-detail extraction over-counts the file
412/// by 5 lines (the prefix). Legacy `shell` outputs do not include the
413/// header, so the search returns the original slice unchanged when no
414/// `\nOutput:\n` marker is found.
415fn strip_exec_command_metadata_prefix(output: &str) -> &str {
416    const MARKER: &str = "\nOutput:\n";
417    if let Some(idx) = output.find(MARKER) {
418        &output[idx + MARKER.len()..]
419    } else if let Some(rest) = output.strip_prefix("Output:\n") {
420        rest
421    } else {
422        output
423    }
424}
425
426/// Normalizes legacy strings and structured outputs serialized at the model boundary.
427fn shell_output(output: Option<&str>) -> CodexShellOutput {
428    let Some(output) = output else {
429        return CodexShellOutput {
430            output: String::new(),
431            metadata: None,
432        };
433    };
434
435    serde_json::from_str::<CodexShellOutput>(output).unwrap_or_else(|_| CodexShellOutput {
436        output: serde_json::from_str::<Value>(output)
437            .ok()
438            .and_then(|value| value.get("output")?.as_str().map(str::to_string))
439            .unwrap_or_else(|| output.to_string()),
440        metadata: None,
441    })
442}
443
444/// Build a `CodexShellCall` from either the legacy `shell` function or the
445/// current `exec_command` function.
446///
447/// Returns `None` when the function name is unrelated (e.g. `update_plan`,
448/// MCP tool calls) or when the arguments fail to deserialize. Both shapes
449/// collapse to the same downstream representation so the patch / sed / cat
450/// dispatch in `handle_shell_call` does not need to branch on the source
451/// function name.
452fn parse_function_call(name: &str, args_str: &str, ts: i64) -> Option<CodexShellCall> {
453    match name {
454        "shell" => {
455            let args = serde_json::from_str::<CodexShellArguments>(args_str).ok()?;
456            let script = args.command.last().cloned().unwrap_or_default();
457            Some(CodexShellCall {
458                timestamp: ts,
459                script,
460                full_command: args.command,
461            })
462        }
463        "exec_command" => {
464            let args = serde_json::from_str::<CodexExecCommandArguments>(args_str).ok()?;
465            let cmd = args.cmd;
466            Some(CodexShellCall {
467                timestamp: ts,
468                script: cmd.clone(),
469                full_command: vec![cmd],
470            })
471        }
472        _ => None,
473    }
474}
475
476enum CodexCustomCall {
477    Exec {
478        source: Option<String>,
479        timestamp: i64,
480    },
481    ApplyPatch {
482        patches: Vec<CodexPatch>,
483        timestamp: i64,
484    },
485}
486
487fn parse_custom_call(
488    name: &str,
489    input: &str,
490    timestamp: i64,
491    mode: ParseMode,
492) -> Option<CodexCustomCall> {
493    match name {
494        "exec" if !input.trim().is_empty() => Some(CodexCustomCall::Exec {
495            source: matches!(mode, ParseMode::Full).then(|| input.to_string()),
496            timestamp,
497        }),
498        "apply_patch" => {
499            let patches: Vec<_> = parse_apply_patch_script(input)
500                .into_iter()
501                .filter(|patch| !patch.file_path.is_empty())
502                .collect();
503            (!patches.is_empty()).then_some(CodexCustomCall::ApplyPatch { patches, timestamp })
504        }
505        _ => None,
506    }
507}
508
509fn dispatch_custom_call(
510    state: &mut SessionParseState,
511    call: CodexCustomCall,
512    output: Option<&str>,
513    call_id: &str,
514    patch_counted_ids: &mut HashSet<String>,
515) -> bool {
516    match call {
517        CodexCustomCall::Exec { source, timestamp } => {
518            // The JavaScript wrapper can contain branches, loops, retries, or
519            // tool calls whose execution cannot be proven from source text.
520            // Treat the completed cell itself as the only observable action.
521            if let Some(source) = source {
522                state.add_run_command(&source, "", timestamp);
523            } else {
524                state.tool_counts.bash += 1;
525            }
526            true
527        }
528        CodexCustomCall::ApplyPatch { patches, timestamp } => {
529            match custom_apply_patch_result(output) {
530                CustomApplyPatchResult::Success => {
531                    // Count once per call_id; a paired patch_apply_end for the
532                    // same id is then skipped (whichever arrives first wins).
533                    if patch_counted_ids.insert(call_id.to_string()) {
534                        for patch in patches {
535                            state.handle_patch(patch, timestamp);
536                        }
537                    }
538                    true
539                }
540                CustomApplyPatchResult::Failure => true,
541                CustomApplyPatchResult::Unknown => false,
542            }
543        }
544    }
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548enum CustomApplyPatchResult {
549    Success,
550    Failure,
551    Unknown,
552}
553
554fn custom_apply_patch_result(output: Option<&str>) -> CustomApplyPatchResult {
555    let output = normalize_custom_output(output);
556    let output = output.trim();
557    if output == "Done!" || output.starts_with("Success. Updated the following files:") {
558        CustomApplyPatchResult::Success
559    } else if output.starts_with("Failed")
560        || output.starts_with("Error")
561        || output.starts_with("apply_patch verification failed:")
562        || output.starts_with("Invalid patch")
563        || output.starts_with("apply_patch handler received")
564        || output.starts_with("apply_patch is unavailable")
565    {
566        CustomApplyPatchResult::Failure
567    } else {
568        CustomApplyPatchResult::Unknown
569    }
570}
571
572/// Decodes a custom tool's normalized string or `JSON.stringify` result.
573fn normalize_custom_output(output: Option<&str>) -> String {
574    let body = strip_exec_command_metadata_prefix(output.unwrap_or_default()).trim();
575    match serde_json::from_str::<Value>(body) {
576        Ok(Value::Object(object)) => object
577            .get("output")
578            .and_then(Value::as_str)
579            .unwrap_or(body)
580            .to_string(),
581        Ok(Value::String(text)) => text,
582        _ => body.to_string(),
583    }
584}
585
586/// Codex-specific dispatch helpers grafted onto [`SessionParseState`].
587///
588/// Kept as a private extension trait so the generic state type stays free of
589/// Codex's `apply_patch`/`sed`/`cat` heuristics.
590trait CodexAnalysisExt {
591    /// Routes a completed shell call to the read / patch / run-command tally
592    /// based on what its `script` did.
593    fn handle_shell_call(&mut self, call: CodexShellCall, output: CodexShellOutput);
594    /// Applies one parsed `apply_patch` hunk as a write, delete, or edit.
595    fn handle_patch(&mut self, patch: CodexPatch, ts: i64);
596    /// Records a shell call that was not a file operation as a run command.
597    fn record_run_command(&mut self, call: CodexShellCall);
598}
599
600impl CodexAnalysisExt for SessionParseState {
601    fn handle_shell_call(&mut self, call: CodexShellCall, output: CodexShellOutput) {
602        // Patch payloads carry a stable envelope regardless of the launcher name.
603        if call.script.contains("*** Begin Patch") {
604            let patches = parse_apply_patch_script(&call.script);
605            for patch in patches {
606                self.handle_patch(patch, call.timestamp);
607            }
608            return;
609        }
610
611        // The legacy `shell` function returned just the raw command output
612        // in `output`. The current `exec_command` function wraps that
613        // output with a metadata header — strip it so line counting sees
614        // only what the model actually saw as the file body.
615        let output_body = strip_exec_command_metadata_prefix(&output.output);
616
617        // Check for sed command
618        if let Some(path) = extract_sed_file_path(&call.script) {
619            self.add_read_detail(&path, output_body, call.timestamp);
620            return;
621        }
622
623        // Check for cat command
624        if let Some((path, content)) = extract_cat_read(&call.script, output_body) {
625            self.add_read_detail(&path, &content, call.timestamp);
626            return;
627        }
628
629        // Record as run command
630        self.record_run_command(call);
631    }
632
633    fn handle_patch(&mut self, patch: CodexPatch, ts: i64) {
634        if patch.file_path.is_empty() {
635            return;
636        }
637
638        let resolved = self.normalize_path(&patch.file_path);
639        if resolved.is_empty() {
640            return;
641        }
642
643        let (old_str, new_str) = extract_patch_strings(&patch.lines);
644
645        match patch.action.as_str() {
646            "add" => {
647                self.add_write_detail(&resolved, &new_str, ts);
648            }
649            "delete" => {
650                // A delete is a real file operation on a unique file; record
651                // it even when the diff carried no removed content (an entry
652                // may omit `unified_diff`), consistent with add/update.
653                let content = old_str.trim_end_matches('\n');
654                self.add_edit_detail_raw(&resolved, content, "", ts);
655            }
656            _ => {
657                self.add_edit_detail(&resolved, &old_str, &new_str, ts);
658            }
659        }
660    }
661
662    fn record_run_command(&mut self, call: CodexShellCall) {
663        let command_str = if call.full_command.is_empty() {
664            call.script.trim()
665        } else {
666            &call.full_command.join(" ")
667        };
668
669        self.add_run_command(command_str, "", call.timestamp);
670    }
671}
672
673/// A Codex shell invocation awaiting its output, normalized across the
674/// legacy `shell` and current `exec_command` function shapes.
675struct CodexShellCall {
676    /// Event timestamp in epoch milliseconds.
677    timestamp: i64,
678    /// The command line actually executed (used for `sed`/`cat`/patch sniffing).
679    script: String,
680    /// The full argv as written by the model, for verbatim run-command display.
681    full_command: Vec<String>,
682}
683
684/// One file hunk extracted from an `apply_patch` script.
685struct CodexPatch {
686    /// `"add"`, `"delete"`, or `"update"`.
687    action: String,
688    /// Target file path as written in the patch header.
689    file_path: String,
690    /// Raw diff body lines (with their leading `+`/`-`/context markers).
691    lines: Vec<String>,
692}
693
694/// Parses an `apply_patch` script into its constituent per-file hunks.
695///
696/// Returns an empty `Vec` when no `*** Begin Patch` marker is present.
697fn parse_apply_patch_script(script: &str) -> Vec<CodexPatch> {
698    let start = match script.find("*** Begin Patch") {
699        Some(idx) => idx,
700        None => return Vec::new(),
701    };
702
703    let segment = &script[start..];
704    // Pre-allocate capacity based on typical patch count (1-5 patches)
705    let mut patches = Vec::with_capacity(3);
706    let mut current: Option<CodexPatch> = None;
707
708    for line in segment.lines() {
709        let line = line.trim_end_matches('\r');
710
711        if line.starts_with("*** End Patch") {
712            if let Some(patch) = current.take() {
713                patches.push(patch);
714            }
715            break;
716        } else if line.starts_with("*** Begin Patch") {
717            continue;
718        } else if line.starts_with("*** Update File:") {
719            if let Some(patch) = current.take() {
720                patches.push(patch);
721            }
722            let file_path = line
723                .trim_start_matches("*** Update File:")
724                .trim()
725                .to_string();
726            current = Some(CodexPatch {
727                action: "update".to_string(),
728                file_path,
729                lines: Vec::with_capacity(20), // typical: 10-30 lines per patch
730            });
731        } else if line.starts_with("*** Add File:") {
732            if let Some(patch) = current.take() {
733                patches.push(patch);
734            }
735            let file_path = line.trim_start_matches("*** Add File:").trim().to_string();
736            current = Some(CodexPatch {
737                action: "add".to_string(),
738                file_path,
739                lines: Vec::with_capacity(20),
740            });
741        } else if line.starts_with("*** Delete File:") {
742            if let Some(patch) = current.take() {
743                patches.push(patch);
744            }
745            let file_path = line
746                .trim_start_matches("*** Delete File:")
747                .trim()
748                .to_string();
749            current = Some(CodexPatch {
750                action: "delete".to_string(),
751                file_path,
752                lines: Vec::with_capacity(20),
753            });
754        } else if let Some(ref mut patch) = current {
755            patch.lines.push(line.to_string());
756        }
757    }
758
759    if let Some(patch) = current {
760        patches.push(patch);
761    }
762
763    patches
764}
765
766/// Normalizes a `patch_apply_end` `changes` map into patch hunks.
767///
768/// Returns `None` when the payload is missing, not an object, or carries only
769/// unrecognizable entries (schema drift). An empty object is a well-formed
770/// no-op and yields an empty `Vec`. Entries are visited in sorted-path order
771/// (the underlying map is a `BTreeMap`) so the folded detail order is stable.
772fn parse_patch_changes(changes: Option<&Value>) -> Option<Vec<CodexPatch>> {
773    let obj = changes?.as_object()?;
774    let mut patches = Vec::with_capacity(obj.len());
775    for (path, entry) in obj {
776        if let Some(patch) = parse_patch_change_entry(path, entry) {
777            patches.push(patch);
778        }
779    }
780    if !obj.is_empty() && patches.is_empty() {
781        return None;
782    }
783    Some(patches)
784}
785
786/// Builds one [`CodexPatch`] from a single `changes` entry.
787///
788/// The unified diff feeds the same `(old, new)` extraction as the direct
789/// apply_patch path, so add / update / delete map to the identical metrics.
790fn parse_patch_change_entry(path: &str, entry: &Value) -> Option<CodexPatch> {
791    if path.is_empty() {
792        return None;
793    }
794    let entry = entry.as_object()?;
795    let action = entry.get("type").and_then(Value::as_str)?;
796    if !matches!(action, "add" | "update" | "delete") {
797        return None;
798    }
799    // Real logs omit `unified_diff` on some entries (observed on `add`); the
800    // operation still counts, with no invented line/char content.
801    let lines = entry
802        .get("unified_diff")
803        .and_then(Value::as_str)
804        .map(|diff| diff.lines().map(str::to_string).collect())
805        .unwrap_or_default();
806    Some(CodexPatch {
807        action: action.to_string(),
808        file_path: path.to_string(),
809        lines,
810    })
811}
812
813/// Splits diff `lines` into the joined `(old, new)` text.
814///
815/// `+`-prefixed lines build the new content, `-`-prefixed lines the old;
816/// `@@` hunk headers and `\` no-newline markers are skipped. Both results
817/// have their trailing newline trimmed.
818fn extract_patch_strings(lines: &[String]) -> (String, String) {
819    // Pre-allocate with estimated capacity
820    let estimated_size = lines.iter().map(|l| l.len()).sum::<usize>();
821    let mut old_str = String::with_capacity(estimated_size / 2);
822    let mut new_str = String::with_capacity(estimated_size / 2);
823
824    for line in lines {
825        if line.is_empty() {
826            continue;
827        }
828
829        if line.len() > 1 && line.starts_with("@@") {
830            continue;
831        }
832
833        let Some(first_char) = line.chars().next() else {
834            continue;
835        };
836        match first_char {
837            '+' => {
838                new_str.push_str(&line[1..]);
839                new_str.push('\n');
840            }
841            '-' => {
842                old_str.push_str(&line[1..]);
843                old_str.push('\n');
844            }
845            '\\' => continue,
846            _ => {}
847        }
848    }
849
850    // Trim in-place instead of allocating new strings
851    let old_len = old_str.trim_end_matches('\n').len();
852    old_str.truncate(old_len);
853    let new_len = new_str.trim_end_matches('\n').len();
854    new_str.truncate(new_len);
855
856    (old_str, new_str)
857}
858
859/// Extracts the file path read by a `sed -n '<range>' <path>` command.
860///
861/// Returns `None` when the script is not a recognised `sed -n` read.
862fn extract_sed_file_path(script: &str) -> Option<String> {
863    use std::sync::OnceLock;
864    static RE: OnceLock<Regex> = OnceLock::new();
865    let re = RE.get_or_init(|| Regex::new(r"sed\s+-n\s+'[^']*'\s+([^\s]+)").unwrap());
866    let caps = re.captures(script)?;
867    Some(
868        caps.get(1)?
869            .as_str()
870            .trim_matches(|c| c == '"' || c == '\'')
871            .to_string(),
872    )
873}
874
875/// Extracts the `(path, content)` read by a `cat <path>` command.
876///
877/// `output` is the captured stdout (already metadata-stripped); content after
878/// a `\n---` separator is dropped. Returns `None` when no `cat` line is found.
879fn extract_cat_read(script: &str, output: &str) -> Option<(String, String)> {
880    for line in script.lines() {
881        let trimmed = line.trim();
882        if !trimmed.starts_with("cat ") {
883            continue;
884        }
885
886        // `trimmed` starts with "cat "; skip that field and take the path.
887        let mut fields = trimmed.split_whitespace();
888        fields.next();
889        let Some(path_field) = fields.next() else {
890            continue;
891        };
892
893        let path = path_field.trim_matches(|c| c == '"' || c == '\'');
894
895        // Optimize: avoid multiple allocations
896        let clean_output = if let Some(idx) = output.find("\n---") {
897            output[..idx].trim_end_matches('\n').to_string()
898        } else {
899            output.trim_end_matches('\n').to_string()
900        };
901
902        return Some((path.to_string(), clean_output));
903    }
904
905    None
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911
912    fn response_item(payload: Value) -> CodexLog {
913        serde_json::from_value(serde_json::json!({
914            "timestamp": "2026-07-12T00:00:00Z",
915            "type": "response_item",
916            "payload": payload
917        }))
918        .unwrap()
919    }
920
921    fn custom_output(call_id: &str, blocks: Value) -> CodexLog {
922        response_item(serde_json::json!({
923            "type": "custom_tool_call_output",
924            "call_id": call_id,
925            "output": blocks
926        }))
927    }
928
929    fn patch_apply_end(call_id: &str, success: bool, changes: Value) -> CodexLog {
930        serde_json::from_value(serde_json::json!({
931            "timestamp": "2026-07-12T00:00:00Z",
932            "type": "event_msg",
933            "payload": {
934                "type": "patch_apply_end",
935                "call_id": call_id,
936                "success": success,
937                "changes": changes
938            }
939        }))
940        .unwrap()
941    }
942
943    fn apply_patch_success_output(file: &str) -> CodexLog {
944        let wire = serde_json::json!({
945            "output": format!("Success. Updated the following files:\nM {file}")
946        })
947        .to_string();
948        custom_output("shared-call", Value::String(wire))
949    }
950
951    #[test]
952    fn legacy_shell_function_parses_into_call() {
953        // Old schema: arguments = {"command": ["bash", "-lc", "<script>"]}
954        let args = r#"{"command":["bash","-lc","ls -la"]}"#;
955        let call = parse_function_call("shell", args, 42).expect("shell call should parse");
956        assert_eq!(call.timestamp, 42);
957        assert_eq!(call.script, "ls -la");
958        assert_eq!(call.full_command, vec!["bash", "-lc", "ls -la"]);
959    }
960
961    #[test]
962    fn current_exec_command_function_parses_into_call() {
963        // Current schema: arguments = {"cmd":"...","workdir":"...","yield_time_ms":...}
964        let args =
965            r#"{"cmd":"sed -n '1,260p' src/main.rs","workdir":"/repo","yield_time_ms":1000}"#;
966        let call =
967            parse_function_call("exec_command", args, 99).expect("exec_command should parse");
968        assert_eq!(call.timestamp, 99);
969        assert_eq!(call.script, "sed -n '1,260p' src/main.rs");
970        // `full_command` collapses to the single cmd string so
971        // `record_run_command`'s `join(" ")` produces the verbatim command.
972        assert_eq!(call.full_command, vec!["sed -n '1,260p' src/main.rs"]);
973    }
974
975    #[test]
976    fn structured_function_arguments_are_normalized_end_to_end() {
977        let logs = vec![
978            response_item(serde_json::json!({
979                "type": "function_call",
980                "name": "exec_command",
981                "arguments": { "cmd": "pwd", "workdir": "/repo" },
982                "call_id": "exec-object"
983            })),
984            response_item(serde_json::json!({
985                "type": "function_call_output",
986                "call_id": "exec-object",
987                "output": "done"
988            })),
989        ];
990
991        let analysis = parse_codex_logs(&logs, ParseMode::Full).unwrap();
992        let record = &analysis.records[0];
993        assert_eq!(record.tool_call_counts.bash, 1);
994        assert_eq!(record.run_command_details[0].command, "pwd");
995    }
996
997    #[test]
998    fn unrelated_function_names_are_ignored() {
999        // MCP tool calls, `update_plan`, etc. must not be treated as shell.
1000        assert!(parse_function_call("update_plan", "{}", 0).is_none());
1001        assert!(parse_function_call("_fetch_pr", "{}", 0).is_none());
1002    }
1003
1004    #[test]
1005    fn malformed_arguments_yield_none_instead_of_panicking() {
1006        assert!(parse_function_call("shell", "not json", 0).is_none());
1007        assert!(parse_function_call("exec_command", "not json", 0).is_none());
1008    }
1009
1010    #[test]
1011    fn paired_exec_argument_errors_are_supported_metric_free_lifecycles() {
1012        for arguments in ["{not json", r#"{"command_as_key":""}"#] {
1013            let logs = vec![
1014                response_item(serde_json::json!({
1015                    "type": "function_call",
1016                    "name": "exec_command",
1017                    "arguments": arguments,
1018                    "call_id": "bad-exec"
1019                })),
1020                response_item(serde_json::json!({
1021                    "type": "function_call_output",
1022                    "call_id": "bad-exec",
1023                    "output": "Error: failed to parse function arguments"
1024                })),
1025            ];
1026
1027            let parsed =
1028                parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1029            assert_eq!(parsed.diagnostics.partial_failure_count(), 0);
1030            assert!(!parsed.diagnostics.is_complete_failure());
1031            assert_eq!(parsed.analysis.records[0].tool_call_counts.bash, 0);
1032            assert!(parsed.analysis.records[0].run_command_details.is_empty());
1033        }
1034    }
1035
1036    #[test]
1037    fn invalid_exec_arguments_without_an_explicit_error_remain_drift() {
1038        let logs = vec![
1039            response_item(serde_json::json!({
1040                "type": "function_call",
1041                "name": "exec_command",
1042                "arguments": { "future_command": "pwd" },
1043                "call_id": "future-exec"
1044            })),
1045            response_item(serde_json::json!({
1046                "type": "function_call_output",
1047                "call_id": "future-exec",
1048                "output": "completed"
1049            })),
1050        ];
1051
1052        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1053        assert!(parsed.diagnostics.is_complete_failure());
1054        assert_eq!(parsed.analysis.records[0].tool_call_counts.bash, 0);
1055    }
1056
1057    #[test]
1058    fn valid_token_count_without_model_context_is_not_schema_failure() {
1059        let logs: Vec<CodexLog> = [
1060            serde_json::json!({
1061                "timestamp": "2026-07-12T00:00:00Z",
1062                "type": "session_meta",
1063                "payload": { "id": "compacted-session", "model_provider": "openai" }
1064            }),
1065            serde_json::json!({
1066                "timestamp": "2026-07-12T00:00:01Z",
1067                "type": "event_msg",
1068                "payload": {
1069                    "type": "token_count",
1070                    "info": {
1071                        "total_token_usage": { "input_tokens": 1, "output_tokens": 1 },
1072                        "last_token_usage": { "input_tokens": 1, "output_tokens": 1 },
1073                        "model_context_window": 200000
1074                    }
1075                }
1076            }),
1077        ]
1078        .into_iter()
1079        .map(|value| serde_json::from_value(value).unwrap())
1080        .collect();
1081
1082        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1083        assert!(!parsed.diagnostics.is_complete_failure());
1084        assert_eq!(parsed.diagnostics.partial_failure_count(), 0);
1085        assert_eq!(parsed.diagnostics.relevant_records, 1);
1086        assert_eq!(parsed.diagnostics.normalized_records, 1);
1087        assert_eq!(parsed.diagnostics.failed_relevant_records, 0);
1088        assert!(parsed.analysis.records[0].conversation_usage.is_empty());
1089    }
1090
1091    #[test]
1092    fn resumed_usage_subtracts_the_pre_context_replay_baseline() {
1093        let logs: Vec<CodexLog> = [
1094            serde_json::json!({
1095                "timestamp": "2026-07-12T00:00:00Z",
1096                "type": "session_meta",
1097                "payload": { "id": "resumed-session", "model_provider": "openai" }
1098            }),
1099            serde_json::json!({
1100                "timestamp": "2026-07-12T00:00:01Z",
1101                "type": "event_msg",
1102                "payload": {
1103                    "type": "token_count",
1104                    "info": {
1105                        "total_token_usage": {
1106                            "input_tokens": 100,
1107                            "cached_input_tokens": 20,
1108                            "output_tokens": 30,
1109                            "reasoning_output_tokens": 10,
1110                            "total_tokens": 130
1111                        },
1112                        "model_context_window": 200000
1113                    }
1114                }
1115            }),
1116            serde_json::json!({
1117                "timestamp": "2026-07-12T00:00:02Z",
1118                "type": "turn_context",
1119                "payload": { "model": "gpt-5.3-codex" }
1120            }),
1121            serde_json::json!({
1122                "timestamp": "2026-07-12T00:00:03Z",
1123                "type": "event_msg",
1124                "payload": {
1125                    "type": "token_count",
1126                    "info": {
1127                        "total_token_usage": {
1128                            "input_tokens": 160,
1129                            "cached_input_tokens": 25,
1130                            "output_tokens": 50,
1131                            "reasoning_output_tokens": 15,
1132                            "total_tokens": 210
1133                        },
1134                        "last_token_usage": { "input_tokens": 60, "output_tokens": 20 },
1135                        "model_context_window": 200000
1136                    }
1137                }
1138            }),
1139            serde_json::json!({
1140                "timestamp": "2026-07-12T00:00:04Z",
1141                "type": "event_msg",
1142                "payload": {
1143                    "type": "token_count",
1144                    "info": {
1145                        "total_token_usage": {
1146                            "input_tokens": 190,
1147                            "cached_input_tokens": 30,
1148                            "output_tokens": 60,
1149                            "reasoning_output_tokens": 20,
1150                            "total_tokens": 250
1151                        },
1152                        "last_token_usage": { "input_tokens": 30, "output_tokens": 10 },
1153                        "model_context_window": 200000
1154                    }
1155                }
1156            }),
1157        ]
1158        .into_iter()
1159        .map(|value| serde_json::from_value(value).unwrap())
1160        .collect();
1161
1162        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1163        assert!(!parsed.diagnostics.is_complete_failure());
1164        assert_eq!(parsed.diagnostics.partial_failure_count(), 0);
1165
1166        let usage = &parsed.analysis.records[0].conversation_usage["gpt-5.3-codex"];
1167        assert_eq!(usage["total_token_usage"]["input_tokens"], 90);
1168        assert_eq!(usage["total_token_usage"]["cached_input_tokens"], 10);
1169        assert_eq!(usage["total_token_usage"]["output_tokens"], 30);
1170        assert_eq!(usage["total_token_usage"]["reasoning_output_tokens"], 10);
1171        assert_eq!(usage["total_token_usage"]["total_tokens"], 120);
1172        assert_eq!(usage["last_token_usage"]["output_tokens"], 10);
1173        assert_eq!(usage["model_context_window"], 200000);
1174    }
1175
1176    #[test]
1177    fn pre_context_usage_is_not_guessed_from_a_later_model() {
1178        let logs: Vec<CodexLog> = [
1179            serde_json::json!({
1180                "timestamp": "2026-07-12T00:00:00Z",
1181                "type": "event_msg",
1182                "payload": {
1183                    "type": "token_count",
1184                    "info": { "total_token_usage": { "input_tokens": 10 } }
1185                }
1186            }),
1187            serde_json::json!({
1188                "timestamp": "2026-07-12T00:00:01Z",
1189                "type": "turn_context",
1190                "payload": { "model": "gpt-5.3-codex" }
1191            }),
1192        ]
1193        .into_iter()
1194        .map(|value| serde_json::from_value(value).unwrap())
1195        .collect();
1196
1197        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1198        assert_eq!(parsed.diagnostics.partial_failure_count(), 0);
1199        assert!(parsed.analysis.records[0].conversation_usage.is_empty());
1200    }
1201
1202    #[test]
1203    fn mid_session_model_switch_bills_each_model_its_own_delta() {
1204        // The cumulative counter keeps growing across a model switch; the
1205        // second model must be billed only the post-switch increment, not the
1206        // whole-session cumulative that already contains the first model.
1207        let token_count = |input: i64, cached: i64, output: i64, total: i64| {
1208            serde_json::json!({
1209                "timestamp": "2026-07-12T00:00:00Z",
1210                "type": "event_msg",
1211                "payload": {
1212                    "type": "token_count",
1213                    "info": {
1214                        "total_token_usage": {
1215                            "input_tokens": input,
1216                            "cached_input_tokens": cached,
1217                            "output_tokens": output,
1218                            "reasoning_output_tokens": 0,
1219                            "total_tokens": total
1220                        },
1221                        "model_context_window": 200000
1222                    }
1223                }
1224            })
1225        };
1226        let turn_context = |model: &str| {
1227            serde_json::json!({
1228                "timestamp": "2026-07-12T00:00:00Z",
1229                "type": "turn_context",
1230                "payload": { "model": model }
1231            })
1232        };
1233        let logs: Vec<CodexLog> = [
1234            turn_context("gpt-5.6-luna"),
1235            token_count(20_000, 4_000, 3_289, 23_289),
1236            turn_context("gpt-5.6-sol"),
1237            token_count(60_000, 30_000, 10_000, 70_000),
1238            token_count(90_000, 56_000, 14_576, 104_576),
1239        ]
1240        .into_iter()
1241        .map(|value| serde_json::from_value(value).unwrap())
1242        .collect();
1243
1244        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1245        assert_eq!(parsed.diagnostics.partial_failure_count(), 0);
1246
1247        let usage = &parsed.analysis.records[0].conversation_usage;
1248        let luna = usage["gpt-5.6-luna"]["total_token_usage"]
1249            .as_object()
1250            .unwrap();
1251        let sol = usage["gpt-5.6-sol"]["total_token_usage"]
1252            .as_object()
1253            .unwrap();
1254        assert_eq!(luna["total_tokens"].as_i64().unwrap(), 23_289);
1255        assert_eq!(sol["total_tokens"].as_i64().unwrap(), 104_576 - 23_289);
1256        assert_eq!(
1257            luna["total_tokens"].as_i64().unwrap() + sol["total_tokens"].as_i64().unwrap(),
1258            104_576,
1259            "the two models' attributed totals must reconstruct the session cumulative"
1260        );
1261    }
1262
1263    #[test]
1264    fn per_turn_tier_classification_uses_the_turns_own_context() {
1265        // Two turns: the first's own prompt (last_token_usage.input_tokens)
1266        // is below the 272k threshold, the second's is above. Only the second
1267        // turn's delta lands in above_tier even though the cumulative total
1268        // crosses the threshold much earlier.
1269        let event = |cum_input: i64, cum_out: i64, cum_total: i64, last_input: i64| {
1270            serde_json::json!({
1271                "timestamp": "2026-07-12T00:00:00Z",
1272                "type": "event_msg",
1273                "payload": {
1274                    "type": "token_count",
1275                    "info": {
1276                        "total_token_usage": {
1277                            "input_tokens": cum_input,
1278                            "cached_input_tokens": 0,
1279                            "output_tokens": cum_out,
1280                            "reasoning_output_tokens": 0,
1281                            "total_tokens": cum_total
1282                        },
1283                        "last_token_usage": { "input_tokens": last_input },
1284                        "model_context_window": 400000
1285                    }
1286                }
1287            })
1288        };
1289        let logs: Vec<CodexLog> = [
1290            serde_json::json!({
1291                "timestamp": "2026-07-12T00:00:00Z",
1292                "type": "turn_context",
1293                "payload": { "model": "gpt-5.4" }
1294            }),
1295            event(200_000, 1_000, 201_000, 200_000),
1296            event(500_000, 2_500, 502_500, 300_000),
1297        ]
1298        .into_iter()
1299        .map(|value| serde_json::from_value(value).unwrap())
1300        .collect();
1301
1302        let tiers =
1303            crate::pricing::TierThresholds::from_entries([("gpt-5.4", 272_000)].into_iter());
1304        let parsed =
1305            parse_codex_log_iter_with_diagnostics(&logs, ParseMode::UsageOnly, Some(&tiers))
1306                .unwrap();
1307        let usage = &parsed.analysis.records[0].conversation_usage["gpt-5.4"];
1308        assert_eq!(usage["total_token_usage"]["total_tokens"], 502_500);
1309        // Only the second turn's delta (input 300k, output 1.5k) is above.
1310        assert_eq!(usage["above_tier"]["input_tokens"], 300_000);
1311        assert_eq!(usage["above_tier"]["output_tokens"], 1_500);
1312        assert!(
1313            usage["above_tier"].get("cache_read_tokens").is_none(),
1314            "no cached tokens in this session"
1315        );
1316    }
1317
1318    #[test]
1319    fn malformed_pre_context_usage_remains_schema_failure() {
1320        let logs: Vec<CodexLog> = [serde_json::json!({
1321            "timestamp": "2026-07-12T00:00:00Z",
1322            "type": "event_msg",
1323            "payload": {
1324                "type": "token_count",
1325                "info": { "total_token_usage": { "input_tokens": "10" } }
1326            }
1327        })]
1328        .into_iter()
1329        .map(|value| serde_json::from_value(value).unwrap())
1330        .collect();
1331
1332        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1333        assert!(parsed.diagnostics.is_complete_failure());
1334        assert!(parsed.analysis.records[0].conversation_usage.is_empty());
1335    }
1336
1337    #[test]
1338    fn usage_validation_rejects_unknown_only_and_wrong_typed_token_payloads() {
1339        assert!(is_supported_codex_usage(&serde_json::json!({
1340            "total_token_usage": {}
1341        })));
1342        assert!(is_supported_codex_usage(&serde_json::json!({
1343            "last_token_usage": { "input_tokens": 0, "future_metric": 5 }
1344        })));
1345        assert!(is_supported_codex_usage(&serde_json::json!({
1346            "total_token_usage": { "output_tokens": 0 },
1347            "model_context_window": null
1348        })));
1349        assert!(is_supported_codex_usage(&serde_json::json!({
1350            "model_context_window": 200000
1351        })));
1352
1353        assert!(!is_supported_codex_usage(&serde_json::json!({
1354            "total_token_usage": { "prompt_tokens": 3 }
1355        })));
1356        assert!(!is_supported_codex_usage(&serde_json::json!({
1357            "last_token_usage": { "output_tokens": "3" }
1358        })));
1359        assert!(!is_supported_codex_usage(&serde_json::json!({
1360            "total_token_usage": { "input_tokens": 3 },
1361            "last_token_usage": { "completion_tokens": 1 }
1362        })));
1363        assert!(!is_supported_codex_usage(&serde_json::json!({
1364            "model_context_window": "200000"
1365        })));
1366    }
1367
1368    #[test]
1369    fn exec_command_metadata_prefix_is_stripped() {
1370        // Real-world Codex `exec_command` output wraps actual stdout with
1371        // a 5-line header; without stripping, the analyzer over-counts
1372        // file reads by 5 lines per sed/cat invocation.
1373        let raw = "Chunk ID: deadbeef\n\
1374                   Wall time: 0.0000 seconds\n\
1375                   Process exited with code 0\n\
1376                   Original token count: 100\n\
1377                   Output:\n\
1378                   line one\n\
1379                   line two\n";
1380        assert_eq!(
1381            strip_exec_command_metadata_prefix(raw),
1382            "line one\nline two\n"
1383        );
1384    }
1385
1386    #[test]
1387    fn legacy_shell_output_passes_through_unchanged() {
1388        // Legacy `shell` function output has no metadata header; the
1389        // helper must leave non-prefixed strings exactly as-is so the
1390        // existing fixture-based tests keep matching.
1391        let raw = "line one\nline two\n";
1392        assert_eq!(strip_exec_command_metadata_prefix(raw), raw);
1393    }
1394
1395    #[test]
1396    fn output_starting_with_marker_handles_no_leading_newline() {
1397        // Defensive: if a future Codex variant drops the leading newline
1398        // before the `Output:` marker, still strip it cleanly.
1399        let raw = "Output:\nthe content";
1400        assert_eq!(strip_exec_command_metadata_prefix(raw), "the content");
1401    }
1402
1403    #[test]
1404    fn custom_apply_patch_requires_supported_nonempty_file_headers() {
1405        let drifted = "*** Begin Patch\n*** Future File: src/lib.rs\n+new\n*** End Patch";
1406        assert!(
1407            parse_custom_call("apply_patch", drifted, 1, ParseMode::Full).is_none(),
1408            "an unknown file header must not normalize as a successful patch"
1409        );
1410
1411        for patch in [
1412            "*** Begin Patch\n*** Add File: empty.txt\n*** End Patch",
1413            "*** Begin Patch\n*** Delete File: empty.txt\n*** End Patch",
1414        ] {
1415            let Some(CodexCustomCall::ApplyPatch { patches, .. }) =
1416                parse_custom_call("apply_patch", patch, 1, ParseMode::Full)
1417            else {
1418                panic!("supported empty-body patch was rejected");
1419            };
1420            assert_eq!(patches.len(), 1);
1421            assert_eq!(patches[0].file_path, "empty.txt");
1422        }
1423
1424        let empty_path = "*** Begin Patch\n*** Add File:\n+new\n*** End Patch";
1425        assert!(parse_custom_call("apply_patch", empty_path, 1, ParseMode::Full).is_none());
1426    }
1427
1428    #[test]
1429    fn direct_custom_apply_patch_success_is_paired_and_parsed() {
1430        let patch = "*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch";
1431        let wire_result = serde_json::json!({
1432            "output": "Success. Updated the following files:\nM src/lib.rs",
1433            "metadata": { "exit_code": 0, "duration_seconds": 0.01 }
1434        })
1435        .to_string();
1436        let logs = vec![
1437            response_item(serde_json::json!({
1438                "type": "custom_tool_call",
1439                "name": "apply_patch",
1440                "input": patch,
1441                "call_id": "patch-1"
1442            })),
1443            custom_output("patch-1", Value::String(wire_result)),
1444        ];
1445
1446        let analysis = parse_codex_logs(&logs, ParseMode::Full).unwrap();
1447        let record = &analysis.records[0];
1448        assert_eq!(record.tool_call_counts.edit, 1);
1449        assert_eq!(record.edit_file_details.len(), 1);
1450        assert_eq!(record.edit_file_details[0].base.file_path, "src/lib.rs");
1451    }
1452
1453    #[test]
1454    fn direct_object_custom_apply_patch_output_is_paired_and_parsed() {
1455        let patch = "*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch";
1456        let logs = vec![
1457            response_item(serde_json::json!({
1458                "type": "custom_tool_call",
1459                "name": "apply_patch",
1460                "input": patch,
1461                "call_id": "patch-object"
1462            })),
1463            custom_output(
1464                "patch-object",
1465                serde_json::json!({
1466                    "output": "Success. Updated the following files:\nM src/lib.rs",
1467                    "metadata": { "exit_code": 0, "duration_seconds": 0.01 }
1468                }),
1469            ),
1470        ];
1471
1472        let analysis = parse_codex_logs(&logs, ParseMode::Full).unwrap();
1473        let record = &analysis.records[0];
1474        assert_eq!(record.tool_call_counts.edit, 1);
1475        assert_eq!(record.edit_file_details.len(), 1);
1476    }
1477
1478    #[test]
1479    fn failed_direct_custom_apply_patch_is_skipped() {
1480        let patch = "*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch";
1481        let wire_result = serde_json::json!({
1482            "output": "Failed to find expected lines in src/lib.rs",
1483            "metadata": { "exit_code": 1, "duration_seconds": 0.01 }
1484        })
1485        .to_string();
1486        let logs = vec![
1487            response_item(serde_json::json!({
1488                "type": "custom_tool_call",
1489                "name": "apply_patch",
1490                "input": patch,
1491                "call_id": "patch-failed"
1492            })),
1493            custom_output("patch-failed", Value::String(wire_result)),
1494        ];
1495
1496        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1497        let record = &parsed.analysis.records[0];
1498        assert_eq!(record.tool_call_counts.edit, 0);
1499        assert!(record.edit_file_details.is_empty());
1500        assert_eq!(parsed.diagnostics.partial_failure_count(), 0);
1501    }
1502
1503    #[test]
1504    fn unknown_direct_custom_apply_patch_result_is_skipped() {
1505        let patch = "*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch";
1506        for output in [
1507            Value::Null,
1508            serde_json::json!("Patch command finished"),
1509            serde_json::json!({
1510                "success": true,
1511                "updated_files": ["src/lib.rs"]
1512            }),
1513        ] {
1514            let logs = vec![
1515                response_item(serde_json::json!({
1516                    "type": "custom_tool_call",
1517                    "name": "apply_patch",
1518                    "input": patch,
1519                    "call_id": "patch-unknown"
1520                })),
1521                custom_output("patch-unknown", output),
1522            ];
1523
1524            let parsed =
1525                parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1526            let record = &parsed.analysis.records[0];
1527            assert_eq!(record.tool_call_counts.edit, 0);
1528            assert!(record.edit_file_details.is_empty());
1529            assert_eq!(parsed.diagnostics.relevant_records, 2);
1530            assert_eq!(parsed.diagnostics.normalized_records, 1);
1531            assert_eq!(parsed.diagnostics.failed_relevant_records, 1);
1532            assert_eq!(parsed.diagnostics.partial_failure_count(), 1);
1533            assert!(!parsed.diagnostics.is_complete_failure());
1534        }
1535    }
1536
1537    #[test]
1538    fn custom_output_envelope_is_stripped_before_json_result() {
1539        let result = serde_json::json!({
1540            "output": "Success. Updated the following files:\nM src/lib.rs",
1541            "metadata": { "exit_code": 0, "duration_seconds": 0.01 }
1542        })
1543        .to_string();
1544        let output = custom_output(
1545            "patch-array",
1546            serde_json::json!([
1547                { "type": "input_text", "text": "Script completed\nWall time: 0.1 seconds\nOutput:\n" },
1548                { "type": "input_text", "text": result }
1549            ]),
1550        )
1551        .payload
1552        .output;
1553
1554        assert_eq!(
1555            normalize_custom_output(output.as_deref()),
1556            "Success. Updated the following files:\nM src/lib.rs"
1557        );
1558        assert_eq!(
1559            custom_apply_patch_result(output.as_deref()),
1560            CustomApplyPatchResult::Success
1561        );
1562    }
1563
1564    #[test]
1565    fn custom_exec_is_one_bash_cell_not_nested_operations() {
1566        let input = "const results = await Promise.all([tools.exec_command({cmd: \"sed -n '1,2p' src/lib.rs\"}), tools.apply_patch(`*** Begin Patch\n*** Add File: notes.txt\n+hello\n*** End Patch`)]); text(JSON.stringify(results));";
1567        let logs = vec![
1568            response_item(serde_json::json!({
1569                "type": "custom_tool_call",
1570                "name": "exec",
1571                "input": input,
1572                "call_id": "exec-1"
1573            })),
1574            custom_output("exec-1", serde_json::json!("Script completed")),
1575        ];
1576
1577        let analysis = parse_codex_logs(&logs, ParseMode::Full).unwrap();
1578        let record = &analysis.records[0];
1579        assert_eq!(record.tool_call_counts.bash, 1);
1580        assert_eq!(record.tool_call_counts.read, 0);
1581        assert_eq!(record.tool_call_counts.write, 0);
1582        assert_eq!(record.tool_call_counts.edit, 0);
1583        assert_eq!(record.run_command_details.len(), 1);
1584        assert_eq!(record.run_command_details[0].command, input);
1585    }
1586
1587    #[test]
1588    fn custom_exec_usage_only_keeps_count_without_source_detail() {
1589        let logs = vec![
1590            response_item(serde_json::json!({
1591                "type": "custom_tool_call",
1592                "name": "exec",
1593                "input": "await tools.exec_command({cmd: dynamicCommand});",
1594                "call_id": "exec-dynamic"
1595            })),
1596            custom_output("exec-dynamic", serde_json::json!("Script completed")),
1597        ];
1598
1599        let analysis = parse_codex_log_iter(logs, ParseMode::UsageOnly).unwrap();
1600        let record = &analysis.records[0];
1601        assert_eq!(record.tool_call_counts.bash, 1);
1602        assert!(record.run_command_details.is_empty());
1603    }
1604
1605    #[test]
1606    fn current_exec_command_apply_patch_marker_is_parsed() {
1607        let args = serde_json::json!({
1608            "cmd": "apply_patch <<'PATCH'\n*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch\nPATCH"
1609        })
1610        .to_string();
1611        let call = parse_function_call("exec_command", &args, 1).unwrap();
1612        let mut state = SessionParseState::new();
1613        state.handle_shell_call(
1614            call,
1615            CodexShellOutput {
1616                output: String::new(),
1617                metadata: None,
1618            },
1619        );
1620        assert_eq!(state.tool_counts.edit, 1);
1621        assert_eq!(state.edit_details.len(), 1);
1622    }
1623
1624    #[test]
1625    fn patch_apply_end_success_counts_add_as_write_and_update_as_edit() {
1626        let changes = serde_json::json!({
1627            "/repo/new.rs": {
1628                "type": "add",
1629                "unified_diff": "@@ -0,0 +1,2 @@\n+fn main() {}\n+// added\n"
1630            },
1631            "/repo/lib.rs": {
1632                "type": "update",
1633                "unified_diff": "@@ -1,1 +1,1 @@\n-old\n+new\n"
1634            }
1635        });
1636        let logs = vec![patch_apply_end("call-1", true, changes)];
1637
1638        let analysis = parse_codex_logs(&logs, ParseMode::Full).unwrap();
1639        let record = &analysis.records[0];
1640        assert_eq!(record.tool_call_counts.write, 1);
1641        assert_eq!(record.tool_call_counts.edit, 1);
1642        assert_eq!(record.total_unique_files, 2);
1643        assert_eq!(record.write_file_details[0].base.file_path, "/repo/new.rs");
1644        assert_eq!(record.edit_file_details[0].base.file_path, "/repo/lib.rs");
1645    }
1646
1647    #[test]
1648    fn patch_apply_end_without_unified_diff_still_counts_the_operation() {
1649        // Observed in real 2026-06 logs: an `add` entry with no unified_diff.
1650        // The write must count (invocation + unique file, zero line/char
1651        // content) instead of flagging the record as schema drift.
1652        let changes = serde_json::json!({
1653            "/repo/generated.bin": { "type": "add" }
1654        });
1655        let logs = vec![patch_apply_end("call-nodiff", true, changes)];
1656
1657        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1658        let record = &parsed.analysis.records[0];
1659        assert_eq!(record.tool_call_counts.write, 1);
1660        assert_eq!(record.total_unique_files, 1);
1661        assert_eq!(record.total_write_lines, 0);
1662        assert_eq!(parsed.diagnostics.partial_failure_count(), 0);
1663    }
1664
1665    #[test]
1666    fn patch_apply_end_failure_creates_no_file_ops() {
1667        let changes = serde_json::json!({
1668            "/repo/x.rs": { "type": "add", "unified_diff": "@@ -0,0 +1,1 @@\n+nope\n" }
1669        });
1670        let logs = vec![patch_apply_end("call-fail", false, changes)];
1671
1672        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1673        let record = &parsed.analysis.records[0];
1674        assert_eq!(record.tool_call_counts.write, 0);
1675        assert_eq!(record.total_unique_files, 0);
1676        assert!(record.write_file_details.is_empty());
1677        // Recognized event, but a failed apply is not a relevant normalization.
1678        assert_eq!(parsed.diagnostics.relevant_records, 0);
1679        assert!(!parsed.diagnostics.is_complete_failure());
1680    }
1681
1682    #[test]
1683    fn patch_apply_end_malformed_changes_is_relevant_failure() {
1684        // success is true but `changes` is not an object → schema drift.
1685        let logs = vec![patch_apply_end(
1686            "call-x",
1687            true,
1688            Value::String("nope".into()),
1689        )];
1690
1691        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1692        assert_eq!(parsed.analysis.records[0].tool_call_counts.edit, 0);
1693        assert_eq!(parsed.analysis.records[0].tool_call_counts.write, 0);
1694        assert!(parsed.diagnostics.is_complete_failure());
1695    }
1696
1697    #[test]
1698    fn patch_apply_end_deduped_against_direct_apply_patch_before_output() {
1699        let patch = "*** Begin Patch\n*** Update File: lib.rs\n@@\n-old\n+new\n*** End Patch";
1700        let changes = serde_json::json!({
1701            "lib.rs": { "type": "update", "unified_diff": "@@ -1,1 +1,1 @@\n-old\n+new\n" }
1702        });
1703        // Event arrives between the custom_tool_call and its output.
1704        let logs = vec![
1705            response_item(serde_json::json!({
1706                "type": "custom_tool_call",
1707                "name": "apply_patch",
1708                "input": patch,
1709                "call_id": "shared-call"
1710            })),
1711            patch_apply_end("shared-call", true, changes),
1712            apply_patch_success_output("lib.rs"),
1713        ];
1714
1715        let analysis = parse_codex_logs(&logs, ParseMode::Full).unwrap();
1716        let record = &analysis.records[0];
1717        assert_eq!(record.tool_call_counts.edit, 1);
1718        assert_eq!(record.edit_file_details.len(), 1);
1719    }
1720
1721    #[test]
1722    fn patch_apply_end_deduped_against_direct_apply_patch_after_output() {
1723        let patch = "*** Begin Patch\n*** Update File: lib.rs\n@@\n-old\n+new\n*** End Patch";
1724        let changes = serde_json::json!({
1725            "lib.rs": { "type": "update", "unified_diff": "@@ -1,1 +1,1 @@\n-old\n+new\n" }
1726        });
1727        // Event arrives after the pending call was already consumed by its output.
1728        let logs = vec![
1729            response_item(serde_json::json!({
1730                "type": "custom_tool_call",
1731                "name": "apply_patch",
1732                "input": patch,
1733                "call_id": "shared-call"
1734            })),
1735            apply_patch_success_output("lib.rs"),
1736            patch_apply_end("shared-call", true, changes),
1737        ];
1738
1739        let analysis = parse_codex_logs(&logs, ParseMode::Full).unwrap();
1740        let record = &analysis.records[0];
1741        assert_eq!(record.tool_call_counts.edit, 1);
1742        assert_eq!(record.edit_file_details.len(), 1);
1743    }
1744
1745    #[test]
1746    fn unrecognized_direct_output_does_not_suppress_authoritative_event() {
1747        // A direct apply_patch whose output envelope is unrecognized counts
1748        // nothing (Unknown); the paired successful patch_apply_end is the only
1749        // authoritative record and must still be folded in.
1750        let patch = "*** Begin Patch\n*** Update File: lib.rs\n@@\n-old\n+new\n*** End Patch";
1751        let changes = serde_json::json!({
1752            "lib.rs": { "type": "update", "unified_diff": "@@ -1,1 +1,1 @@\n-old\n+new\n" }
1753        });
1754        let logs = vec![
1755            response_item(serde_json::json!({
1756                "type": "custom_tool_call",
1757                "name": "apply_patch",
1758                "input": patch,
1759                "call_id": "shared-call"
1760            })),
1761            custom_output("shared-call", Value::String("<unknown envelope>".into())),
1762            patch_apply_end("shared-call", true, changes),
1763        ];
1764
1765        let analysis = parse_codex_logs(&logs, ParseMode::Full).unwrap();
1766        let record = &analysis.records[0];
1767        assert_eq!(record.tool_call_counts.edit, 1);
1768        assert_eq!(record.edit_file_details.len(), 1);
1769    }
1770
1771    #[test]
1772    fn patch_apply_end_delete_without_diff_counts_the_operation() {
1773        let changes = serde_json::json!({
1774            "/repo/gone.rs": { "type": "delete" }
1775        });
1776        let logs = vec![patch_apply_end("call-del", true, changes)];
1777
1778        let parsed = parse_codex_log_iter_with_diagnostics(&logs, ParseMode::Full, None).unwrap();
1779        let record = &parsed.analysis.records[0];
1780        assert_eq!(record.tool_call_counts.edit, 1);
1781        assert_eq!(record.total_unique_files, 1);
1782        assert_eq!(record.total_edit_lines, 0);
1783        assert_eq!(parsed.diagnostics.partial_failure_count(), 0);
1784    }
1785
1786    #[test]
1787    fn patch_apply_end_scalar_counts_match_across_parse_modes() {
1788        let changes = serde_json::json!({
1789            "/repo/new.rs": {
1790                "type": "add",
1791                "unified_diff": "@@ -0,0 +1,2 @@\n+fn main() {}\n+// added\n"
1792            },
1793            "/repo/lib.rs": {
1794                "type": "update",
1795                "unified_diff": "@@ -1,1 +1,1 @@\n-old\n+new\n"
1796            }
1797        });
1798        let build = || vec![patch_apply_end("call-1", true, changes.clone())];
1799
1800        let full = parse_codex_logs(&build(), ParseMode::Full).unwrap();
1801        let usage = parse_codex_logs(&build(), ParseMode::UsageOnly).unwrap();
1802        let (f, u) = (&full.records[0], &usage.records[0]);
1803
1804        assert_eq!(f.tool_call_counts.write, u.tool_call_counts.write);
1805        assert_eq!(f.tool_call_counts.edit, u.tool_call_counts.edit);
1806        assert_eq!(f.total_unique_files, u.total_unique_files);
1807        assert_eq!(f.total_write_lines, u.total_write_lines);
1808        assert_eq!(f.total_edit_lines, u.total_edit_lines);
1809        assert_eq!(f.total_write_characters, u.total_write_characters);
1810        assert_eq!(f.total_edit_characters, u.total_edit_characters);
1811        // Detail bodies are retained only in Full mode.
1812        assert!(!f.write_file_details.is_empty());
1813        assert!(u.write_file_details.is_empty());
1814    }
1815}