1use 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
25pub fn parse_codex_logs(logs: &[CodexLog], mode: ParseMode) -> Result<CodeAnalysis> {
38 parse_codex_log_iter(logs, mode)
39}
40
41pub(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
50pub(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 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 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); }
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); }
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 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 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(¤t_model, request_context)
173 });
174 process_codex_usage(
175 &mut conversation_usage,
176 ¤t_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 if entry.payload.payload_type.as_deref() == Some("patch_apply_end") {
192 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 }
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 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 } 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
399fn 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
426fn 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
444fn 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 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 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
572fn 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
586trait CodexAnalysisExt {
591 fn handle_shell_call(&mut self, call: CodexShellCall, output: CodexShellOutput);
594 fn handle_patch(&mut self, patch: CodexPatch, ts: i64);
596 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 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 let output_body = strip_exec_command_metadata_prefix(&output.output);
616
617 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 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 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 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
673struct CodexShellCall {
676 timestamp: i64,
678 script: String,
680 full_command: Vec<String>,
682}
683
684struct CodexPatch {
686 action: String,
688 file_path: String,
690 lines: Vec<String>,
692}
693
694fn 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 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), });
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
766fn 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
786fn 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 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
813fn extract_patch_strings(lines: &[String]) -> (String, String) {
819 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 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
859fn 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
875fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(!f.write_file_details.is_empty());
1813 assert!(u.write_file_details.is_empty());
1814 }
1815}