Skip to main content

tsift_agent_doc/
session_cost.rs

1use anyhow::{Result, bail};
2use serde::Serialize;
3use serde_json::Value;
4use std::collections::{BTreeMap, BTreeSet};
5
6use tsift_quality::runtime_churn::{RestartChurnState, RestartChurnSummary};
7
8const MAX_LARGEST_TURNS: usize = 5;
9const MAX_RUNTIME_EVENTS: usize = 8;
10const MAX_GUARDRAILS: usize = 8;
11const MAX_LOOP_CLUSTERS: usize = 8;
12const MAX_FILE_READ_DIAGNOSTICS: usize = 8;
13const MAX_COMMANDS_PER_BUNDLE: usize = 6;
14const PROMPT_BUDGET_WARN_TOKENS: u64 = 100_000;
15const CACHED_RATIO_WARN_PERCENT: f64 = 90.0;
16const CACHED_RATIO_WARN_PROMPT_TOKENS: u64 = 50_000;
17const RESTART_LOOP_WARN_OCCURRENCES: usize = 3;
18const NOOP_CLOSEOUT_WARN_OCCURRENCES: usize = 3;
19const DEFAULT_FULL_FILE_READ_TOKENS: u64 = 4_000;
20const ESTIMATED_TOKENS_PER_SOURCE_LINE: u64 = 18;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum SessionCostSource {
25    ClaudeJsonl,
26    CodexJsonl,
27    AgentDocLog,
28}
29
30impl SessionCostSource {
31    pub fn parse(raw: &str) -> Result<Self> {
32        match raw.trim().to_ascii_lowercase().as_str() {
33            "claude" | "claude-jsonl" => Ok(Self::ClaudeJsonl),
34            "codex" | "codex-jsonl" => Ok(Self::CodexJsonl),
35            "agent-doc-log" | "agent_doc_log" | "log" => Ok(Self::AgentDocLog),
36            other => bail!(
37                "unsupported session-cost source `{other}`; expected claude-jsonl, codex-jsonl, or agent-doc-log"
38            ),
39        }
40    }
41
42    pub fn as_str(self) -> &'static str {
43        match self {
44            Self::ClaudeJsonl => "claude_jsonl",
45            Self::CodexJsonl => "codex_jsonl",
46            Self::AgentDocLog => "agent_doc_log",
47        }
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct SessionCostTurn {
53    pub label: String,
54    pub prompt_tokens: u64,
55    pub cached_input_tokens: u64,
56    pub cache_creation_input_tokens: u64,
57    pub output_tokens: u64,
58    pub reasoning_output_tokens: u64,
59    pub total_tokens: u64,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct SessionCostRuntimeEvent {
64    pub event: String,
65    pub occurrences: usize,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
69pub struct SessionCostGuardrail {
70    pub kind: String,
71    pub severity: String,
72    pub message: String,
73    pub guidance: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct SessionCostLoopCluster {
78    pub kind: String,
79    pub label: String,
80    pub occurrences: usize,
81    pub max_consecutive: usize,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct SessionCostFileReadDiagnostic {
86    pub path: String,
87    pub range: String,
88    pub occurrences: usize,
89    pub estimated_tokens: u64,
90    pub duplicate_estimated_tokens: u64,
91    pub follow_up_commands: Vec<String>,
92}
93
94#[derive(Debug, Clone, Default)]
95pub struct SessionCostGuardrailInput {
96    pub largest_prompt_turn_tokens: u64,
97    pub largest_prompt_turn_label: Option<String>,
98    pub prompt_tokens: u64,
99    pub cached_input_ratio: Option<f64>,
100    pub fresh_restart_occurrences: usize,
101    pub auto_trigger_timeout_occurrences: usize,
102    pub ctrl_d_restart_loop_occurrences: usize,
103    pub noop_closeout_occurrences: usize,
104    pub max_restart_count: Option<usize>,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize)]
108pub struct SessionCostReport {
109    pub source: String,
110    pub record_count: usize,
111    pub usage_samples: usize,
112    pub prompt_tokens: u64,
113    pub cached_input_tokens: u64,
114    pub cache_creation_input_tokens: u64,
115    pub output_tokens: u64,
116    pub reasoning_output_tokens: u64,
117    pub total_tokens: u64,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub cached_input_ratio: Option<f64>,
120    pub largest_turn_total_tokens: u64,
121    pub runtime_event_groups: usize,
122    pub total_runtime_events: usize,
123    pub restart_churn_groups: usize,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub max_restart_count: Option<usize>,
126    pub largest_turns: Vec<SessionCostTurn>,
127    pub runtime_events: Vec<SessionCostRuntimeEvent>,
128    #[serde(skip_serializing_if = "Vec::is_empty", default)]
129    pub loop_clusters: Vec<SessionCostLoopCluster>,
130    #[serde(skip_serializing_if = "Vec::is_empty", default)]
131    pub file_read_diagnostics: Vec<SessionCostFileReadDiagnostic>,
132    #[serde(skip_serializing_if = "Vec::is_empty", default)]
133    pub restart_churn: Vec<RestartChurnSummary>,
134    #[serde(skip_serializing_if = "Vec::is_empty", default)]
135    pub guardrails: Vec<SessionCostGuardrail>,
136    #[serde(skip_serializing_if = "Vec::is_empty", default)]
137    pub warnings: Vec<String>,
138}
139
140#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
141struct UsageTotals {
142    prompt_tokens: u64,
143    cached_input_tokens: u64,
144    cache_creation_input_tokens: u64,
145    output_tokens: u64,
146    reasoning_output_tokens: u64,
147    total_tokens: u64,
148}
149
150impl UsageTotals {
151    fn delta_from(self, previous: Self) -> Self {
152        Self {
153            prompt_tokens: self.prompt_tokens.saturating_sub(previous.prompt_tokens),
154            cached_input_tokens: self
155                .cached_input_tokens
156                .saturating_sub(previous.cached_input_tokens),
157            cache_creation_input_tokens: self
158                .cache_creation_input_tokens
159                .saturating_sub(previous.cache_creation_input_tokens),
160            output_tokens: self.output_tokens.saturating_sub(previous.output_tokens),
161            reasoning_output_tokens: self
162                .reasoning_output_tokens
163                .saturating_sub(previous.reasoning_output_tokens),
164            total_tokens: self.total_tokens.saturating_sub(previous.total_tokens),
165        }
166    }
167
168    fn is_zero(self) -> bool {
169        self.prompt_tokens == 0
170            && self.cached_input_tokens == 0
171            && self.cache_creation_input_tokens == 0
172            && self.output_tokens == 0
173            && self.reasoning_output_tokens == 0
174            && self.total_tokens == 0
175    }
176}
177
178#[derive(Debug, Default)]
179struct CostState {
180    warnings: Vec<String>,
181    usage_turns: Vec<SessionCostTurn>,
182    runtime_events: BTreeMap<String, usize>,
183    seen_document_cycle_events: BTreeSet<(String, String)>,
184    total_runtime_events: usize,
185    max_restart_count: Option<usize>,
186    restart_churn: RestartChurnState,
187    pending_commands: Vec<String>,
188    loop_signals: Vec<LoopSignal>,
189    file_read_signals: Vec<FileReadSignal>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
193struct LoopSignal {
194    kind: LoopClusterKind,
195    label: String,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199struct FileReadSignal {
200    path: String,
201    range: String,
202    start: Option<usize>,
203    lines: Option<usize>,
204    estimated_tokens: u64,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
208enum LoopClusterKind {
209    PromptRepeat,
210    CommandBundle,
211    CloseoutChurn,
212}
213
214impl LoopClusterKind {
215    fn as_str(self) -> &'static str {
216        match self {
217            Self::PromptRepeat => "prompt_repeat",
218            Self::CommandBundle => "command_bundle",
219            Self::CloseoutChurn => "closeout_churn",
220        }
221    }
222}
223
224#[derive(Debug, Clone)]
225enum TranscriptBlock {
226    Text { role: Option<String>, text: String },
227    ToolUse { name: String, input: Value },
228}
229
230pub fn compute(input: &str, source_hint: Option<&str>) -> Result<SessionCostReport> {
231    if input.trim().is_empty() {
232        bail!(
233            "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
234        );
235    }
236
237    let source = resolve_source(input, source_hint)?;
238    let mut state = CostState::default();
239    let record_count = input.lines().filter(|line| !line.trim().is_empty()).count();
240
241    match source {
242        SessionCostSource::ClaudeJsonl => ingest_claude_jsonl(input, &mut state)?,
243        SessionCostSource::CodexJsonl => ingest_codex_jsonl(input, &mut state)?,
244        SessionCostSource::AgentDocLog => ingest_agent_doc_log(input, &mut state),
245    }
246
247    let usage_samples = state.usage_turns.len();
248    let mut prompt_tokens = 0_u64;
249    let mut cached_input_tokens = 0_u64;
250    let mut cache_creation_input_tokens = 0_u64;
251    let mut output_tokens = 0_u64;
252    let mut reasoning_output_tokens = 0_u64;
253    let mut total_tokens = 0_u64;
254    let mut largest_turn_total_tokens = 0_u64;
255    for turn in &state.usage_turns {
256        prompt_tokens += turn.prompt_tokens;
257        cached_input_tokens += turn.cached_input_tokens;
258        cache_creation_input_tokens += turn.cache_creation_input_tokens;
259        output_tokens += turn.output_tokens;
260        reasoning_output_tokens += turn.reasoning_output_tokens;
261        total_tokens += turn.total_tokens;
262        largest_turn_total_tokens = largest_turn_total_tokens.max(turn.total_tokens);
263    }
264
265    let cached_input_ratio = (prompt_tokens > 0).then_some(
266        ((cached_input_tokens as f64) / (prompt_tokens as f64) * 10_000.0).round() / 100.0,
267    );
268    let largest_prompt_turn = state
269        .usage_turns
270        .iter()
271        .max_by(|left, right| {
272            left.prompt_tokens
273                .cmp(&right.prompt_tokens)
274                .then(left.label.cmp(&right.label))
275        })
276        .map(|turn| (turn.prompt_tokens, turn.label.clone()));
277    let noop_closeout_occurrences = state
278        .runtime_events
279        .get("commit_already_current")
280        .copied()
281        .unwrap_or(0);
282    flush_pending_commands(&mut state);
283    let loop_clusters = collect_loop_clusters(&state.loop_signals);
284    let file_read_diagnostics = collect_file_read_diagnostics(&state.file_read_signals);
285
286    let mut largest_turns = state.usage_turns;
287    largest_turns.sort_by(|left, right| {
288        right
289            .total_tokens
290            .cmp(&left.total_tokens)
291            .then(right.prompt_tokens.cmp(&left.prompt_tokens))
292            .then(left.label.cmp(&right.label))
293    });
294    largest_turns.truncate(MAX_LARGEST_TURNS);
295
296    let mut runtime_events = state
297        .runtime_events
298        .into_iter()
299        .map(|(event, occurrences)| SessionCostRuntimeEvent { event, occurrences })
300        .collect::<Vec<_>>();
301    runtime_events.sort_by(|left, right| {
302        right
303            .occurrences
304            .cmp(&left.occurrences)
305            .then(left.event.cmp(&right.event))
306    });
307    let runtime_event_groups = runtime_events.len();
308    runtime_events.truncate(MAX_RUNTIME_EVENTS);
309    let restart_churn_groups = state.restart_churn.groups();
310    let restart_churn = state.restart_churn.summaries();
311    let guardrails = derive_guardrails(&SessionCostGuardrailInput {
312        largest_prompt_turn_tokens: largest_prompt_turn.as_ref().map_or(0, |turn| turn.0),
313        largest_prompt_turn_label: largest_prompt_turn.as_ref().map(|turn| turn.1.clone()),
314        prompt_tokens,
315        cached_input_ratio,
316        fresh_restart_occurrences: count_restart_family(&restart_churn, "fresh_restart"),
317        auto_trigger_timeout_occurrences: count_restart_family(
318            &restart_churn,
319            "auto_trigger_timeout",
320        ),
321        ctrl_d_restart_loop_occurrences: count_restart_family(
322            &restart_churn,
323            "ctrl_d_restart_loop",
324        ),
325        noop_closeout_occurrences,
326        max_restart_count: state.max_restart_count,
327    });
328
329    if usage_samples == 0 && runtime_event_groups == 0 {
330        state
331            .warnings
332            .push("no cost or runtime signals were detected in the provided input".to_string());
333    }
334
335    Ok(SessionCostReport {
336        source: source.as_str().to_string(),
337        record_count,
338        usage_samples,
339        prompt_tokens,
340        cached_input_tokens,
341        cache_creation_input_tokens,
342        output_tokens,
343        reasoning_output_tokens,
344        total_tokens,
345        cached_input_ratio,
346        largest_turn_total_tokens,
347        runtime_event_groups,
348        total_runtime_events: state.total_runtime_events,
349        restart_churn_groups,
350        max_restart_count: state.max_restart_count,
351        largest_turns,
352        runtime_events,
353        loop_clusters,
354        file_read_diagnostics,
355        restart_churn,
356        guardrails,
357        warnings: state.warnings,
358    })
359}
360
361pub fn derive_guardrails(input: &SessionCostGuardrailInput) -> Vec<SessionCostGuardrail> {
362    let mut guardrails = Vec::new();
363
364    if input.largest_prompt_turn_tokens >= PROMPT_BUDGET_WARN_TOKENS {
365        let label = input
366            .largest_prompt_turn_label
367            .as_deref()
368            .map(|label| format!(" at {label}"))
369            .unwrap_or_default();
370        guardrails.push(SessionCostGuardrail {
371            kind: "prompt_budget".to_string(),
372            severity: "warn".to_string(),
373            message: format!(
374                "largest prompt turn reached {} tokens{label}",
375                input.largest_prompt_turn_tokens
376            ),
377            guidance:
378                "compact the session or split the task before another large turn resends the same context"
379                    .to_string(),
380        });
381    }
382
383    if input.prompt_tokens >= CACHED_RATIO_WARN_PROMPT_TOKENS
384        && input
385            .cached_input_ratio
386            .is_some_and(|ratio| ratio >= CACHED_RATIO_WARN_PERCENT)
387    {
388        guardrails.push(SessionCostGuardrail {
389            kind: "cache_resend".to_string(),
390            severity: "warn".to_string(),
391            message: format!(
392                "cached input ratio was {:.2}% across {} prompt tokens",
393                input.cached_input_ratio.unwrap_or_default(),
394                input.prompt_tokens
395            ),
396            guidance:
397                "compact or restart the session when most prompt spend is cached context instead of new work"
398                    .to_string(),
399        });
400    }
401
402    let restart_signal_count = input.fresh_restart_occurrences
403        + input.auto_trigger_timeout_occurrences
404        + input.ctrl_d_restart_loop_occurrences;
405    if restart_signal_count >= RESTART_LOOP_WARN_OCCURRENCES
406        || input.ctrl_d_restart_loop_occurrences > 0
407        || input.auto_trigger_timeout_occurrences > 0
408    {
409        let max_restart = input
410            .max_restart_count
411            .map(|count| format!(" max_restart={count}."))
412            .unwrap_or_default();
413        guardrails.push(SessionCostGuardrail {
414            kind: "restart_loop".to_string(),
415            severity: "warn".to_string(),
416            message: format!(
417                "restart churn detected: fresh_restart={} auto_trigger_timeout={} ctrl_d_restart_loop={}.{}",
418                input.fresh_restart_occurrences,
419                input.auto_trigger_timeout_occurrences,
420                input.ctrl_d_restart_loop_occurrences,
421                max_restart
422            )
423            .trim()
424            .to_string(),
425            guidance:
426                "fix the startup/retry issue before another restart, or compact and reopen cleanly instead of looping"
427                    .to_string(),
428        });
429    }
430
431    if input.noop_closeout_occurrences >= NOOP_CLOSEOUT_WARN_OCCURRENCES {
432        guardrails.push(SessionCostGuardrail {
433            kind: "noop_closeout".to_string(),
434            severity: "warn".to_string(),
435            message: format!(
436                "commit_already_current appeared {} times",
437                input.noop_closeout_occurrences
438            ),
439            guidance:
440                "compact the document or avoid reopening it without new edits when closeouts are mostly no-ops"
441                    .to_string(),
442        });
443    }
444
445    guardrails.truncate(MAX_GUARDRAILS);
446    guardrails
447}
448
449fn resolve_source(input: &str, source_hint: Option<&str>) -> Result<SessionCostSource> {
450    if let Some(raw) = source_hint {
451        return SessionCostSource::parse(raw);
452    }
453
454    let non_empty = input
455        .lines()
456        .map(str::trim)
457        .filter(|line| !line.is_empty())
458        .collect::<Vec<_>>();
459    if non_empty.is_empty() {
460        bail!(
461            "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
462        );
463    }
464
465    if non_empty
466        .iter()
467        .all(|line| line.starts_with('{') && serde_json::from_str::<Value>(line).is_ok())
468    {
469        for line in &non_empty {
470            let value = serde_json::from_str::<Value>(line).unwrap_or(Value::Null);
471            if value
472                .get("message")
473                .and_then(|message| message.get("usage"))
474                .is_some()
475            {
476                return Ok(SessionCostSource::ClaudeJsonl);
477            }
478            if value.get("type").and_then(Value::as_str) == Some("event_msg")
479                && value
480                    .get("payload")
481                    .and_then(|payload| payload.get("type"))
482                    .and_then(Value::as_str)
483                    == Some("token_count")
484            {
485                return Ok(SessionCostSource::CodexJsonl);
486            }
487        }
488        if non_empty.iter().any(|line| line.contains("\"parentUuid\"")) {
489            return Ok(SessionCostSource::ClaudeJsonl);
490        }
491        if non_empty
492            .iter()
493            .any(|line| line.contains("\"response_item\"") || line.contains("\"turn_context\""))
494        {
495            return Ok(SessionCostSource::CodexJsonl);
496        }
497    }
498
499    if non_empty
500        .iter()
501        .all(|line| line.starts_with('[') && line.contains(']'))
502    {
503        return Ok(SessionCostSource::AgentDocLog);
504    }
505
506    bail!(
507        "could not auto-detect session-cost input; pass --source claude-jsonl, codex-jsonl, or agent-doc-log"
508    )
509}
510
511fn ingest_claude_jsonl(input: &str, state: &mut CostState) -> Result<()> {
512    let mut seen_keys = BTreeSet::new();
513    for (index, raw_line) in input.lines().enumerate() {
514        let trimmed = raw_line.trim();
515        if trimmed.is_empty() {
516            continue;
517        }
518        let value = match serde_json::from_str::<Value>(trimmed) {
519            Ok(value) => value,
520            Err(_) => {
521                state.warnings.push(format!(
522                    "skipping malformed Claude transcript jsonl line {}",
523                    index + 1
524                ));
525                continue;
526            }
527        };
528        let Some(message) = value.get("message") else {
529            collect_claude_loop_signals(&value, state);
530            continue;
531        };
532        collect_claude_loop_signals(&value, state);
533        if message.get("role").and_then(Value::as_str) != Some("assistant") {
534            continue;
535        }
536        let Some(usage) = message.get("usage") else {
537            continue;
538        };
539
540        let key = message
541            .get("id")
542            .and_then(Value::as_str)
543            .or_else(|| value.get("requestId").and_then(Value::as_str))
544            .or_else(|| value.get("uuid").and_then(Value::as_str))
545            .map(|value| value.to_string())
546            .unwrap_or_else(|| format!("line-{}", index + 1));
547        if !seen_keys.insert(key.clone()) {
548            continue;
549        }
550
551        let prompt_tokens = usage_u64(usage, "input_tokens")
552            + usage_u64(usage, "cache_creation_input_tokens")
553            + usage_u64(usage, "cache_read_input_tokens");
554        let cached_input_tokens = usage_u64(usage, "cache_read_input_tokens");
555        let cache_creation_input_tokens = usage_u64(usage, "cache_creation_input_tokens");
556        let output_tokens = usage_u64(usage, "output_tokens");
557        let total_tokens = prompt_tokens + output_tokens;
558        if prompt_tokens == 0 && output_tokens == 0 {
559            continue;
560        }
561
562        state.usage_turns.push(SessionCostTurn {
563            label: value
564                .get("timestamp")
565                .and_then(Value::as_str)
566                .map(|value| value.to_string())
567                .unwrap_or(key),
568            prompt_tokens,
569            cached_input_tokens,
570            cache_creation_input_tokens,
571            output_tokens,
572            reasoning_output_tokens: 0,
573            total_tokens,
574        });
575    }
576    Ok(())
577}
578
579fn ingest_codex_jsonl(input: &str, state: &mut CostState) -> Result<()> {
580    let mut previous = UsageTotals::default();
581    let mut seen_cumulative_snapshots = BTreeSet::<UsageTotals>::new();
582    let mut saw_token_count = false;
583    for (index, raw_line) in input.lines().enumerate() {
584        let trimmed = raw_line.trim();
585        if trimmed.is_empty() {
586            continue;
587        }
588        let value = match serde_json::from_str::<Value>(trimmed) {
589            Ok(value) => value,
590            Err(_) => {
591                state.warnings.push(format!(
592                    "skipping malformed Codex transcript jsonl line {}",
593                    index + 1
594                ));
595                continue;
596            }
597        };
598        match value.get("type").and_then(Value::as_str) {
599            Some("response_item") => {
600                collect_codex_response_item_loop_signals(&value, index + 1, state)
601            }
602            Some("event_msg") => collect_codex_event_msg_loop_signals(&value, index + 1, state),
603            _ => {}
604        }
605        if value.get("type").and_then(Value::as_str) != Some("event_msg") {
606            continue;
607        }
608        let Some(payload) = value.get("payload") else {
609            continue;
610        };
611        if payload.get("type").and_then(Value::as_str) != Some("token_count") {
612            continue;
613        }
614        saw_token_count = true;
615
616        let Some(total) = payload
617            .get("info")
618            .and_then(|info| info.get("total_token_usage"))
619        else {
620            state.warnings.push(format!(
621                "codex token_count event on line {} did not include info.total_token_usage",
622                index + 1
623            ));
624            continue;
625        };
626        let cumulative = codex_usage_totals(total);
627        let duplicate_snapshot = !seen_cumulative_snapshots.insert(cumulative);
628        let delta = if duplicate_snapshot {
629            UsageTotals::default()
630        } else if let Some(last) = payload
631            .get("info")
632            .and_then(|info| info.get("last_token_usage"))
633            .map(codex_usage_totals)
634            .filter(|last| !last.is_zero())
635        {
636            last
637        } else if previous.is_zero() {
638            cumulative
639        } else {
640            cumulative.delta_from(previous)
641        };
642        previous = cumulative;
643        if delta.is_zero() {
644            continue;
645        }
646
647        state.usage_turns.push(SessionCostTurn {
648            label: value
649                .get("timestamp")
650                .and_then(Value::as_str)
651                .map(|value| value.to_string())
652                .unwrap_or_else(|| format!("line-{}", index + 1)),
653            prompt_tokens: delta.prompt_tokens,
654            cached_input_tokens: delta.cached_input_tokens,
655            cache_creation_input_tokens: 0,
656            output_tokens: delta.output_tokens,
657            reasoning_output_tokens: delta.reasoning_output_tokens,
658            total_tokens: delta
659                .total_tokens
660                .max(delta.prompt_tokens + delta.output_tokens),
661        });
662    }
663
664    if !saw_token_count {
665        state.warnings.push(
666            "codex transcript did not contain any token_count events; no token cost summary could be derived"
667                .to_string(),
668        );
669    }
670    Ok(())
671}
672
673fn ingest_agent_doc_log(input: &str, state: &mut CostState) {
674    for raw_line in input.lines() {
675        let trimmed = raw_line.trim();
676        if trimmed.is_empty() {
677            continue;
678        }
679        let Some((_, after_bracket)) = trimmed.split_once("] ") else {
680            continue;
681        };
682        let detail = after_bracket.trim();
683        let Some(event_name) = detail.split_whitespace().next() else {
684            continue;
685        };
686        let normalized = normalize_runtime_event(event_name, detail);
687        let closeout_event = is_closeout_runtime_event(event_name, &normalized);
688        if should_count_runtime_event(event_name, detail, &normalized, state) {
689            *state.runtime_events.entry(normalized.clone()).or_default() += 1;
690            state.total_runtime_events += 1;
691            if closeout_event {
692                push_closeout_signal(&normalized, state);
693            }
694        }
695        state.restart_churn.observe(event_name, detail);
696        if let Some(restart_count) =
697            extract_field(detail, "restart_count").and_then(|value| value.parse::<usize>().ok())
698        {
699            state.max_restart_count = Some(
700                state
701                    .max_restart_count
702                    .map_or(restart_count, |current| current.max(restart_count)),
703            );
704        }
705    }
706}
707
708fn collect_claude_loop_signals(value: &Value, state: &mut CostState) {
709    let mut blocks = Vec::new();
710    collect_transcript_blocks(value, &mut blocks);
711    if blocks.is_empty() && is_ignorable_claude_record(value) {
712        return;
713    }
714    for block in blocks {
715        match block {
716            TranscriptBlock::Text { role, text } => {
717                let user_bias = role
718                    .as_deref()
719                    .is_some_and(|value| value.eq_ignore_ascii_case("user"));
720                collect_text_loop_signals(&text, user_bias, state);
721            }
722            TranscriptBlock::ToolUse { name, input } => {
723                collect_tool_use_loop_signals(&name, &input, state);
724            }
725        }
726    }
727}
728
729fn collect_codex_response_item_loop_signals(
730    value: &Value,
731    line_number: usize,
732    state: &mut CostState,
733) {
734    let Some(payload) = value.get("payload") else {
735        return;
736    };
737    match payload.get("type").and_then(Value::as_str) {
738        Some("message") => {
739            let Some(content) = payload.get("content").and_then(Value::as_array) else {
740                return;
741            };
742            for item in content {
743                let Some(text) = item
744                    .get("text")
745                    .and_then(Value::as_str)
746                    .or_else(|| item.get("content").and_then(Value::as_str))
747                else {
748                    continue;
749                };
750                collect_text_loop_signals(text, false, state);
751            }
752        }
753        Some("function_call") => {
754            let name = payload
755                .get("name")
756                .and_then(Value::as_str)
757                .unwrap_or("function_call");
758            let Some(arguments) = payload.get("arguments").and_then(Value::as_str) else {
759                return;
760            };
761            let input = serde_json::from_str::<Value>(arguments).unwrap_or_else(|_| {
762                state.warnings.push(format!(
763                    "codex function_call arguments on line {} were not valid JSON; loop extraction may be incomplete",
764                    line_number
765                ));
766                Value::String(arguments.to_string())
767            });
768            collect_tool_use_loop_signals(name, &input, state);
769        }
770        _ => {}
771    }
772}
773
774fn collect_codex_event_msg_loop_signals(value: &Value, _line_number: usize, state: &mut CostState) {
775    let Some(payload) = value.get("payload") else {
776        return;
777    };
778    match payload.get("type").and_then(Value::as_str) {
779        Some("user_message") => {
780            if let Some(message) = payload.get("message").and_then(Value::as_str) {
781                collect_text_loop_signals(message, true, state);
782            }
783        }
784        Some("agent_message") => {
785            if let Some(message) = payload.get("message").and_then(Value::as_str) {
786                collect_text_loop_signals(message, false, state);
787            }
788        }
789        Some("exec_command_end") => {
790            if let Some(command) = extract_raw_codex_exec_command(payload) {
791                collect_file_read_command_signals(&command, state);
792            }
793            if let Some(command) = extract_codex_exec_command(payload) {
794                push_command(command, state);
795            }
796            if let Some(output) = payload
797                .get("aggregated_output")
798                .and_then(Value::as_str)
799                .or_else(|| payload.get("stdout").and_then(Value::as_str))
800            {
801                collect_text_loop_signals(output, false, state);
802            }
803        }
804        _ => {}
805    }
806}
807
808fn collect_tool_use_loop_signals(name: &str, input: &Value, state: &mut CostState) {
809    collect_file_read_tool_signals(name, input, state);
810    if let Some(command) = extract_raw_tool_command(name, input) {
811        collect_file_read_command_signals(&command, state);
812    }
813    if let Some(command) = extract_tool_command(name, input) {
814        push_command(command, state);
815    }
816    if let Some(text) = extract_tool_text(input) {
817        collect_text_loop_signals(&text, false, state);
818    }
819}
820
821fn collect_file_read_tool_signals(name: &str, input: &Value, state: &mut CostState) {
822    let lower = name.to_ascii_lowercase();
823    if !matches!(lower.as_str(), "read" | "file_read" | "read_file") {
824        return;
825    }
826    let Value::Object(map) = input else {
827        return;
828    };
829    let Some(path) = ["file_path", "path"]
830        .iter()
831        .find_map(|key| map.get(*key).and_then(Value::as_str))
832        .map(normalize_file_read_path)
833        .filter(|path| !path.is_empty())
834    else {
835        return;
836    };
837    let start = ["offset", "start", "line"]
838        .iter()
839        .find_map(|key| map.get(*key).and_then(Value::as_u64))
840        .and_then(|value| usize::try_from(value).ok())
841        .filter(|value| *value > 0);
842    let lines = ["limit", "lines", "line_count"]
843        .iter()
844        .find_map(|key| map.get(*key).and_then(Value::as_u64))
845        .and_then(|value| usize::try_from(value).ok())
846        .filter(|value| *value > 0);
847    push_file_read_signal(path, start, lines, state);
848}
849
850fn collect_file_read_command_signals(command: &str, state: &mut CostState) {
851    if let Some(signal) = parse_file_read_command(command) {
852        state.file_read_signals.push(signal);
853    }
854}
855
856fn parse_file_read_command(command: &str) -> Option<FileReadSignal> {
857    let tokens = shell_words(command);
858    let head = tokens.first()?.as_str();
859    match head {
860        "cat" | "bat" | "batcat" | "nl" => {
861            let path = first_non_option_arg(&tokens[1..])?;
862            Some(file_read_signal(
863                normalize_file_read_path(path),
864                "full".to_string(),
865                None,
866                None,
867            ))
868        }
869        "sed" => parse_sed_file_read(&tokens),
870        "head" => parse_head_file_read(&tokens),
871        "tail" => parse_tail_file_read(&tokens),
872        _ => None,
873    }
874}
875
876fn parse_sed_file_read(tokens: &[String]) -> Option<FileReadSignal> {
877    let mut expr = None::<String>;
878    let mut path = None::<String>;
879    let mut skip_next = false;
880    for token in tokens.iter().skip(1) {
881        if skip_next {
882            skip_next = false;
883            continue;
884        }
885        if token == "-n" {
886            continue;
887        }
888        if token == "-e" {
889            skip_next = true;
890            continue;
891        }
892        if expr.is_none() && parse_sed_range(token).is_some() {
893            expr = Some(token.clone());
894            continue;
895        }
896        if !token.starts_with('-') {
897            path = Some(token.clone());
898        }
899    }
900    let expr = expr?;
901    let path = path?;
902    let (start, lines) = parse_sed_range(&expr)?;
903    Some(file_read_signal(
904        normalize_file_read_path(&path),
905        format!("{}-{}", start, start + lines - 1),
906        Some(start),
907        Some(lines),
908    ))
909}
910
911fn parse_sed_range(expr: &str) -> Option<(usize, usize)> {
912    let trimmed = expr.trim_matches(['\'', '"']).trim();
913    let body = trimmed.strip_suffix('p')?;
914    let (start_raw, end_raw) = body.split_once(',')?;
915    let start = start_raw.trim().parse::<usize>().ok()?;
916    let lines = if let Some(relative) = end_raw.trim().strip_prefix('+') {
917        relative.trim().parse::<usize>().ok()?.saturating_add(1)
918    } else {
919        let end = end_raw.trim().parse::<usize>().ok()?;
920        end.checked_sub(start)?.saturating_add(1)
921    };
922    (lines > 0).then_some((start, lines))
923}
924
925fn parse_head_file_read(tokens: &[String]) -> Option<FileReadSignal> {
926    let mut lines = 10_usize;
927    let mut path = None::<String>;
928    let mut index = 1_usize;
929    while index < tokens.len() {
930        let token = &tokens[index];
931        if token == "-n" || token == "--lines" {
932            index += 1;
933            lines = tokens.get(index)?.parse::<usize>().ok()?;
934        } else if let Some(value) = token.strip_prefix("-n") {
935            lines = value.parse::<usize>().ok()?;
936        } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
937            lines = token[1..].parse::<usize>().ok()?;
938        } else if !token.starts_with('-') {
939            path = Some(token.clone());
940        }
941        index += 1;
942    }
943    let path = path?;
944    Some(file_read_signal(
945        normalize_file_read_path(&path),
946        format!("head:{lines}"),
947        Some(1),
948        Some(lines),
949    ))
950}
951
952fn parse_tail_file_read(tokens: &[String]) -> Option<FileReadSignal> {
953    let mut lines = 10_usize;
954    let mut path = None::<String>;
955    let mut index = 1_usize;
956    while index < tokens.len() {
957        let token = &tokens[index];
958        if token == "-n" || token == "--lines" {
959            index += 1;
960            lines = tokens.get(index)?.parse::<usize>().ok()?;
961        } else if let Some(value) = token.strip_prefix("-n") {
962            lines = value.trim_start_matches('+').parse::<usize>().ok()?;
963        } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
964            lines = token[1..].parse::<usize>().ok()?;
965        } else if !token.starts_with('-') {
966            path = Some(token.clone());
967        }
968        index += 1;
969    }
970    let path = path?;
971    Some(file_read_signal(
972        normalize_file_read_path(&path),
973        format!("tail:{lines}"),
974        None,
975        Some(lines),
976    ))
977}
978
979fn first_non_option_arg(tokens: &[String]) -> Option<&str> {
980    tokens
981        .iter()
982        .find(|token| !token.starts_with('-'))
983        .map(String::as_str)
984}
985
986fn push_file_read_signal(
987    path: String,
988    start: Option<usize>,
989    lines: Option<usize>,
990    state: &mut CostState,
991) {
992    let range = match (start, lines) {
993        (Some(start), Some(lines)) => format!("{}-{}", start, start + lines - 1),
994        (Some(start), None) => format!("{start}-end"),
995        (None, Some(lines)) => format!("window:{lines}"),
996        (None, None) => "full".to_string(),
997    };
998    state
999        .file_read_signals
1000        .push(file_read_signal(path, range, start, lines));
1001}
1002
1003fn file_read_signal(
1004    path: String,
1005    range: String,
1006    start: Option<usize>,
1007    lines: Option<usize>,
1008) -> FileReadSignal {
1009    FileReadSignal {
1010        path,
1011        range,
1012        start,
1013        lines,
1014        estimated_tokens: estimate_file_read_tokens(lines),
1015    }
1016}
1017
1018fn estimate_file_read_tokens(lines: Option<usize>) -> u64 {
1019    lines
1020        .map(|lines| (lines as u64).saturating_mul(ESTIMATED_TOKENS_PER_SOURCE_LINE))
1021        .unwrap_or(DEFAULT_FULL_FILE_READ_TOKENS)
1022        .max(80)
1023}
1024
1025fn collect_file_read_diagnostics(signals: &[FileReadSignal]) -> Vec<SessionCostFileReadDiagnostic> {
1026    let mut grouped = BTreeMap::<(String, String), FileReadDiagnosticBuilder>::new();
1027    for signal in signals {
1028        let entry = grouped
1029            .entry((signal.path.clone(), signal.range.clone()))
1030            .or_insert_with(|| FileReadDiagnosticBuilder {
1031                path: signal.path.clone(),
1032                range: signal.range.clone(),
1033                start: signal.start,
1034                lines: signal.lines,
1035                occurrences: 0,
1036                estimated_tokens: 0,
1037                max_single_read_tokens: 0,
1038            });
1039        entry.occurrences += 1;
1040        entry.estimated_tokens = entry
1041            .estimated_tokens
1042            .saturating_add(signal.estimated_tokens);
1043        entry.max_single_read_tokens = entry.max_single_read_tokens.max(signal.estimated_tokens);
1044        entry.start = entry.start.or(signal.start);
1045        entry.lines = entry.lines.or(signal.lines);
1046    }
1047
1048    let mut diagnostics = grouped
1049        .into_values()
1050        .filter(|entry| entry.occurrences >= 2)
1051        .map(|entry| {
1052            let duplicate_estimated_tokens = entry
1053                .estimated_tokens
1054                .saturating_sub(entry.max_single_read_tokens);
1055            SessionCostFileReadDiagnostic {
1056                path: entry.path.clone(),
1057                range: entry.range.clone(),
1058                occurrences: entry.occurrences,
1059                estimated_tokens: entry.estimated_tokens,
1060                duplicate_estimated_tokens,
1061                follow_up_commands: file_read_follow_up_commands(
1062                    &entry.path,
1063                    entry.start,
1064                    entry.lines,
1065                ),
1066            }
1067        })
1068        .collect::<Vec<_>>();
1069    diagnostics.sort_by(|left, right| {
1070        right
1071            .duplicate_estimated_tokens
1072            .cmp(&left.duplicate_estimated_tokens)
1073            .then(right.occurrences.cmp(&left.occurrences))
1074            .then(left.path.cmp(&right.path))
1075            .then(left.range.cmp(&right.range))
1076    });
1077    diagnostics.truncate(MAX_FILE_READ_DIAGNOSTICS);
1078    diagnostics
1079}
1080
1081#[derive(Debug)]
1082struct FileReadDiagnosticBuilder {
1083    path: String,
1084    range: String,
1085    start: Option<usize>,
1086    lines: Option<usize>,
1087    occurrences: usize,
1088    estimated_tokens: u64,
1089    max_single_read_tokens: u64,
1090}
1091
1092fn file_read_follow_up_commands(
1093    path: &str,
1094    start: Option<usize>,
1095    lines: Option<usize>,
1096) -> Vec<String> {
1097    let start = start.unwrap_or(1);
1098    let lines = lines.unwrap_or(120).max(1);
1099    vec![
1100        format!(
1101            "tsift source-read {} --start {} --lines {} --budget normal",
1102            shell_quote(path),
1103            start,
1104            lines
1105        ),
1106        format!("tsift summarize --file {}", shell_quote(path)),
1107    ]
1108}
1109
1110fn normalize_file_read_path(raw: &str) -> String {
1111    raw.trim()
1112        .trim_matches(['\'', '"'])
1113        .trim_start_matches("./")
1114        .to_string()
1115}
1116
1117fn shell_words(command: &str) -> Vec<String> {
1118    let mut words = Vec::new();
1119    let mut current = String::new();
1120    let mut quote = None::<char>;
1121    let mut escaped = false;
1122
1123    for ch in command.chars() {
1124        if escaped {
1125            current.push(ch);
1126            escaped = false;
1127            continue;
1128        }
1129        if ch == '\\' {
1130            escaped = true;
1131            continue;
1132        }
1133        if let Some(quote_ch) = quote {
1134            if ch == quote_ch {
1135                quote = None;
1136            } else {
1137                current.push(ch);
1138            }
1139            continue;
1140        }
1141        if ch == '\'' || ch == '"' {
1142            quote = Some(ch);
1143            continue;
1144        }
1145        if ch.is_whitespace() {
1146            if !current.is_empty() {
1147                words.push(std::mem::take(&mut current));
1148            }
1149            continue;
1150        }
1151        current.push(ch);
1152    }
1153    if !current.is_empty() {
1154        words.push(current);
1155    }
1156    words
1157}
1158
1159fn shell_quote(value: &str) -> String {
1160    if value
1161        .chars()
1162        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
1163    {
1164        return value.to_string();
1165    }
1166    format!("'{}'", value.replace('\'', "'\\''"))
1167}
1168
1169fn collect_text_loop_signals(text: &str, user_bias: bool, state: &mut CostState) {
1170    for raw_line in text.lines() {
1171        let trimmed = raw_line.trim();
1172        if trimmed.is_empty() || looks_like_instruction_ballast(trimmed) {
1173            continue;
1174        }
1175        let prompt_candidate = trimmed
1176            .strip_prefix("❯ ")
1177            .or_else(|| trimmed.strip_prefix("> "))
1178            .unwrap_or(trimmed)
1179            .trim();
1180        if looks_like_prompt_target(prompt_candidate, user_bias || prompt_candidate != trimmed) {
1181            push_prompt_signal(prompt_candidate, state);
1182            continue;
1183        }
1184        for (kind, detail) in detect_closeout(trimmed) {
1185            push_closeout_signal(&format!("{kind}: {detail}"), state);
1186        }
1187    }
1188}
1189
1190fn push_prompt_signal(text: &str, state: &mut CostState) {
1191    flush_pending_commands(state);
1192    push_loop_signal(LoopClusterKind::PromptRepeat, text, state);
1193}
1194
1195fn push_closeout_signal(text: &str, state: &mut CostState) {
1196    flush_pending_commands(state);
1197    push_loop_signal(LoopClusterKind::CloseoutChurn, text, state);
1198}
1199
1200fn push_command(command: String, state: &mut CostState) {
1201    let normalized = normalize_whitespace(&command);
1202    if normalized.is_empty() {
1203        return;
1204    }
1205    if state
1206        .pending_commands
1207        .last()
1208        .is_some_and(|existing| existing == &normalized)
1209    {
1210        return;
1211    }
1212    state.pending_commands.push(normalized);
1213}
1214
1215fn flush_pending_commands(state: &mut CostState) {
1216    if state.pending_commands.is_empty() {
1217        return;
1218    }
1219    let label = truncate_detail(
1220        &state
1221            .pending_commands
1222            .iter()
1223            .take(MAX_COMMANDS_PER_BUNDLE)
1224            .cloned()
1225            .collect::<Vec<_>>()
1226            .join(" -> "),
1227        220,
1228    );
1229    state.pending_commands.clear();
1230    push_loop_signal(LoopClusterKind::CommandBundle, &label, state);
1231}
1232
1233fn push_loop_signal(kind: LoopClusterKind, label: &str, state: &mut CostState) {
1234    let normalized = truncate_detail(&normalize_whitespace(label), 220);
1235    if normalized.is_empty() {
1236        return;
1237    }
1238    state.loop_signals.push(LoopSignal {
1239        kind,
1240        label: normalized,
1241    });
1242}
1243
1244fn collect_loop_clusters(signals: &[LoopSignal]) -> Vec<SessionCostLoopCluster> {
1245    let mut summary = BTreeMap::<(LoopClusterKind, String), (usize, usize)>::new();
1246    let mut previous = None::<(LoopClusterKind, String)>;
1247    let mut streak = 0_usize;
1248
1249    for signal in signals {
1250        let key = (signal.kind, signal.label.clone());
1251        let entry = summary.entry(key.clone()).or_insert((0, 0));
1252        entry.0 += 1;
1253        if previous.as_ref() == Some(&key) {
1254            streak += 1;
1255        } else {
1256            previous = Some(key.clone());
1257            streak = 1;
1258        }
1259        entry.1 = entry.1.max(streak);
1260    }
1261
1262    let mut clusters = summary
1263        .into_iter()
1264        .filter_map(|((kind, label), (occurrences, max_consecutive))| {
1265            (occurrences >= 2).then_some(SessionCostLoopCluster {
1266                kind: kind.as_str().to_string(),
1267                label,
1268                occurrences,
1269                max_consecutive,
1270            })
1271        })
1272        .collect::<Vec<_>>();
1273    clusters.sort_by(|left, right| {
1274        right
1275            .occurrences
1276            .cmp(&left.occurrences)
1277            .then(right.max_consecutive.cmp(&left.max_consecutive))
1278            .then(left.kind.cmp(&right.kind))
1279            .then(left.label.cmp(&right.label))
1280    });
1281    clusters.truncate(MAX_LOOP_CLUSTERS);
1282    clusters
1283}
1284
1285fn is_ignorable_claude_record(value: &Value) -> bool {
1286    value.get("attachment").is_some()
1287        || value.get("toolUseResult").is_some()
1288        || (value.get("message").is_none()
1289            && value.get("content").is_none()
1290            && value.get("text").is_none())
1291}
1292
1293fn collect_transcript_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
1294    if let Some(message) = value.get("message") {
1295        collect_message_blocks(message, out);
1296        return;
1297    }
1298    collect_message_blocks(value, out);
1299}
1300
1301fn collect_message_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
1302    let role = value
1303        .get("role")
1304        .and_then(Value::as_str)
1305        .map(|value| value.to_string());
1306    if let Some(content) = value.get("content") {
1307        match content {
1308            Value::String(text) => out.push(TranscriptBlock::Text {
1309                role,
1310                text: text.to_string(),
1311            }),
1312            Value::Array(items) => {
1313                for item in items {
1314                    collect_content_block(role.clone(), item, out);
1315                }
1316            }
1317            _ => {}
1318        }
1319    } else if let Some(text) = value.get("text").and_then(Value::as_str) {
1320        out.push(TranscriptBlock::Text {
1321            role,
1322            text: text.to_string(),
1323        });
1324    }
1325}
1326
1327fn collect_content_block(role: Option<String>, value: &Value, out: &mut Vec<TranscriptBlock>) {
1328    match value.get("type").and_then(Value::as_str) {
1329        Some("text") => {
1330            if let Some(text) = value.get("text").and_then(Value::as_str) {
1331                out.push(TranscriptBlock::Text {
1332                    role,
1333                    text: text.to_string(),
1334                });
1335            }
1336        }
1337        Some("tool_use") => {
1338            let name = value
1339                .get("name")
1340                .and_then(Value::as_str)
1341                .unwrap_or("tool_use")
1342                .to_string();
1343            let input = value.get("input").cloned().unwrap_or(Value::Null);
1344            out.push(TranscriptBlock::ToolUse { name, input });
1345        }
1346        Some("tool_result") => match value.get("content") {
1347            Some(Value::String(text)) => out.push(TranscriptBlock::Text {
1348                role,
1349                text: text.to_string(),
1350            }),
1351            Some(Value::Array(items)) => {
1352                for item in items {
1353                    collect_content_block(role.clone(), item, out);
1354                }
1355            }
1356            _ => {}
1357        },
1358        _ => {
1359            if let Some(text) = value.get("text").and_then(Value::as_str) {
1360                out.push(TranscriptBlock::Text {
1361                    role,
1362                    text: text.to_string(),
1363                });
1364            }
1365        }
1366    }
1367}
1368
1369fn extract_tool_command(name: &str, input: &Value) -> Option<String> {
1370    let normalized = extract_raw_tool_command(name, input)?;
1371    looks_like_command(&normalized).then_some(normalized)
1372}
1373
1374fn extract_raw_tool_command(name: &str, input: &Value) -> Option<String> {
1375    if !matches!(
1376        name.to_ascii_lowercase().as_str(),
1377        "bash" | "exec_command" | "shell" | "terminal" | "sh"
1378    ) {
1379        return None;
1380    }
1381
1382    match input {
1383        Value::Object(map) => {
1384            for key in ["command", "cmd", "shell_command"] {
1385                if let Some(raw) = map.get(key).and_then(Value::as_str) {
1386                    let normalized = normalize_whitespace(raw);
1387                    if !normalized.is_empty() {
1388                        return Some(normalized);
1389                    }
1390                }
1391            }
1392            None
1393        }
1394        Value::String(raw) => {
1395            let normalized = normalize_whitespace(raw);
1396            (!normalized.is_empty()).then_some(normalized)
1397        }
1398        _ => None,
1399    }
1400}
1401
1402fn extract_tool_text(input: &Value) -> Option<String> {
1403    match input {
1404        Value::Object(map) => {
1405            for key in ["text", "output", "stderr", "stdout", "content", "message"] {
1406                if let Some(raw) = map.get(key).and_then(Value::as_str) {
1407                    return Some(raw.to_string());
1408                }
1409            }
1410            None
1411        }
1412        Value::String(raw) => Some(raw.to_string()),
1413        _ => None,
1414    }
1415}
1416
1417fn extract_codex_exec_command(payload: &Value) -> Option<String> {
1418    let normalized = extract_raw_codex_exec_command(payload)?;
1419    looks_like_command(&normalized).then_some(normalized)
1420}
1421
1422fn extract_raw_codex_exec_command(payload: &Value) -> Option<String> {
1423    if let Some(parsed) = payload.get("parsed_cmd").and_then(Value::as_array) {
1424        for item in parsed {
1425            if let Some(command) = item.get("cmd").and_then(Value::as_str) {
1426                let normalized = normalize_whitespace(command);
1427                if !normalized.is_empty() {
1428                    return Some(normalized);
1429                }
1430            }
1431        }
1432    }
1433
1434    if let Some(command) = payload.get("command").and_then(Value::as_array)
1435        && let Some(last) = command.last().and_then(Value::as_str)
1436    {
1437        let normalized = normalize_whitespace(last);
1438        if !normalized.is_empty() {
1439            return Some(normalized);
1440        }
1441    }
1442    None
1443}
1444
1445fn looks_like_prompt_target(text: &str, user_bias: bool) -> bool {
1446    let trimmed = text.trim();
1447    if trimmed.is_empty()
1448        || looks_like_markdown_heading(trimmed)
1449        || looks_like_slash_command_example(trimmed)
1450        || trimmed == "#"
1451        || trimmed.starts_with("#!")
1452        || trimmed.starts_with("#[")
1453        || trimmed.starts_with("/**")
1454        || trimmed.starts_with("*/")
1455        || trimmed.starts_with("//")
1456        || trimmed.starts_with("###")
1457        || trimmed.starts_with("<!--")
1458        || trimmed.starts_with("- [")
1459        || trimmed == "###"
1460    {
1461        return false;
1462    }
1463
1464    if trimmed.starts_with("do ")
1465        || trimmed.starts_with('#')
1466        || looks_like_slash_prompt_target(trimmed)
1467        || trimmed.ends_with('?')
1468    {
1469        return true;
1470    }
1471
1472    if user_bias
1473        && (trimmed.contains("commit + push")
1474            || trimmed.contains("run tests")
1475            || trimmed.contains("build + install")
1476            || trimmed.contains("#spec-test"))
1477    {
1478        return true;
1479    }
1480
1481    false
1482}
1483
1484fn looks_like_instruction_ballast(text: &str) -> bool {
1485    let trimmed = strip_common_prefixes(text.trim());
1486    if trimmed.is_empty() {
1487        return false;
1488    }
1489
1490    looks_like_markdown_heading(trimmed)
1491        || looks_like_slash_command_example(trimmed)
1492        || looks_like_frontmatter_prompt_preset(trimmed)
1493        || looks_like_completed_backlog_archive(trimmed)
1494        || trimmed.starts_with("<!-- tsift:")
1495        || trimmed.starts_with("<!-- /tsift:")
1496        || looks_like_instruction_label(trimmed)
1497}
1498
1499fn looks_like_markdown_heading(text: &str) -> bool {
1500    let trimmed = text.trim_start();
1501    let heading_level = trimmed.chars().take_while(|ch| *ch == '#').count();
1502    heading_level > 0
1503        && heading_level <= 6
1504        && trimmed
1505            .chars()
1506            .nth(heading_level)
1507            .is_some_and(|ch| ch.is_whitespace())
1508}
1509
1510fn looks_like_slash_command_example(text: &str) -> bool {
1511    let trimmed = text.trim();
1512    trimmed.starts_with('/')
1513        && trimmed.contains('<')
1514        && trimmed.contains('>')
1515        && !trimmed.contains('`')
1516}
1517
1518fn looks_like_slash_prompt_target(text: &str) -> bool {
1519    let Some(first_token) = text.split_whitespace().next() else {
1520        return false;
1521    };
1522    first_token.starts_with('/') && !first_token[1..].contains('/')
1523}
1524
1525fn looks_like_instruction_label(text: &str) -> bool {
1526    let trimmed = text.trim();
1527    if !trimmed.starts_with("**") {
1528        return false;
1529    }
1530    let Some(label_end) = trimmed[2..].find("**") else {
1531        return false;
1532    };
1533    let label = &trimmed[..label_end + 4];
1534    if label.len() <= 4 {
1535        return false;
1536    }
1537    let remainder = trimmed[label_end + 4..]
1538        .trim_start_matches([' ', ':', '-', '—'])
1539        .trim_start();
1540    if remainder.is_empty() {
1541        return false;
1542    }
1543    let lower = remainder.to_ascii_lowercase();
1544    matches!(
1545        lower.split_whitespace().next(),
1546        Some("run")
1547            | Some("use")
1548            | Some("treat")
1549            | Some("respond")
1550            | Some("print")
1551            | Some("prefer")
1552            | Some("preserve")
1553            | Some("show")
1554            | Some("complete")
1555            | Some("append")
1556            | Some("when")
1557            | Some("if")
1558    )
1559}
1560
1561fn strip_common_prefixes(text: &str) -> &str {
1562    text.strip_prefix("❯ ")
1563        .or_else(|| text.strip_prefix("- "))
1564        .or_else(|| text.strip_prefix("* "))
1565        .or_else(|| text.strip_prefix("> "))
1566        .unwrap_or(text)
1567        .trim()
1568}
1569
1570fn looks_like_frontmatter_prompt_preset(text: &str) -> bool {
1571    let trimmed = strip_common_prefixes(text.trim());
1572    if trimmed == "prompt_presets:" || trimmed.starts_with("prompt_presets:") {
1573        return true;
1574    }
1575    let Some((key, _)) = trimmed.split_once(':') else {
1576        return false;
1577    };
1578    let key = key.trim().trim_matches(['"', '\'']);
1579    key.starts_with('#') && key.len() > 1 && key[1..].chars().all(is_prompt_preset_char)
1580}
1581
1582fn is_prompt_preset_char(ch: char) -> bool {
1583    ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')
1584}
1585
1586fn looks_like_completed_backlog_archive(text: &str) -> bool {
1587    let stripped = strip_common_prefixes(text.trim());
1588    let Some(date) = stripped.get(..10) else {
1589        return false;
1590    };
1591    date.chars().enumerate().all(|(index, ch)| match index {
1592        4 | 7 => ch == '-',
1593        _ => ch.is_ascii_digit(),
1594    }) && stripped[10..].contains("[#")
1595}
1596
1597fn looks_like_command(text: &str) -> bool {
1598    if text.is_empty()
1599        || text.contains('\n')
1600        || text.contains("://")
1601        || text.starts_with('/')
1602        || text.starts_with("###")
1603    {
1604        return false;
1605    }
1606
1607    let head = text.split_whitespace().next().unwrap_or_default();
1608    matches!(
1609        head,
1610        "agent-doc"
1611            | "cargo"
1612            | "git"
1613            | "make"
1614            | "pytest"
1615            | "python"
1616            | "uv"
1617            | "tsift"
1618            | "npm"
1619            | "pnpm"
1620            | "yarn"
1621            | "bash"
1622            | "zsh"
1623            | "rg"
1624            | "grep"
1625            | "./scripts/run_benchmark.sh"
1626    ) || head.starts_with("./")
1627}
1628
1629fn detect_closeout(text: &str) -> Vec<(String, String)> {
1630    let mut out = Vec::new();
1631    let normalized = normalize_whitespace(strip_common_prefixes(text));
1632    let lower = normalized.to_ascii_lowercase();
1633
1634    if normalized.starts_with("document_cycle ") {
1635        let phase = extract_field(&normalized, "phase");
1636        let event = extract_field(&normalized, "event");
1637        if phase == Some("committed")
1638            && let Some(event) = event
1639        {
1640            out.push((
1641                "commit".to_string(),
1642                format!("document_cycle phase=committed event={event}"),
1643            ));
1644        }
1645        return dedupe_pairs(out);
1646    }
1647
1648    if lower.contains("verification passed") || lower.starts_with("verification in ") {
1649        out.push((
1650            "verification".to_string(),
1651            truncate_detail(&normalized, 220),
1652        ));
1653    }
1654    if lower.contains("cargo build")
1655        || lower.contains("make check")
1656        || lower.contains("cargo test")
1657        || lower.contains("pytest")
1658    {
1659        out.push((
1660            "verification".to_string(),
1661            truncate_detail(&normalized, 220),
1662        ));
1663    }
1664    if lower.contains("cargo install") || lower.contains("installed") {
1665        out.push(("install".to_string(), truncate_detail(&normalized, 220)));
1666    }
1667    if lower.contains("committed and pushed") {
1668        out.push(("push".to_string(), truncate_detail(&normalized, 220)));
1669    } else if lower.contains("committed") {
1670        out.push(("commit".to_string(), truncate_detail(&normalized, 220)));
1671    }
1672    if lower.contains("tsift --version") || lower.contains("tsift v0.") {
1673        out.push(("version".to_string(), truncate_detail(&normalized, 220)));
1674    }
1675    if lower.contains("agent-doc finalize") || lower.contains("session-check") {
1676        out.push(("closeout".to_string(), truncate_detail(&normalized, 220)));
1677    }
1678
1679    dedupe_pairs(out)
1680}
1681
1682fn is_closeout_runtime_event(event_name: &str, normalized: &str) -> bool {
1683    event_name == "document_cycle"
1684        || matches!(
1685            normalized,
1686            "preflight_started"
1687                | "response_captured"
1688                | "commit_staging"
1689                | "commit_success"
1690                | "commit_already_current"
1691                | "snapshot_save"
1692                | "write_origin"
1693                | "ipc_write_attempt"
1694                | "ipc_write_consumed"
1695                | "out_of_band_write"
1696        )
1697}
1698
1699fn dedupe_pairs(items: Vec<(String, String)>) -> Vec<(String, String)> {
1700    let mut seen = BTreeSet::new();
1701    let mut deduped = Vec::new();
1702    for item in items {
1703        if seen.insert(item.clone()) {
1704            deduped.push(item);
1705        }
1706    }
1707    deduped
1708}
1709
1710fn normalize_whitespace(raw: &str) -> String {
1711    raw.split_whitespace().collect::<Vec<_>>().join(" ")
1712}
1713
1714fn truncate_detail(text: &str, max_chars: usize) -> String {
1715    if text.chars().count() <= max_chars {
1716        return text.to_string();
1717    }
1718    let mut truncated = String::new();
1719    for ch in text.chars().take(max_chars.saturating_sub(1)) {
1720        truncated.push(ch);
1721    }
1722    truncated.push('…');
1723    truncated
1724}
1725
1726fn normalize_runtime_event(event_name: &str, detail: &str) -> String {
1727    if event_name == "document_cycle"
1728        && let Some(document_event) = extract_field(detail, "event")
1729    {
1730        return document_event.to_string();
1731    }
1732    if matches!(
1733        event_name,
1734        "claude_start" | "codex_start" | "claude_restart" | "codex_restart"
1735    ) && let Some(mode) = extract_field(detail, "mode")
1736    {
1737        return format!("{event_name}:{mode}");
1738    }
1739    event_name.to_string()
1740}
1741
1742fn should_count_runtime_event(
1743    event_name: &str,
1744    detail: &str,
1745    normalized: &str,
1746    state: &mut CostState,
1747) -> bool {
1748    if event_name == "document_cycle"
1749        && let Some(cycle) = extract_field(detail, "cycle")
1750    {
1751        return state
1752            .seen_document_cycle_events
1753            .insert((cycle.to_string(), normalized.to_string()));
1754    }
1755    true
1756}
1757
1758fn usage_u64(value: &Value, key: &str) -> u64 {
1759    value.get(key).and_then(Value::as_u64).unwrap_or(0)
1760}
1761
1762fn codex_usage_totals(value: &Value) -> UsageTotals {
1763    UsageTotals {
1764        prompt_tokens: usage_u64(value, "input_tokens"),
1765        cached_input_tokens: usage_u64(value, "cached_input_tokens"),
1766        cache_creation_input_tokens: 0,
1767        output_tokens: usage_u64(value, "output_tokens"),
1768        reasoning_output_tokens: usage_u64(value, "reasoning_output_tokens"),
1769        total_tokens: usage_u64(value, "total_tokens"),
1770    }
1771}
1772
1773fn count_restart_family(restart_churn: &[RestartChurnSummary], family: &str) -> usize {
1774    restart_churn
1775        .iter()
1776        .find(|entry| entry.family == family)
1777        .map_or(0, |entry| entry.occurrences)
1778}
1779
1780fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
1781    let needle = format!("{key}=");
1782    let start = detail.find(&needle)? + needle.len();
1783    let remainder = &detail[start..];
1784    let end = remainder
1785        .find(char::is_whitespace)
1786        .unwrap_or(remainder.len());
1787    Some(remainder[..end].trim_matches('"'))
1788}
1789
1790#[cfg(test)]
1791mod tests {
1792    use super::*;
1793
1794    #[test]
1795    fn auto_detects_claude_jsonl_and_dedupes_usage_by_message_id() {
1796        let input = concat!(
1797            r#"{"timestamp":"2026-05-05T00:00:01Z","requestId":"req-1","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":9,"cache_creation_input_tokens":300,"cache_read_input_tokens":1200,"output_tokens":10}}}"#,
1798            "\n",
1799            r#"{"timestamp":"2026-05-05T00:00:02Z","requestId":"req-1","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":9,"cache_creation_input_tokens":300,"cache_read_input_tokens":1200,"output_tokens":10}}}"#,
1800            "\n",
1801            r#"{"timestamp":"2026-05-05T00:00:03Z","requestId":"req-2","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":800,"output_tokens":8}}}"#,
1802            "\n"
1803        );
1804
1805        let report = compute(input, None).unwrap();
1806        assert_eq!(report.source, "claude_jsonl");
1807        assert_eq!(report.usage_samples, 2);
1808        assert_eq!(report.prompt_tokens, 2321);
1809        assert_eq!(report.cached_input_tokens, 2000);
1810        assert_eq!(report.cache_creation_input_tokens, 300);
1811        assert_eq!(report.output_tokens, 18);
1812        assert_eq!(report.total_tokens, 2339);
1813        assert_eq!(report.cached_input_ratio, Some(86.17));
1814    }
1815
1816    #[test]
1817    fn codex_jsonl_uses_cumulative_deltas_and_skips_duplicate_snapshots() {
1818        let input = concat!(
1819            r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050}}}}"#,
1820            "\n",
1821            r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690}}}}"#,
1822            "\n",
1823            r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690}}}}"#,
1824            "\n"
1825        );
1826
1827        let report = compute(input, Some("codex-jsonl")).unwrap();
1828        assert_eq!(report.usage_samples, 2);
1829        assert_eq!(report.prompt_tokens, 1600);
1830        assert_eq!(report.cached_input_tokens, 1400);
1831        assert_eq!(report.output_tokens, 90);
1832        assert_eq!(report.reasoning_output_tokens, 20);
1833        assert_eq!(report.total_tokens, 1690);
1834        assert_eq!(report.largest_turn_total_tokens, 1050);
1835        assert_eq!(report.largest_turns[0].total_tokens, 1050);
1836        assert_eq!(report.largest_turns[1].total_tokens, 640);
1837    }
1838
1839    #[test]
1840    fn codex_jsonl_prefers_last_usage_for_interleaved_cumulative_streams() {
1841        let input = concat!(
1842            r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050}}}}"#,
1843            "\n",
1844            r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":500,"cached_input_tokens":450,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":520},"last_token_usage":{"input_tokens":500,"cached_input_tokens":450,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":520}}}}"#,
1845            "\n",
1846            r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690},"last_token_usage":{"input_tokens":600,"cached_input_tokens":500,"output_tokens":40,"reasoning_output_tokens":10,"total_tokens":640}}}}"#,
1847            "\n",
1848            r#"{"timestamp":"2026-05-05T00:00:04Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":900,"cached_input_tokens":800,"output_tokens":45,"reasoning_output_tokens":10,"total_tokens":945},"last_token_usage":{"input_tokens":400,"cached_input_tokens":350,"output_tokens":25,"reasoning_output_tokens":5,"total_tokens":425}}}}"#,
1849            "\n",
1850            r#"{"timestamp":"2026-05-05T00:00:05Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":900,"cached_input_tokens":800,"output_tokens":45,"reasoning_output_tokens":10,"total_tokens":945},"last_token_usage":{"input_tokens":400,"cached_input_tokens":350,"output_tokens":25,"reasoning_output_tokens":5,"total_tokens":425}}}}"#,
1851            "\n"
1852        );
1853
1854        let report = compute(input, Some("codex-jsonl")).unwrap();
1855        assert_eq!(report.usage_samples, 4);
1856        assert_eq!(report.prompt_tokens, 2500);
1857        assert_eq!(report.cached_input_tokens, 2200);
1858        assert_eq!(report.output_tokens, 135);
1859        assert_eq!(report.reasoning_output_tokens, 30);
1860        assert_eq!(report.total_tokens, 2635);
1861        assert_eq!(report.largest_turn_total_tokens, 1050);
1862    }
1863
1864    #[test]
1865    fn agent_doc_log_summarizes_runtime_churn() {
1866        let input = "\
1867[1776452736] claude_start mode=fresh restart_count=0
1868[1776528398] claude_start mode=fresh_restart restart_count=1
1869[1776528446] auto_trigger_timeout (no prompt after 30s)
1870[1776528450] ctrl_d_restart_fresh restart_count=2
1871[1776528582] claude_start mode=fresh_restart restart_count=2
1872[1776528599] codex_start mode=continue restart_count=3
1873[1776528601] user_quit_after_ctrl_d
1874[1776528602] commit_already_current file=tasks/software/tsift.md basis=head
1875[1776528603] commit_already_current file=tasks/software/tsift.md basis=head
1876[1776528604] commit_already_current file=tasks/software/tsift.md basis=head
1877";
1878
1879        let report = compute(input, Some("agent-doc-log")).unwrap();
1880        assert_eq!(report.source, "agent_doc_log");
1881        assert_eq!(report.usage_samples, 0);
1882        assert_eq!(report.runtime_event_groups, 7);
1883        assert_eq!(report.total_runtime_events, 10);
1884        assert_eq!(report.restart_churn_groups, 4);
1885        assert_eq!(report.max_restart_count, Some(3));
1886        assert!(
1887            report
1888                .runtime_events
1889                .iter()
1890                .any(|event| event.event == "claude_start:fresh_restart" && event.occurrences == 2)
1891        );
1892        assert!(
1893            report
1894                .runtime_events
1895                .iter()
1896                .any(|event| event.event == "auto_trigger_timeout" && event.occurrences == 1)
1897        );
1898        assert!(
1899            report
1900                .restart_churn
1901                .iter()
1902                .any(|entry| entry.family == "fresh_restart" && entry.occurrences == 3)
1903        );
1904        assert!(
1905            report
1906                .restart_churn
1907                .iter()
1908                .any(|entry| entry.family == "ctrl_d_restart_loop" && entry.occurrences == 1)
1909        );
1910        assert!(
1911            report
1912                .restart_churn
1913                .iter()
1914                .any(|entry| entry.family == "quit_after_eof" && entry.occurrences == 1)
1915        );
1916        assert!(
1917            report
1918                .guardrails
1919                .iter()
1920                .any(|guardrail| guardrail.kind == "restart_loop")
1921        );
1922        assert!(
1923            report
1924                .guardrails
1925                .iter()
1926                .any(|guardrail| guardrail.kind == "noop_closeout")
1927        );
1928        assert!(
1929            report
1930                .loop_clusters
1931                .iter()
1932                .any(|cluster| cluster.kind == "closeout_churn"
1933                    && cluster.label == "commit_already_current"
1934                    && cluster.occurrences == 3)
1935        );
1936    }
1937
1938    #[test]
1939    fn agent_doc_log_dedupes_document_cycle_runtime_events_by_cycle() {
1940        let input = "\
1941[1777603275] document_cycle phase=response_captured cycle=cycle-1 event=response_captured capture_id=cycle-1
1942[1777603276] document_cycle phase=committed cycle=cycle-1 event=commit_success capture_id=cycle-1
1943[1777603403] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
1944[1777603404] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
1945[1777603405] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
1946[1777603500] document_cycle phase=preflight_started cycle=cycle-2 event=preflight_started
1947[1777603600] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
1948[1777603601] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
1949[1777603700] document_cycle phase=committed cycle=cycle-3 event=commit_already_current
1950";
1951
1952        let report = compute(input, Some("agent-doc-log")).unwrap();
1953
1954        assert_eq!(report.total_runtime_events, 6);
1955        assert!(
1956            report
1957                .runtime_events
1958                .iter()
1959                .any(|event| event.event == "commit_already_current" && event.occurrences == 3)
1960        );
1961        assert!(
1962            report
1963                .runtime_events
1964                .iter()
1965                .any(|event| event.event == "commit_success" && event.occurrences == 1)
1966        );
1967        assert!(
1968            report
1969                .runtime_events
1970                .iter()
1971                .any(|event| event.event == "response_captured" && event.occurrences == 1)
1972        );
1973        assert!(
1974            report
1975                .guardrails
1976                .iter()
1977                .any(|guardrail| guardrail.kind == "noop_closeout")
1978        );
1979        assert!(
1980            report
1981                .loop_clusters
1982                .iter()
1983                .any(|cluster| cluster.kind == "closeout_churn"
1984                    && cluster.label == "commit_already_current"
1985                    && cluster.occurrences == 3)
1986        );
1987    }
1988
1989    #[test]
1990    fn codex_jsonl_surfaces_prompt_and_command_loop_clusters() {
1991        let input = concat!(
1992            r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
1993            "\n",
1994            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
1995            "\n",
1996            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
1997            "\n",
1998            r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
1999            "\n",
2000            r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
2001            "\n",
2002            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
2003            "\n",
2004            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
2005            "\n",
2006            r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
2007            "\n"
2008        );
2009
2010        let report = compute(input, Some("codex-jsonl")).unwrap();
2011
2012        assert!(
2013            report
2014                .loop_clusters
2015                .iter()
2016                .any(|cluster| cluster.kind == "prompt_repeat"
2017                    && cluster.label == "do [#looprank]. spec-test-build-install-commit-push"
2018                    && cluster.occurrences == 2)
2019        );
2020        assert!(
2021            report
2022                .loop_clusters
2023                .iter()
2024                .any(|cluster| cluster.kind == "command_bundle"
2025                    && cluster.label == "cargo test -> cargo build --release"
2026                    && cluster.occurrences == 2)
2027        );
2028        assert!(report.loop_clusters.iter().any(|cluster| {
2029            cluster.kind == "closeout_churn"
2030                && cluster
2031                    .label
2032                    .contains("Committed and pushed in `src/tsift`")
2033                && cluster.occurrences == 2
2034        }));
2035    }
2036
2037    #[test]
2038    fn codex_jsonl_surfaces_repeated_file_read_diagnostics() {
2039        let input = concat!(
2040            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
2041            "\n",
2042            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
2043            "\n",
2044            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
2045            "\n",
2046            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
2047            "\n"
2048        );
2049
2050        let report = compute(input, Some("codex-jsonl")).unwrap();
2051
2052        assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
2053            diagnostic.path == "src/session_cost.rs"
2054                && diagnostic.range == "1-220"
2055                && diagnostic.occurrences == 2
2056                && diagnostic.duplicate_estimated_tokens == 3_960
2057                && diagnostic.follow_up_commands.iter().any(|command| {
2058                    command == "tsift source-read src/session_cost.rs --start 1 --lines 220 --budget normal"
2059                })
2060        }));
2061        assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
2062            diagnostic.path == "src/main.rs"
2063                && diagnostic.range == "full"
2064                && diagnostic.duplicate_estimated_tokens == 4_000
2065                && diagnostic
2066                    .follow_up_commands
2067                    .iter()
2068                    .any(|command| command == "tsift summarize --file src/main.rs")
2069        }));
2070    }
2071
2072    #[test]
2073    fn claude_jsonl_surfaces_repeated_native_read_tool_diagnostics() {
2074        let input = concat!(
2075            r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
2076            "\n",
2077            r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
2078            "\n"
2079        );
2080
2081        let report = compute(input, Some("claude-jsonl")).unwrap();
2082
2083        assert_eq!(report.file_read_diagnostics.len(), 1);
2084        let diagnostic = &report.file_read_diagnostics[0];
2085        assert_eq!(diagnostic.path, "src/lib.rs");
2086        assert_eq!(diagnostic.range, "40-119");
2087        assert_eq!(diagnostic.occurrences, 2);
2088        assert_eq!(diagnostic.duplicate_estimated_tokens, 1_440);
2089        assert!(diagnostic.follow_up_commands.iter().any(|command| {
2090            command == "tsift source-read src/lib.rs --start 40 --lines 80 --budget normal"
2091        }));
2092    }
2093
2094    #[test]
2095    fn derive_guardrails_flags_large_prompt_turns() {
2096        let guardrails = derive_guardrails(&SessionCostGuardrailInput {
2097            largest_prompt_turn_tokens: 140_000,
2098            largest_prompt_turn_label: Some("2026-05-05T00:00:01Z".to_string()),
2099            ..SessionCostGuardrailInput::default()
2100        });
2101
2102        assert!(
2103            guardrails
2104                .iter()
2105                .any(|guardrail| guardrail.kind == "prompt_budget")
2106        );
2107    }
2108
2109    #[test]
2110    fn derive_guardrails_flags_cached_resend_ratio() {
2111        let guardrails = derive_guardrails(&SessionCostGuardrailInput {
2112            prompt_tokens: 80_000,
2113            cached_input_ratio: Some(96.0),
2114            ..SessionCostGuardrailInput::default()
2115        });
2116
2117        assert!(
2118            guardrails
2119                .iter()
2120                .any(|guardrail| guardrail.kind == "cache_resend")
2121        );
2122    }
2123
2124    #[test]
2125    fn derive_guardrails_ignores_restart_count_without_churn() {
2126        let guardrails = derive_guardrails(&SessionCostGuardrailInput {
2127            max_restart_count: Some(3),
2128            ..SessionCostGuardrailInput::default()
2129        });
2130
2131        assert!(
2132            guardrails
2133                .iter()
2134                .all(|guardrail| guardrail.kind != "restart_loop")
2135        );
2136    }
2137}