#![allow(dead_code)]
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::transcript::{TranscriptBlock, TranscriptEvent, parse_line};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindingCategory {
ErrorCascade,
WastedReads,
UndoRedoLoop,
ContextBloat,
CostEfficiency,
}
impl FindingCategory {
pub fn label(&self) -> &'static str {
match self {
FindingCategory::ErrorCascade => "Error Cascade",
FindingCategory::WastedReads => "Wasted Reads",
FindingCategory::UndoRedoLoop => "Undo-Redo Loop",
FindingCategory::ContextBloat => "Context Bloat",
FindingCategory::CostEfficiency => "Cost Efficiency",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FindingSeverity {
Info,
Warning,
Critical,
}
impl FindingSeverity {
pub fn label(&self) -> &'static str {
match self {
FindingSeverity::Info => "info",
FindingSeverity::Warning => "warning",
FindingSeverity::Critical => "critical",
}
}
}
#[derive(Debug, Clone)]
pub struct AutopsyFinding {
pub category: FindingCategory,
pub severity: FindingSeverity,
pub summary: String,
pub detail: Option<String>,
pub tokens_wasted: u64,
pub message_range: (usize, usize),
}
#[derive(Debug, Clone)]
pub struct CostBreakdown {
pub total_tokens: u64,
pub productive_tokens: u64,
pub wasted_retry_tokens: u64,
pub wasted_read_tokens: u64,
pub wasted_undo_tokens: u64,
pub efficiency_pct: f64,
}
#[derive(Debug, Clone)]
pub struct QualityScore {
pub overall: u8,
pub ran_tests: bool,
pub tests_passed: Option<bool>,
pub ran_lint: bool,
pub edit_efficiency: u8,
}
#[derive(Debug, Clone)]
pub struct AutopsyReport {
pub session_id: String,
pub project: String,
pub model: String,
pub duration_secs: u64,
pub total_messages: usize,
pub total_tool_calls: u32,
pub total_errors: u32,
pub quality: QualityScore,
pub cost: CostBreakdown,
pub findings: Vec<AutopsyFinding>,
pub generated_at: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct ToolCall {
pub message_idx: usize,
pub tool_name: String,
pub input: serde_json::Value,
pub is_error: bool,
pub result_content: Option<String>,
pub tokens_at_call: u64,
}
#[derive(Debug)]
pub(crate) struct TranscriptWalker {
pub tool_calls: Vec<ToolCall>,
pub message_count: usize,
pub read_history: HashMap<String, Vec<(usize, u64)>>,
pub edit_history: HashMap<String, Vec<(usize, u64)>>,
pub token_curve: Vec<u64>,
pub edits_per_message: Vec<u32>,
pub total_tokens: u64,
pub model: String,
pub duration_secs: u64,
}
impl TranscriptWalker {
pub fn from_file(path: &Path) -> Result<Self, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
Self::from_lines(content.lines())
}
pub fn from_lines<'a>(lines: impl Iterator<Item = &'a str>) -> Result<Self, String> {
let mut tool_calls = Vec::new();
let mut read_history: HashMap<String, Vec<(usize, u64)>> = HashMap::new();
let mut edit_history: HashMap<String, Vec<(usize, u64)>> = HashMap::new();
let mut token_curve = Vec::new();
let mut edits_per_message = Vec::new();
let mut cumulative_tokens: u64 = 0;
let mut message_idx: usize = 0;
let mut model = String::new();
let mut pending_tools: Vec<(String, serde_json::Value, usize, u64)> = Vec::new();
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
let Some(event) = parse_line(line) else {
continue;
};
match event {
TranscriptEvent::WaitingForTask => {}
TranscriptEvent::Message(msg) => {
if let Some(ref usage) = msg.usage {
let delta = usage.input_tokens
+ usage.cache_read_input_tokens
+ usage.cache_creation_input_tokens
+ usage.output_tokens;
cumulative_tokens += delta;
}
token_curve.push(cumulative_tokens);
if let Some(ref m) = msg.model {
model = m.clone();
}
let mut msg_edits: u32 = 0;
for block in &msg.content {
match block {
TranscriptBlock::ToolUse { name, input } => {
pending_tools.push((
name.clone(),
input.clone(),
message_idx,
cumulative_tokens,
));
if matches!(name.as_str(), "Read" | "Grep" | "Glob") {
if let Some(fp) =
input.get("file_path").and_then(|v| v.as_str())
{
read_history
.entry(fp.to_string())
.or_default()
.push((message_idx, cumulative_tokens));
}
}
if matches!(name.as_str(), "Edit" | "Write" | "NotebookEdit") {
if let Some(fp) =
input.get("file_path").and_then(|v| v.as_str())
{
edit_history
.entry(fp.to_string())
.or_default()
.push((message_idx, cumulative_tokens));
}
msg_edits += 1;
}
}
TranscriptBlock::ToolResult { is_error, content } => {
if let Some((name, input, call_idx, tokens_at)) =
pending_tools.first().cloned()
{
pending_tools.remove(0);
tool_calls.push(ToolCall {
message_idx: call_idx,
tool_name: name,
input,
is_error: *is_error,
result_content: Some(content.clone()),
tokens_at_call: tokens_at,
});
}
}
TranscriptBlock::Text(_) => {}
}
}
edits_per_message.push(msg_edits);
message_idx += 1;
}
}
}
for (name, input, call_idx, tokens_at) in pending_tools {
tool_calls.push(ToolCall {
message_idx: call_idx,
tool_name: name,
input,
is_error: false,
result_content: None,
tokens_at_call: tokens_at,
});
}
Ok(Self {
tool_calls,
message_count: message_idx,
read_history,
edit_history,
token_curve,
edits_per_message,
total_tokens: cumulative_tokens,
model,
duration_secs: 0, })
}
}
pub(crate) fn detect_error_cascades(walker: &TranscriptWalker) -> Vec<AutopsyFinding> {
let mut findings = Vec::new();
let mut i = 0;
let calls = &walker.tool_calls;
while i < calls.len() {
if !calls[i].is_error {
i += 1;
continue;
}
let chain_start = i;
let tool_name = &calls[i].tool_name;
let mut chain_len = 1;
let tokens_start = calls[i].tokens_at_call;
while i + chain_len < calls.len()
&& calls[i + chain_len].is_error
&& calls[i + chain_len].tool_name == *tool_name
{
chain_len += 1;
}
if chain_len >= 2 {
let last = &calls[chain_start + chain_len - 1];
let tokens_end = last.tokens_at_call;
let wasted = tokens_end.saturating_sub(tokens_start);
let severity = if chain_len >= 5 {
FindingSeverity::Critical
} else if chain_len >= 3 {
FindingSeverity::Warning
} else {
FindingSeverity::Info
};
let first_error = calls[chain_start]
.result_content
.as_deref()
.unwrap_or("(unknown)");
let truncated = if first_error.len() > 120 {
format!("{}...", &first_error[..120])
} else {
first_error.to_string()
};
findings.push(AutopsyFinding {
category: FindingCategory::ErrorCascade,
severity,
summary: format!(
"[{tool_name}] {chain_len} consecutive errors starting at message {}",
calls[chain_start].message_idx
),
detail: Some(format!("First error: {truncated}")),
tokens_wasted: wasted,
message_range: (calls[chain_start].message_idx, last.message_idx),
});
}
i += chain_len;
}
findings
}
pub(crate) fn detect_wasted_reads(walker: &TranscriptWalker) -> Vec<AutopsyFinding> {
let mut findings = Vec::new();
for (file_path, reads) in &walker.read_history {
if reads.len() < 3 {
continue;
}
let edits = walker.edit_history.get(file_path.as_str());
let edit_indices: Vec<usize> = edits
.map(|e| e.iter().map(|(idx, _)| *idx).collect())
.unwrap_or_default();
let mut consecutive_reads = 0u32;
let mut wasted_tokens: u64 = 0;
let mut prev_read_idx: Option<usize> = None;
for &(read_idx, tokens) in reads {
let had_edit = if let Some(prev) = prev_read_idx {
edit_indices.iter().any(|&e| e > prev && e < read_idx)
} else {
true };
if had_edit {
consecutive_reads = 1;
} else {
consecutive_reads += 1;
if consecutive_reads >= 3 {
if let Some(prev) = prev_read_idx {
let prev_tokens = reads
.iter()
.find(|(idx, _)| *idx == prev)
.map(|(_, t)| *t)
.unwrap_or(0);
wasted_tokens += tokens.saturating_sub(prev_tokens);
}
}
}
prev_read_idx = Some(read_idx);
}
if consecutive_reads >= 3 {
let short_path = shorten_path(file_path);
findings.push(AutopsyFinding {
category: FindingCategory::WastedReads,
severity: if consecutive_reads >= 5 {
FindingSeverity::Warning
} else {
FindingSeverity::Info
},
summary: format!(
"{short_path} read {consecutive_reads} times without an intervening edit"
),
detail: None,
tokens_wasted: wasted_tokens,
message_range: (
reads.first().map(|(i, _)| *i).unwrap_or(0),
reads.last().map(|(i, _)| *i).unwrap_or(0),
),
});
}
}
findings
}
pub(crate) fn detect_undo_redo_loops(walker: &TranscriptWalker) -> Vec<AutopsyFinding> {
let mut findings = Vec::new();
for (file_path, edits) in &walker.edit_history {
if edits.len() < 3 {
continue;
}
let mut edit_error_edit_count = 0u32;
for window in edits.windows(2) {
let (idx_a, _) = window[0];
let (idx_b, _) = window[1];
let has_error_between = walker
.tool_calls
.iter()
.any(|tc| tc.message_idx > idx_a && tc.message_idx < idx_b && tc.is_error);
if has_error_between {
edit_error_edit_count += 1;
}
}
if edit_error_edit_count >= 2 {
let total_edit_tokens: u64 = if edits.len() >= 2 {
edits
.last()
.unwrap()
.1
.saturating_sub(edits.first().unwrap().1)
} else {
0
};
let short_path = shorten_path(file_path);
findings.push(AutopsyFinding {
category: FindingCategory::UndoRedoLoop,
severity: if edit_error_edit_count >= 4 {
FindingSeverity::Critical
} else {
FindingSeverity::Warning
},
summary: format!(
"{short_path}: {edit_error_edit_count} edit-error-edit cycles ({} total edits)",
edits.len()
),
detail: Some(
"Session repeatedly edited this file, hit errors, and re-edited".to_string(),
),
tokens_wasted: total_edit_tokens / 2, message_range: (
edits.first().map(|(i, _)| *i).unwrap_or(0),
edits.last().map(|(i, _)| *i).unwrap_or(0),
),
});
}
}
findings
}
pub(crate) fn detect_context_bloat(walker: &TranscriptWalker) -> Vec<AutopsyFinding> {
let mut findings = Vec::new();
let window_size = 8;
if walker.token_curve.len() < window_size || walker.edits_per_message.len() < window_size {
return findings;
}
let total = walker.total_tokens.max(1) as f64;
let mut i = 0;
while i + window_size <= walker.token_curve.len() {
let tokens_start = if i == 0 { 0 } else { walker.token_curve[i - 1] };
let tokens_end = walker.token_curve[i + window_size - 1];
let window_tokens = tokens_end.saturating_sub(tokens_start);
let window_pct = (window_tokens as f64 / total) * 100.0;
let edits_in_window: u32 = walker.edits_per_message[i..i + window_size].iter().sum();
if window_pct > 15.0 && edits_in_window == 0 {
findings.push(AutopsyFinding {
category: FindingCategory::ContextBloat,
severity: if window_pct > 30.0 {
FindingSeverity::Critical
} else {
FindingSeverity::Warning
},
summary: format!(
"Messages {}-{}: {window_pct:.0}% of tokens consumed with zero file edits",
i,
i + window_size - 1,
),
detail: Some(format!("{window_tokens} tokens spent without progress")),
tokens_wasted: window_tokens,
message_range: (i, i + window_size - 1),
});
i += window_size;
} else {
i += 1;
}
}
findings
}
pub(crate) fn compute_cost_breakdown(walker: &TranscriptWalker) -> CostBreakdown {
let mut productive: u64 = 0;
let mut wasted_retry: u64 = 0;
let calls = &walker.tool_calls;
for (i, call) in calls.iter().enumerate() {
let next_tokens = if i + 1 < calls.len() {
calls[i + 1].tokens_at_call
} else {
walker.total_tokens
};
let call_tokens = next_tokens.saturating_sub(call.tokens_at_call);
if call.is_error {
wasted_retry += call_tokens;
} else {
productive += call_tokens;
}
}
let mut wasted_reads: u64 = 0;
for (file_path, reads) in &walker.read_history {
if !walker.edit_history.contains_key(file_path) && reads.len() >= 2 {
for &(_, tokens) in reads.iter().skip(1) {
wasted_reads += 500; let _ = tokens; }
}
}
let mut wasted_undo: u64 = 0;
for edits in walker.edit_history.values() {
if edits.len() >= 4 {
let span = edits
.last()
.unwrap()
.1
.saturating_sub(edits.first().unwrap().1);
wasted_undo += span / 4;
}
}
let total = walker.total_tokens.max(1);
let efficiency = if total > 0 {
(productive as f64 / total as f64) * 100.0
} else {
100.0
};
CostBreakdown {
total_tokens: walker.total_tokens,
productive_tokens: productive,
wasted_retry_tokens: wasted_retry,
wasted_read_tokens: wasted_reads,
wasted_undo_tokens: wasted_undo,
efficiency_pct: efficiency.clamp(0.0, 100.0),
}
}
pub(crate) fn compute_quality_score(walker: &TranscriptWalker) -> QualityScore {
let mut ran_tests = false;
let mut tests_passed: Option<bool> = None;
let mut ran_lint = false;
for call in &walker.tool_calls {
if call.tool_name != "Bash" {
continue;
}
let cmd = call
.input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
let is_test = cmd.contains("cargo test")
|| cmd.contains("npm test")
|| cmd.contains("pytest")
|| cmd.contains("jest")
|| cmd.contains("go test")
|| cmd.contains("make test");
let is_lint = cmd.contains("cargo clippy")
|| cmd.contains("cargo fmt")
|| cmd.contains("eslint")
|| cmd.contains("prettier")
|| cmd.contains("ruff")
|| cmd.contains("black");
if is_test {
ran_tests = true;
tests_passed = Some(!call.is_error);
}
if is_lint {
ran_lint = true;
}
}
let total_edits: usize = walker.edit_history.values().map(|v| v.len()).sum();
let unique_files = walker.edit_history.len();
let edit_efficiency = if total_edits > 0 {
((unique_files as f64 / total_edits as f64) * 100.0).clamp(0.0, 100.0) as u8
} else {
100
};
let mut score: u8 = 50;
if ran_tests {
score += 15;
}
if tests_passed == Some(true) {
score += 15;
}
if tests_passed == Some(false) {
score = score.saturating_sub(20);
}
if ran_lint {
score += 10;
}
if edit_efficiency > 70 {
score += 10;
}
let error_count = walker.tool_calls.iter().filter(|c| c.is_error).count();
let total_calls = walker.tool_calls.len().max(1);
let error_rate = error_count as f64 / total_calls as f64;
if error_rate > 0.3 {
score = score.saturating_sub(15);
}
if error_rate > 0.5 {
score = score.saturating_sub(10);
}
QualityScore {
overall: score.min(100),
ran_tests,
tests_passed,
ran_lint,
edit_efficiency,
}
}
pub fn run_autopsy(jsonl_path: &Path) -> Result<AutopsyReport, String> {
let walker = TranscriptWalker::from_file(jsonl_path)?;
let mut findings = Vec::new();
findings.extend(detect_error_cascades(&walker));
findings.extend(detect_wasted_reads(&walker));
findings.extend(detect_undo_redo_loops(&walker));
findings.extend(detect_context_bloat(&walker));
findings.sort_by_key(|f| std::cmp::Reverse(f.severity));
let cost = compute_cost_breakdown(&walker);
let quality = compute_quality_score(&walker);
let total_errors = walker.tool_calls.iter().filter(|c| c.is_error).count() as u32;
let session_id = jsonl_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let generated_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Ok(AutopsyReport {
session_id,
project: String::new(), model: walker.model,
duration_secs: walker.duration_secs,
total_messages: walker.message_count,
total_tool_calls: walker.tool_calls.len() as u32,
total_errors,
quality,
cost,
findings,
generated_at,
})
}
pub fn format_report(report: &AutopsyReport) -> String {
let mut out = Vec::new();
out.push(format!("Session Autopsy: {}", report.session_id));
out.push("\u{2500}".repeat(50));
out.push(String::new());
out.push(format!(
" Messages: {} | Tool calls: {} | Errors: {}",
report.total_messages, report.total_tool_calls, report.total_errors
));
if !report.model.is_empty() {
out.push(format!(" Model: {}", report.model));
}
out.push(String::new());
let q = &report.quality;
let test_status = match q.tests_passed {
Some(true) => "passed",
Some(false) => "FAILED",
None => "not run",
};
out.push(format!(" Quality Score: {}/100", q.overall));
out.push(format!(
" Tests: {} | Lint: {} | Edit efficiency: {}%",
test_status,
if q.ran_lint { "ran" } else { "not run" },
q.edit_efficiency,
));
out.push(String::new());
let c = &report.cost;
out.push(format!(" Cost Efficiency: {:.0}%", c.efficiency_pct));
out.push(format!(
" Total tokens: {} | Productive: {}",
format_tokens(c.total_tokens),
format_tokens(c.productive_tokens),
));
if c.wasted_retry_tokens > 0 {
out.push(format!(
" Wasted on retries: {}",
format_tokens(c.wasted_retry_tokens)
));
}
if c.wasted_read_tokens > 0 {
out.push(format!(
" Wasted on redundant reads: {}",
format_tokens(c.wasted_read_tokens)
));
}
if c.wasted_undo_tokens > 0 {
out.push(format!(
" Wasted on undo-redo: {}",
format_tokens(c.wasted_undo_tokens)
));
}
out.push(String::new());
if report.findings.is_empty() {
out.push(" No issues detected.".to_string());
} else {
out.push(format!(" Findings ({})", report.findings.len()));
out.push(" \u{2500}".to_string() + &"\u{2500}".repeat(40));
for (i, f) in report.findings.iter().enumerate() {
let severity_marker = match f.severity {
FindingSeverity::Critical => "!!",
FindingSeverity::Warning => " !",
FindingSeverity::Info => " ",
};
out.push(format!(
" {severity_marker} {}. [{}] {}",
i + 1,
f.category.label(),
f.summary
));
if let Some(ref detail) = f.detail {
out.push(format!(" {detail}"));
}
if f.tokens_wasted > 0 {
out.push(format!(
" ~{} tokens wasted",
format_tokens(f.tokens_wasted)
));
}
}
}
out.push(String::new());
out.join("\n")
}
pub fn report_to_json(report: &AutopsyReport) -> serde_json::Value {
serde_json::json!({
"session_id": report.session_id,
"project": report.project,
"model": report.model,
"duration_secs": report.duration_secs,
"total_messages": report.total_messages,
"total_tool_calls": report.total_tool_calls,
"total_errors": report.total_errors,
"quality": {
"overall": report.quality.overall,
"ran_tests": report.quality.ran_tests,
"tests_passed": report.quality.tests_passed,
"ran_lint": report.quality.ran_lint,
"edit_efficiency": report.quality.edit_efficiency,
},
"cost": {
"total_tokens": report.cost.total_tokens,
"productive_tokens": report.cost.productive_tokens,
"wasted_retry_tokens": report.cost.wasted_retry_tokens,
"wasted_read_tokens": report.cost.wasted_read_tokens,
"wasted_undo_tokens": report.cost.wasted_undo_tokens,
"efficiency_pct": report.cost.efficiency_pct,
},
"findings": report.findings.iter().map(|f| {
serde_json::json!({
"category": f.category.label(),
"severity": f.severity.label(),
"summary": f.summary,
"detail": f.detail,
"tokens_wasted": f.tokens_wasted,
"message_range": [f.message_range.0, f.message_range.1],
})
}).collect::<Vec<_>>(),
"generated_at": report.generated_at,
})
}
fn autopsies_dir() -> PathBuf {
super::decisions::decisions_dir().join("autopsies")
}
pub fn save_report(report: &AutopsyReport) -> Result<PathBuf, String> {
let dir = autopsies_dir();
let _ = fs::create_dir_all(&dir);
let path = dir.join(format!("{}.json", report.session_id));
let json = serde_json::to_string_pretty(&report_to_json(report)).map_err(|e| format!("{e}"))?;
fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
fn format_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn shorten_path(path: &str) -> &str {
let parts: Vec<&str> = path.rsplitn(3, '/').collect();
if parts.len() >= 2 {
let start = path.len() - parts[0].len() - 1 - parts[1].len();
&path[start..]
} else {
path
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_assistant_with_tool_use(tool: &str, input_json: &str) -> String {
format!(
r#"{{"type":"assistant","message":{{"role":"assistant","model":"claude-sonnet-4-6-20260401","stop_reason":"tool_use","usage":{{"input_tokens":1000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":200}},"content":[{{"type":"tool_use","name":"{tool}","input":{input_json}}}]}}}}"#
)
}
fn make_user_with_result(is_error: bool, content: &str) -> String {
format!(
r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","is_error":{is_error},"content":"{content}"}}]}}}}"#
)
}
fn make_user_with_result_and_tool(
is_error: bool,
content: &str,
tool: &str,
input_json: &str,
) -> String {
let _ = (tool, input_json);
make_user_with_result(is_error, content)
}
#[test]
fn walker_parses_basic_transcript() {
let lines = [
make_assistant_with_tool_use("Bash", r#"{"command":"ls"}"#),
make_user_with_result(false, "file1.rs file2.rs"),
make_assistant_with_tool_use("Read", r#"{"file_path":"/src/main.rs"}"#),
make_user_with_result(false, "fn main() {}"),
];
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
assert_eq!(walker.message_count, 4);
assert_eq!(walker.tool_calls.len(), 2);
assert!(!walker.tool_calls[0].is_error);
assert_eq!(walker.tool_calls[0].tool_name, "Bash");
assert_eq!(walker.tool_calls[1].tool_name, "Read");
assert!(walker.read_history.contains_key("/src/main.rs"));
}
#[test]
fn detect_error_cascade_basic() {
let lines = [
make_assistant_with_tool_use("Bash", r#"{"command":"cargo build"}"#),
make_user_with_result(true, "error: compilation failed"),
make_assistant_with_tool_use("Bash", r#"{"command":"cargo build"}"#),
make_user_with_result(true, "error: compilation failed"),
make_assistant_with_tool_use("Bash", r#"{"command":"cargo build"}"#),
make_user_with_result(true, "error: compilation failed"),
];
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let findings = detect_error_cascades(&walker);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].category, FindingCategory::ErrorCascade);
assert!(findings[0].summary.contains("3 consecutive errors"));
}
#[test]
fn no_cascade_on_single_error() {
let lines = [
make_assistant_with_tool_use("Bash", r#"{"command":"cargo build"}"#),
make_user_with_result(true, "error"),
make_assistant_with_tool_use("Bash", r#"{"command":"cargo test"}"#),
make_user_with_result(false, "ok"),
];
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let findings = detect_error_cascades(&walker);
assert!(findings.is_empty());
}
#[test]
fn detect_wasted_reads_basic() {
let mut lines = Vec::new();
for _ in 0..4 {
lines.push(make_assistant_with_tool_use(
"Read",
r#"{"file_path":"/src/lib.rs"}"#,
));
lines.push(make_user_with_result(false, "contents"));
}
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let findings = detect_wasted_reads(&walker);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].category, FindingCategory::WastedReads);
assert!(findings[0].summary.contains("read"));
}
#[test]
fn reads_before_edit_not_flagged() {
let lines = [
make_assistant_with_tool_use("Read", r#"{"file_path":"/src/lib.rs"}"#),
make_user_with_result(false, "old content"),
make_assistant_with_tool_use(
"Edit",
r#"{"file_path":"/src/lib.rs","old_string":"old","new_string":"new"}"#,
),
make_user_with_result(false, "ok"),
make_assistant_with_tool_use("Read", r#"{"file_path":"/src/lib.rs"}"#),
make_user_with_result(false, "new content"),
];
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let findings = detect_wasted_reads(&walker);
assert!(findings.is_empty());
}
#[test]
fn detect_undo_redo_basic() {
let lines = [
make_assistant_with_tool_use(
"Edit",
r#"{"file_path":"/src/main.rs","old_string":"a","new_string":"b"}"#,
),
make_user_with_result(false, "ok"),
make_assistant_with_tool_use("Bash", r#"{"command":"cargo build"}"#),
make_user_with_result(true, "compilation error"),
make_assistant_with_tool_use(
"Edit",
r#"{"file_path":"/src/main.rs","old_string":"b","new_string":"c"}"#,
),
make_user_with_result(false, "ok"),
make_assistant_with_tool_use("Bash", r#"{"command":"cargo build"}"#),
make_user_with_result(true, "another error"),
make_assistant_with_tool_use(
"Edit",
r#"{"file_path":"/src/main.rs","old_string":"c","new_string":"d"}"#,
),
make_user_with_result(false, "ok"),
];
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let findings = detect_undo_redo_loops(&walker);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].category, FindingCategory::UndoRedoLoop);
}
#[test]
fn cost_efficiency_all_success() {
let lines = [
make_assistant_with_tool_use("Bash", r#"{"command":"ls"}"#),
make_user_with_result(false, "ok"),
make_assistant_with_tool_use(
"Edit",
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y"}"#,
),
make_user_with_result(false, "ok"),
];
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let breakdown = compute_cost_breakdown(&walker);
assert_eq!(breakdown.wasted_retry_tokens, 0);
assert!(breakdown.productive_tokens > 0);
}
#[test]
fn cost_efficiency_half_errors() {
let lines = [
make_assistant_with_tool_use("Bash", r#"{"command":"cargo build"}"#),
make_user_with_result(true, "error"),
make_assistant_with_tool_use("Bash", r#"{"command":"cargo build"}"#),
make_user_with_result(false, "ok"),
];
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let breakdown = compute_cost_breakdown(&walker);
assert!(breakdown.wasted_retry_tokens > 0);
assert!(breakdown.efficiency_pct < 100.0);
}
#[test]
fn quality_score_with_passing_tests() {
let lines = [
make_assistant_with_tool_use("Bash", r#"{"command":"cargo test"}"#),
make_user_with_result(false, "test result: ok. 10 passed"),
make_assistant_with_tool_use("Bash", r#"{"command":"cargo clippy"}"#),
make_user_with_result(false, "no warnings"),
];
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let quality = compute_quality_score(&walker);
assert!(quality.ran_tests);
assert_eq!(quality.tests_passed, Some(true));
assert!(quality.ran_lint);
assert!(quality.overall >= 75);
}
#[test]
fn quality_score_no_tests() {
let lines = [
make_assistant_with_tool_use(
"Edit",
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y"}"#,
),
make_user_with_result(false, "ok"),
];
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let quality = compute_quality_score(&walker);
assert!(!quality.ran_tests);
assert_eq!(quality.tests_passed, None);
assert!(!quality.ran_lint);
}
#[test]
fn format_report_contains_sections() {
let report = AutopsyReport {
session_id: "test-session".into(),
project: "test-proj".into(),
model: "sonnet".into(),
duration_secs: 120,
total_messages: 10,
total_tool_calls: 5,
total_errors: 2,
quality: QualityScore {
overall: 65,
ran_tests: true,
tests_passed: Some(true),
ran_lint: false,
edit_efficiency: 80,
},
cost: CostBreakdown {
total_tokens: 50000,
productive_tokens: 35000,
wasted_retry_tokens: 10000,
wasted_read_tokens: 3000,
wasted_undo_tokens: 2000,
efficiency_pct: 70.0,
},
findings: vec![AutopsyFinding {
category: FindingCategory::ErrorCascade,
severity: FindingSeverity::Warning,
summary: "3 consecutive Bash errors".into(),
detail: Some("first error: compilation failed".into()),
tokens_wasted: 5000,
message_range: (2, 7),
}],
generated_at: 0,
};
let output = format_report(&report);
assert!(output.contains("Session Autopsy: test-session"));
assert!(output.contains("Quality Score: 65/100"));
assert!(output.contains("Cost Efficiency: 70%"));
assert!(output.contains("Error Cascade"));
assert!(output.contains("3 consecutive Bash errors"));
}
#[test]
fn report_to_json_roundtrip() {
let report = AutopsyReport {
session_id: "test".into(),
project: "proj".into(),
model: "sonnet".into(),
duration_secs: 60,
total_messages: 5,
total_tool_calls: 3,
total_errors: 1,
quality: QualityScore {
overall: 50,
ran_tests: false,
tests_passed: None,
ran_lint: false,
edit_efficiency: 100,
},
cost: CostBreakdown {
total_tokens: 10000,
productive_tokens: 8000,
wasted_retry_tokens: 2000,
wasted_read_tokens: 0,
wasted_undo_tokens: 0,
efficiency_pct: 80.0,
},
findings: vec![],
generated_at: 1234567890,
};
let json = report_to_json(&report);
assert_eq!(json["session_id"].as_str().unwrap(), "test");
assert_eq!(json["total_messages"].as_u64().unwrap(), 5);
assert_eq!(json["quality"]["overall"].as_u64().unwrap(), 50);
assert_eq!(json["cost"]["efficiency_pct"].as_f64().unwrap(), 80.0);
assert!(json["findings"].as_array().unwrap().is_empty());
}
#[test]
fn context_bloat_detected() {
let mut lines = Vec::new();
for _ in 0..12 {
lines.push(make_assistant_with_tool_use(
"Read",
r#"{"file_path":"/a.rs"}"#,
));
lines.push(make_user_with_result(false, "contents"));
}
let joined = lines.join("\n");
let walker = TranscriptWalker::from_lines(joined.lines()).unwrap();
let findings = detect_context_bloat(&walker);
assert!(
!findings.is_empty(),
"Expected context bloat finding for 12 read-only messages"
);
}
#[test]
fn shorten_path_works() {
assert_eq!(
shorten_path("/Users/foo/project/src/main.rs"),
"src/main.rs"
);
assert_eq!(shorten_path("main.rs"), "main.rs");
}
#[test]
fn walker_handles_empty_input() {
let walker = TranscriptWalker::from_lines(std::iter::empty()).unwrap();
assert_eq!(walker.message_count, 0);
assert!(walker.tool_calls.is_empty());
}
}