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