1#![allow(dead_code)]
2
3use crate::session::ClaudeSession;
4
5#[derive(Debug, Clone)]
10pub struct HealthThresholds {
11 pub cache_critical_pct: f64,
12 pub cache_warning_pct: f64,
13 pub cache_min_tokens: u64,
14 pub cost_spike_critical: f64,
15 pub cost_spike_warning: f64,
16 pub loop_max_calls: u32,
17 pub stall_min_cost: f64,
18 pub stall_min_minutes: u64,
19 pub context_critical_pct: f64,
20 pub context_warning_pct: f64,
21 pub decay_compaction_pct: f64,
22 pub efficiency_critical_factor: f64,
23 pub error_accel_factor: f64,
24 pub repetition_threshold: u32,
25}
26
27impl Default for HealthThresholds {
28 fn default() -> Self {
29 Self {
30 cache_critical_pct: 10.0,
31 cache_warning_pct: 30.0,
32 cache_min_tokens: 10_000,
33 cost_spike_critical: 5.0,
34 cost_spike_warning: 2.5,
35 loop_max_calls: 10,
36 stall_min_cost: 5.0,
37 stall_min_minutes: 10,
38 context_critical_pct: 90.0,
39 context_warning_pct: 80.0,
40 decay_compaction_pct: 50.0,
41 efficiency_critical_factor: 2.0,
42 error_accel_factor: 2.0,
43 repetition_threshold: 3,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Severity {
50 Info,
51 Warning,
52 Critical,
53}
54
55#[derive(Debug, Clone)]
56pub struct HealthCheck {
57 pub icon: &'static str,
58 pub name: &'static str,
59 pub severity: Severity,
60 pub message: String,
61}
62
63pub fn check_session(session: &ClaudeSession, t: &HealthThresholds) -> Vec<HealthCheck> {
65 let mut checks = Vec::new();
66
67 if let Some(c) = check_cache_health(session, t) {
68 checks.push(c);
69 }
70 if let Some(c) = check_cost_spike(session, t) {
71 checks.push(c);
72 }
73 if let Some(c) = check_loop_detection(session, t) {
74 checks.push(c);
75 }
76 if let Some(c) = check_stalled(session, t) {
77 checks.push(c);
78 }
79 if let Some(c) = check_context_saturation(session, t) {
80 checks.push(c);
81 }
82 if let Some(c) = check_cognitive_decay(session, t) {
83 checks.push(c);
84 }
85 if let Some(c) = check_proactive_compaction(session, t) {
86 checks.push(c);
87 }
88 if let Some(c) = check_token_efficiency(session, t) {
89 checks.push(c);
90 }
91 if let Some(c) = check_error_acceleration(session, t) {
92 checks.push(c);
93 }
94 if let Some(c) = check_repetition(session, t) {
95 checks.push(c);
96 }
97
98 checks.sort_by_key(|c| match c.severity {
100 Severity::Critical => 0,
101 Severity::Warning => 1,
102 Severity::Info => 2,
103 });
104
105 checks
106}
107
108pub fn status_icon(session: &ClaudeSession, t: &HealthThresholds) -> &'static str {
110 let checks = check_session(session, t);
111 match checks.first() {
112 Some(c) if c.severity == Severity::Critical => c.icon,
113 Some(c) if c.severity == Severity::Warning => c.icon,
114 _ => "",
115 }
116}
117
118pub fn format_health_summary(sessions: &[ClaudeSession], t: &HealthThresholds) -> Option<String> {
120 let mut warnings = 0;
121 let mut criticals = 0;
122 let mut worst_msg = String::new();
123
124 for session in sessions {
125 for check in check_session(session, t) {
126 match check.severity {
127 Severity::Critical => {
128 criticals += 1;
129 if worst_msg.is_empty() {
130 worst_msg =
131 format!("{} {}: {}", check.icon, session.display_name(), check.name);
132 }
133 }
134 Severity::Warning => warnings += 1,
135 Severity::Info => {}
136 }
137 }
138 }
139
140 if criticals == 0 && warnings == 0 {
141 return None;
142 }
143
144 let count = criticals + warnings;
145 Some(format!(
146 "{} health issue{} | {}",
147 count,
148 if count == 1 { "" } else { "s" },
149 worst_msg,
150 ))
151}
152
153fn check_cache_health(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
159 let total_input = session.total_input_tokens;
160 let cache_read = session.cache_read_tokens;
161
162 if total_input < t.cache_min_tokens {
163 return None;
164 }
165
166 let hit_ratio = cache_read as f64 / total_input as f64;
167 let critical_threshold = t.cache_critical_pct / 100.0;
168 let warning_threshold = t.cache_warning_pct / 100.0;
169
170 if hit_ratio < critical_threshold {
171 Some(HealthCheck {
172 icon: "🔥",
173 name: "low cache",
174 severity: Severity::Critical,
175 message: format!(
176 "Cache hit ratio is {:.0}% — expected >50% for long sessions. \
177 Possible cache TTL issue (check telemetry settings).",
178 hit_ratio * 100.0
179 ),
180 })
181 } else if hit_ratio < warning_threshold {
182 Some(HealthCheck {
183 icon: "⚠",
184 name: "low cache",
185 severity: Severity::Warning,
186 message: format!(
187 "Cache hit ratio is {:.0}% — below typical range. \
188 May indicate cache TTL or model configuration issue.",
189 hit_ratio * 100.0
190 ),
191 })
192 } else {
193 None
194 }
195}
196
197fn check_cost_spike(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
199 if session.cost_usd < 1.0 || session.burn_rate_per_hr <= 0.0 {
200 return None;
201 }
202
203 let elapsed_hrs = session.elapsed.as_secs_f64() / 3600.0;
204 if elapsed_hrs < 0.01 {
205 return None;
206 }
207 let avg_rate = session.cost_usd / elapsed_hrs;
208
209 if avg_rate <= 0.0 {
210 return None;
211 }
212
213 let spike_factor = session.burn_rate_per_hr / avg_rate;
214
215 if spike_factor > t.cost_spike_critical {
216 Some(HealthCheck {
217 icon: "💸",
218 name: "cost spike",
219 severity: Severity::Critical,
220 message: format!(
221 "Burn rate ${:.1}/hr is {:.0}x the session average ${:.1}/hr.",
222 session.burn_rate_per_hr, spike_factor, avg_rate,
223 ),
224 })
225 } else if spike_factor > t.cost_spike_warning {
226 Some(HealthCheck {
227 icon: "💰",
228 name: "cost spike",
229 severity: Severity::Warning,
230 message: format!(
231 "Burn rate ${:.1}/hr is {:.1}x the session average.",
232 session.burn_rate_per_hr, spike_factor,
233 ),
234 })
235 } else {
236 None
237 }
238}
239
240fn check_loop_detection(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
242 if !session.last_tool_error {
243 return None;
244 }
245
246 let max_calls = session
247 .tool_usage
248 .values()
249 .map(|ts| ts.calls)
250 .max()
251 .unwrap_or(0);
252
253 if max_calls >= t.loop_max_calls && session.last_tool_error {
254 let tool_name = session
255 .tool_usage
256 .iter()
257 .max_by_key(|(_, ts)| ts.calls)
258 .map(|(name, _)| name.as_str())
259 .unwrap_or("?");
260
261 Some(HealthCheck {
262 icon: "🔄",
263 name: "looping",
264 severity: Severity::Warning,
265 message: format!(
266 "{tool_name} called {max_calls} times with recent errors — may be stuck in a retry loop.",
267 ),
268 })
269 } else {
270 None
271 }
272}
273
274fn check_stalled(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
276 if session.cost_usd < t.stall_min_cost {
277 return None;
278 }
279
280 let files_edited: u32 = session.files_modified.values().sum();
281 let elapsed_mins = session.elapsed.as_secs() / 60;
282
283 if files_edited == 0 && elapsed_mins > t.stall_min_minutes {
284 Some(HealthCheck {
285 icon: "🐌",
286 name: "stalled",
287 severity: Severity::Warning,
288 message: format!(
289 "Spent ${:.1} over {} min with no file edits.",
290 session.cost_usd, elapsed_mins,
291 ),
292 })
293 } else {
294 None
295 }
296}
297
298fn check_context_saturation(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
300 if session.context_max == 0 {
301 return None;
302 }
303
304 let pct = (session.context_tokens as f64 / session.context_max as f64) * 100.0;
305
306 if pct > t.context_critical_pct {
307 Some(HealthCheck {
308 icon: "🧠",
309 name: "context full",
310 severity: Severity::Critical,
311 message: format!(
312 "Context at {:.0}% — session may degrade or auto-compact. \
313 Consider spawning a fresh session.",
314 pct,
315 ),
316 })
317 } else if pct > t.context_warning_pct {
318 Some(HealthCheck {
319 icon: "🧠",
320 name: "context high",
321 severity: Severity::Warning,
322 message: format!("Context at {:.0}% — approaching limit.", pct),
323 })
324 } else {
325 None
326 }
327}
328
329pub fn compute_decay_score(session: &ClaudeSession, _t: &HealthThresholds) -> u32 {
335 let mut score: f64 = 0.0;
336
337 let ctx_pct = session.context_percent();
339 if ctx_pct > 40.0 {
340 score += ((ctx_pct - 40.0) / 60.0) * 40.0;
341 }
342
343 if let Some(baseline) = session.baseline_error_rate {
345 if baseline > 0.0 && session.error_counts_per_window.len() >= 2 {
346 let recent_count = session.error_counts_per_window.len().min(3);
347 let recent: f64 = session
348 .error_counts_per_window
349 .iter()
350 .rev()
351 .take(recent_count)
352 .sum::<u32>() as f64
353 / recent_count as f64;
354 let ratio = recent / baseline;
355 score += (ratio - 1.0).clamp(0.0, 1.0) * 25.0;
356 }
357 }
358
359 if let Some(baseline) = session.baseline_tokens_per_edit {
361 if baseline > 0.0 && session.edit_event_count > 5 {
362 let current =
363 session.total_tokens_at_edit_count as f64 / session.edit_event_count as f64;
364 let ratio = current / baseline;
365 score += (ratio - 1.0).clamp(0.0, 1.0) * 20.0;
366 }
367 }
368
369 let max_rereads = session
371 .file_reads_since_edit
372 .values()
373 .copied()
374 .max()
375 .unwrap_or(0);
376 if max_rereads >= 2 {
377 score += ((max_rereads as f64 - 1.0) / 4.0).min(1.0) * 15.0;
378 }
379
380 (score.round() as u32).min(100)
381}
382
383fn check_cognitive_decay(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
385 let score = compute_decay_score(session, t);
386 if score >= 80 {
387 Some(HealthCheck {
388 icon: "⊘",
389 name: "severe decay",
390 severity: Severity::Critical,
391 message: format!(
392 "Decay score {}/100 — session is severely compromised. Restart with fresh context.",
393 score,
394 ),
395 })
396 } else if score >= 60 {
397 Some(HealthCheck {
398 icon: "◉",
399 name: "significant decay",
400 severity: Severity::Warning,
401 message: format!(
402 "Decay score {}/100 — consider restarting. Generate a state transfer summary first.",
403 score,
404 ),
405 })
406 } else if score >= 30 {
407 Some(HealthCheck {
408 icon: "◐",
409 name: "early decay",
410 severity: Severity::Info,
411 message: format!(
412 "Decay score {}/100 — consider /compact with preservation notes.",
413 score,
414 ),
415 })
416 } else {
417 None
418 }
419}
420
421fn check_proactive_compaction(
423 session: &ClaudeSession,
424 t: &HealthThresholds,
425) -> Option<HealthCheck> {
426 if session.context_max == 0 {
427 return None;
428 }
429
430 let pct = session.context_percent();
431 if pct > t.decay_compaction_pct && pct <= t.context_warning_pct {
432 Some(HealthCheck {
433 icon: "📋",
434 name: "consider compact",
435 severity: Severity::Info,
436 message: format!(
437 "Context at {:.0}% — research shows degradation begins here. Consider /compact.",
438 pct,
439 ),
440 })
441 } else {
442 None
443 }
444}
445
446fn check_token_efficiency(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
448 let baseline = session.baseline_tokens_per_edit?;
449 if baseline < 100.0 || session.edit_event_count < 8 {
450 return None;
451 }
452
453 let current = session.total_tokens_at_edit_count as f64 / session.edit_event_count as f64;
454 let ratio = current / baseline;
455
456 if ratio > t.efficiency_critical_factor {
457 Some(HealthCheck {
458 icon: "📉",
459 name: "low efficiency",
460 severity: Severity::Warning,
461 message: format!(
462 "Tokens per edit is {:.1}x baseline — agent is working harder for less output.",
463 ratio,
464 ),
465 })
466 } else {
467 None
468 }
469}
470
471fn check_error_acceleration(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
473 let baseline = session.baseline_error_rate?;
474 if baseline <= 0.0 || session.error_counts_per_window.len() < 4 {
475 return None;
476 }
477
478 let recent_count = session.error_counts_per_window.len().min(3);
479 let recent: f64 = session
480 .error_counts_per_window
481 .iter()
482 .rev()
483 .take(recent_count)
484 .sum::<u32>() as f64
485 / recent_count as f64;
486 let ratio = recent / baseline;
487
488 if ratio > t.error_accel_factor {
489 Some(HealthCheck {
490 icon: "⚠",
491 name: "error acceleration",
492 severity: Severity::Warning,
493 message: format!(
494 "Error rate is {:.1}x baseline — agent may be stuck or confused.",
495 ratio,
496 ),
497 })
498 } else {
499 None
500 }
501}
502
503fn check_repetition(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
505 let max_rereads = session
506 .file_reads_since_edit
507 .values()
508 .copied()
509 .max()
510 .unwrap_or(0);
511
512 if max_rereads >= t.repetition_threshold {
513 let file = session
514 .file_reads_since_edit
515 .iter()
516 .max_by_key(|(_, v)| *v)
517 .map(|(k, _)| {
518 k.rsplit('/').next().unwrap_or(k)
520 })
521 .unwrap_or("?");
522 Some(HealthCheck {
523 icon: "🔁",
524 name: "repetition",
525 severity: Severity::Warning,
526 message: format!(
527 "{} read {} times without editing — agent may be looping.",
528 file, max_rereads,
529 ),
530 })
531 } else {
532 None
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::session::{RawSession, SessionStatus, TelemetryStatus};
540
541 fn defaults() -> HealthThresholds {
542 HealthThresholds::default()
543 }
544
545 fn make_session() -> ClaudeSession {
546 let raw = RawSession {
547 pid: 1,
548 session_id: "test".into(),
549 cwd: "/tmp/test".into(),
550 started_at: 0,
551 };
552 let mut s = ClaudeSession::from_raw(raw);
553 s.status = SessionStatus::Processing;
554 s.telemetry_status = TelemetryStatus::Available;
555 s.model = "opus".into();
556 s
557 }
558
559 #[test]
560 fn healthy_session_no_warnings() {
561 let s = make_session();
562 assert!(check_session(&s, &defaults()).is_empty());
563 }
564
565 #[test]
566 fn low_cache_critical() {
567 let mut s = make_session();
568 s.total_input_tokens = 100_000;
569 s.cache_read_tokens = 5_000; let checks = check_session(&s, &defaults());
571 assert!(
572 checks
573 .iter()
574 .any(|c| c.name == "low cache" && c.severity == Severity::Critical)
575 );
576 }
577
578 #[test]
579 fn low_cache_warning() {
580 let mut s = make_session();
581 s.total_input_tokens = 100_000;
582 s.cache_read_tokens = 20_000; let checks = check_session(&s, &defaults());
584 assert!(
585 checks
586 .iter()
587 .any(|c| c.name == "low cache" && c.severity == Severity::Warning)
588 );
589 }
590
591 #[test]
592 fn healthy_cache_no_warning() {
593 let mut s = make_session();
594 s.total_input_tokens = 100_000;
595 s.cache_read_tokens = 60_000; assert!(check_cache_health(&s, &defaults()).is_none());
597 }
598
599 #[test]
600 fn context_saturation_critical() {
601 let mut s = make_session();
602 s.context_tokens = 190_000;
603 s.context_max = 200_000;
604 let checks = check_session(&s, &defaults());
605 assert!(
606 checks
607 .iter()
608 .any(|c| c.name == "context full" && c.severity == Severity::Critical)
609 );
610 }
611
612 #[test]
613 fn context_saturation_warning() {
614 let mut s = make_session();
615 s.context_tokens = 170_000;
616 s.context_max = 200_000;
617 let checks = check_session(&s, &defaults());
618 assert!(
619 checks
620 .iter()
621 .any(|c| c.name == "context high" && c.severity == Severity::Warning)
622 );
623 }
624
625 #[test]
626 fn stalled_detection() {
627 let mut s = make_session();
628 s.cost_usd = 10.0;
629 s.elapsed = std::time::Duration::from_secs(15 * 60);
630 let checks = check_session(&s, &defaults());
632 assert!(checks.iter().any(|c| c.name == "stalled"));
633 }
634
635 #[test]
636 fn status_icon_returns_worst() {
637 let mut s = make_session();
638 s.context_tokens = 190_000;
639 s.context_max = 200_000;
640 assert_eq!(status_icon(&s, &defaults()), "🧠");
641 }
642
643 #[test]
644 fn status_icon_empty_when_healthy() {
645 let s = make_session();
646 assert_eq!(status_icon(&s, &defaults()), "");
647 }
648
649 #[test]
650 fn sorted_by_severity() {
651 let mut s = make_session();
652 s.total_input_tokens = 100_000;
653 s.cache_read_tokens = 5_000; s.context_tokens = 170_000;
655 s.context_max = 200_000; let checks = check_session(&s, &defaults());
657 assert!(checks.len() >= 2);
658 assert_eq!(checks[0].severity, Severity::Critical);
659 }
660
661 #[test]
662 fn custom_thresholds_change_trigger() {
663 let mut s = make_session();
664 s.total_input_tokens = 100_000;
665 s.cache_read_tokens = 8_000; let checks = check_session(&s, &defaults());
669 assert!(
670 checks
671 .iter()
672 .any(|c| c.name == "low cache" && c.severity == Severity::Critical)
673 );
674
675 let mut relaxed = defaults();
677 relaxed.cache_critical_pct = 5.0;
678 let checks = check_session(&s, &relaxed);
679 assert!(
680 checks
681 .iter()
682 .any(|c| c.name == "low cache" && c.severity == Severity::Warning)
683 );
684 assert!(
685 !checks
686 .iter()
687 .any(|c| c.name == "low cache" && c.severity == Severity::Critical)
688 );
689 }
690
691 #[test]
692 fn custom_context_thresholds() {
693 let mut s = make_session();
694 s.context_tokens = 170_000;
695 s.context_max = 200_000; let checks = check_session(&s, &defaults());
699 assert!(
700 checks
701 .iter()
702 .any(|c| c.name == "context high" && c.severity == Severity::Warning)
703 );
704
705 let mut tight = defaults();
707 tight.context_critical_pct = 84.0;
708 let checks = check_session(&s, &tight);
709 assert!(
710 checks
711 .iter()
712 .any(|c| c.name == "context full" && c.severity == Severity::Critical)
713 );
714 }
715
716 #[test]
719 fn proactive_compaction_fires_at_50pct() {
720 let mut s = make_session();
721 s.context_tokens = 110_000;
722 s.context_max = 200_000; s.usage_metrics_available = true;
724 let check = check_proactive_compaction(&s, &defaults());
725 assert!(check.is_some());
726 let c = check.unwrap();
727 assert_eq!(c.name, "consider compact");
728 assert_eq!(c.severity, Severity::Info);
729 }
730
731 #[test]
732 fn proactive_compaction_silent_below_threshold() {
733 let mut s = make_session();
734 s.context_tokens = 70_000;
735 s.context_max = 200_000; assert!(check_proactive_compaction(&s, &defaults()).is_none());
737 }
738
739 #[test]
740 fn token_efficiency_detects_degradation() {
741 let mut s = make_session();
742 s.baseline_tokens_per_edit = Some(1000.0);
743 s.edit_event_count = 10;
744 s.total_tokens_at_edit_count = 25_000; let check = check_token_efficiency(&s, &defaults());
746 assert!(check.is_some());
747 let c = check.unwrap();
748 assert_eq!(c.name, "low efficiency");
749 assert_eq!(c.severity, Severity::Warning);
750 }
751
752 #[test]
753 fn token_efficiency_silent_when_healthy() {
754 let mut s = make_session();
755 s.baseline_tokens_per_edit = Some(1000.0);
756 s.edit_event_count = 10;
757 s.total_tokens_at_edit_count = 12_000; assert!(check_token_efficiency(&s, &defaults()).is_none());
759 }
760
761 #[test]
762 fn error_acceleration_detects_increase() {
763 let mut s = make_session();
764 s.baseline_error_rate = Some(1.0);
765 s.error_counts_per_window = vec![1, 1, 1, 2, 3, 4]; let check = check_error_acceleration(&s, &defaults());
767 assert!(check.is_some());
768 let c = check.unwrap();
769 assert_eq!(c.name, "error acceleration");
770 assert_eq!(c.severity, Severity::Warning);
771 }
772
773 #[test]
774 fn error_acceleration_silent_when_stable() {
775 let mut s = make_session();
776 s.baseline_error_rate = Some(1.0);
777 s.error_counts_per_window = vec![1, 1, 1, 1]; assert!(check_error_acceleration(&s, &defaults()).is_none());
779 }
780
781 #[test]
782 fn repetition_detects_rereads() {
783 let mut s = make_session();
784 s.file_reads_since_edit
785 .insert("/tmp/test/src/main.rs".into(), 4);
786 let check = check_repetition(&s, &defaults());
787 assert!(check.is_some());
788 let c = check.unwrap();
789 assert_eq!(c.name, "repetition");
790 assert_eq!(c.severity, Severity::Warning);
791 assert!(c.message.contains("main.rs"));
792 }
793
794 #[test]
795 fn repetition_silent_below_threshold() {
796 let mut s = make_session();
797 s.file_reads_since_edit.insert("/tmp/test/foo.rs".into(), 2);
798 assert!(check_repetition(&s, &defaults()).is_none());
799 }
800
801 #[test]
802 fn decay_score_zero_for_fresh_session() {
803 let s = make_session();
804 assert_eq!(compute_decay_score(&s, &defaults()), 0);
805 }
806
807 #[test]
808 fn decay_score_context_only_contribution() {
809 let mut s = make_session();
810 s.context_tokens = 140_000;
811 s.context_max = 200_000; s.usage_metrics_available = true;
813 let score = compute_decay_score(&s, &defaults());
814 assert_eq!(score, 20);
816 }
817
818 #[test]
819 fn decay_score_high_for_saturated_session() {
820 let mut s = make_session();
821 s.context_tokens = 180_000;
822 s.context_max = 200_000; s.usage_metrics_available = true;
824 s.baseline_error_rate = Some(1.0);
825 s.error_counts_per_window = vec![1, 1, 1, 3, 4, 5]; s.file_reads_since_edit
827 .insert("/tmp/test/main.rs".into(), 5); let score = compute_decay_score(&s, &defaults());
829 assert!(score >= 60, "expected >= 60, got {score}");
830 }
831
832 #[test]
833 fn cognitive_decay_check_critical_at_80() {
834 let mut s = make_session();
835 s.context_tokens = 200_000;
836 s.context_max = 200_000; s.usage_metrics_available = true;
838 s.baseline_tokens_per_edit = Some(1000.0);
839 s.edit_event_count = 10;
840 s.total_tokens_at_edit_count = 20_000; s.baseline_error_rate = Some(1.0);
842 s.error_counts_per_window = vec![1, 1, 1, 3, 4, 5]; let check = check_cognitive_decay(&s, &defaults());
844 assert!(check.is_some());
845 let c = check.unwrap();
846 assert_eq!(c.name, "severe decay");
847 assert_eq!(c.severity, Severity::Critical);
848 assert!(c.icon == "⊘");
849 }
850}