Skip to main content

tsift_agent_doc/
session_cost.rs

1use anyhow::{Result, bail};
2use serde::{Deserialize, 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_PROMPT_CACHE_TIMELINE: usize = 8;
14const MAX_PROMPT_CACHE_DIAGNOSTICS: usize = 6;
15const MAX_PROMPT_CACHE_BREAKPOINTS: usize = 8;
16const MAX_COMMANDS_PER_BUNDLE: usize = 6;
17const PROMPT_BUDGET_WARN_TOKENS: u64 = 100_000;
18const CACHED_RATIO_WARN_PERCENT: f64 = 90.0;
19const CACHED_RATIO_WARN_PROMPT_TOKENS: u64 = 50_000;
20const PROMPT_CACHE_CANDIDATE_TOKENS: u64 = 16_000;
21const PROMPT_CACHE_GOOD_HIT_PERCENT: f64 = 75.0;
22const PROMPT_CACHE_TREND_DELTA_PERCENT: f64 = 5.0;
23const PROMPT_CACHE_RATIO_DROP_WARN_PERCENT: f64 = 20.0;
24const PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT: f64 = 20.0;
25const PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO: f64 = 2.0;
26const RESTART_LOOP_WARN_OCCURRENCES: usize = 3;
27const NOOP_CLOSEOUT_WARN_OCCURRENCES: usize = 3;
28const DEFAULT_FULL_FILE_READ_TOKENS: u64 = 4_000;
29const ESTIMATED_TOKENS_PER_SOURCE_LINE: u64 = 18;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "snake_case")]
33pub enum SessionCostSource {
34    ClaudeJsonl,
35    CodexJsonl,
36    AgentDocLog,
37}
38
39impl SessionCostSource {
40    pub fn parse(raw: &str) -> Result<Self> {
41        match raw.trim().to_ascii_lowercase().as_str() {
42            "claude" | "claude-jsonl" => Ok(Self::ClaudeJsonl),
43            "codex" | "codex-jsonl" => Ok(Self::CodexJsonl),
44            "agent-doc-log" | "agent_doc_log" | "log" => Ok(Self::AgentDocLog),
45            other => bail!(
46                "unsupported session-cost source `{other}`; expected claude-jsonl, codex-jsonl, or agent-doc-log"
47            ),
48        }
49    }
50
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::ClaudeJsonl => "claude_jsonl",
54            Self::CodexJsonl => "codex_jsonl",
55            Self::AgentDocLog => "agent_doc_log",
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct SessionCostPromptCacheMetadata {
62    pub provider: String,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub cache_key: Option<String>,
65    pub stable_prefix_fingerprint: String,
66    #[serde(skip_serializing_if = "Vec::is_empty", default)]
67    pub breakpoints: Vec<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub routing_affinity: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct SessionCostTurn {
74    pub label: String,
75    pub prompt_tokens: u64,
76    pub cached_input_tokens: u64,
77    pub cache_creation_input_tokens: u64,
78    pub output_tokens: u64,
79    pub reasoning_output_tokens: u64,
80    pub total_tokens: u64,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub prompt_cache_metadata: Option<SessionCostPromptCacheMetadata>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86pub struct SessionCostRuntimeEvent {
87    pub event: String,
88    pub occurrences: usize,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct SessionCostGuardrail {
93    pub kind: String,
94    pub severity: String,
95    pub message: String,
96    pub guidance: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100pub struct SessionCostPromptCachePlan {
101    pub status: String,
102    pub feasible: bool,
103    pub observed_cached_input_tokens: u64,
104    pub observed_cache_creation_tokens: u64,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub observed_cached_input_ratio: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub analytics: Option<SessionCostPromptCacheAnalytics>,
109    pub invariants: Vec<String>,
110    pub provider_adapters: Vec<SessionCostPromptCacheProvider>,
111    pub actions: Vec<SessionCostPromptCacheAction>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct SessionCostPromptCacheProvider {
116    pub provider: String,
117    pub status: String,
118    pub requirements: Vec<String>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
122pub struct SessionCostPromptCacheAction {
123    pub kind: String,
124    pub severity: String,
125    pub message: String,
126    pub guidance: String,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130pub struct SessionCostPromptCacheAnalytics {
131    pub sample_count: usize,
132    pub effective: bool,
133    pub trend: String,
134    pub total_prompt_tokens: u64,
135    pub total_cached_input_tokens: u64,
136    pub total_cache_creation_tokens: u64,
137    pub net_cached_input_tokens: i64,
138    pub timeline_truncated: bool,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub average_cached_input_ratio: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub first_cached_input_ratio: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub last_cached_input_ratio: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub cached_input_ratio_delta: Option<String>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub cache_read_to_creation_ratio: Option<String>,
149    #[serde(skip_serializing_if = "Vec::is_empty", default)]
150    pub diagnostics: Vec<SessionCostPromptCacheDiagnostic>,
151    pub timeline: Vec<SessionCostPromptCacheTimelineEntry>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155pub struct SessionCostPromptCacheDiagnostic {
156    pub kind: String,
157    pub severity: String,
158    pub label: String,
159    pub message: String,
160    pub likely_causes: Vec<String>,
161    pub guidance: String,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct SessionCostPromptCacheTimelineEntry {
166    pub label: String,
167    pub prompt_tokens: u64,
168    pub cached_input_tokens: u64,
169    pub cache_creation_input_tokens: u64,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub cached_input_ratio: Option<String>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub cache_creation_ratio: Option<String>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub prompt_cache_metadata: Option<SessionCostPromptCacheMetadata>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179pub struct SessionCostLoopCluster {
180    pub kind: String,
181    pub label: String,
182    pub occurrences: usize,
183    pub max_consecutive: usize,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187pub struct SessionCostFileReadDiagnostic {
188    pub path: String,
189    pub range: String,
190    pub occurrences: usize,
191    pub estimated_tokens: u64,
192    pub duplicate_estimated_tokens: u64,
193    pub follow_up_commands: Vec<String>,
194}
195
196#[derive(Debug, Clone, Default)]
197pub struct SessionCostGuardrailInput {
198    pub largest_prompt_turn_tokens: u64,
199    pub largest_prompt_turn_label: Option<String>,
200    pub prompt_tokens: u64,
201    pub cached_input_ratio: Option<f64>,
202    pub fresh_restart_occurrences: usize,
203    pub auto_trigger_timeout_occurrences: usize,
204    pub ctrl_d_restart_loop_occurrences: usize,
205    pub noop_closeout_occurrences: usize,
206    pub max_restart_count: Option<usize>,
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize)]
210pub struct SessionCostReport {
211    pub source: String,
212    pub record_count: usize,
213    pub usage_samples: usize,
214    pub prompt_tokens: u64,
215    pub cached_input_tokens: u64,
216    pub cache_creation_input_tokens: u64,
217    pub output_tokens: u64,
218    pub reasoning_output_tokens: u64,
219    pub total_tokens: u64,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub cached_input_ratio: Option<f64>,
222    pub largest_turn_total_tokens: u64,
223    pub runtime_event_groups: usize,
224    pub total_runtime_events: usize,
225    pub restart_churn_groups: usize,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub max_restart_count: Option<usize>,
228    pub largest_turns: Vec<SessionCostTurn>,
229    pub runtime_events: Vec<SessionCostRuntimeEvent>,
230    #[serde(skip_serializing_if = "Vec::is_empty", default)]
231    pub loop_clusters: Vec<SessionCostLoopCluster>,
232    #[serde(skip_serializing_if = "Vec::is_empty", default)]
233    pub file_read_diagnostics: Vec<SessionCostFileReadDiagnostic>,
234    #[serde(skip_serializing_if = "Vec::is_empty", default)]
235    pub restart_churn: Vec<RestartChurnSummary>,
236    #[serde(skip_serializing_if = "Vec::is_empty", default)]
237    pub guardrails: Vec<SessionCostGuardrail>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub prompt_cache_plan: Option<SessionCostPromptCachePlan>,
240    #[serde(skip_serializing_if = "Vec::is_empty", default)]
241    pub warnings: Vec<String>,
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct SessionCostPromptCacheEffectivenessFixture {
246    pub schema_version: u64,
247    #[serde(default)]
248    pub description: String,
249    pub cases: Vec<SessionCostPromptCacheEffectivenessCase>,
250}
251
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct SessionCostPromptCacheEffectivenessCase {
254    pub name: String,
255    pub source: String,
256    pub input_lines: Vec<String>,
257    pub minimum_cached_input_ratio: f64,
258    pub minimum_net_cached_input_tokens: i64,
259    pub maximum_read_create_regressions: usize,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize)]
263pub struct SessionCostPromptCacheEffectivenessReport {
264    pub schema_version: u64,
265    pub pass: bool,
266    pub totals: SessionCostPromptCacheEffectivenessTotals,
267    pub cases: Vec<SessionCostPromptCacheEffectivenessCaseReport>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
271pub struct SessionCostPromptCacheEffectivenessTotals {
272    pub cases: usize,
273    pub passed: usize,
274    pub failed: usize,
275    pub prompt_tokens: u64,
276    pub cached_input_tokens: u64,
277    pub cache_creation_input_tokens: u64,
278    pub net_cached_input_tokens: i64,
279    pub read_create_regressions: usize,
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize)]
283pub struct SessionCostPromptCacheEffectivenessCaseReport {
284    pub name: String,
285    pub source: String,
286    pub status: String,
287    pub prompt_tokens: u64,
288    pub cached_input_tokens: u64,
289    pub cache_creation_input_tokens: u64,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub cached_input_ratio: Option<f64>,
292    pub minimum_cached_input_ratio: f64,
293    pub net_cached_input_tokens: i64,
294    pub minimum_net_cached_input_tokens: i64,
295    pub read_create_regressions: usize,
296    pub maximum_read_create_regressions: usize,
297    #[serde(skip_serializing_if = "Vec::is_empty", default)]
298    pub failures: Vec<String>,
299}
300
301#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
302struct UsageTotals {
303    prompt_tokens: u64,
304    cached_input_tokens: u64,
305    cache_creation_input_tokens: u64,
306    output_tokens: u64,
307    reasoning_output_tokens: u64,
308    total_tokens: u64,
309}
310
311impl UsageTotals {
312    fn delta_from(self, previous: Self) -> Self {
313        Self {
314            prompt_tokens: self.prompt_tokens.saturating_sub(previous.prompt_tokens),
315            cached_input_tokens: self
316                .cached_input_tokens
317                .saturating_sub(previous.cached_input_tokens),
318            cache_creation_input_tokens: self
319                .cache_creation_input_tokens
320                .saturating_sub(previous.cache_creation_input_tokens),
321            output_tokens: self.output_tokens.saturating_sub(previous.output_tokens),
322            reasoning_output_tokens: self
323                .reasoning_output_tokens
324                .saturating_sub(previous.reasoning_output_tokens),
325            total_tokens: self.total_tokens.saturating_sub(previous.total_tokens),
326        }
327    }
328
329    fn is_zero(self) -> bool {
330        self.prompt_tokens == 0
331            && self.cached_input_tokens == 0
332            && self.cache_creation_input_tokens == 0
333            && self.output_tokens == 0
334            && self.reasoning_output_tokens == 0
335            && self.total_tokens == 0
336    }
337}
338
339#[derive(Debug, Default)]
340struct CostState {
341    warnings: Vec<String>,
342    usage_turns: Vec<SessionCostTurn>,
343    runtime_events: BTreeMap<String, usize>,
344    seen_document_cycle_events: BTreeSet<(String, String)>,
345    total_runtime_events: usize,
346    max_restart_count: Option<usize>,
347    restart_churn: RestartChurnState,
348    pending_commands: Vec<String>,
349    loop_signals: Vec<LoopSignal>,
350    file_read_signals: Vec<FileReadSignal>,
351}
352
353#[derive(Debug, Default)]
354struct PromptCacheAdapterEvidence {
355    anthropic_samples: usize,
356    anthropic_cache_control_samples: usize,
357    openai_samples: usize,
358    openai_prompt_cache_key_samples: usize,
359    openai_prompt_cache_keys: BTreeSet<String>,
360    routed_provider_samples: usize,
361    routing_affinity_samples: usize,
362    routing_affinity_values: BTreeSet<String>,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
366struct LoopSignal {
367    kind: LoopClusterKind,
368    label: String,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
372struct FileReadSignal {
373    path: String,
374    range: String,
375    start: Option<usize>,
376    lines: Option<usize>,
377    estimated_tokens: u64,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
381enum LoopClusterKind {
382    PromptRepeat,
383    CommandBundle,
384    CloseoutChurn,
385}
386
387impl LoopClusterKind {
388    fn as_str(self) -> &'static str {
389        match self {
390            Self::PromptRepeat => "prompt_repeat",
391            Self::CommandBundle => "command_bundle",
392            Self::CloseoutChurn => "closeout_churn",
393        }
394    }
395}
396
397#[derive(Debug, Clone)]
398enum TranscriptBlock {
399    Text { role: Option<String>, text: String },
400    ToolUse { name: String, input: Value },
401}
402
403pub fn compute(input: &str, source_hint: Option<&str>) -> Result<SessionCostReport> {
404    if input.trim().is_empty() {
405        bail!(
406            "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
407        );
408    }
409
410    let source = resolve_source(input, source_hint)?;
411    let mut state = CostState::default();
412    let record_count = input.lines().filter(|line| !line.trim().is_empty()).count();
413
414    match source {
415        SessionCostSource::ClaudeJsonl => ingest_claude_jsonl(input, &mut state)?,
416        SessionCostSource::CodexJsonl => ingest_codex_jsonl(input, &mut state)?,
417        SessionCostSource::AgentDocLog => ingest_agent_doc_log(input, &mut state),
418    }
419
420    let usage_samples = state.usage_turns.len();
421    let mut prompt_tokens = 0_u64;
422    let mut cached_input_tokens = 0_u64;
423    let mut cache_creation_input_tokens = 0_u64;
424    let mut output_tokens = 0_u64;
425    let mut reasoning_output_tokens = 0_u64;
426    let mut total_tokens = 0_u64;
427    let mut largest_turn_total_tokens = 0_u64;
428    for turn in &state.usage_turns {
429        prompt_tokens += turn.prompt_tokens;
430        cached_input_tokens += turn.cached_input_tokens;
431        cache_creation_input_tokens += turn.cache_creation_input_tokens;
432        output_tokens += turn.output_tokens;
433        reasoning_output_tokens += turn.reasoning_output_tokens;
434        total_tokens += turn.total_tokens;
435        largest_turn_total_tokens = largest_turn_total_tokens.max(turn.total_tokens);
436    }
437
438    let cached_input_ratio = (prompt_tokens > 0).then_some(
439        ((cached_input_tokens as f64) / (prompt_tokens as f64) * 10_000.0).round() / 100.0,
440    );
441    let largest_prompt_turn = state
442        .usage_turns
443        .iter()
444        .max_by(|left, right| {
445            left.prompt_tokens
446                .cmp(&right.prompt_tokens)
447                .then(left.label.cmp(&right.label))
448        })
449        .map(|turn| (turn.prompt_tokens, turn.label.clone()));
450    let noop_closeout_occurrences = state
451        .runtime_events
452        .get("commit_already_current")
453        .copied()
454        .unwrap_or(0);
455    flush_pending_commands(&mut state);
456    let loop_clusters = collect_loop_clusters(&state.loop_signals);
457    let file_read_diagnostics = collect_file_read_diagnostics(&state.file_read_signals);
458    let prompt_cache_plan = derive_prompt_cache_plan(
459        prompt_tokens,
460        cached_input_tokens,
461        cache_creation_input_tokens,
462        cached_input_ratio,
463        &state.usage_turns,
464    );
465
466    let mut largest_turns = state.usage_turns;
467    largest_turns.sort_by(|left, right| {
468        right
469            .total_tokens
470            .cmp(&left.total_tokens)
471            .then(right.prompt_tokens.cmp(&left.prompt_tokens))
472            .then(left.label.cmp(&right.label))
473    });
474    largest_turns.truncate(MAX_LARGEST_TURNS);
475
476    let mut runtime_events = state
477        .runtime_events
478        .into_iter()
479        .map(|(event, occurrences)| SessionCostRuntimeEvent { event, occurrences })
480        .collect::<Vec<_>>();
481    runtime_events.sort_by(|left, right| {
482        right
483            .occurrences
484            .cmp(&left.occurrences)
485            .then(left.event.cmp(&right.event))
486    });
487    let runtime_event_groups = runtime_events.len();
488    runtime_events.truncate(MAX_RUNTIME_EVENTS);
489    let restart_churn_groups = state.restart_churn.groups();
490    let restart_churn = state.restart_churn.summaries();
491    let guardrails = derive_guardrails(&SessionCostGuardrailInput {
492        largest_prompt_turn_tokens: largest_prompt_turn.as_ref().map_or(0, |turn| turn.0),
493        largest_prompt_turn_label: largest_prompt_turn.as_ref().map(|turn| turn.1.clone()),
494        prompt_tokens,
495        cached_input_ratio,
496        fresh_restart_occurrences: count_restart_family(&restart_churn, "fresh_restart"),
497        auto_trigger_timeout_occurrences: count_restart_family(
498            &restart_churn,
499            "auto_trigger_timeout",
500        ),
501        ctrl_d_restart_loop_occurrences: count_restart_family(
502            &restart_churn,
503            "ctrl_d_restart_loop",
504        ),
505        noop_closeout_occurrences,
506        max_restart_count: state.max_restart_count,
507    });
508
509    if usage_samples == 0 && runtime_event_groups == 0 {
510        state
511            .warnings
512            .push("no cost or runtime signals were detected in the provided input".to_string());
513    }
514
515    Ok(SessionCostReport {
516        source: source.as_str().to_string(),
517        record_count,
518        usage_samples,
519        prompt_tokens,
520        cached_input_tokens,
521        cache_creation_input_tokens,
522        output_tokens,
523        reasoning_output_tokens,
524        total_tokens,
525        cached_input_ratio,
526        largest_turn_total_tokens,
527        runtime_event_groups,
528        total_runtime_events: state.total_runtime_events,
529        restart_churn_groups,
530        max_restart_count: state.max_restart_count,
531        largest_turns,
532        runtime_events,
533        loop_clusters,
534        file_read_diagnostics,
535        restart_churn,
536        guardrails,
537        prompt_cache_plan,
538        warnings: state.warnings,
539    })
540}
541
542pub fn build_prompt_cache_effectiveness_report(
543    fixture: &SessionCostPromptCacheEffectivenessFixture,
544) -> Result<SessionCostPromptCacheEffectivenessReport> {
545    if fixture.cases.is_empty() {
546        bail!("prompt-cache effectiveness fixture has no cases");
547    }
548
549    let mut cases = Vec::new();
550    let mut totals = SessionCostPromptCacheEffectivenessTotals {
551        cases: 0,
552        passed: 0,
553        failed: 0,
554        prompt_tokens: 0,
555        cached_input_tokens: 0,
556        cache_creation_input_tokens: 0,
557        net_cached_input_tokens: 0,
558        read_create_regressions: 0,
559    };
560
561    for case in &fixture.cases {
562        if case.input_lines.is_empty() {
563            bail!(
564                "prompt-cache fixture case `{}` has no input_lines",
565                case.name
566            );
567        }
568        let input = format!("{}\n", case.input_lines.join("\n"));
569        let report = compute(&input, Some(&case.source))
570            .map_err(|err| err.context(format!("evaluating prompt-cache fixture {}", case.name)))?;
571        let analytics = report
572            .prompt_cache_plan
573            .as_ref()
574            .and_then(|plan| plan.analytics.as_ref());
575        let net_cached_input_tokens = analytics.map_or(
576            signed_token_delta(
577                report.cached_input_tokens,
578                report.cache_creation_input_tokens,
579            ),
580            |analytics| analytics.net_cached_input_tokens,
581        );
582        let read_create_regressions = analytics.map_or(0, |analytics| {
583            analytics
584                .diagnostics
585                .iter()
586                .filter(|diagnostic| diagnostic.kind == "read_create_regression")
587                .count()
588        });
589
590        let mut failures = Vec::new();
591        if report.prompt_cache_plan.is_none() {
592            failures.push("missing prompt_cache_plan".to_string());
593        }
594        if analytics.is_none() {
595            failures.push("missing prompt_cache_plan.analytics".to_string());
596        }
597        match report.cached_input_ratio {
598            Some(ratio) if ratio >= case.minimum_cached_input_ratio => {}
599            Some(ratio) => failures.push(format!(
600                "cached_input_ratio {:.2}% below required {:.2}%",
601                ratio, case.minimum_cached_input_ratio
602            )),
603            None => failures.push(format!(
604                "cached_input_ratio missing; required {:.2}%",
605                case.minimum_cached_input_ratio
606            )),
607        }
608        if net_cached_input_tokens < case.minimum_net_cached_input_tokens {
609            failures.push(format!(
610                "net_cached_input_tokens {} below required {}",
611                net_cached_input_tokens, case.minimum_net_cached_input_tokens
612            ));
613        }
614        if read_create_regressions > case.maximum_read_create_regressions {
615            failures.push(format!(
616                "read_create_regressions {} exceeded allowed {}",
617                read_create_regressions, case.maximum_read_create_regressions
618            ));
619        }
620        failures.extend(prompt_cache_provider_adapter_failures(
621            case,
622            report.prompt_cache_plan.as_ref(),
623        ));
624
625        let status = if failures.is_empty() {
626            "pass".to_string()
627        } else {
628            "fail".to_string()
629        };
630        totals.cases += 1;
631        if status == "pass" {
632            totals.passed += 1;
633        } else {
634            totals.failed += 1;
635        }
636        totals.prompt_tokens += report.prompt_tokens;
637        totals.cached_input_tokens += report.cached_input_tokens;
638        totals.cache_creation_input_tokens += report.cache_creation_input_tokens;
639        totals.net_cached_input_tokens += net_cached_input_tokens;
640        totals.read_create_regressions += read_create_regressions;
641
642        cases.push(SessionCostPromptCacheEffectivenessCaseReport {
643            name: case.name.clone(),
644            source: report.source,
645            status,
646            prompt_tokens: report.prompt_tokens,
647            cached_input_tokens: report.cached_input_tokens,
648            cache_creation_input_tokens: report.cache_creation_input_tokens,
649            cached_input_ratio: report.cached_input_ratio,
650            minimum_cached_input_ratio: case.minimum_cached_input_ratio,
651            net_cached_input_tokens,
652            minimum_net_cached_input_tokens: case.minimum_net_cached_input_tokens,
653            read_create_regressions,
654            maximum_read_create_regressions: case.maximum_read_create_regressions,
655            failures,
656        });
657    }
658
659    Ok(SessionCostPromptCacheEffectivenessReport {
660        schema_version: fixture.schema_version,
661        pass: totals.failed == 0,
662        totals,
663        cases,
664    })
665}
666
667fn prompt_cache_provider_adapter_failures(
668    case: &SessionCostPromptCacheEffectivenessCase,
669    plan: Option<&SessionCostPromptCachePlan>,
670) -> Vec<String> {
671    let mut failures = Vec::new();
672    let Some(plan) = plan else {
673        return failures;
674    };
675    let Ok(source) = SessionCostSource::parse(&case.source) else {
676        return failures;
677    };
678
679    match source {
680        SessionCostSource::ClaudeJsonl => require_prompt_cache_provider_adapter(
681            plan,
682            "anthropic",
683            "cache_control",
684            "Anthropic cache_control",
685            &mut failures,
686        ),
687        SessionCostSource::CodexJsonl => require_prompt_cache_provider_adapter(
688            plan,
689            "openai",
690            "prompt_cache_key",
691            "OpenAI prompt_cache_key",
692            &mut failures,
693        ),
694        SessionCostSource::AgentDocLog => {}
695    }
696    if matches!(
697        source,
698        SessionCostSource::ClaudeJsonl | SessionCostSource::CodexJsonl
699    ) {
700        require_prompt_cache_provider_adapter(
701            plan,
702            "replica_local",
703            "routing_affinity",
704            "replica-local routing_affinity",
705            &mut failures,
706        );
707    }
708
709    failures
710}
711
712fn require_prompt_cache_provider_adapter(
713    plan: &SessionCostPromptCachePlan,
714    provider: &str,
715    expected_status: &str,
716    label: &str,
717    failures: &mut Vec<String>,
718) {
719    match plan
720        .provider_adapters
721        .iter()
722        .find(|adapter| adapter.provider == provider)
723    {
724        Some(adapter) if adapter.status == expected_status => {}
725        Some(adapter) => failures.push(format!(
726            "{label} adapter status `{}`; expected `{expected_status}`",
727            adapter.status
728        )),
729        None => failures.push(format!("missing {label} adapter")),
730    }
731}
732
733pub fn derive_guardrails(input: &SessionCostGuardrailInput) -> Vec<SessionCostGuardrail> {
734    let mut guardrails = Vec::new();
735
736    if input.largest_prompt_turn_tokens >= PROMPT_BUDGET_WARN_TOKENS {
737        let label = input
738            .largest_prompt_turn_label
739            .as_deref()
740            .map(|label| format!(" at {label}"))
741            .unwrap_or_default();
742        guardrails.push(SessionCostGuardrail {
743            kind: "prompt_budget".to_string(),
744            severity: "warn".to_string(),
745            message: format!(
746                "largest prompt turn reached {} tokens{label}",
747                input.largest_prompt_turn_tokens
748            ),
749            guidance:
750                "compact the session or split the task before another large turn resends the same context"
751                    .to_string(),
752        });
753    }
754
755    if input.prompt_tokens >= CACHED_RATIO_WARN_PROMPT_TOKENS
756        && input
757            .cached_input_ratio
758            .is_some_and(|ratio| ratio >= CACHED_RATIO_WARN_PERCENT)
759    {
760        guardrails.push(SessionCostGuardrail {
761            kind: "cache_resend".to_string(),
762            severity: "warn".to_string(),
763            message: format!(
764                "cached input ratio was {:.2}% across {} prompt tokens",
765                input.cached_input_ratio.unwrap_or_default(),
766                input.prompt_tokens
767            ),
768            guidance:
769                "compact or restart the session when most prompt spend is cached context instead of new work"
770                    .to_string(),
771        });
772    }
773
774    let restart_signal_count = input.fresh_restart_occurrences
775        + input.auto_trigger_timeout_occurrences
776        + input.ctrl_d_restart_loop_occurrences;
777    if restart_signal_count >= RESTART_LOOP_WARN_OCCURRENCES
778        || input.ctrl_d_restart_loop_occurrences > 0
779        || input.auto_trigger_timeout_occurrences > 0
780    {
781        let max_restart = input
782            .max_restart_count
783            .map(|count| format!(" max_restart={count}."))
784            .unwrap_or_default();
785        guardrails.push(SessionCostGuardrail {
786            kind: "restart_loop".to_string(),
787            severity: "warn".to_string(),
788            message: format!(
789                "restart churn detected: fresh_restart={} auto_trigger_timeout={} ctrl_d_restart_loop={}.{}",
790                input.fresh_restart_occurrences,
791                input.auto_trigger_timeout_occurrences,
792                input.ctrl_d_restart_loop_occurrences,
793                max_restart
794            )
795            .trim()
796            .to_string(),
797            guidance:
798                "fix the startup/retry issue before another restart, or compact and reopen cleanly instead of looping"
799                    .to_string(),
800        });
801    }
802
803    if input.noop_closeout_occurrences >= NOOP_CLOSEOUT_WARN_OCCURRENCES {
804        guardrails.push(SessionCostGuardrail {
805            kind: "noop_closeout".to_string(),
806            severity: "warn".to_string(),
807            message: format!(
808                "commit_already_current appeared {} times",
809                input.noop_closeout_occurrences
810            ),
811            guidance:
812                "compact the document or avoid reopening it without new edits when closeouts are mostly no-ops"
813                    .to_string(),
814        });
815    }
816
817    guardrails.truncate(MAX_GUARDRAILS);
818    guardrails
819}
820
821fn derive_prompt_cache_plan(
822    prompt_tokens: u64,
823    cached_input_tokens: u64,
824    cache_creation_input_tokens: u64,
825    cached_input_ratio: Option<f64>,
826    usage_turns: &[SessionCostTurn],
827) -> Option<SessionCostPromptCachePlan> {
828    let usage_samples = usage_turns.len();
829    if usage_samples == 0 {
830        return None;
831    }
832
833    let observed = cached_input_tokens > 0 || cache_creation_input_tokens > 0;
834    let candidate = prompt_tokens >= PROMPT_CACHE_CANDIDATE_TOKENS;
835    if !observed && !candidate {
836        return None;
837    }
838
839    let adapter_evidence = prompt_cache_adapter_evidence(usage_turns);
840    let mut actions = Vec::new();
841    if !observed {
842        actions.push(SessionCostPromptCacheAction {
843            kind: "enable_provider_cache".to_string(),
844            severity: "recommend".to_string(),
845            message: format!(
846                "prompt volume reached {prompt_tokens} tokens without observed cache reads"
847            ),
848            guidance: "add a provider adapter that keeps stable context byte-identical and passes the provider cache hint on each turn"
849                .to_string(),
850        });
851    } else if cached_input_ratio.is_some_and(|ratio| ratio < PROMPT_CACHE_GOOD_HIT_PERCENT) {
852        actions.push(SessionCostPromptCacheAction {
853            kind: "improve_cache_hit_rate".to_string(),
854            severity: "recommend".to_string(),
855            message: format!(
856                "cached input ratio was {:.2}% across {prompt_tokens} prompt tokens",
857                cached_input_ratio.unwrap_or_default()
858            ),
859            guidance:
860                "move volatile timestamps, generated headers, and one-off compaction prompts after the cached prefix"
861                    .to_string(),
862        });
863    } else {
864        actions.push(SessionCostPromptCacheAction {
865            kind: "preserve_cache_shape".to_string(),
866            severity: "info".to_string(),
867            message: format!(
868                "cache reads were observed across {cached_input_tokens} input tokens"
869            ),
870            guidance:
871                "keep the stable prefix and append-only transcript shape intact while adding new tools or context"
872                    .to_string(),
873        });
874    }
875
876    if cache_creation_input_tokens > cached_input_tokens && cached_input_tokens > 0 {
877        actions.push(SessionCostPromptCacheAction {
878            kind: "reduce_cache_rewrites".to_string(),
879            severity: "recommend".to_string(),
880            message: format!(
881                "cache creation tokens ({cache_creation_input_tokens}) exceeded cache read tokens ({cached_input_tokens})"
882            ),
883            guidance:
884                "check for prefix churn before each model call; repeated writes can erase the economics of prompt caching"
885            .to_string(),
886        });
887    }
888    push_prompt_cache_adapter_actions(&adapter_evidence, &mut actions);
889
890    Some(SessionCostPromptCachePlan {
891        status: if observed { "observed" } else { "candidate" }.to_string(),
892        feasible: true,
893        observed_cached_input_tokens: cached_input_tokens,
894        observed_cache_creation_tokens: cache_creation_input_tokens,
895        observed_cached_input_ratio: cached_input_ratio.map(|ratio| format!("{ratio:.2}%")),
896        analytics: derive_prompt_cache_analytics(
897            usage_turns,
898            prompt_tokens,
899            cached_input_tokens,
900            cache_creation_input_tokens,
901            cached_input_ratio,
902        ),
903        invariants: vec![
904            "place stable system/developer context before per-turn content".to_string(),
905            "treat conversation history as append-only until an intentional compaction boundary"
906                .to_string(),
907            "exclude volatile timestamps, random ids, and transient instructions from the cached prefix"
908                .to_string(),
909            "run compaction against the same live prefix whenever the provider cache is still warm"
910                .to_string(),
911        ],
912        provider_adapters: derive_prompt_cache_provider_adapters(&adapter_evidence),
913        actions,
914    })
915}
916
917fn derive_prompt_cache_provider_adapters(
918    evidence: &PromptCacheAdapterEvidence,
919) -> Vec<SessionCostPromptCacheProvider> {
920    vec![
921        SessionCostPromptCacheProvider {
922            provider: "anthropic".to_string(),
923            status: anthropic_cache_control_status(evidence).to_string(),
924            requirements: vec![
925                "attach cache_control to the stable system block".to_string(),
926                "attach cache_control to the final tool definition when tools are sent".to_string(),
927                "attach cache_control to the last two user-role messages; skip one-off compaction instructions"
928                    .to_string(),
929            ],
930        },
931        SessionCostPromptCacheProvider {
932            provider: "openai".to_string(),
933            status: openai_prompt_cache_key_status(evidence).to_string(),
934            requirements: vec![
935                "derive prompt_cache_key from the stable thread/session id".to_string(),
936                "keep prefixes byte-identical across consecutive calls for the same key".to_string(),
937            ],
938        },
939        SessionCostPromptCacheProvider {
940            provider: "replica_local".to_string(),
941            status: replica_local_routing_affinity_status(evidence).to_string(),
942            requirements: vec![
943                "route consecutive calls for the same cache key to the same replica when the provider cache is replica-local"
944                    .to_string(),
945            ],
946        },
947    ]
948}
949
950fn prompt_cache_adapter_evidence(usage_turns: &[SessionCostTurn]) -> PromptCacheAdapterEvidence {
951    let mut evidence = PromptCacheAdapterEvidence::default();
952    for metadata in usage_turns
953        .iter()
954        .filter_map(|turn| turn.prompt_cache_metadata.as_ref())
955    {
956        let anthropic = is_anthropic_provider(&metadata.provider);
957        let openai = is_openai_provider(&metadata.provider);
958        if anthropic {
959            evidence.anthropic_samples += 1;
960            if metadata_has_cache_control_breakpoint(metadata) {
961                evidence.anthropic_cache_control_samples += 1;
962            }
963        }
964        if openai {
965            evidence.openai_samples += 1;
966            if let Some(cache_key) = metadata.cache_key.as_ref() {
967                evidence.openai_prompt_cache_key_samples += 1;
968                evidence.openai_prompt_cache_keys.insert(cache_key.clone());
969            }
970        }
971        if anthropic || openai {
972            evidence.routed_provider_samples += 1;
973            if let Some(routing_affinity) = metadata.routing_affinity.as_ref() {
974                evidence.routing_affinity_samples += 1;
975                evidence
976                    .routing_affinity_values
977                    .insert(routing_affinity.clone());
978            }
979        }
980    }
981    evidence
982}
983
984fn anthropic_cache_control_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
985    if evidence.anthropic_samples == 0 {
986        "not_observed"
987    } else if evidence.anthropic_cache_control_samples == evidence.anthropic_samples {
988        "cache_control"
989    } else if evidence.anthropic_cache_control_samples > 0 {
990        "partial_cache_control"
991    } else {
992        "missing_cache_control"
993    }
994}
995
996fn openai_prompt_cache_key_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
997    if evidence.openai_samples == 0 {
998        "not_observed"
999    } else if evidence.openai_prompt_cache_key_samples < evidence.openai_samples {
1000        if evidence.openai_prompt_cache_key_samples == 0 {
1001            "missing_prompt_cache_key"
1002        } else {
1003            "partial_prompt_cache_key"
1004        }
1005    } else if evidence.openai_prompt_cache_keys.len() > 1 {
1006        "prompt_cache_key_churn"
1007    } else {
1008        "prompt_cache_key"
1009    }
1010}
1011
1012fn replica_local_routing_affinity_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
1013    if evidence.routed_provider_samples == 0 {
1014        "not_observed"
1015    } else if evidence.routing_affinity_samples < evidence.routed_provider_samples {
1016        if evidence.routing_affinity_samples == 0 {
1017            "missing_routing_affinity"
1018        } else {
1019            "partial_routing_affinity"
1020        }
1021    } else if evidence.routing_affinity_values.len() > 1 {
1022        "routing_affinity_churn"
1023    } else {
1024        "routing_affinity"
1025    }
1026}
1027
1028fn push_prompt_cache_adapter_actions(
1029    evidence: &PromptCacheAdapterEvidence,
1030    actions: &mut Vec<SessionCostPromptCacheAction>,
1031) {
1032    match anthropic_cache_control_status(evidence) {
1033        "missing_cache_control" | "partial_cache_control" => {
1034            actions.push(SessionCostPromptCacheAction {
1035                kind: "fix_anthropic_cache_control".to_string(),
1036                severity: "recommend".to_string(),
1037                message: "Anthropic prompt-cache calls are missing cache_control breakpoints"
1038                    .to_string(),
1039                guidance: "attach cache_control to the stable Anthropic system/tool/user blocks that should be cached"
1040                    .to_string(),
1041            });
1042        }
1043        _ => {}
1044    }
1045    match openai_prompt_cache_key_status(evidence) {
1046        "missing_prompt_cache_key" | "partial_prompt_cache_key" | "prompt_cache_key_churn" => {
1047            actions.push(SessionCostPromptCacheAction {
1048                kind: "fix_openai_prompt_cache_key".to_string(),
1049                severity: "recommend".to_string(),
1050                message: "OpenAI prompt-cache calls need a stable prompt_cache_key".to_string(),
1051                guidance: "derive prompt_cache_key from the stable session/thread id and keep it unchanged across warm-prefix calls"
1052                    .to_string(),
1053            });
1054        }
1055        _ => {}
1056    }
1057    match replica_local_routing_affinity_status(evidence) {
1058        "missing_routing_affinity" | "partial_routing_affinity" | "routing_affinity_churn" => {
1059            actions.push(SessionCostPromptCacheAction {
1060                kind: "fix_replica_routing_affinity".to_string(),
1061                severity: "recommend".to_string(),
1062                message: "prompt-cache calls need stable replica-local routing affinity"
1063                    .to_string(),
1064                guidance: "route consecutive calls for the same cache key to the same provider replica or deployment"
1065                    .to_string(),
1066            });
1067        }
1068        _ => {}
1069    }
1070}
1071
1072fn derive_prompt_cache_analytics(
1073    usage_turns: &[SessionCostTurn],
1074    prompt_tokens: u64,
1075    cached_input_tokens: u64,
1076    cache_creation_input_tokens: u64,
1077    cached_input_ratio: Option<f64>,
1078) -> Option<SessionCostPromptCacheAnalytics> {
1079    if usage_turns.is_empty() {
1080        return None;
1081    }
1082
1083    let first_ratio = usage_turns
1084        .first()
1085        .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1086    let last_ratio = usage_turns
1087        .last()
1088        .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1089    let ratio_delta = first_ratio
1090        .zip(last_ratio)
1091        .map(|(first, last)| last - first);
1092    let trend = prompt_cache_trend(usage_turns.len(), ratio_delta).to_string();
1093    let effective = cached_input_ratio.is_some_and(|ratio| ratio >= PROMPT_CACHE_GOOD_HIT_PERCENT)
1094        && cached_input_tokens >= cache_creation_input_tokens;
1095    let cache_read_to_creation_ratio = (cache_creation_input_tokens > 0).then(|| {
1096        format!(
1097            "{:.2}x",
1098            (cached_input_tokens as f64) / (cache_creation_input_tokens as f64)
1099        )
1100    });
1101    let timeline = prompt_cache_timeline(usage_turns);
1102    let diagnostics = derive_prompt_cache_diagnostics(
1103        usage_turns,
1104        cached_input_tokens,
1105        cache_creation_input_tokens,
1106    );
1107
1108    Some(SessionCostPromptCacheAnalytics {
1109        sample_count: usage_turns.len(),
1110        effective,
1111        trend,
1112        total_prompt_tokens: prompt_tokens,
1113        total_cached_input_tokens: cached_input_tokens,
1114        total_cache_creation_tokens: cache_creation_input_tokens,
1115        net_cached_input_tokens: signed_token_delta(
1116            cached_input_tokens,
1117            cache_creation_input_tokens,
1118        ),
1119        timeline_truncated: usage_turns.len() > MAX_PROMPT_CACHE_TIMELINE,
1120        average_cached_input_ratio: cached_input_ratio.map(format_percent),
1121        first_cached_input_ratio: first_ratio.map(format_percent),
1122        last_cached_input_ratio: last_ratio.map(format_percent),
1123        cached_input_ratio_delta: ratio_delta.map(format_signed_percent),
1124        cache_read_to_creation_ratio,
1125        diagnostics,
1126        timeline,
1127    })
1128}
1129
1130fn derive_prompt_cache_diagnostics(
1131    usage_turns: &[SessionCostTurn],
1132    cached_input_tokens: u64,
1133    cache_creation_input_tokens: u64,
1134) -> Vec<SessionCostPromptCacheDiagnostic> {
1135    let mut diagnostics = Vec::new();
1136
1137    for pair in usage_turns.windows(2) {
1138        let previous = &pair[0];
1139        let current = &pair[1];
1140        let Some(previous_ratio) =
1141            percent_ratio(previous.cached_input_tokens, previous.prompt_tokens)
1142        else {
1143            continue;
1144        };
1145        let Some(current_ratio) = percent_ratio(current.cached_input_tokens, current.prompt_tokens)
1146        else {
1147            continue;
1148        };
1149        let drop = previous_ratio - current_ratio;
1150        if drop >= PROMPT_CACHE_RATIO_DROP_WARN_PERCENT {
1151            diagnostics.push(SessionCostPromptCacheDiagnostic {
1152                kind: "cached_ratio_drop".to_string(),
1153                severity: "warn".to_string(),
1154                label: current.label.clone(),
1155                message: format!(
1156                    "cached input ratio dropped from {} to {} at {}",
1157                    format_percent(previous_ratio),
1158                    format_percent(current_ratio),
1159                    current.label
1160                ),
1161                likely_causes: vec![
1162                    "stable prefix bytes changed before the cache boundary".to_string(),
1163                    "prompt_cache_key or thread/session id changed".to_string(),
1164                    "replica-local cache affinity was lost".to_string(),
1165                ],
1166                guidance:
1167                    "compare the prefix, tool set, cache key, compaction boundary, and routing between the previous turn and this turn"
1168                        .to_string(),
1169            });
1170        }
1171    }
1172
1173    for turn in usage_turns {
1174        let Some(creation_ratio) =
1175            percent_ratio(turn.cache_creation_input_tokens, turn.prompt_tokens)
1176        else {
1177            continue;
1178        };
1179        if turn.cache_creation_input_tokens > 0
1180            && creation_ratio >= PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT
1181        {
1182            diagnostics.push(SessionCostPromptCacheDiagnostic {
1183                kind: "cache_creation_spike".to_string(),
1184                severity: "warn".to_string(),
1185                label: turn.label.clone(),
1186                message: format!(
1187                    "cache creation was {} of prompt tokens at {}",
1188                    format_percent(creation_ratio),
1189                    turn.label
1190                ),
1191                likely_causes: vec![
1192                    "provider created a fresh cached prefix instead of reusing the warm prefix"
1193                        .to_string(),
1194                    "system, developer, or tool block changed before the cache boundary"
1195                        .to_string(),
1196                    "compaction or transient instructions entered the cached prefix".to_string(),
1197                ],
1198                guidance:
1199                    "inspect the cached prefix and provider breakpoint placement for this turn before treating the cache as effective"
1200                        .to_string(),
1201            });
1202        }
1203    }
1204
1205    if cache_creation_input_tokens > 0 {
1206        let read_to_creation = (cached_input_tokens as f64) / (cache_creation_input_tokens as f64);
1207        if read_to_creation < PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO {
1208            diagnostics.push(SessionCostPromptCacheDiagnostic {
1209                kind: "read_create_regression".to_string(),
1210                severity: "recommend".to_string(),
1211                label: "session".to_string(),
1212                message: format!(
1213                    "cache read/create ratio was {read_to_creation:.2}x ({cached_input_tokens} read tokens, {cache_creation_input_tokens} creation tokens)"
1214                ),
1215                likely_causes: vec![
1216                    "cached prefix is being rewritten too often for warm reuse".to_string(),
1217                    "volatile values are inside the cached prefix".to_string(),
1218                    "cache key or replica routing is changing between turns".to_string(),
1219                ],
1220                guidance:
1221                    "stabilize the prefix/key/routing path until cache reads clearly exceed creation work"
1222                        .to_string(),
1223            });
1224        }
1225    }
1226
1227    diagnostics.truncate(MAX_PROMPT_CACHE_DIAGNOSTICS);
1228    diagnostics
1229}
1230
1231fn prompt_cache_timeline(
1232    usage_turns: &[SessionCostTurn],
1233) -> Vec<SessionCostPromptCacheTimelineEntry> {
1234    let selected = if usage_turns.len() <= MAX_PROMPT_CACHE_TIMELINE {
1235        usage_turns.iter().collect::<Vec<_>>()
1236    } else {
1237        let tail_count = MAX_PROMPT_CACHE_TIMELINE.saturating_sub(1);
1238        let mut selected = Vec::with_capacity(MAX_PROMPT_CACHE_TIMELINE);
1239        if let Some(first) = usage_turns.first() {
1240            selected.push(first);
1241        }
1242        selected.extend(usage_turns.iter().skip(usage_turns.len() - tail_count));
1243        selected
1244    };
1245
1246    selected
1247        .into_iter()
1248        .map(|turn| SessionCostPromptCacheTimelineEntry {
1249            label: turn.label.clone(),
1250            prompt_tokens: turn.prompt_tokens,
1251            cached_input_tokens: turn.cached_input_tokens,
1252            cache_creation_input_tokens: turn.cache_creation_input_tokens,
1253            cached_input_ratio: percent_ratio(turn.cached_input_tokens, turn.prompt_tokens)
1254                .map(format_percent),
1255            cache_creation_ratio: percent_ratio(
1256                turn.cache_creation_input_tokens,
1257                turn.prompt_tokens,
1258            )
1259            .map(format_percent),
1260            prompt_cache_metadata: turn.prompt_cache_metadata.clone(),
1261        })
1262        .collect()
1263}
1264
1265fn prompt_cache_trend(sample_count: usize, ratio_delta: Option<f64>) -> &'static str {
1266    if sample_count < 2 {
1267        return "single_sample";
1268    }
1269    let Some(delta) = ratio_delta else {
1270        return "insufficient_data";
1271    };
1272    if delta >= PROMPT_CACHE_TREND_DELTA_PERCENT {
1273        "improving"
1274    } else if delta <= -PROMPT_CACHE_TREND_DELTA_PERCENT {
1275        "declining"
1276    } else {
1277        "stable"
1278    }
1279}
1280
1281fn percent_ratio(numerator: u64, denominator: u64) -> Option<f64> {
1282    (denominator > 0)
1283        .then_some(((numerator as f64) / (denominator as f64) * 10_000.0).round() / 100.0)
1284}
1285
1286fn format_percent(value: f64) -> String {
1287    format!("{value:.2}%")
1288}
1289
1290fn format_signed_percent(value: f64) -> String {
1291    format!("{value:+.2}%")
1292}
1293
1294fn signed_token_delta(read_tokens: u64, creation_tokens: u64) -> i64 {
1295    if read_tokens >= creation_tokens {
1296        i64::try_from(read_tokens - creation_tokens).unwrap_or(i64::MAX)
1297    } else {
1298        -i64::try_from(creation_tokens - read_tokens).unwrap_or(i64::MAX)
1299    }
1300}
1301
1302fn resolve_source(input: &str, source_hint: Option<&str>) -> Result<SessionCostSource> {
1303    if let Some(raw) = source_hint {
1304        return SessionCostSource::parse(raw);
1305    }
1306
1307    let non_empty = input
1308        .lines()
1309        .map(str::trim)
1310        .filter(|line| !line.is_empty())
1311        .collect::<Vec<_>>();
1312    if non_empty.is_empty() {
1313        bail!(
1314            "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
1315        );
1316    }
1317
1318    if non_empty
1319        .iter()
1320        .all(|line| line.starts_with('{') && serde_json::from_str::<Value>(line).is_ok())
1321    {
1322        for line in &non_empty {
1323            let value = serde_json::from_str::<Value>(line).unwrap_or(Value::Null);
1324            if value
1325                .get("message")
1326                .and_then(|message| message.get("usage"))
1327                .is_some()
1328            {
1329                return Ok(SessionCostSource::ClaudeJsonl);
1330            }
1331            if value.get("type").and_then(Value::as_str) == Some("event_msg")
1332                && value
1333                    .get("payload")
1334                    .and_then(|payload| payload.get("type"))
1335                    .and_then(Value::as_str)
1336                    == Some("token_count")
1337            {
1338                return Ok(SessionCostSource::CodexJsonl);
1339            }
1340        }
1341        if non_empty.iter().any(|line| line.contains("\"parentUuid\"")) {
1342            return Ok(SessionCostSource::ClaudeJsonl);
1343        }
1344        if non_empty
1345            .iter()
1346            .any(|line| line.contains("\"response_item\"") || line.contains("\"turn_context\""))
1347        {
1348            return Ok(SessionCostSource::CodexJsonl);
1349        }
1350    }
1351
1352    if non_empty
1353        .iter()
1354        .all(|line| line.starts_with('[') && line.contains(']'))
1355    {
1356        return Ok(SessionCostSource::AgentDocLog);
1357    }
1358
1359    bail!(
1360        "could not auto-detect session-cost input; pass --source claude-jsonl, codex-jsonl, or agent-doc-log"
1361    )
1362}
1363
1364fn ingest_claude_jsonl(input: &str, state: &mut CostState) -> Result<()> {
1365    let mut seen_keys = BTreeSet::new();
1366    for (index, raw_line) in input.lines().enumerate() {
1367        let trimmed = raw_line.trim();
1368        if trimmed.is_empty() {
1369            continue;
1370        }
1371        let value = match serde_json::from_str::<Value>(trimmed) {
1372            Ok(value) => value,
1373            Err(_) => {
1374                state.warnings.push(format!(
1375                    "skipping malformed Claude transcript jsonl line {}",
1376                    index + 1
1377                ));
1378                continue;
1379            }
1380        };
1381        let Some(message) = value.get("message") else {
1382            collect_claude_loop_signals(&value, state);
1383            continue;
1384        };
1385        collect_claude_loop_signals(&value, state);
1386        if message.get("role").and_then(Value::as_str) != Some("assistant") {
1387            continue;
1388        }
1389        let Some(usage) = message.get("usage") else {
1390            continue;
1391        };
1392
1393        let key = message
1394            .get("id")
1395            .and_then(Value::as_str)
1396            .or_else(|| value.get("requestId").and_then(Value::as_str))
1397            .or_else(|| value.get("uuid").and_then(Value::as_str))
1398            .map(|value| value.to_string())
1399            .unwrap_or_else(|| format!("line-{}", index + 1));
1400        if !seen_keys.insert(key.clone()) {
1401            continue;
1402        }
1403
1404        let prompt_tokens = usage_u64(usage, "input_tokens")
1405            + usage_u64(usage, "cache_creation_input_tokens")
1406            + usage_u64(usage, "cache_read_input_tokens");
1407        let cached_input_tokens = usage_u64(usage, "cache_read_input_tokens");
1408        let cache_creation_input_tokens = usage_u64(usage, "cache_creation_input_tokens");
1409        let output_tokens = usage_u64(usage, "output_tokens");
1410        let total_tokens = prompt_tokens + output_tokens;
1411        if prompt_tokens == 0 && output_tokens == 0 {
1412            continue;
1413        }
1414
1415        state.usage_turns.push(SessionCostTurn {
1416            label: value
1417                .get("timestamp")
1418                .and_then(Value::as_str)
1419                .map(|value| value.to_string())
1420                .unwrap_or(key),
1421            prompt_tokens,
1422            cached_input_tokens,
1423            cache_creation_input_tokens,
1424            output_tokens,
1425            reasoning_output_tokens: 0,
1426            total_tokens,
1427            prompt_cache_metadata: Some(prompt_cache_metadata(
1428                &value,
1429                SessionCostSource::ClaudeJsonl,
1430            )),
1431        });
1432    }
1433    Ok(())
1434}
1435
1436fn ingest_codex_jsonl(input: &str, state: &mut CostState) -> Result<()> {
1437    let mut previous = UsageTotals::default();
1438    let mut seen_cumulative_snapshots = BTreeSet::<UsageTotals>::new();
1439    let mut saw_token_count = false;
1440    for (index, raw_line) in input.lines().enumerate() {
1441        let trimmed = raw_line.trim();
1442        if trimmed.is_empty() {
1443            continue;
1444        }
1445        let value = match serde_json::from_str::<Value>(trimmed) {
1446            Ok(value) => value,
1447            Err(_) => {
1448                state.warnings.push(format!(
1449                    "skipping malformed Codex transcript jsonl line {}",
1450                    index + 1
1451                ));
1452                continue;
1453            }
1454        };
1455        match value.get("type").and_then(Value::as_str) {
1456            Some("response_item") => {
1457                collect_codex_response_item_loop_signals(&value, index + 1, state)
1458            }
1459            Some("event_msg") => collect_codex_event_msg_loop_signals(&value, index + 1, state),
1460            _ => {}
1461        }
1462        if value.get("type").and_then(Value::as_str) != Some("event_msg") {
1463            continue;
1464        }
1465        let Some(payload) = value.get("payload") else {
1466            continue;
1467        };
1468        if payload.get("type").and_then(Value::as_str) != Some("token_count") {
1469            continue;
1470        }
1471        saw_token_count = true;
1472
1473        let Some(total) = payload
1474            .get("info")
1475            .and_then(|info| info.get("total_token_usage"))
1476        else {
1477            state.warnings.push(format!(
1478                "codex token_count event on line {} did not include info.total_token_usage",
1479                index + 1
1480            ));
1481            continue;
1482        };
1483        let cumulative = codex_usage_totals(total);
1484        let duplicate_snapshot = !seen_cumulative_snapshots.insert(cumulative);
1485        let delta = if duplicate_snapshot {
1486            UsageTotals::default()
1487        } else if let Some(last) = payload
1488            .get("info")
1489            .and_then(|info| info.get("last_token_usage"))
1490            .map(codex_usage_totals)
1491            .filter(|last| !last.is_zero())
1492        {
1493            last
1494        } else if previous.is_zero() {
1495            cumulative
1496        } else {
1497            cumulative.delta_from(previous)
1498        };
1499        previous = cumulative;
1500        if delta.is_zero() {
1501            continue;
1502        }
1503
1504        state.usage_turns.push(SessionCostTurn {
1505            label: value
1506                .get("timestamp")
1507                .and_then(Value::as_str)
1508                .map(|value| value.to_string())
1509                .unwrap_or_else(|| format!("line-{}", index + 1)),
1510            prompt_tokens: delta.prompt_tokens,
1511            cached_input_tokens: delta.cached_input_tokens,
1512            cache_creation_input_tokens: 0,
1513            output_tokens: delta.output_tokens,
1514            reasoning_output_tokens: delta.reasoning_output_tokens,
1515            total_tokens: delta
1516                .total_tokens
1517                .max(delta.prompt_tokens + delta.output_tokens),
1518            prompt_cache_metadata: Some(prompt_cache_metadata(
1519                &value,
1520                SessionCostSource::CodexJsonl,
1521            )),
1522        });
1523    }
1524
1525    if !saw_token_count {
1526        state.warnings.push(
1527            "codex transcript did not contain any token_count events; no token cost summary could be derived"
1528                .to_string(),
1529        );
1530    }
1531    Ok(())
1532}
1533
1534fn ingest_agent_doc_log(input: &str, state: &mut CostState) {
1535    for raw_line in input.lines() {
1536        let trimmed = raw_line.trim();
1537        if trimmed.is_empty() {
1538            continue;
1539        }
1540        let Some((_, after_bracket)) = trimmed.split_once("] ") else {
1541            continue;
1542        };
1543        let detail = after_bracket.trim();
1544        let Some(event_name) = detail.split_whitespace().next() else {
1545            continue;
1546        };
1547        let normalized = normalize_runtime_event(event_name, detail);
1548        let closeout_event = is_closeout_runtime_event(event_name, &normalized);
1549        if should_count_runtime_event(event_name, detail, &normalized, state) {
1550            *state.runtime_events.entry(normalized.clone()).or_default() += 1;
1551            state.total_runtime_events += 1;
1552            if closeout_event {
1553                push_closeout_signal(&normalized, state);
1554            }
1555        }
1556        state.restart_churn.observe(event_name, detail);
1557        if let Some(restart_count) =
1558            extract_field(detail, "restart_count").and_then(|value| value.parse::<usize>().ok())
1559        {
1560            state.max_restart_count = Some(
1561                state
1562                    .max_restart_count
1563                    .map_or(restart_count, |current| current.max(restart_count)),
1564            );
1565        }
1566    }
1567}
1568
1569fn collect_claude_loop_signals(value: &Value, state: &mut CostState) {
1570    let mut blocks = Vec::new();
1571    collect_transcript_blocks(value, &mut blocks);
1572    if blocks.is_empty() && is_ignorable_claude_record(value) {
1573        return;
1574    }
1575    for block in blocks {
1576        match block {
1577            TranscriptBlock::Text { role, text } => {
1578                let user_bias = role
1579                    .as_deref()
1580                    .is_some_and(|value| value.eq_ignore_ascii_case("user"));
1581                collect_text_loop_signals(&text, user_bias, state);
1582            }
1583            TranscriptBlock::ToolUse { name, input } => {
1584                collect_tool_use_loop_signals(&name, &input, state);
1585            }
1586        }
1587    }
1588}
1589
1590fn collect_codex_response_item_loop_signals(
1591    value: &Value,
1592    line_number: usize,
1593    state: &mut CostState,
1594) {
1595    let Some(payload) = value.get("payload") else {
1596        return;
1597    };
1598    match payload.get("type").and_then(Value::as_str) {
1599        Some("message") => {
1600            let Some(content) = payload.get("content").and_then(Value::as_array) else {
1601                return;
1602            };
1603            for item in content {
1604                let Some(text) = item
1605                    .get("text")
1606                    .and_then(Value::as_str)
1607                    .or_else(|| item.get("content").and_then(Value::as_str))
1608                else {
1609                    continue;
1610                };
1611                collect_text_loop_signals(text, false, state);
1612            }
1613        }
1614        Some("function_call") => {
1615            let name = payload
1616                .get("name")
1617                .and_then(Value::as_str)
1618                .unwrap_or("function_call");
1619            let Some(arguments) = payload.get("arguments").and_then(Value::as_str) else {
1620                return;
1621            };
1622            let input = serde_json::from_str::<Value>(arguments).unwrap_or_else(|_| {
1623                state.warnings.push(format!(
1624                    "codex function_call arguments on line {} were not valid JSON; loop extraction may be incomplete",
1625                    line_number
1626                ));
1627                Value::String(arguments.to_string())
1628            });
1629            collect_tool_use_loop_signals(name, &input, state);
1630        }
1631        _ => {}
1632    }
1633}
1634
1635fn collect_codex_event_msg_loop_signals(value: &Value, _line_number: usize, state: &mut CostState) {
1636    let Some(payload) = value.get("payload") else {
1637        return;
1638    };
1639    match payload.get("type").and_then(Value::as_str) {
1640        Some("user_message") => {
1641            if let Some(message) = payload.get("message").and_then(Value::as_str) {
1642                collect_text_loop_signals(message, true, state);
1643            }
1644        }
1645        Some("agent_message") => {
1646            if let Some(message) = payload.get("message").and_then(Value::as_str) {
1647                collect_text_loop_signals(message, false, state);
1648            }
1649        }
1650        Some("exec_command_end") => {
1651            if let Some(command) = extract_raw_codex_exec_command(payload) {
1652                collect_file_read_command_signals(&command, state);
1653            }
1654            if let Some(command) = extract_codex_exec_command(payload) {
1655                push_command(command, state);
1656            }
1657            if let Some(output) = payload
1658                .get("aggregated_output")
1659                .and_then(Value::as_str)
1660                .or_else(|| payload.get("stdout").and_then(Value::as_str))
1661            {
1662                collect_text_loop_signals(output, false, state);
1663            }
1664        }
1665        _ => {}
1666    }
1667}
1668
1669fn collect_tool_use_loop_signals(name: &str, input: &Value, state: &mut CostState) {
1670    collect_file_read_tool_signals(name, input, state);
1671    if let Some(command) = extract_raw_tool_command(name, input) {
1672        collect_file_read_command_signals(&command, state);
1673    }
1674    if let Some(command) = extract_tool_command(name, input) {
1675        push_command(command, state);
1676    }
1677    if let Some(text) = extract_tool_text(input) {
1678        collect_text_loop_signals(&text, false, state);
1679    }
1680}
1681
1682fn collect_file_read_tool_signals(name: &str, input: &Value, state: &mut CostState) {
1683    let lower = name.to_ascii_lowercase();
1684    if !matches!(lower.as_str(), "read" | "file_read" | "read_file") {
1685        return;
1686    }
1687    let Value::Object(map) = input else {
1688        return;
1689    };
1690    let Some(path) = ["file_path", "path"]
1691        .iter()
1692        .find_map(|key| map.get(*key).and_then(Value::as_str))
1693        .map(normalize_file_read_path)
1694        .filter(|path| !path.is_empty())
1695    else {
1696        return;
1697    };
1698    let start = ["offset", "start", "line"]
1699        .iter()
1700        .find_map(|key| map.get(*key).and_then(Value::as_u64))
1701        .and_then(|value| usize::try_from(value).ok())
1702        .filter(|value| *value > 0);
1703    let lines = ["limit", "lines", "line_count"]
1704        .iter()
1705        .find_map(|key| map.get(*key).and_then(Value::as_u64))
1706        .and_then(|value| usize::try_from(value).ok())
1707        .filter(|value| *value > 0);
1708    push_file_read_signal(path, start, lines, state);
1709}
1710
1711fn collect_file_read_command_signals(command: &str, state: &mut CostState) {
1712    if let Some(signal) = parse_file_read_command(command) {
1713        state.file_read_signals.push(signal);
1714    }
1715}
1716
1717fn parse_file_read_command(command: &str) -> Option<FileReadSignal> {
1718    let tokens = shell_words(command);
1719    let head = tokens.first()?.as_str();
1720    match head {
1721        "cat" | "bat" | "batcat" | "nl" => {
1722            let path = first_non_option_arg(&tokens[1..])?;
1723            Some(file_read_signal(
1724                normalize_file_read_path(path),
1725                "full".to_string(),
1726                None,
1727                None,
1728            ))
1729        }
1730        "sed" => parse_sed_file_read(&tokens),
1731        "head" => parse_head_file_read(&tokens),
1732        "tail" => parse_tail_file_read(&tokens),
1733        _ => None,
1734    }
1735}
1736
1737fn parse_sed_file_read(tokens: &[String]) -> Option<FileReadSignal> {
1738    let mut expr = None::<String>;
1739    let mut path = None::<String>;
1740    let mut skip_next = false;
1741    for token in tokens.iter().skip(1) {
1742        if skip_next {
1743            skip_next = false;
1744            continue;
1745        }
1746        if token == "-n" {
1747            continue;
1748        }
1749        if token == "-e" {
1750            skip_next = true;
1751            continue;
1752        }
1753        if expr.is_none() && parse_sed_range(token).is_some() {
1754            expr = Some(token.clone());
1755            continue;
1756        }
1757        if !token.starts_with('-') {
1758            path = Some(token.clone());
1759        }
1760    }
1761    let expr = expr?;
1762    let path = path?;
1763    let (start, lines) = parse_sed_range(&expr)?;
1764    Some(file_read_signal(
1765        normalize_file_read_path(&path),
1766        format!("{}-{}", start, start + lines - 1),
1767        Some(start),
1768        Some(lines),
1769    ))
1770}
1771
1772fn parse_sed_range(expr: &str) -> Option<(usize, usize)> {
1773    let trimmed = expr.trim_matches(['\'', '"']).trim();
1774    let body = trimmed.strip_suffix('p')?;
1775    let (start_raw, end_raw) = body.split_once(',')?;
1776    let start = start_raw.trim().parse::<usize>().ok()?;
1777    let lines = if let Some(relative) = end_raw.trim().strip_prefix('+') {
1778        relative.trim().parse::<usize>().ok()?.saturating_add(1)
1779    } else {
1780        let end = end_raw.trim().parse::<usize>().ok()?;
1781        end.checked_sub(start)?.saturating_add(1)
1782    };
1783    (lines > 0).then_some((start, lines))
1784}
1785
1786fn parse_head_file_read(tokens: &[String]) -> Option<FileReadSignal> {
1787    let mut lines = 10_usize;
1788    let mut path = None::<String>;
1789    let mut index = 1_usize;
1790    while index < tokens.len() {
1791        let token = &tokens[index];
1792        if token == "-n" || token == "--lines" {
1793            index += 1;
1794            lines = tokens.get(index)?.parse::<usize>().ok()?;
1795        } else if let Some(value) = token.strip_prefix("-n") {
1796            lines = value.parse::<usize>().ok()?;
1797        } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
1798            lines = token[1..].parse::<usize>().ok()?;
1799        } else if !token.starts_with('-') {
1800            path = Some(token.clone());
1801        }
1802        index += 1;
1803    }
1804    let path = path?;
1805    Some(file_read_signal(
1806        normalize_file_read_path(&path),
1807        format!("head:{lines}"),
1808        Some(1),
1809        Some(lines),
1810    ))
1811}
1812
1813fn parse_tail_file_read(tokens: &[String]) -> Option<FileReadSignal> {
1814    let mut lines = 10_usize;
1815    let mut path = None::<String>;
1816    let mut index = 1_usize;
1817    while index < tokens.len() {
1818        let token = &tokens[index];
1819        if token == "-n" || token == "--lines" {
1820            index += 1;
1821            lines = tokens.get(index)?.parse::<usize>().ok()?;
1822        } else if let Some(value) = token.strip_prefix("-n") {
1823            lines = value.trim_start_matches('+').parse::<usize>().ok()?;
1824        } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
1825            lines = token[1..].parse::<usize>().ok()?;
1826        } else if !token.starts_with('-') {
1827            path = Some(token.clone());
1828        }
1829        index += 1;
1830    }
1831    let path = path?;
1832    Some(file_read_signal(
1833        normalize_file_read_path(&path),
1834        format!("tail:{lines}"),
1835        None,
1836        Some(lines),
1837    ))
1838}
1839
1840fn first_non_option_arg(tokens: &[String]) -> Option<&str> {
1841    tokens
1842        .iter()
1843        .find(|token| !token.starts_with('-'))
1844        .map(String::as_str)
1845}
1846
1847fn push_file_read_signal(
1848    path: String,
1849    start: Option<usize>,
1850    lines: Option<usize>,
1851    state: &mut CostState,
1852) {
1853    let range = match (start, lines) {
1854        (Some(start), Some(lines)) => format!("{}-{}", start, start + lines - 1),
1855        (Some(start), None) => format!("{start}-end"),
1856        (None, Some(lines)) => format!("window:{lines}"),
1857        (None, None) => "full".to_string(),
1858    };
1859    state
1860        .file_read_signals
1861        .push(file_read_signal(path, range, start, lines));
1862}
1863
1864fn file_read_signal(
1865    path: String,
1866    range: String,
1867    start: Option<usize>,
1868    lines: Option<usize>,
1869) -> FileReadSignal {
1870    FileReadSignal {
1871        path,
1872        range,
1873        start,
1874        lines,
1875        estimated_tokens: estimate_file_read_tokens(lines),
1876    }
1877}
1878
1879fn estimate_file_read_tokens(lines: Option<usize>) -> u64 {
1880    lines
1881        .map(|lines| (lines as u64).saturating_mul(ESTIMATED_TOKENS_PER_SOURCE_LINE))
1882        .unwrap_or(DEFAULT_FULL_FILE_READ_TOKENS)
1883        .max(80)
1884}
1885
1886fn collect_file_read_diagnostics(signals: &[FileReadSignal]) -> Vec<SessionCostFileReadDiagnostic> {
1887    let mut grouped = BTreeMap::<(String, String), FileReadDiagnosticBuilder>::new();
1888    for signal in signals {
1889        let entry = grouped
1890            .entry((signal.path.clone(), signal.range.clone()))
1891            .or_insert_with(|| FileReadDiagnosticBuilder {
1892                path: signal.path.clone(),
1893                range: signal.range.clone(),
1894                start: signal.start,
1895                lines: signal.lines,
1896                occurrences: 0,
1897                estimated_tokens: 0,
1898                max_single_read_tokens: 0,
1899            });
1900        entry.occurrences += 1;
1901        entry.estimated_tokens = entry
1902            .estimated_tokens
1903            .saturating_add(signal.estimated_tokens);
1904        entry.max_single_read_tokens = entry.max_single_read_tokens.max(signal.estimated_tokens);
1905        entry.start = entry.start.or(signal.start);
1906        entry.lines = entry.lines.or(signal.lines);
1907    }
1908
1909    let mut diagnostics = grouped
1910        .into_values()
1911        .filter(|entry| entry.occurrences >= 2)
1912        .map(|entry| {
1913            let duplicate_estimated_tokens = entry
1914                .estimated_tokens
1915                .saturating_sub(entry.max_single_read_tokens);
1916            SessionCostFileReadDiagnostic {
1917                path: entry.path.clone(),
1918                range: entry.range.clone(),
1919                occurrences: entry.occurrences,
1920                estimated_tokens: entry.estimated_tokens,
1921                duplicate_estimated_tokens,
1922                follow_up_commands: file_read_follow_up_commands(
1923                    &entry.path,
1924                    entry.start,
1925                    entry.lines,
1926                ),
1927            }
1928        })
1929        .collect::<Vec<_>>();
1930    diagnostics.sort_by(|left, right| {
1931        right
1932            .duplicate_estimated_tokens
1933            .cmp(&left.duplicate_estimated_tokens)
1934            .then(right.occurrences.cmp(&left.occurrences))
1935            .then(left.path.cmp(&right.path))
1936            .then(left.range.cmp(&right.range))
1937    });
1938    diagnostics.truncate(MAX_FILE_READ_DIAGNOSTICS);
1939    diagnostics
1940}
1941
1942#[derive(Debug)]
1943struct FileReadDiagnosticBuilder {
1944    path: String,
1945    range: String,
1946    start: Option<usize>,
1947    lines: Option<usize>,
1948    occurrences: usize,
1949    estimated_tokens: u64,
1950    max_single_read_tokens: u64,
1951}
1952
1953fn file_read_follow_up_commands(
1954    path: &str,
1955    start: Option<usize>,
1956    lines: Option<usize>,
1957) -> Vec<String> {
1958    let start = start.unwrap_or(1);
1959    let lines = lines.unwrap_or(120).max(1);
1960    vec![
1961        format!(
1962            "tsift source-read {} --start {} --lines {} --budget normal",
1963            shell_quote(path),
1964            start,
1965            lines
1966        ),
1967        format!("tsift summarize --file {}", shell_quote(path)),
1968    ]
1969}
1970
1971fn normalize_file_read_path(raw: &str) -> String {
1972    raw.trim()
1973        .trim_matches(['\'', '"'])
1974        .trim_start_matches("./")
1975        .to_string()
1976}
1977
1978fn shell_words(command: &str) -> Vec<String> {
1979    let mut words = Vec::new();
1980    let mut current = String::new();
1981    let mut quote = None::<char>;
1982    let mut escaped = false;
1983
1984    for ch in command.chars() {
1985        if escaped {
1986            current.push(ch);
1987            escaped = false;
1988            continue;
1989        }
1990        if ch == '\\' {
1991            escaped = true;
1992            continue;
1993        }
1994        if let Some(quote_ch) = quote {
1995            if ch == quote_ch {
1996                quote = None;
1997            } else {
1998                current.push(ch);
1999            }
2000            continue;
2001        }
2002        if ch == '\'' || ch == '"' {
2003            quote = Some(ch);
2004            continue;
2005        }
2006        if ch.is_whitespace() {
2007            if !current.is_empty() {
2008                words.push(std::mem::take(&mut current));
2009            }
2010            continue;
2011        }
2012        current.push(ch);
2013    }
2014    if !current.is_empty() {
2015        words.push(current);
2016    }
2017    words
2018}
2019
2020fn shell_quote(value: &str) -> String {
2021    if value
2022        .chars()
2023        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
2024    {
2025        return value.to_string();
2026    }
2027    format!("'{}'", value.replace('\'', "'\\''"))
2028}
2029
2030fn collect_text_loop_signals(text: &str, user_bias: bool, state: &mut CostState) {
2031    for raw_line in text.lines() {
2032        let trimmed = raw_line.trim();
2033        if trimmed.is_empty() || looks_like_instruction_ballast(trimmed) {
2034            continue;
2035        }
2036        let prompt_candidate = trimmed
2037            .strip_prefix("❯ ")
2038            .or_else(|| trimmed.strip_prefix("> "))
2039            .unwrap_or(trimmed)
2040            .trim();
2041        if looks_like_prompt_target(prompt_candidate, user_bias || prompt_candidate != trimmed) {
2042            push_prompt_signal(prompt_candidate, state);
2043            continue;
2044        }
2045        for (kind, detail) in detect_closeout(trimmed) {
2046            push_closeout_signal(&format!("{kind}: {detail}"), state);
2047        }
2048    }
2049}
2050
2051fn push_prompt_signal(text: &str, state: &mut CostState) {
2052    flush_pending_commands(state);
2053    push_loop_signal(LoopClusterKind::PromptRepeat, text, state);
2054}
2055
2056fn push_closeout_signal(text: &str, state: &mut CostState) {
2057    flush_pending_commands(state);
2058    push_loop_signal(LoopClusterKind::CloseoutChurn, text, state);
2059}
2060
2061fn push_command(command: String, state: &mut CostState) {
2062    let normalized = normalize_whitespace(&command);
2063    if normalized.is_empty() {
2064        return;
2065    }
2066    if state
2067        .pending_commands
2068        .last()
2069        .is_some_and(|existing| existing == &normalized)
2070    {
2071        return;
2072    }
2073    state.pending_commands.push(normalized);
2074}
2075
2076fn flush_pending_commands(state: &mut CostState) {
2077    if state.pending_commands.is_empty() {
2078        return;
2079    }
2080    let label = truncate_detail(
2081        &state
2082            .pending_commands
2083            .iter()
2084            .take(MAX_COMMANDS_PER_BUNDLE)
2085            .cloned()
2086            .collect::<Vec<_>>()
2087            .join(" -> "),
2088        220,
2089    );
2090    state.pending_commands.clear();
2091    push_loop_signal(LoopClusterKind::CommandBundle, &label, state);
2092}
2093
2094fn push_loop_signal(kind: LoopClusterKind, label: &str, state: &mut CostState) {
2095    let normalized = truncate_detail(&normalize_whitespace(label), 220);
2096    if normalized.is_empty() {
2097        return;
2098    }
2099    state.loop_signals.push(LoopSignal {
2100        kind,
2101        label: normalized,
2102    });
2103}
2104
2105fn collect_loop_clusters(signals: &[LoopSignal]) -> Vec<SessionCostLoopCluster> {
2106    let mut summary = BTreeMap::<(LoopClusterKind, String), (usize, usize)>::new();
2107    let mut previous = None::<(LoopClusterKind, String)>;
2108    let mut streak = 0_usize;
2109
2110    for signal in signals {
2111        let key = (signal.kind, signal.label.clone());
2112        let entry = summary.entry(key.clone()).or_insert((0, 0));
2113        entry.0 += 1;
2114        if previous.as_ref() == Some(&key) {
2115            streak += 1;
2116        } else {
2117            previous = Some(key.clone());
2118            streak = 1;
2119        }
2120        entry.1 = entry.1.max(streak);
2121    }
2122
2123    let mut clusters = summary
2124        .into_iter()
2125        .filter_map(|((kind, label), (occurrences, max_consecutive))| {
2126            (occurrences >= 2).then_some(SessionCostLoopCluster {
2127                kind: kind.as_str().to_string(),
2128                label,
2129                occurrences,
2130                max_consecutive,
2131            })
2132        })
2133        .collect::<Vec<_>>();
2134    clusters.sort_by(|left, right| {
2135        right
2136            .occurrences
2137            .cmp(&left.occurrences)
2138            .then(right.max_consecutive.cmp(&left.max_consecutive))
2139            .then(left.kind.cmp(&right.kind))
2140            .then(left.label.cmp(&right.label))
2141    });
2142    clusters.truncate(MAX_LOOP_CLUSTERS);
2143    clusters
2144}
2145
2146fn is_ignorable_claude_record(value: &Value) -> bool {
2147    value.get("attachment").is_some()
2148        || value.get("toolUseResult").is_some()
2149        || (value.get("message").is_none()
2150            && value.get("content").is_none()
2151            && value.get("text").is_none())
2152}
2153
2154fn collect_transcript_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
2155    if let Some(message) = value.get("message") {
2156        collect_message_blocks(message, out);
2157        return;
2158    }
2159    collect_message_blocks(value, out);
2160}
2161
2162fn collect_message_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
2163    let role = value
2164        .get("role")
2165        .and_then(Value::as_str)
2166        .map(|value| value.to_string());
2167    if let Some(content) = value.get("content") {
2168        match content {
2169            Value::String(text) => out.push(TranscriptBlock::Text {
2170                role,
2171                text: text.to_string(),
2172            }),
2173            Value::Array(items) => {
2174                for item in items {
2175                    collect_content_block(role.clone(), item, out);
2176                }
2177            }
2178            _ => {}
2179        }
2180    } else if let Some(text) = value.get("text").and_then(Value::as_str) {
2181        out.push(TranscriptBlock::Text {
2182            role,
2183            text: text.to_string(),
2184        });
2185    }
2186}
2187
2188fn collect_content_block(role: Option<String>, value: &Value, out: &mut Vec<TranscriptBlock>) {
2189    match value.get("type").and_then(Value::as_str) {
2190        Some("text") => {
2191            if let Some(text) = value.get("text").and_then(Value::as_str) {
2192                out.push(TranscriptBlock::Text {
2193                    role,
2194                    text: text.to_string(),
2195                });
2196            }
2197        }
2198        Some("tool_use") => {
2199            let name = value
2200                .get("name")
2201                .and_then(Value::as_str)
2202                .unwrap_or("tool_use")
2203                .to_string();
2204            let input = value.get("input").cloned().unwrap_or(Value::Null);
2205            out.push(TranscriptBlock::ToolUse { name, input });
2206        }
2207        Some("tool_result") => match value.get("content") {
2208            Some(Value::String(text)) => out.push(TranscriptBlock::Text {
2209                role,
2210                text: text.to_string(),
2211            }),
2212            Some(Value::Array(items)) => {
2213                for item in items {
2214                    collect_content_block(role.clone(), item, out);
2215                }
2216            }
2217            _ => {}
2218        },
2219        _ => {
2220            if let Some(text) = value.get("text").and_then(Value::as_str) {
2221                out.push(TranscriptBlock::Text {
2222                    role,
2223                    text: text.to_string(),
2224                });
2225            }
2226        }
2227    }
2228}
2229
2230fn extract_tool_command(name: &str, input: &Value) -> Option<String> {
2231    let normalized = extract_raw_tool_command(name, input)?;
2232    looks_like_command(&normalized).then_some(normalized)
2233}
2234
2235fn extract_raw_tool_command(name: &str, input: &Value) -> Option<String> {
2236    if !matches!(
2237        name.to_ascii_lowercase().as_str(),
2238        "bash" | "exec_command" | "shell" | "terminal" | "sh"
2239    ) {
2240        return None;
2241    }
2242
2243    match input {
2244        Value::Object(map) => {
2245            for key in ["command", "cmd", "shell_command"] {
2246                if let Some(raw) = map.get(key).and_then(Value::as_str) {
2247                    let normalized = normalize_whitespace(raw);
2248                    if !normalized.is_empty() {
2249                        return Some(normalized);
2250                    }
2251                }
2252            }
2253            None
2254        }
2255        Value::String(raw) => {
2256            let normalized = normalize_whitespace(raw);
2257            (!normalized.is_empty()).then_some(normalized)
2258        }
2259        _ => None,
2260    }
2261}
2262
2263fn extract_tool_text(input: &Value) -> Option<String> {
2264    match input {
2265        Value::Object(map) => {
2266            for key in ["text", "output", "stderr", "stdout", "content", "message"] {
2267                if let Some(raw) = map.get(key).and_then(Value::as_str) {
2268                    return Some(raw.to_string());
2269                }
2270            }
2271            None
2272        }
2273        Value::String(raw) => Some(raw.to_string()),
2274        _ => None,
2275    }
2276}
2277
2278fn extract_codex_exec_command(payload: &Value) -> Option<String> {
2279    let normalized = extract_raw_codex_exec_command(payload)?;
2280    looks_like_command(&normalized).then_some(normalized)
2281}
2282
2283fn extract_raw_codex_exec_command(payload: &Value) -> Option<String> {
2284    if let Some(parsed) = payload.get("parsed_cmd").and_then(Value::as_array) {
2285        for item in parsed {
2286            if let Some(command) = item.get("cmd").and_then(Value::as_str) {
2287                let normalized = normalize_whitespace(command);
2288                if !normalized.is_empty() {
2289                    return Some(normalized);
2290                }
2291            }
2292        }
2293    }
2294
2295    if let Some(command) = payload.get("command").and_then(Value::as_array)
2296        && let Some(last) = command.last().and_then(Value::as_str)
2297    {
2298        let normalized = normalize_whitespace(last);
2299        if !normalized.is_empty() {
2300            return Some(normalized);
2301        }
2302    }
2303    None
2304}
2305
2306fn looks_like_prompt_target(text: &str, user_bias: bool) -> bool {
2307    let trimmed = text.trim();
2308    if trimmed.is_empty()
2309        || looks_like_markdown_heading(trimmed)
2310        || looks_like_slash_command_example(trimmed)
2311        || trimmed == "#"
2312        || trimmed.starts_with("#!")
2313        || trimmed.starts_with("#[")
2314        || trimmed.starts_with("/**")
2315        || trimmed.starts_with("*/")
2316        || trimmed.starts_with("//")
2317        || trimmed.starts_with("###")
2318        || trimmed.starts_with("<!--")
2319        || trimmed.starts_with("- [")
2320        || trimmed == "###"
2321    {
2322        return false;
2323    }
2324
2325    if trimmed.starts_with("do ")
2326        || trimmed.starts_with('#')
2327        || looks_like_slash_prompt_target(trimmed)
2328        || trimmed.ends_with('?')
2329    {
2330        return true;
2331    }
2332
2333    if user_bias
2334        && (trimmed.contains("commit + push")
2335            || trimmed.contains("run tests")
2336            || trimmed.contains("build + install")
2337            || trimmed.contains("#spec-test"))
2338    {
2339        return true;
2340    }
2341
2342    false
2343}
2344
2345fn looks_like_instruction_ballast(text: &str) -> bool {
2346    let trimmed = strip_common_prefixes(text.trim());
2347    if trimmed.is_empty() {
2348        return false;
2349    }
2350
2351    looks_like_markdown_heading(trimmed)
2352        || looks_like_slash_command_example(trimmed)
2353        || looks_like_frontmatter_prompt_preset(trimmed)
2354        || looks_like_completed_backlog_archive(trimmed)
2355        || trimmed.starts_with("<!-- tsift:")
2356        || trimmed.starts_with("<!-- /tsift:")
2357        || looks_like_instruction_label(trimmed)
2358}
2359
2360fn looks_like_markdown_heading(text: &str) -> bool {
2361    let trimmed = text.trim_start();
2362    let heading_level = trimmed.chars().take_while(|ch| *ch == '#').count();
2363    heading_level > 0
2364        && heading_level <= 6
2365        && trimmed
2366            .chars()
2367            .nth(heading_level)
2368            .is_some_and(|ch| ch.is_whitespace())
2369}
2370
2371fn looks_like_slash_command_example(text: &str) -> bool {
2372    let trimmed = text.trim();
2373    trimmed.starts_with('/')
2374        && trimmed.contains('<')
2375        && trimmed.contains('>')
2376        && !trimmed.contains('`')
2377}
2378
2379fn looks_like_slash_prompt_target(text: &str) -> bool {
2380    let Some(first_token) = text.split_whitespace().next() else {
2381        return false;
2382    };
2383    first_token.starts_with('/') && !first_token[1..].contains('/')
2384}
2385
2386fn looks_like_instruction_label(text: &str) -> bool {
2387    let trimmed = text.trim();
2388    if !trimmed.starts_with("**") {
2389        return false;
2390    }
2391    let Some(label_end) = trimmed[2..].find("**") else {
2392        return false;
2393    };
2394    let label = &trimmed[..label_end + 4];
2395    if label.len() <= 4 {
2396        return false;
2397    }
2398    let remainder = trimmed[label_end + 4..]
2399        .trim_start_matches([' ', ':', '-', '—'])
2400        .trim_start();
2401    if remainder.is_empty() {
2402        return false;
2403    }
2404    let lower = remainder.to_ascii_lowercase();
2405    matches!(
2406        lower.split_whitespace().next(),
2407        Some("run")
2408            | Some("use")
2409            | Some("treat")
2410            | Some("respond")
2411            | Some("print")
2412            | Some("prefer")
2413            | Some("preserve")
2414            | Some("show")
2415            | Some("complete")
2416            | Some("append")
2417            | Some("when")
2418            | Some("if")
2419    )
2420}
2421
2422fn strip_common_prefixes(text: &str) -> &str {
2423    text.strip_prefix("❯ ")
2424        .or_else(|| text.strip_prefix("- "))
2425        .or_else(|| text.strip_prefix("* "))
2426        .or_else(|| text.strip_prefix("> "))
2427        .unwrap_or(text)
2428        .trim()
2429}
2430
2431fn looks_like_frontmatter_prompt_preset(text: &str) -> bool {
2432    let trimmed = strip_common_prefixes(text.trim());
2433    if trimmed == "prompt_presets:" || trimmed.starts_with("prompt_presets:") {
2434        return true;
2435    }
2436    let Some((key, _)) = trimmed.split_once(':') else {
2437        return false;
2438    };
2439    let key = key.trim().trim_matches(['"', '\'']);
2440    key.starts_with('#') && key.len() > 1 && key[1..].chars().all(is_prompt_preset_char)
2441}
2442
2443fn is_prompt_preset_char(ch: char) -> bool {
2444    ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')
2445}
2446
2447fn looks_like_completed_backlog_archive(text: &str) -> bool {
2448    let stripped = strip_common_prefixes(text.trim());
2449    let Some(date) = stripped.get(..10) else {
2450        return false;
2451    };
2452    date.chars().enumerate().all(|(index, ch)| match index {
2453        4 | 7 => ch == '-',
2454        _ => ch.is_ascii_digit(),
2455    }) && stripped[10..].contains("[#")
2456}
2457
2458fn looks_like_command(text: &str) -> bool {
2459    if text.is_empty()
2460        || text.contains('\n')
2461        || text.contains("://")
2462        || text.starts_with('/')
2463        || text.starts_with("###")
2464    {
2465        return false;
2466    }
2467
2468    let head = text.split_whitespace().next().unwrap_or_default();
2469    matches!(
2470        head,
2471        "agent-doc"
2472            | "cargo"
2473            | "git"
2474            | "make"
2475            | "pytest"
2476            | "python"
2477            | "uv"
2478            | "tsift"
2479            | "npm"
2480            | "pnpm"
2481            | "yarn"
2482            | "bash"
2483            | "zsh"
2484            | "rg"
2485            | "grep"
2486            | "./scripts/run_benchmark.sh"
2487    ) || head.starts_with("./")
2488}
2489
2490fn detect_closeout(text: &str) -> Vec<(String, String)> {
2491    let mut out = Vec::new();
2492    let normalized = normalize_whitespace(strip_common_prefixes(text));
2493    let lower = normalized.to_ascii_lowercase();
2494
2495    if normalized.starts_with("document_cycle ") {
2496        let phase = extract_field(&normalized, "phase");
2497        let event = extract_field(&normalized, "event");
2498        if phase == Some("committed")
2499            && let Some(event) = event
2500        {
2501            out.push((
2502                "commit".to_string(),
2503                format!("document_cycle phase=committed event={event}"),
2504            ));
2505        }
2506        return dedupe_pairs(out);
2507    }
2508
2509    if lower.contains("verification passed") || lower.starts_with("verification in ") {
2510        out.push((
2511            "verification".to_string(),
2512            truncate_detail(&normalized, 220),
2513        ));
2514    }
2515    if lower.contains("cargo build")
2516        || lower.contains("make check")
2517        || lower.contains("cargo test")
2518        || lower.contains("pytest")
2519    {
2520        out.push((
2521            "verification".to_string(),
2522            truncate_detail(&normalized, 220),
2523        ));
2524    }
2525    if lower.contains("cargo install") || lower.contains("installed") {
2526        out.push(("install".to_string(), truncate_detail(&normalized, 220)));
2527    }
2528    if lower.contains("committed and pushed") {
2529        out.push(("push".to_string(), truncate_detail(&normalized, 220)));
2530    } else if lower.contains("committed") {
2531        out.push(("commit".to_string(), truncate_detail(&normalized, 220)));
2532    }
2533    if lower.contains("tsift --version") || lower.contains("tsift v0.") {
2534        out.push(("version".to_string(), truncate_detail(&normalized, 220)));
2535    }
2536    if lower.contains("agent-doc finalize") || lower.contains("session-check") {
2537        out.push(("closeout".to_string(), truncate_detail(&normalized, 220)));
2538    }
2539
2540    dedupe_pairs(out)
2541}
2542
2543fn is_closeout_runtime_event(event_name: &str, normalized: &str) -> bool {
2544    event_name == "document_cycle"
2545        || matches!(
2546            normalized,
2547            "preflight_started"
2548                | "response_captured"
2549                | "commit_staging"
2550                | "commit_success"
2551                | "commit_already_current"
2552                | "snapshot_save"
2553                | "write_origin"
2554                | "ipc_write_attempt"
2555                | "ipc_write_consumed"
2556                | "out_of_band_write"
2557        )
2558}
2559
2560fn dedupe_pairs(items: Vec<(String, String)>) -> Vec<(String, String)> {
2561    let mut seen = BTreeSet::new();
2562    let mut deduped = Vec::new();
2563    for item in items {
2564        if seen.insert(item.clone()) {
2565            deduped.push(item);
2566        }
2567    }
2568    deduped
2569}
2570
2571fn normalize_whitespace(raw: &str) -> String {
2572    raw.split_whitespace().collect::<Vec<_>>().join(" ")
2573}
2574
2575fn truncate_detail(text: &str, max_chars: usize) -> String {
2576    if text.chars().count() <= max_chars {
2577        return text.to_string();
2578    }
2579    let mut truncated = String::new();
2580    for ch in text.chars().take(max_chars.saturating_sub(1)) {
2581        truncated.push(ch);
2582    }
2583    truncated.push('…');
2584    truncated
2585}
2586
2587fn normalize_runtime_event(event_name: &str, detail: &str) -> String {
2588    if event_name == "document_cycle"
2589        && let Some(document_event) = extract_field(detail, "event")
2590    {
2591        return document_event.to_string();
2592    }
2593    if matches!(
2594        event_name,
2595        "claude_start" | "codex_start" | "claude_restart" | "codex_restart"
2596    ) && let Some(mode) = extract_field(detail, "mode")
2597    {
2598        return format!("{event_name}:{mode}");
2599    }
2600    event_name.to_string()
2601}
2602
2603fn should_count_runtime_event(
2604    event_name: &str,
2605    detail: &str,
2606    normalized: &str,
2607    state: &mut CostState,
2608) -> bool {
2609    if event_name == "document_cycle"
2610        && let Some(cycle) = extract_field(detail, "cycle")
2611    {
2612        return state
2613            .seen_document_cycle_events
2614            .insert((cycle.to_string(), normalized.to_string()));
2615    }
2616    true
2617}
2618
2619fn prompt_cache_metadata(
2620    value: &Value,
2621    source: SessionCostSource,
2622) -> SessionCostPromptCacheMetadata {
2623    let provider = find_first_string_field(
2624        value,
2625        &[
2626            "provider",
2627            "model_provider",
2628            "provider_id",
2629            "model_provider_id",
2630        ],
2631    )
2632    .unwrap_or_else(|| default_prompt_cache_provider(source).to_string());
2633    let cache_key = find_first_string_field(
2634        value,
2635        &[
2636            "prompt_cache_key",
2637            "promptCacheKey",
2638            "cache_key",
2639            "cacheKey",
2640        ],
2641    );
2642    let routing_affinity = find_first_string_field(
2643        value,
2644        &[
2645            "routing_affinity",
2646            "routingAffinity",
2647            "replica",
2648            "replica_id",
2649            "replicaId",
2650            "deployment_id",
2651            "deploymentId",
2652        ],
2653    );
2654    let explicit_fingerprint = find_first_string_field(
2655        value,
2656        &[
2657            "stable_prefix_fingerprint",
2658            "stablePrefixFingerprint",
2659            "prefix_fingerprint",
2660            "prefixFingerprint",
2661        ],
2662    );
2663    let stable_prefix = find_first_string_field(
2664        value,
2665        &[
2666            "stable_prefix",
2667            "stablePrefix",
2668            "cached_prefix",
2669            "cachedPrefix",
2670            "prompt_prefix",
2671            "promptPrefix",
2672        ],
2673    );
2674    let mut breakpoints = Vec::new();
2675    collect_prompt_cache_breakpoints(value, "$", &mut breakpoints);
2676    breakpoints.sort();
2677    breakpoints.dedup();
2678    breakpoints.truncate(MAX_PROMPT_CACHE_BREAKPOINTS);
2679
2680    let stable_prefix_fingerprint = explicit_fingerprint.unwrap_or_else(|| {
2681        let mut material = vec![format!("provider={provider}")];
2682        if let Some(cache_key) = &cache_key {
2683            material.push(format!("cache_key={cache_key}"));
2684        }
2685        if let Some(stable_prefix) = &stable_prefix {
2686            material.push(format!("stable_prefix={stable_prefix}"));
2687        }
2688        for breakpoint in &breakpoints {
2689            material.push(format!("breakpoint={breakpoint}"));
2690        }
2691        stable_prompt_cache_fingerprint(&material.join("\n"))
2692    });
2693
2694    SessionCostPromptCacheMetadata {
2695        provider,
2696        cache_key,
2697        stable_prefix_fingerprint,
2698        breakpoints,
2699        routing_affinity,
2700    }
2701}
2702
2703fn default_prompt_cache_provider(source: SessionCostSource) -> &'static str {
2704    match source {
2705        SessionCostSource::ClaudeJsonl => "anthropic",
2706        SessionCostSource::CodexJsonl => "openai",
2707        SessionCostSource::AgentDocLog => "agent_doc_log",
2708    }
2709}
2710
2711fn find_first_string_field(value: &Value, keys: &[&str]) -> Option<String> {
2712    let mut matches = Vec::new();
2713    collect_string_field_matches(value, "$", keys, &mut matches);
2714    matches.sort_by(|left, right| left.0.cmp(&right.0));
2715    matches
2716        .into_iter()
2717        .map(|(_, value)| value)
2718        .find(|value| !value.trim().is_empty())
2719}
2720
2721fn collect_string_field_matches(
2722    value: &Value,
2723    path: &str,
2724    keys: &[&str],
2725    matches: &mut Vec<(String, String)>,
2726) {
2727    match value {
2728        Value::Object(object) => {
2729            for (key, child) in object {
2730                let child_path = json_child_path(path, key);
2731                if metadata_key_matches(key, keys)
2732                    && let Some(text) = child.as_str()
2733                {
2734                    matches.push((child_path.clone(), text.to_string()));
2735                }
2736                collect_string_field_matches(child, &child_path, keys, matches);
2737            }
2738        }
2739        Value::Array(items) => {
2740            for (index, child) in items.iter().enumerate() {
2741                let child_path = format!("{path}[{index}]");
2742                collect_string_field_matches(child, &child_path, keys, matches);
2743            }
2744        }
2745        _ => {}
2746    }
2747}
2748
2749fn collect_prompt_cache_breakpoints(value: &Value, path: &str, breakpoints: &mut Vec<String>) {
2750    match value {
2751        Value::Object(object) => {
2752            for (key, child) in object {
2753                let child_path = json_child_path(path, key);
2754                if metadata_key_matches(
2755                    key,
2756                    &[
2757                        "cache_control",
2758                        "cacheControl",
2759                        "cache_breakpoint",
2760                        "cacheBreakpoint",
2761                        "prompt_cache_breakpoint",
2762                        "promptCacheBreakpoint",
2763                    ],
2764                ) {
2765                    breakpoints.push(format!(
2766                        "{}={}",
2767                        child_path.trim_start_matches("$."),
2768                        describe_prompt_cache_breakpoint(child)
2769                    ));
2770                }
2771                collect_prompt_cache_breakpoints(child, &child_path, breakpoints);
2772            }
2773        }
2774        Value::Array(items) => {
2775            for (index, child) in items.iter().enumerate() {
2776                let child_path = format!("{path}[{index}]");
2777                collect_prompt_cache_breakpoints(child, &child_path, breakpoints);
2778            }
2779        }
2780        _ => {}
2781    }
2782}
2783
2784fn describe_prompt_cache_breakpoint(value: &Value) -> String {
2785    if let Some(text) = value.as_str() {
2786        return text.to_string();
2787    }
2788    if let Some(enabled) = value.as_bool() {
2789        return enabled.to_string();
2790    }
2791    if let Some(object) = value.as_object()
2792        && let Some(kind) = object.get("type").and_then(Value::as_str)
2793    {
2794        return format!("type:{kind}");
2795    }
2796    value.to_string()
2797}
2798
2799fn metadata_has_cache_control_breakpoint(metadata: &SessionCostPromptCacheMetadata) -> bool {
2800    metadata.breakpoints.iter().any(|breakpoint| {
2801        let key = breakpoint
2802            .split_once('=')
2803            .map_or(breakpoint.as_str(), |(key, _)| key);
2804        normalize_metadata_key(key).contains("cachecontrol")
2805    })
2806}
2807
2808fn is_anthropic_provider(provider: &str) -> bool {
2809    let provider = normalize_metadata_key(provider);
2810    provider.contains("anthropic") || provider.contains("claude")
2811}
2812
2813fn is_openai_provider(provider: &str) -> bool {
2814    let provider = normalize_metadata_key(provider);
2815    provider.contains("openai") || provider.contains("azureopenai") || provider.contains("codex")
2816}
2817
2818fn metadata_key_matches(key: &str, candidates: &[&str]) -> bool {
2819    let key = normalize_metadata_key(key);
2820    candidates
2821        .iter()
2822        .any(|candidate| key == normalize_metadata_key(candidate))
2823}
2824
2825fn normalize_metadata_key(key: &str) -> String {
2826    key.chars()
2827        .filter(|value| *value != '_' && *value != '-')
2828        .flat_map(char::to_lowercase)
2829        .collect()
2830}
2831
2832fn json_child_path(parent: &str, key: &str) -> String {
2833    if parent == "$" {
2834        format!("$.{key}")
2835    } else {
2836        format!("{parent}.{key}")
2837    }
2838}
2839
2840fn stable_prompt_cache_fingerprint(material: &str) -> String {
2841    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
2842    for byte in material.as_bytes() {
2843        hash ^= u64::from(*byte);
2844        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2845    }
2846    format!("spfx-{hash:016x}")
2847}
2848
2849fn usage_u64(value: &Value, key: &str) -> u64 {
2850    value.get(key).and_then(Value::as_u64).unwrap_or(0)
2851}
2852
2853fn codex_usage_totals(value: &Value) -> UsageTotals {
2854    UsageTotals {
2855        prompt_tokens: usage_u64(value, "input_tokens"),
2856        cached_input_tokens: usage_u64(value, "cached_input_tokens"),
2857        cache_creation_input_tokens: 0,
2858        output_tokens: usage_u64(value, "output_tokens"),
2859        reasoning_output_tokens: usage_u64(value, "reasoning_output_tokens"),
2860        total_tokens: usage_u64(value, "total_tokens"),
2861    }
2862}
2863
2864fn count_restart_family(restart_churn: &[RestartChurnSummary], family: &str) -> usize {
2865    restart_churn
2866        .iter()
2867        .find(|entry| entry.family == family)
2868        .map_or(0, |entry| entry.occurrences)
2869}
2870
2871fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
2872    let needle = format!("{key}=");
2873    let start = detail.find(&needle)? + needle.len();
2874    let remainder = &detail[start..];
2875    let end = remainder
2876        .find(char::is_whitespace)
2877        .unwrap_or(remainder.len());
2878    Some(remainder[..end].trim_matches('"'))
2879}
2880
2881#[cfg(test)]
2882mod tests {
2883    use super::*;
2884
2885    fn prompt_cache_adapter_status<'a>(
2886        plan: &'a SessionCostPromptCachePlan,
2887        provider: &str,
2888    ) -> Option<&'a str> {
2889        plan.provider_adapters
2890            .iter()
2891            .find(|adapter| adapter.provider == provider)
2892            .map(|adapter| adapter.status.as_str())
2893    }
2894
2895    #[test]
2896    fn auto_detects_claude_jsonl_and_dedupes_usage_by_message_id() {
2897        let input = concat!(
2898            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}}}"#,
2899            "\n",
2900            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}}}"#,
2901            "\n",
2902            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}}}"#,
2903            "\n"
2904        );
2905
2906        let report = compute(input, None).unwrap();
2907        assert_eq!(report.source, "claude_jsonl");
2908        assert_eq!(report.usage_samples, 2);
2909        assert_eq!(report.prompt_tokens, 2321);
2910        assert_eq!(report.cached_input_tokens, 2000);
2911        assert_eq!(report.cache_creation_input_tokens, 300);
2912        assert_eq!(report.output_tokens, 18);
2913        assert_eq!(report.total_tokens, 2339);
2914        assert_eq!(report.cached_input_ratio, Some(86.17));
2915    }
2916
2917    #[test]
2918    fn codex_jsonl_uses_cumulative_deltas_and_skips_duplicate_snapshots() {
2919        let input = concat!(
2920            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}}}}"#,
2921            "\n",
2922            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}}}}"#,
2923            "\n",
2924            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}}}}"#,
2925            "\n"
2926        );
2927
2928        let report = compute(input, Some("codex-jsonl")).unwrap();
2929        assert_eq!(report.usage_samples, 2);
2930        assert_eq!(report.prompt_tokens, 1600);
2931        assert_eq!(report.cached_input_tokens, 1400);
2932        assert_eq!(report.output_tokens, 90);
2933        assert_eq!(report.reasoning_output_tokens, 20);
2934        assert_eq!(report.total_tokens, 1690);
2935        assert_eq!(report.largest_turn_total_tokens, 1050);
2936        assert_eq!(report.largest_turns[0].total_tokens, 1050);
2937        assert_eq!(report.largest_turns[1].total_tokens, 640);
2938    }
2939
2940    #[test]
2941    fn codex_jsonl_prefers_last_usage_for_interleaved_cumulative_streams() {
2942        let input = concat!(
2943            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}}}}"#,
2944            "\n",
2945            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}}}}"#,
2946            "\n",
2947            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}}}}"#,
2948            "\n",
2949            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}}}}"#,
2950            "\n",
2951            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}}}}"#,
2952            "\n"
2953        );
2954
2955        let report = compute(input, Some("codex-jsonl")).unwrap();
2956        assert_eq!(report.usage_samples, 4);
2957        assert_eq!(report.prompt_tokens, 2500);
2958        assert_eq!(report.cached_input_tokens, 2200);
2959        assert_eq!(report.output_tokens, 135);
2960        assert_eq!(report.reasoning_output_tokens, 30);
2961        assert_eq!(report.total_tokens, 2635);
2962        assert_eq!(report.largest_turn_total_tokens, 1050);
2963    }
2964
2965    #[test]
2966    fn prompt_cache_plan_summarizes_effectiveness_over_time() {
2967        let input = concat!(
2968            r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
2969            "\n",
2970            r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2000,"cached_input_tokens":600,"output_tokens":100,"reasoning_output_tokens":0,"total_tokens":2100},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":500,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
2971            "\n",
2972            r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":3000,"cached_input_tokens":1500,"output_tokens":150,"reasoning_output_tokens":0,"total_tokens":3150},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
2973            "\n",
2974        );
2975
2976        let report = compute(input, Some("codex-jsonl")).unwrap();
2977        let analytics = report
2978            .prompt_cache_plan
2979            .as_ref()
2980            .and_then(|plan| plan.analytics.as_ref())
2981            .expect("prompt cache analytics should be present");
2982
2983        assert_eq!(analytics.sample_count, 3);
2984        assert!(!analytics.effective);
2985        assert_eq!(analytics.trend, "improving");
2986        assert_eq!(
2987            analytics.average_cached_input_ratio.as_deref(),
2988            Some("50.00%")
2989        );
2990        assert_eq!(
2991            analytics.first_cached_input_ratio.as_deref(),
2992            Some("10.00%")
2993        );
2994        assert_eq!(analytics.last_cached_input_ratio.as_deref(), Some("90.00%"));
2995        assert_eq!(
2996            analytics.cached_input_ratio_delta.as_deref(),
2997            Some("+80.00%")
2998        );
2999        assert_eq!(analytics.net_cached_input_tokens, 1500);
3000        assert_eq!(analytics.timeline.len(), 3);
3001        assert_eq!(
3002            analytics.timeline[2].cached_input_ratio.as_deref(),
3003            Some("90.00%")
3004        );
3005    }
3006
3007    #[test]
3008    fn prompt_cache_timeline_emits_attribution_metadata() {
3009        let input = concat!(
3010            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":900,"output_tokens":10}}}"#,
3011            "\n",
3012            r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1100,"cache_creation_input_tokens":0,"cache_read_input_tokens":1000,"output_tokens":12}}}"#,
3013            "\n",
3014        );
3015
3016        let report = compute(input, Some("claude-jsonl")).unwrap();
3017        let plan = report
3018            .prompt_cache_plan
3019            .as_ref()
3020            .expect("prompt cache plan should be present");
3021        let analytics = report
3022            .prompt_cache_plan
3023            .as_ref()
3024            .and_then(|plan| plan.analytics.as_ref())
3025            .expect("prompt cache analytics should be present");
3026        let first = analytics.timeline[0]
3027            .prompt_cache_metadata
3028            .as_ref()
3029            .expect("timeline should include prompt cache metadata");
3030        let second = analytics.timeline[1]
3031            .prompt_cache_metadata
3032            .as_ref()
3033            .expect("timeline should include prompt cache metadata");
3034
3035        assert_eq!(first.provider, "anthropic");
3036        assert_eq!(first.cache_key.as_deref(), Some("agent-doc:tsift"));
3037        assert_eq!(first.routing_affinity.as_deref(), Some("replica-a"));
3038        assert!(
3039            first.breakpoints.iter().any(|breakpoint| {
3040                breakpoint == "message.content[0].cache_control=type:ephemeral"
3041            })
3042        );
3043        assert!(first.stable_prefix_fingerprint.starts_with("spfx-"));
3044        assert_eq!(
3045            first.stable_prefix_fingerprint,
3046            second.stable_prefix_fingerprint
3047        );
3048        assert_eq!(
3049            prompt_cache_adapter_status(plan, "anthropic"),
3050            Some("cache_control")
3051        );
3052        assert_eq!(
3053            prompt_cache_adapter_status(plan, "replica_local"),
3054            Some("routing_affinity")
3055        );
3056    }
3057
3058    #[test]
3059    fn prompt_cache_plan_marks_missing_provider_adapter_evidence() {
3060        let input = concat!(
3061            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#,
3062            "\n",
3063        );
3064
3065        let report = compute(input, Some("codex-jsonl")).unwrap();
3066        let plan = report
3067            .prompt_cache_plan
3068            .as_ref()
3069            .expect("prompt cache plan should be present");
3070
3071        assert_eq!(
3072            prompt_cache_adapter_status(plan, "openai"),
3073            Some("missing_prompt_cache_key")
3074        );
3075        assert_eq!(
3076            prompt_cache_adapter_status(plan, "replica_local"),
3077            Some("missing_routing_affinity")
3078        );
3079        assert!(plan.actions.iter().any(|action| {
3080            action.kind == "fix_openai_prompt_cache_key"
3081                && action.guidance.contains("prompt_cache_key")
3082        }));
3083        assert!(plan.actions.iter().any(|action| {
3084            action.kind == "fix_replica_routing_affinity"
3085                && action.guidance.contains("same provider replica")
3086        }));
3087
3088        let anthropic = concat!(
3089            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","routing_affinity":"replica-a","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":900,"output_tokens":10}}}"#,
3090            "\n",
3091        );
3092        let report = compute(anthropic, Some("claude-jsonl")).unwrap();
3093        let plan = report
3094            .prompt_cache_plan
3095            .as_ref()
3096            .expect("prompt cache plan should be present");
3097        assert_eq!(
3098            prompt_cache_adapter_status(plan, "anthropic"),
3099            Some("missing_cache_control")
3100        );
3101        assert!(plan.actions.iter().any(|action| {
3102            action.kind == "fix_anthropic_cache_control"
3103                && action.guidance.contains("cache_control")
3104        }));
3105    }
3106
3107    #[test]
3108    fn prompt_cache_plan_marks_routing_affinity_churn() {
3109        let input = concat!(
3110            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#,
3111            "\n",
3112            r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-b","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#,
3113            "\n",
3114        );
3115
3116        let report = compute(input, Some("codex-jsonl")).unwrap();
3117        let plan = report
3118            .prompt_cache_plan
3119            .as_ref()
3120            .expect("prompt cache plan should be present");
3121
3122        assert_eq!(
3123            prompt_cache_adapter_status(plan, "openai"),
3124            Some("prompt_cache_key")
3125        );
3126        assert_eq!(
3127            prompt_cache_adapter_status(plan, "replica_local"),
3128            Some("routing_affinity_churn")
3129        );
3130        assert!(
3131            plan.actions
3132                .iter()
3133                .any(|action| action.kind == "fix_replica_routing_affinity")
3134        );
3135    }
3136
3137    #[test]
3138    fn prompt_cache_plan_classifies_likely_invalidation_diagnostics() {
3139        let input = concat!(
3140            r#"{"timestamp":"2026-05-05T00:00:01Z","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
3141            "\n",
3142            r#"{"timestamp":"2026-05-05T00:00:02Z","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
3143            "\n",
3144            r#"{"timestamp":"2026-05-05T00:00:03Z","message":{"id":"msg-3","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
3145            "\n",
3146        );
3147
3148        let report = compute(input, Some("claude-jsonl")).unwrap();
3149        let diagnostics = &report
3150            .prompt_cache_plan
3151            .as_ref()
3152            .and_then(|plan| plan.analytics.as_ref())
3153            .expect("prompt cache analytics should be present")
3154            .diagnostics;
3155
3156        assert!(diagnostics.iter().any(|diagnostic| {
3157            diagnostic.kind == "cached_ratio_drop"
3158                && diagnostic.label == "2026-05-05T00:00:02Z"
3159                && diagnostic
3160                    .likely_causes
3161                    .iter()
3162                    .any(|cause| cause.contains("prompt_cache_key"))
3163        }));
3164        assert!(diagnostics.iter().any(|diagnostic| {
3165            diagnostic.kind == "cache_creation_spike" && diagnostic.message.contains("60.00%")
3166        }));
3167        assert!(diagnostics.iter().any(|diagnostic| {
3168            diagnostic.kind == "read_create_regression" && diagnostic.message.contains("0.92x")
3169        }));
3170    }
3171
3172    #[test]
3173    fn prompt_cache_effectiveness_fixture_passes_thresholds() {
3174        let fixture = SessionCostPromptCacheEffectivenessFixture {
3175            schema_version: 1,
3176            description: "fixture".to_string(),
3177            cases: vec![SessionCostPromptCacheEffectivenessCase {
3178                name: "warm-codex-prefix".to_string(),
3179                source: "codex-jsonl".to_string(),
3180                input_lines: vec![
3181                    r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#.to_string(),
3182                    r#"{"timestamp":"2026-05-05T00:00:04Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#.to_string(),
3183                ],
3184                minimum_cached_input_ratio: 90.0,
3185                minimum_net_cached_input_tokens: 40_000,
3186                maximum_read_create_regressions: 0,
3187            }],
3188        };
3189
3190        let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
3191
3192        assert!(report.pass);
3193        assert_eq!(report.totals.passed, 1);
3194        assert_eq!(report.totals.failed, 0);
3195        assert_eq!(report.cases[0].status, "pass");
3196        assert_eq!(report.cases[0].cached_input_ratio, Some(96.0));
3197        assert_eq!(report.cases[0].net_cached_input_tokens, 48_000);
3198        assert_eq!(report.cases[0].read_create_regressions, 0);
3199    }
3200
3201    #[test]
3202    fn prompt_cache_effectiveness_fixture_fails_missing_adapter_evidence() {
3203        let fixture = SessionCostPromptCacheEffectivenessFixture {
3204            schema_version: 1,
3205            description: "fixture".to_string(),
3206            cases: vec![
3207                SessionCostPromptCacheEffectivenessCase {
3208                    name: "missing-openai-key".to_string(),
3209                    source: "codex-jsonl".to_string(),
3210                    input_lines: vec![
3211                        r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#.to_string(),
3212                        r#"{"timestamp":"2026-05-05T00:00:04Z","provider":"openai","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#.to_string(),
3213                    ],
3214                    minimum_cached_input_ratio: 90.0,
3215                    minimum_net_cached_input_tokens: 40_000,
3216                    maximum_read_create_regressions: 0,
3217                },
3218                SessionCostPromptCacheEffectivenessCase {
3219                    name: "missing-anthropic-cache-control".to_string(),
3220                    source: "claude-jsonl".to_string(),
3221                    input_lines: vec![
3222                        r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":9000,"output_tokens":10}}}"#.to_string(),
3223                        r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1100,"cache_creation_input_tokens":0,"cache_read_input_tokens":10000,"output_tokens":12}}}"#.to_string(),
3224                    ],
3225                    minimum_cached_input_ratio: 70.0,
3226                    minimum_net_cached_input_tokens: 1,
3227                    maximum_read_create_regressions: 0,
3228                },
3229            ],
3230        };
3231
3232        let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
3233
3234        assert!(!report.pass);
3235        assert_eq!(report.totals.failed, 2);
3236        assert!(report.cases[0].failures.iter().any(|failure| {
3237            failure.contains("OpenAI prompt_cache_key")
3238                && failure.contains("missing_prompt_cache_key")
3239        }));
3240        assert!(report.cases[0].failures.iter().any(|failure| {
3241            failure.contains("replica-local routing_affinity")
3242                && failure.contains("missing_routing_affinity")
3243        }));
3244        assert!(report.cases[1].failures.iter().any(|failure| {
3245            failure.contains("Anthropic cache_control") && failure.contains("missing_cache_control")
3246        }));
3247    }
3248
3249    #[test]
3250    fn prompt_cache_effectiveness_fixture_fails_read_create_regression() {
3251        let fixture = SessionCostPromptCacheEffectivenessFixture {
3252            schema_version: 1,
3253            description: "fixture".to_string(),
3254            cases: vec![SessionCostPromptCacheEffectivenessCase {
3255                name: "cold-rewrite".to_string(),
3256                source: "claude-jsonl".to_string(),
3257                input_lines: vec![
3258                    r#"{"timestamp":"2026-05-05T00:00:01Z","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#.to_string(),
3259                    r#"{"timestamp":"2026-05-05T00:00:02Z","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#.to_string(),
3260                    r#"{"timestamp":"2026-05-05T00:00:03Z","message":{"id":"msg-3","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#.to_string(),
3261                ],
3262                minimum_cached_input_ratio: 70.0,
3263                minimum_net_cached_input_tokens: 1,
3264                maximum_read_create_regressions: 0,
3265            }],
3266        };
3267
3268        let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
3269
3270        assert!(!report.pass);
3271        assert_eq!(report.totals.failed, 1);
3272        assert_eq!(report.cases[0].status, "fail");
3273        assert_eq!(report.cases[0].read_create_regressions, 1);
3274        assert!(
3275            report.cases[0]
3276                .failures
3277                .iter()
3278                .any(|failure| failure.contains("read_create_regressions"))
3279        );
3280    }
3281
3282    #[test]
3283    fn agent_doc_log_summarizes_runtime_churn() {
3284        let input = "\
3285[1776452736] claude_start mode=fresh restart_count=0
3286[1776528398] claude_start mode=fresh_restart restart_count=1
3287[1776528446] auto_trigger_timeout (no prompt after 30s)
3288[1776528450] ctrl_d_restart_fresh restart_count=2
3289[1776528582] claude_start mode=fresh_restart restart_count=2
3290[1776528599] codex_start mode=continue restart_count=3
3291[1776528601] user_quit_after_ctrl_d
3292[1776528602] commit_already_current file=tasks/software/tsift.md basis=head
3293[1776528603] commit_already_current file=tasks/software/tsift.md basis=head
3294[1776528604] commit_already_current file=tasks/software/tsift.md basis=head
3295";
3296
3297        let report = compute(input, Some("agent-doc-log")).unwrap();
3298        assert_eq!(report.source, "agent_doc_log");
3299        assert_eq!(report.usage_samples, 0);
3300        assert_eq!(report.runtime_event_groups, 7);
3301        assert_eq!(report.total_runtime_events, 10);
3302        assert_eq!(report.restart_churn_groups, 4);
3303        assert_eq!(report.max_restart_count, Some(3));
3304        assert!(
3305            report
3306                .runtime_events
3307                .iter()
3308                .any(|event| event.event == "claude_start:fresh_restart" && event.occurrences == 2)
3309        );
3310        assert!(
3311            report
3312                .runtime_events
3313                .iter()
3314                .any(|event| event.event == "auto_trigger_timeout" && event.occurrences == 1)
3315        );
3316        assert!(
3317            report
3318                .restart_churn
3319                .iter()
3320                .any(|entry| entry.family == "fresh_restart" && entry.occurrences == 3)
3321        );
3322        assert!(
3323            report
3324                .restart_churn
3325                .iter()
3326                .any(|entry| entry.family == "ctrl_d_restart_loop" && entry.occurrences == 1)
3327        );
3328        assert!(
3329            report
3330                .restart_churn
3331                .iter()
3332                .any(|entry| entry.family == "quit_after_eof" && entry.occurrences == 1)
3333        );
3334        assert!(
3335            report
3336                .guardrails
3337                .iter()
3338                .any(|guardrail| guardrail.kind == "restart_loop")
3339        );
3340        assert!(
3341            report
3342                .guardrails
3343                .iter()
3344                .any(|guardrail| guardrail.kind == "noop_closeout")
3345        );
3346        assert!(
3347            report
3348                .loop_clusters
3349                .iter()
3350                .any(|cluster| cluster.kind == "closeout_churn"
3351                    && cluster.label == "commit_already_current"
3352                    && cluster.occurrences == 3)
3353        );
3354    }
3355
3356    #[test]
3357    fn agent_doc_log_dedupes_document_cycle_runtime_events_by_cycle() {
3358        let input = "\
3359[1777603275] document_cycle phase=response_captured cycle=cycle-1 event=response_captured capture_id=cycle-1
3360[1777603276] document_cycle phase=committed cycle=cycle-1 event=commit_success capture_id=cycle-1
3361[1777603403] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
3362[1777603404] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
3363[1777603405] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
3364[1777603500] document_cycle phase=preflight_started cycle=cycle-2 event=preflight_started
3365[1777603600] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
3366[1777603601] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
3367[1777603700] document_cycle phase=committed cycle=cycle-3 event=commit_already_current
3368";
3369
3370        let report = compute(input, Some("agent-doc-log")).unwrap();
3371
3372        assert_eq!(report.total_runtime_events, 6);
3373        assert!(
3374            report
3375                .runtime_events
3376                .iter()
3377                .any(|event| event.event == "commit_already_current" && event.occurrences == 3)
3378        );
3379        assert!(
3380            report
3381                .runtime_events
3382                .iter()
3383                .any(|event| event.event == "commit_success" && event.occurrences == 1)
3384        );
3385        assert!(
3386            report
3387                .runtime_events
3388                .iter()
3389                .any(|event| event.event == "response_captured" && event.occurrences == 1)
3390        );
3391        assert!(
3392            report
3393                .guardrails
3394                .iter()
3395                .any(|guardrail| guardrail.kind == "noop_closeout")
3396        );
3397        assert!(
3398            report
3399                .loop_clusters
3400                .iter()
3401                .any(|cluster| cluster.kind == "closeout_churn"
3402                    && cluster.label == "commit_already_current"
3403                    && cluster.occurrences == 3)
3404        );
3405    }
3406
3407    #[test]
3408    fn codex_jsonl_surfaces_prompt_and_command_loop_clusters() {
3409        let input = concat!(
3410            r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
3411            "\n",
3412            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
3413            "\n",
3414            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
3415            "\n",
3416            r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
3417            "\n",
3418            r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
3419            "\n",
3420            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
3421            "\n",
3422            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
3423            "\n",
3424            r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
3425            "\n"
3426        );
3427
3428        let report = compute(input, Some("codex-jsonl")).unwrap();
3429
3430        assert!(
3431            report
3432                .loop_clusters
3433                .iter()
3434                .any(|cluster| cluster.kind == "prompt_repeat"
3435                    && cluster.label == "do [#looprank]. spec-test-build-install-commit-push"
3436                    && cluster.occurrences == 2)
3437        );
3438        assert!(
3439            report
3440                .loop_clusters
3441                .iter()
3442                .any(|cluster| cluster.kind == "command_bundle"
3443                    && cluster.label == "cargo test -> cargo build --release"
3444                    && cluster.occurrences == 2)
3445        );
3446        assert!(report.loop_clusters.iter().any(|cluster| {
3447            cluster.kind == "closeout_churn"
3448                && cluster
3449                    .label
3450                    .contains("Committed and pushed in `src/tsift`")
3451                && cluster.occurrences == 2
3452        }));
3453    }
3454
3455    #[test]
3456    fn codex_jsonl_surfaces_repeated_file_read_diagnostics() {
3457        let input = concat!(
3458            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
3459            "\n",
3460            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
3461            "\n",
3462            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
3463            "\n",
3464            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
3465            "\n"
3466        );
3467
3468        let report = compute(input, Some("codex-jsonl")).unwrap();
3469
3470        assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
3471            diagnostic.path == "src/session_cost.rs"
3472                && diagnostic.range == "1-220"
3473                && diagnostic.occurrences == 2
3474                && diagnostic.duplicate_estimated_tokens == 3_960
3475                && diagnostic.follow_up_commands.iter().any(|command| {
3476                    command == "tsift source-read src/session_cost.rs --start 1 --lines 220 --budget normal"
3477                })
3478        }));
3479        assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
3480            diagnostic.path == "src/main.rs"
3481                && diagnostic.range == "full"
3482                && diagnostic.duplicate_estimated_tokens == 4_000
3483                && diagnostic
3484                    .follow_up_commands
3485                    .iter()
3486                    .any(|command| command == "tsift summarize --file src/main.rs")
3487        }));
3488    }
3489
3490    #[test]
3491    fn claude_jsonl_surfaces_repeated_native_read_tool_diagnostics() {
3492        let input = concat!(
3493            r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
3494            "\n",
3495            r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
3496            "\n"
3497        );
3498
3499        let report = compute(input, Some("claude-jsonl")).unwrap();
3500
3501        assert_eq!(report.file_read_diagnostics.len(), 1);
3502        let diagnostic = &report.file_read_diagnostics[0];
3503        assert_eq!(diagnostic.path, "src/lib.rs");
3504        assert_eq!(diagnostic.range, "40-119");
3505        assert_eq!(diagnostic.occurrences, 2);
3506        assert_eq!(diagnostic.duplicate_estimated_tokens, 1_440);
3507        assert!(diagnostic.follow_up_commands.iter().any(|command| {
3508            command == "tsift source-read src/lib.rs --start 40 --lines 80 --budget normal"
3509        }));
3510    }
3511
3512    #[test]
3513    fn derive_guardrails_flags_large_prompt_turns() {
3514        let guardrails = derive_guardrails(&SessionCostGuardrailInput {
3515            largest_prompt_turn_tokens: 140_000,
3516            largest_prompt_turn_label: Some("2026-05-05T00:00:01Z".to_string()),
3517            ..SessionCostGuardrailInput::default()
3518        });
3519
3520        assert!(
3521            guardrails
3522                .iter()
3523                .any(|guardrail| guardrail.kind == "prompt_budget")
3524        );
3525    }
3526
3527    #[test]
3528    fn derive_guardrails_flags_cached_resend_ratio() {
3529        let guardrails = derive_guardrails(&SessionCostGuardrailInput {
3530            prompt_tokens: 80_000,
3531            cached_input_ratio: Some(96.0),
3532            ..SessionCostGuardrailInput::default()
3533        });
3534
3535        assert!(
3536            guardrails
3537                .iter()
3538                .any(|guardrail| guardrail.kind == "cache_resend")
3539        );
3540    }
3541
3542    #[test]
3543    fn derive_guardrails_ignores_restart_count_without_churn() {
3544        let guardrails = derive_guardrails(&SessionCostGuardrailInput {
3545            max_restart_count: Some(3),
3546            ..SessionCostGuardrailInput::default()
3547        });
3548
3549        assert!(
3550            guardrails
3551                .iter()
3552                .all(|guardrail| guardrail.kind != "restart_loop")
3553        );
3554    }
3555}