From 5afec71a09305ac14d66fe9ac860636a94ad7806 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 07:33:57 -0400 Subject: [PATCH 01/22] refactor(sdk/analyze): consolidate percentile + hotspots_action helpers Promote the byte-identical f64 percentile in claude_md/subagent_tree to a single generic util::percentile. The u64 variant in tool_output_bloat stays local (it scales p over 0..=100 and sorts internally). Promote findings::hotspots_action to pub(crate) and drop the duplicate copy in tool_call_patterns, matching how severity_from_usd is already shared. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/claude_md.rs | 14 +------------ crates/relayburn-sdk/src/analyze/findings.rs | 2 +- .../src/analyze/subagent_tree.rs | 13 +----------- .../src/analyze/tool_call_patterns.rs | 12 +++-------- crates/relayburn-sdk/src/analyze/util.rs | 21 +++++++++++++++++++ 5 files changed, 27 insertions(+), 35 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/claude_md.rs b/crates/relayburn-sdk/src/analyze/claude_md.rs index aa3169c..3ed1003 100644 --- a/crates/relayburn-sdk/src/analyze/claude_md.rs +++ b/crates/relayburn-sdk/src/analyze/claude_md.rs @@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize}; use crate::analyze::cost::{lookup_model_rate, PER_MILLION}; use crate::analyze::pricing::PricingTable; -use crate::analyze::util::{group_turns_by_session, tokens_from_bytes}; +use crate::analyze::util::{group_turns_by_session, percentile, tokens_from_bytes}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -417,18 +417,6 @@ fn pick_dominant_model(counts: &IndexMap) -> String { best_model } -fn percentile(sorted: &[f64], p: f64) -> f64 { - if sorted.is_empty() { - return 0.0; - } - if sorted.len() == 1 { - return sorted[0]; - } - let raw = (p * sorted.len() as f64).ceil() as i64 - 1; - let idx = raw.clamp(0, sorted.len() as i64 - 1) as usize; - sorted[idx] -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TrimRecommendation { diff --git a/crates/relayburn-sdk/src/analyze/findings.rs b/crates/relayburn-sdk/src/analyze/findings.rs index 764711e..a8192ee 100644 --- a/crates/relayburn-sdk/src/analyze/findings.rs +++ b/crates/relayburn-sdk/src/analyze/findings.rs @@ -307,7 +307,7 @@ pub(crate) fn severity_from_usd(usd: f64) -> WasteSeverity { } } -fn hotspots_action(session_id: &str) -> WasteAction { +pub(crate) fn hotspots_action(session_id: &str) -> WasteAction { WasteAction::Command { label: "Inspect this session".to_string(), text: format!("burn hotspots --session {session_id}"), diff --git a/crates/relayburn-sdk/src/analyze/subagent_tree.rs b/crates/relayburn-sdk/src/analyze/subagent_tree.rs index 6465605..926c0cf 100644 --- a/crates/relayburn-sdk/src/analyze/subagent_tree.rs +++ b/crates/relayburn-sdk/src/analyze/subagent_tree.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::analyze::cost::cost_for_turn; use crate::analyze::pricing::PricingTable; -use crate::analyze::util::group_turns_by_session; +use crate::analyze::util::{group_turns_by_session, percentile}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -823,17 +823,6 @@ pub fn aggregate_subagent_type_stats( out } -fn percentile(sorted: &[f64], p: f64) -> f64 { - if sorted.is_empty() { - return 0.0; - } - let len = sorted.len(); - // Nearest-rank with clamp. - let raw = (p * len as f64).ceil() as i64 - 1; - let rank = raw.clamp(0, len as i64 - 1) as usize; - sorted[rank] -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs b/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs index 508fae6..845c129 100644 --- a/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs +++ b/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs @@ -16,7 +16,7 @@ use phf::phf_set; use serde::{Deserialize, Serialize}; use crate::analyze::cost::lookup_model_rate; -use crate::analyze::findings::{severity_from_usd, EstimatedSavings, WasteAction, WasteFinding}; +use crate::analyze::findings::{severity_from_usd, EstimatedSavings, WasteFinding}; use crate::analyze::pricing::PricingTable; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -448,13 +448,7 @@ only the PR fields the agent reads.", } } -fn hotspots_action(session_id: &str) -> WasteAction { - WasteAction::Command { - label: "Inspect this session".to_string(), - text: format!("burn hotspots --session {session_id}"), - } -} - +use super::findings::hotspots_action; use super::util::{fmt_usd, format_with_commas, group_turns_by_session}; pub fn tool_call_pattern_to_finding(finding: &ToolCallPatternFinding) -> WasteFinding { @@ -511,7 +505,7 @@ Estimated overhead: {tokens} tokens ({usd} at this session's input rate).{eviden #[cfg(test)] mod tests { use super::*; - use crate::analyze::findings::WasteSeverity; + use crate::analyze::findings::{WasteAction, WasteSeverity}; use crate::analyze::pricing::load_builtin_pricing; use crate::reader::{SourceKind, Usage}; diff --git a/crates/relayburn-sdk/src/analyze/util.rs b/crates/relayburn-sdk/src/analyze/util.rs index 3cde7fe..78b770e 100644 --- a/crates/relayburn-sdk/src/analyze/util.rs +++ b/crates/relayburn-sdk/src/analyze/util.rs @@ -69,6 +69,27 @@ pub(crate) fn bytes_from_tokens(tokens: u64) -> u64 { tokens * APPROX_BYTES_PER_TOKEN } +/// Nearest-rank percentile over an already-sorted slice, with `p` expressed as +/// a fraction in `[0, 1]` (e.g. `0.95` for p95). Empty input yields the type's +/// default (`0` / `0.0`); a single element is returned as-is. The index is +/// `ceil(p * len) - 1`, clamped into range — the rank convention the TS analyze +/// port uses for per-session cost percentiles. +/// +/// Callers must pass a sorted slice. Note this differs from the byte-oriented +/// percentile in `tool_output_bloat`, which sorts internally and scales `p` +/// over `0..=100`. +pub(crate) fn percentile(sorted: &[T], p: f64) -> T { + if sorted.is_empty() { + return T::default(); + } + if sorted.len() == 1 { + return sorted[0]; + } + let raw = (p * sorted.len() as f64).ceil() as i64 - 1; + let idx = raw.clamp(0, sorted.len() as i64 - 1) as usize; + sorted[idx] +} + /// Format an integer with thousands separators, matching JS /// `Number.prototype.toLocaleString()` output for the en-US locale. pub(crate) fn format_with_commas(n: u64) -> String { From 736eb1cf2861993beb1ef744a02fad4332954802 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 07:40:20 -0400 Subject: [PATCH 02/22] refactor(sdk/analyze): add cost::total_cost_for_turn + sum_turn_costs helpers Two scalar-cost helpers in the cost module replace the repeated `cost_for_turn(...).map(|c| c.total).unwrap_or(0.0)` idiom and the hand-rolled per-turn sum loops: - total_cost_for_turn(turn, pricing) -> f64 - sum_turn_costs(turns, pricing) -> f64 (iteration-order accumulation) Routed patterns.rs (sum_cost_for_turns now delegates; three idiom/loop sites) and subagent_tree.rs (three idiom sites). compare/provider keep cost_for_turn (they need the full breakdown and priced-turn gating); claude_md sums session totals, not per-turn costs, so it is untouched. Adding $0 for unpriced turns is bit-identical to the prior skip (x + 0.0 == x), so the cost-precision fixtures stay green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/cost.rs | 21 ++++++++++++++++ crates/relayburn-sdk/src/analyze/patterns.rs | 24 ++++++------------- .../src/analyze/subagent_tree.rs | 10 ++++---- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/cost.rs b/crates/relayburn-sdk/src/analyze/cost.rs index 280a97a..5ff2dd7 100644 --- a/crates/relayburn-sdk/src/analyze/cost.rs +++ b/crates/relayburn-sdk/src/analyze/cost.rs @@ -78,6 +78,27 @@ pub fn cost_for_turn(turn: &TurnRecord, pricing: &PricingTable) -> Option f64 { + cost_for_turn(turn, pricing).map_or(0.0, |c| c.total) +} + +/// Sum [`total_cost_for_turn`] over a set of turns; unpriced turns contribute +/// `$0`. Accumulates in iteration order so callers that care about +/// floating-point reproducibility get the same result as the equivalent loop. +pub fn sum_turn_costs<'a, I>(turns: I, pricing: &PricingTable) -> f64 +where + I: IntoIterator, +{ + let mut sum = 0.0; + for t in turns { + sum += total_cost_for_turn(t, pricing); + } + sum +} + fn reasoning_cost(reasoning_tokens: u64, rate: &ModelCost, mode: ReasoningMode) -> f64 { match mode { // Already billed inside `usage.output` — informational only. diff --git a/crates/relayburn-sdk/src/analyze/patterns.rs b/crates/relayburn-sdk/src/analyze/patterns.rs index 44e8d9c..a807b7e 100644 --- a/crates/relayburn-sdk/src/analyze/patterns.rs +++ b/crates/relayburn-sdk/src/analyze/patterns.rs @@ -22,7 +22,9 @@ use crate::reader::{ }; use serde_json::Value; -use crate::analyze::cost::{cost_for_turn, cost_for_usage, CostForUsageOptions}; +use crate::analyze::cost::{ + cost_for_usage, sum_turn_costs, total_cost_for_turn, CostForUsageOptions, +}; use crate::analyze::findings::{ CancellationRun, CompactionLoss, CompactionLostWork, EditHeavySession, EditPreview, EditRevertCycle, EditRevertSamplePreview, FailureRun, FailureRunErrorSignature, @@ -588,13 +590,7 @@ fn extract_edit_preview(input: Option<&BTreeMap>) -> Option f64 { - let mut sum = 0.0; - for t in turns { - if let Some(c) = cost_for_turn(t, pricing) { - sum += c.total; - } - } - sum + sum_turn_costs(turns.iter().copied(), pricing) } // --------------------------------------------------------------------------- @@ -1267,17 +1263,13 @@ pub(crate) fn detect_skill_pruning_protection_for_session( if t.usage.cache_read > 0 { riding_turns += 1; last_cached_turn_index = t.turn_index; - if let Some(c) = cost_for_turn(t, pricing) { - riding_cost += c.total; - } + riding_cost += total_cost_for_turn(t, pricing); } } if riding_turns == 0 { continue; } - let invoke_cost = cost_for_turn(r.turn, pricing) - .map(|c| c.total) - .unwrap_or(0.0); + let invoke_cost = total_cost_for_turn(r.turn, pricing); out.push(SkillPruningProtection { session_id: session_id.to_string(), skill_name, @@ -1330,9 +1322,7 @@ pub(crate) fn detect_system_prompt_tax_for_session( } if t.usage.cache_read > 0 { riding_turns += 1; - if let Some(c) = cost_for_turn(t, pricing) { - total_cost += c.total; - } + total_cost += total_cost_for_turn(t, pricing); } } if riding_turns == 0 { diff --git a/crates/relayburn-sdk/src/analyze/subagent_tree.rs b/crates/relayburn-sdk/src/analyze/subagent_tree.rs index 926c0cf..1bfe3f3 100644 --- a/crates/relayburn-sdk/src/analyze/subagent_tree.rs +++ b/crates/relayburn-sdk/src/analyze/subagent_tree.rs @@ -11,7 +11,7 @@ use crate::reader::{RelationshipType, SessionRelationshipRecord, TurnRecord}; use indexmap::{IndexMap, IndexSet}; use serde::{Deserialize, Serialize}; -use crate::analyze::cost::cost_for_turn; +use crate::analyze::cost::total_cost_for_turn; use crate::analyze::pricing::PricingTable; use crate::analyze::util::{group_turns_by_session, percentile}; @@ -149,7 +149,7 @@ fn build_session_tree( let mut unresolved_created = false; for t in turns { - let cost = cost_for_turn(t, pricing).map(|c| c.total).unwrap_or(0.0); + let cost = total_cost_for_turn(t, pricing); let Some(sub) = &t.subagent else { let node = nodes.get_mut(session_id).unwrap(); node.self_turns += 1; @@ -506,7 +506,7 @@ fn collect_attached_child_ids(state: &GraphState) -> IndexSet { fn attach_turn_costs(state: &mut GraphState, turns: &[TurnRecord], pricing: &PricingTable) { let mut unresolved_by_parent: IndexMap = IndexMap::new(); for t in turns { - let cost = cost_for_turn(t, pricing).map(|c| c.total).unwrap_or(0.0); + let cost = total_cost_for_turn(t, pricing); let sub = t.subagent.as_ref(); if let Some(s) = sub { if s.agent_id.is_none() { @@ -784,9 +784,7 @@ pub fn aggregate_subagent_type_stats( inv.ty = ty; } inv.turns += 1; - inv.cost += cost_for_turn(t, opts.pricing) - .map(|c| c.total) - .unwrap_or(0.0); + inv.cost += total_cost_for_turn(t, opts.pricing); } let mut by_type: IndexMap> = IndexMap::new(); let mut totals_by_type: IndexMap = IndexMap::new(); From 9c2c2e33deba6f0660ad53c164293af300e5a9df Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 07:49:11 -0400 Subject: [PATCH 03/22] refactor(sdk/analyze): add WasteFinding::session_cost builder Collapse the repeated cost-driven finding scaffold (severity_from_usd(cost), session_id clone, usd_per_session savings, hotspots_action, event_source None) into a WasteFinding::session_cost(kind, session_id, cost, title, detail) constructor plus with_severity / with_event_source / with_tokens_per_session chainers. Routes the nine in-file pattern adapters and tool_call_pattern_to_finding through it (net -41 lines). ghost_surface and tool_output_bloat keep struct literals: their bespoke Paste actions and fixed/decoupled severity would mean overriding every default. Output is byte-identical (all 27 finding tests pass, including the compaction zero-tokens guard and the edit-heavy severity cap). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/findings.rs | 404 ++++++++---------- .../src/analyze/tool_call_patterns.rs | 51 +-- 2 files changed, 207 insertions(+), 248 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/findings.rs b/crates/relayburn-sdk/src/analyze/findings.rs index a8192ee..10c290e 100644 --- a/crates/relayburn-sdk/src/analyze/findings.rs +++ b/crates/relayburn-sdk/src/analyze/findings.rs @@ -314,6 +314,54 @@ pub(crate) fn hotspots_action(session_id: &str) -> WasteAction { } } +impl WasteFinding { + /// Build a cost-driven, session-scoped finding: severity derived from + /// `cost` via [`severity_from_usd`], a `usd_per_session` saving of `cost`, + /// and the standard "inspect this session" hotspots action. Adapters that + /// match this shape construct here and then override `severity`, + /// `event_source`, or extra savings via the `with_*` chainers below. + /// + /// Detectors with bespoke actions or a severity that is decoupled from the + /// savings figure (ghost surface, tool-output bloat) build the struct + /// directly rather than overriding every default. + pub(crate) fn session_cost( + kind: &str, + session_id: &str, + cost: f64, + title: String, + detail: String, + ) -> Self { + WasteFinding { + kind: kind.to_string(), + severity: severity_from_usd(cost), + session_id: session_id.to_string(), + title, + detail, + estimated_savings: EstimatedSavings { + usd_per_session: Some(cost), + ..Default::default() + }, + actions: vec![hotspots_action(session_id)], + event_source: None, + } + } + + pub(crate) fn with_severity(mut self, severity: WasteSeverity) -> Self { + self.severity = severity; + self + } + + pub(crate) fn with_event_source(mut self, event_source: Option) -> Self { + self.event_source = event_source; + self + } + + pub(crate) fn with_tokens_per_session(mut self, tokens: u64) -> Self { + self.estimated_savings.tokens_per_session = Some(tokens); + self + } +} + use super::util::{fmt_usd, format_with_commas}; pub fn retry_loop_to_finding(loop_: &RetryLoop) -> WasteFinding { @@ -325,33 +373,24 @@ pub fn retry_loop_to_finding(loop_: &RetryLoop) -> WasteFinding { Some(sig) => format!(": '{sig}'"), None => String::new(), }; - WasteFinding { - kind: "retry-loop".to_string(), - severity: severity_from_usd(loop_.cost), - session_id: loop_.session_id.clone(), - title: format!( - "Retry loop: {tool}{target} failed {attempts}× in a row{title_suffix}", - tool = loop_.tool, - target = target, - attempts = loop_.attempts, - title_suffix = title_suffix, - ), - detail: format!( - "Turns {start}-{end} are {attempts} consecutive errored {tool} calls with the same arguments. \ + let title = format!( + "Retry loop: {tool}{target} failed {attempts}× in a row{title_suffix}", + tool = loop_.tool, + target = target, + attempts = loop_.attempts, + title_suffix = title_suffix, + ); + let detail = format!( + "Turns {start}-{end} are {attempts} consecutive errored {tool} calls with the same arguments. \ Cumulative turn cost {cost} — the agent kept retrying without changing inputs.", - start = loop_.start_turn_index, - end = loop_.end_turn_index, - attempts = loop_.attempts, - tool = loop_.tool, - cost = fmt_usd(loop_.cost), - ), - estimated_savings: EstimatedSavings { - usd_per_session: Some(loop_.cost), - ..Default::default() - }, - actions: vec![hotspots_action(&loop_.session_id)], - event_source: loop_.event_source, - } + start = loop_.start_turn_index, + end = loop_.end_turn_index, + attempts = loop_.attempts, + tool = loop_.tool, + cost = fmt_usd(loop_.cost), + ); + WasteFinding::session_cost("retry-loop", &loop_.session_id, loop_.cost, title, detail) + .with_event_source(loop_.event_source) } pub fn failure_run_to_finding(run: &FailureRun) -> WasteFinding { @@ -365,70 +404,45 @@ pub fn failure_run_to_finding(run: &FailureRun) -> WasteFinding { } _ => String::new(), }; - WasteFinding { - kind: "failure-run".to_string(), - severity: severity_from_usd(run.cost), - session_id: run.session_id.clone(), - title: format!( - "Failure run: {len} consecutive failed tool calls", - len = run.length - ), - detail: format!( - "Turns {start}-{end} failed across {n_tools} distinct tool(s) ({tools}). \ + let title = format!( + "Failure run: {len} consecutive failed tool calls", + len = run.length + ); + let detail = format!( + "Turns {start}-{end} failed across {n_tools} distinct tool(s) ({tools}). \ Cumulative turn cost {cost} — agent likely stuck without recovering or asking for help.{sig}", - start = run.start_turn_index, - end = run.end_turn_index, - n_tools = run.tools_involved.len(), - tools = run.tools_involved.join(", "), - cost = fmt_usd(run.cost), - sig = sig_detail, - ), - estimated_savings: EstimatedSavings { - usd_per_session: Some(run.cost), - ..Default::default() - }, - actions: vec![hotspots_action(&run.session_id)], - event_source: run.event_source, - } + start = run.start_turn_index, + end = run.end_turn_index, + n_tools = run.tools_involved.len(), + tools = run.tools_involved.join(", "), + cost = fmt_usd(run.cost), + sig = sig_detail, + ); + WasteFinding::session_cost("failure-run", &run.session_id, run.cost, title, detail) + .with_event_source(run.event_source) } pub fn cancellation_run_to_finding(run: &CancellationRun) -> WasteFinding { let tool_list = run.tools_involved.join(", "); let plural = if run.length == 1 { "" } else { "s" }; - WasteFinding { - kind: "cancellation-run".to_string(), - severity: severity_from_usd(run.cost), - session_id: run.session_id.clone(), - title: format!( - "Cancellation run: {len} cancelled tool call{plural}", - len = run.length, - plural = plural, - ), - detail: format!( - "Turns {start}-{end} ended with cancelled tool/subagent status ({tools}). \ + let title = format!( + "Cancellation run: {len} cancelled tool call{plural}", + len = run.length, + plural = plural, + ); + let detail = format!( + "Turns {start}-{end} ended with cancelled tool/subagent status ({tools}). \ Cumulative turn cost {cost}.", - start = run.start_turn_index, - end = run.end_turn_index, - tools = tool_list, - cost = fmt_usd(run.cost), - ), - estimated_savings: EstimatedSavings { - usd_per_session: Some(run.cost), - ..Default::default() - }, - actions: vec![hotspots_action(&run.session_id)], - event_source: Some(run.event_source), - } + start = run.start_turn_index, + end = run.end_turn_index, + tools = tool_list, + cost = fmt_usd(run.cost), + ); + WasteFinding::session_cost("cancellation-run", &run.session_id, run.cost, title, detail) + .with_event_source(Some(run.event_source)) } pub fn compaction_loss_to_finding(loss: &CompactionLoss) -> WasteFinding { - let mut savings = EstimatedSavings { - usd_per_session: Some(loss.cache_lost_cost), - ..Default::default() - }; - if loss.tokens_before_compact > 0 { - savings.tokens_per_session = Some(loss.tokens_before_compact); - } let lost_work_detail = match &loss.lost_work { Some(work) => { let mut s = format!( @@ -452,25 +466,24 @@ pub fn compaction_loss_to_finding(loss: &CompactionLoss) -> WasteFinding { } None => String::new(), }; - WasteFinding { - kind: "compaction-loss".to_string(), - severity: severity_from_usd(loss.cache_lost_cost), - session_id: loss.session_id.clone(), - title: format!( - "Compaction lost {tokens} cached tokens", - tokens = format_with_commas(loss.tokens_before_compact) - ), - detail: format!( - "A compaction at {ts} discarded {tokens} tokens of cache. \ + let title = format!( + "Compaction lost {tokens} cached tokens", + tokens = format_with_commas(loss.tokens_before_compact) + ); + let detail = format!( + "A compaction at {ts} discarded {tokens} tokens of cache. \ Pre-compact cacheRead cost {cost} — that cache won't be reused on subsequent turns.{lost}", - ts = loss.ts, - tokens = format_with_commas(loss.tokens_before_compact), - cost = fmt_usd(loss.cache_lost_cost), - lost = lost_work_detail, - ), - estimated_savings: savings, - actions: vec![hotspots_action(&loss.session_id)], - event_source: None, + ts = loss.ts, + tokens = format_with_commas(loss.tokens_before_compact), + cost = fmt_usd(loss.cache_lost_cost), + lost = lost_work_detail, + ); + let finding = + WasteFinding::session_cost("compaction-loss", &loss.session_id, loss.cache_lost_cost, title, detail); + if loss.tokens_before_compact > 0 { + finding.with_tokens_per_session(loss.tokens_before_compact) + } else { + finding } } @@ -485,28 +498,18 @@ pub fn edit_revert_to_finding(cycle: &EditRevertCycle) -> WasteFinding { ), None => String::new(), }; - WasteFinding { - kind: "edit-revert".to_string(), - severity: severity_from_usd(cycle.cost), - session_id: cycle.session_id.clone(), - title: format!("Edit revert on {path}", path = cycle.file_path), - detail: format!( - "Turn {first} edited {path}; turn {revert} restored a prior file state {span} turns later. \ + let title = format!("Edit revert on {path}", path = cycle.file_path); + let detail = format!( + "Turn {first} edited {path}; turn {revert} restored a prior file state {span} turns later. \ Cumulative anchor-turn cost {cost} — the intermediate work was erased.{preview}", - first = cycle.first_edit_turn_index, - path = cycle.file_path, - revert = cycle.revert_turn_index, - span = cycle.span_turns, - cost = fmt_usd(cycle.cost), - preview = preview_detail, - ), - estimated_savings: EstimatedSavings { - usd_per_session: Some(cycle.cost), - ..Default::default() - }, - actions: vec![hotspots_action(&cycle.session_id)], - event_source: None, - } + first = cycle.first_edit_turn_index, + path = cycle.file_path, + revert = cycle.revert_turn_index, + span = cycle.span_turns, + cost = fmt_usd(cycle.cost), + preview = preview_detail, + ); + WasteFinding::session_cost("edit-revert", &cycle.session_id, cycle.cost, title, detail) } pub fn edit_heavy_to_finding(session: &EditHeavySession) -> WasteFinding { @@ -515,131 +518,92 @@ pub fn edit_heavy_to_finding(session: &EditHeavySession) -> WasteFinding { } else { "∞".to_string() }; - let raw_severity = severity_from_usd(session.cost); - let severity = if raw_severity == WasteSeverity::High { - WasteSeverity::Warn - } else { - raw_severity + // Edit-heavy never escalates to High: it is an advisory signal, so cap a + // High cost-derived severity at Warn. + let severity = match severity_from_usd(session.cost) { + WasteSeverity::High => WasteSeverity::Warn, + other => other, }; // Render the source's kebab-case label so the detail string matches TS's // `${session.source}` (which uses the same string set). let source_str = session.source.wire_str(); - WasteFinding { - kind: "edit-heavy".to_string(), - severity, - session_id: session.session_id.clone(), - title: format!( - "Edit-heavy session: {edits} edits / {reads} reads (ratio {ratio})", - edits = session.edit_count, - reads = session.read_count, - ratio = ratio_str, - ), - detail: format!( - "{source} session has {edits} edit-tool calls against only {reads} read-tool calls \ + let title = format!( + "Edit-heavy session: {edits} edits / {reads} reads (ratio {ratio})", + edits = session.edit_count, + reads = session.read_count, + ratio = ratio_str, + ); + let detail = format!( + "{source} session has {edits} edit-tool calls against only {reads} read-tool calls \ (ratio {ratio}, threshold 4×). {retries} edit→bash→edit retry pattern(s) observed. \ Edit-bearing turn cost {cost} — careless editing without first reading surrounding context.", - source = source_str, - edits = session.edit_count, - reads = session.read_count, - ratio = ratio_str, - retries = session.likely_retries, - cost = fmt_usd(session.cost), - ), - estimated_savings: EstimatedSavings { - usd_per_session: Some(session.cost), - ..Default::default() - }, - actions: vec![hotspots_action(&session.session_id)], - event_source: None, - } + source = source_str, + edits = session.edit_count, + reads = session.read_count, + ratio = ratio_str, + retries = session.likely_retries, + cost = fmt_usd(session.cost), + ); + WasteFinding::session_cost("edit-heavy", &session.session_id, session.cost, title, detail) + .with_severity(severity) } pub fn skill_recall_dup_to_finding(dup: &SkillRecallDup) -> WasteFinding { - WasteFinding { - kind: "skill-recall-dup".to_string(), - severity: severity_from_usd(dup.cost), - session_id: dup.session_id.clone(), - title: format!( - "OpenCode skill \"{name}\" called {count}× without dedup", - name = dup.skill_name, - count = dup.call_count, - ), - detail: format!( - "OpenCode does not deduplicate skill tool results, so each of the {count} calls \ + let title = format!( + "OpenCode skill \"{name}\" called {count}× without dedup", + name = dup.skill_name, + count = dup.call_count, + ); + let detail = format!( + "OpenCode does not deduplicate skill tool results, so each of the {count} calls \ (turns {first}-{last}) re-injects the full SKILL.md content into context. \ Cumulative turn cost {cost}.", - count = dup.call_count, - first = dup.first_turn_index, - last = dup.last_turn_index, - cost = fmt_usd(dup.cost), - ), - estimated_savings: EstimatedSavings { - usd_per_session: Some(dup.cost), - ..Default::default() - }, - actions: vec![hotspots_action(&dup.session_id)], - event_source: None, - } + count = dup.call_count, + first = dup.first_turn_index, + last = dup.last_turn_index, + cost = fmt_usd(dup.cost), + ); + WasteFinding::session_cost("skill-recall-dup", &dup.session_id, dup.cost, title, detail) } pub fn skill_pruning_protection_to_finding(prot: &SkillPruningProtection) -> WasteFinding { - WasteFinding { - kind: "skill-pruning-protection".to_string(), - severity: severity_from_usd(prot.cost), - session_id: prot.session_id.clone(), - title: format!( - "OpenCode skill \"{name}\" rode in cache {turns} turn(s)", - name = prot.skill_name, - turns = prot.riding_turns, - ), - detail: format!( - "Skill tool results are listed in OpenCode's PRUNE_PROTECTED_TOOLS and never evict during compaction. \ + let title = format!( + "OpenCode skill \"{name}\" rode in cache {turns} turn(s)", + name = prot.skill_name, + turns = prot.riding_turns, + ); + let detail = format!( + "Skill tool results are listed in OpenCode's PRUNE_PROTECTED_TOOLS and never evict during compaction. \ Invoked at turn {invoked}; still in cacheRead at turn {last}. \ Invoke + riding-turn cost {cost}.", - invoked = prot.invoked_turn_index, - last = prot.last_cached_turn_index, - cost = fmt_usd(prot.cost), - ), - estimated_savings: EstimatedSavings { - usd_per_session: Some(prot.cost), - ..Default::default() - }, - actions: vec![hotspots_action(&prot.session_id)], - event_source: None, - } + invoked = prot.invoked_turn_index, + last = prot.last_cached_turn_index, + cost = fmt_usd(prot.cost), + ); + WasteFinding::session_cost("skill-pruning-protection", &prot.session_id, prot.cost, title, detail) } pub fn system_prompt_tax_to_finding(tax: &SystemPromptTax) -> WasteFinding { let riding_tokens = tax .estimated_system_prompt_tokens .saturating_mul(tax.riding_turns); - WasteFinding { - kind: "system-prompt-tax".to_string(), - severity: severity_from_usd(tax.total_cost), - session_id: tax.session_id.clone(), - title: format!( - "OpenCode system prompt tax: ~{tokens} tokens × {turns} turn(s)", - tokens = format_with_commas(tax.estimated_system_prompt_tokens), - turns = tax.riding_turns, - ), - detail: format!( - "First-turn cacheCreate of {first} tokens minus the first user message ({user}) \ + let title = format!( + "OpenCode system prompt tax: ~{tokens} tokens × {turns} turn(s)", + tokens = format_with_commas(tax.estimated_system_prompt_tokens), + turns = tax.riding_turns, + ); + let detail = format!( + "First-turn cacheCreate of {first} tokens minus the first user message ({user}) \ leaves ~{est} tokens of system prompt + skill catalog riding cacheRead across {turns} subsequent turn(s). \ Total cost {cost}.", - first = format_with_commas(tax.first_turn_cache_create), - user = format_with_commas(tax.first_user_message_tokens), - est = format_with_commas(tax.estimated_system_prompt_tokens), - turns = tax.riding_turns, - cost = fmt_usd(tax.total_cost), - ), - estimated_savings: EstimatedSavings { - tokens_per_session: Some(riding_tokens), - usd_per_session: Some(tax.total_cost), - ..Default::default() - }, - actions: vec![hotspots_action(&tax.session_id)], - event_source: None, - } + first = format_with_commas(tax.first_turn_cache_create), + user = format_with_commas(tax.first_user_message_tokens), + est = format_with_commas(tax.estimated_system_prompt_tokens), + turns = tax.riding_turns, + cost = fmt_usd(tax.total_cost), + ); + WasteFinding::session_cost("system-prompt-tax", &tax.session_id, tax.total_cost, title, detail) + .with_tokens_per_session(riding_tokens) } /// Roll the full PatternsResult into a single severity-ranked list. Within diff --git a/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs b/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs index 845c129..141e9b5 100644 --- a/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs +++ b/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs @@ -16,7 +16,7 @@ use phf::phf_set; use serde::{Deserialize, Serialize}; use crate::analyze::cost::lookup_model_rate; -use crate::analyze::findings::{severity_from_usd, EstimatedSavings, WasteFinding}; +use crate::analyze::findings::WasteFinding; use crate::analyze::pricing::PricingTable; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -448,7 +448,6 @@ only the PR fields the agent reads.", } } -use super::findings::hotspots_action; use super::util::{fmt_usd, format_with_commas, group_turns_by_session}; pub fn tool_call_pattern_to_finding(finding: &ToolCallPatternFinding) -> WasteFinding { @@ -473,33 +472,29 @@ pub fn tool_call_pattern_to_finding(finding: &ToolCallPatternFinding) -> WasteFi Ok(serde_json::Value::String(s)) => s, _ => String::new(), }; - WasteFinding { - kind: "tool-call-pattern".to_string(), - severity: severity_from_usd(finding.estimated_usd_saved), - session_id: finding.session_id.clone(), - title: format!( - "{}: {}×", - category_title(finding.category), - finding.occurrence_count - ), - detail: format!( - "{reason} Observed {n} occurrence(s) in this {source} session. \ + let title = format!( + "{}: {}×", + category_title(finding.category), + finding.occurrence_count + ); + let detail = format!( + "{reason} Observed {n} occurrence(s) in this {source} session. \ Estimated overhead: {tokens} tokens ({usd} at this session's input rate).{evidence}", - reason = category_reason(finding.category), - n = finding.occurrence_count, - source = source_str, - tokens = format_with_commas(finding.estimated_tokens_saved), - usd = fmt_usd(finding.estimated_usd_saved), - evidence = evidence_str, - ), - estimated_savings: EstimatedSavings { - tokens_per_session: Some(finding.estimated_tokens_saved), - usd_per_session: Some(finding.estimated_usd_saved), - ..Default::default() - }, - actions: vec![hotspots_action(&finding.session_id)], - event_source: None, - } + reason = category_reason(finding.category), + n = finding.occurrence_count, + source = source_str, + tokens = format_with_commas(finding.estimated_tokens_saved), + usd = fmt_usd(finding.estimated_usd_saved), + evidence = evidence_str, + ); + WasteFinding::session_cost( + "tool-call-pattern", + &finding.session_id, + finding.estimated_usd_saved, + title, + detail, + ) + .with_tokens_per_session(finding.estimated_tokens_saved) } #[cfg(test)] From c3763bb5fbb68c3a50cc959fd385517a6f783579 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 19:04:11 -0400 Subject: [PATCH 04/22] refactor(sdk/analyze): extract shared detect_streaks skeleton for patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five status-pattern detectors (graph + flat retry, graph + flat failure, graph cancellation) each hand-rolled the same commit-on-boundary streak loop, the bug-prone part being the commit/clear/re-push dispatch. Extract that control flow into detect_streaks(elements, classify, commit) with a StreakOp { Extend, Rotate, Break } verb; each detector now supplies only its own classify (what extends/breaks a streak) and commit (how a streak becomes a finding) over its own element type. This deliberately abstracts the *mechanism*, not the accessors: the graph vs flat domain differences (ToolResultEventRef vs ToolCallRef, dedup_defined_turns vs dedup_turns, event-source coalescing, the failure-run has_non_tool_result rule) stay explicit in local closures rather than behind a leaky accessor trait. Behavior is unchanged — all 992 tests pass, including the graph/flat fallback, cancellation, break-boundary, and retry-vs-failure guard cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/patterns.rs | 530 ++++++++++--------- 1 file changed, 277 insertions(+), 253 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/patterns.rs b/crates/relayburn-sdk/src/analyze/patterns.rs index a807b7e..46ed517 100644 --- a/crates/relayburn-sdk/src/analyze/patterns.rs +++ b/crates/relayburn-sdk/src/analyze/patterns.rs @@ -593,6 +593,63 @@ fn sum_cost_for_turns(turns: &[&TurnRecord], pricing: &PricingTable) -> f64 { sum_turn_costs(turns.iter().copied(), pricing) } +// --------------------------------------------------------------------------- +// Shared streak-accumulation skeleton +// --------------------------------------------------------------------------- + +/// How the streak runner should treat the current element relative to the +/// streak built so far. +enum StreakOp { + /// Append this element to the current streak. + Extend, + /// Commit the current streak, then start a fresh streak holding this + /// element (a same-tool retry streak hitting a different tool). + Rotate, + /// Commit the current streak and drop this element (a boundary marker). + Break, +} + +/// Walk `elements` in order, asking `classify` how each one relates to the +/// in-progress streak, and run `commit` at every streak boundary (and once at +/// the end). `commit` returns `Some(finding)` for a qualifying streak or `None` +/// to drop it, so the per-detector minimum-length and shape guards live there. +/// +/// This centralizes the commit-on-boundary control flow that the retry, +/// failure, and cancellation detectors all share; each detector supplies its +/// own `classify` (what extends/breaks a streak) and `commit` (how a streak +/// becomes a finding) over its own element type — the graph detectors over +/// `ToolResultEventRef`, the flat ones over `ToolCallRef`. +fn detect_streaks( + elements: impl IntoIterator, + classify: impl Fn(Option<&E>, &E) -> StreakOp, + mut commit: impl FnMut(&[E]) -> Option, +) -> Vec { + let mut out: Vec = Vec::new(); + let mut streak: Vec = Vec::new(); + for elem in elements { + match classify(streak.first(), &elem) { + StreakOp::Extend => streak.push(elem), + StreakOp::Rotate => { + if let Some(found) = commit(&streak) { + out.push(found); + } + streak.clear(); + streak.push(elem); + } + StreakOp::Break => { + if let Some(found) = commit(&streak) { + out.push(found); + } + streak.clear(); + } + } + } + if let Some(found) = commit(&streak) { + out.push(found); + } + out +} + // --------------------------------------------------------------------------- // Graph-backed detectors // --------------------------------------------------------------------------- @@ -603,57 +660,48 @@ fn detect_graph_retry_loops_for_session<'a>( pricing: &PricingTable, content_index: Option<&ContentIndex>, ) -> Vec { - let mut loops: Vec = Vec::new(); - let mut streak: Vec> = Vec::new(); - - let commit = |streak: &mut Vec>, out: &mut Vec| { - if streak.len() < MIN_RETRY_LEN { - return; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - let contributing = dedup_defined_turns(streak); - let mut loop_ = RetryLoop { - session_id: session_id.to_string(), - tool: first.tool.clone(), - target: first.target.clone(), - args_hash: first.args_hash.clone().unwrap_or_default(), - attempts: streak.len() as u64, - start_turn_index: first.turn_index, - end_turn_index: last.turn_index, - cost: sum_cost_for_turns(&contributing, pricing), - error_signature: None, - event_source: Some(coalesce_event_source(streak)), - }; - let call_refs = event_refs_to_tool_call_refs(streak); - if let Some(sig) = retry_loop_signature(&call_refs, content_index) { - loop_.error_signature = Some(sig); - } - out.push(loop_); - }; - - for r in refs { - let is_errored = matches!(r.event.status, ToolResultStatus::Errored); - if !is_errored || r.call.is_none() || r.args_hash.is_none() { - commit(&mut streak, &mut loops); - streak.clear(); - continue; - } - if streak.is_empty() { - streak.push(r.clone()); - continue; - } - let head = streak.first().unwrap(); - if head.tool == r.tool && head.args_hash == r.args_hash { - streak.push(r.clone()); - } else { - commit(&mut streak, &mut loops); - streak.clear(); - streak.push(r.clone()); - } - } - commit(&mut streak, &mut loops); - loops + detect_streaks( + refs.iter().cloned(), + |head, r| { + let is_errored = matches!(r.event.status, ToolResultStatus::Errored); + if !is_errored || r.call.is_none() || r.args_hash.is_none() { + StreakOp::Break + } else if let Some(head) = head { + if head.tool == r.tool && head.args_hash == r.args_hash { + StreakOp::Extend + } else { + StreakOp::Rotate + } + } else { + StreakOp::Extend + } + }, + |streak| { + if streak.len() < MIN_RETRY_LEN { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + let contributing = dedup_defined_turns(streak); + let mut loop_ = RetryLoop { + session_id: session_id.to_string(), + tool: first.tool.clone(), + target: first.target.clone(), + args_hash: first.args_hash.clone().unwrap_or_default(), + attempts: streak.len() as u64, + start_turn_index: first.turn_index, + end_turn_index: last.turn_index, + cost: sum_cost_for_turns(&contributing, pricing), + error_signature: None, + event_source: Some(coalesce_event_source(streak)), + }; + let call_refs = event_refs_to_tool_call_refs(streak); + if let Some(sig) = retry_loop_signature(&call_refs, content_index) { + loop_.error_signature = Some(sig); + } + Some(loop_) + }, + ) } fn detect_graph_failure_runs_for_session<'a>( @@ -662,66 +710,62 @@ fn detect_graph_failure_runs_for_session<'a>( pricing: &PricingTable, content_index: Option<&ContentIndex>, ) -> Vec { - let mut runs: Vec = Vec::new(); - let mut streak: Vec> = Vec::new(); - - let commit = |streak: &mut Vec>, out: &mut Vec| { - if streak.len() < MIN_FAILURE_RUN_LEN { - return; - } - let mut keys: HashSet = HashSet::new(); - for r in streak.iter() { - keys.insert(status_pattern_key(r)); - } - let has_non_tool_result = streak - .iter() - .any(|r| !matches!(r.event.event_source, ToolResultEventSource::ToolResult)); - // A same-(tool,args) tool_result run is a retry loop. Non-tool_result - // terminal events (notably subagent notifications) remain failure - // runs — they represent child invocations ending badly, not a parent - // retry loop. Mirrors patterns.ts:706-710. - if keys.len() < 2 && !has_non_tool_result { - return; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - // First-seen unique tool order. - let mut tools: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for r in streak.iter() { - if seen.insert(r.tool.clone()) { - tools.push(r.tool.clone()); + detect_streaks( + refs.iter().cloned(), + |_head, r| { + if matches!(r.event.status, ToolResultStatus::Errored) { + StreakOp::Extend + } else { + StreakOp::Break } - } - let contributing = dedup_defined_turns(streak); - let mut run = FailureRun { - session_id: session_id.to_string(), - length: streak.len() as u64, - start_turn_index: first.turn_index, - end_turn_index: last.turn_index, - tools_involved: tools, - cost: sum_cost_for_turns(&contributing, pricing), - error_signatures: None, - event_source: Some(coalesce_event_source(streak)), - }; - let call_refs = event_refs_to_tool_call_refs(streak); - let sigs = failure_run_signatures(&call_refs, content_index); - if !sigs.is_empty() { - run.error_signatures = Some(sigs); - } - out.push(run); - }; - - for r in refs { - if matches!(r.event.status, ToolResultStatus::Errored) { - streak.push(r.clone()); - } else { - commit(&mut streak, &mut runs); - streak.clear(); - } - } - commit(&mut streak, &mut runs); - runs + }, + |streak| { + if streak.len() < MIN_FAILURE_RUN_LEN { + return None; + } + let mut keys: HashSet = HashSet::new(); + for r in streak.iter() { + keys.insert(status_pattern_key(r)); + } + let has_non_tool_result = streak + .iter() + .any(|r| !matches!(r.event.event_source, ToolResultEventSource::ToolResult)); + // A same-(tool,args) tool_result run is a retry loop. Non-tool_result + // terminal events (notably subagent notifications) remain failure + // runs — they represent child invocations ending badly, not a parent + // retry loop. Mirrors patterns.ts:706-710. + if keys.len() < 2 && !has_non_tool_result { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + // First-seen unique tool order. + let mut tools: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for r in streak.iter() { + if seen.insert(r.tool.clone()) { + tools.push(r.tool.clone()); + } + } + let contributing = dedup_defined_turns(streak); + let mut run = FailureRun { + session_id: session_id.to_string(), + length: streak.len() as u64, + start_turn_index: first.turn_index, + end_turn_index: last.turn_index, + tools_involved: tools, + cost: sum_cost_for_turns(&contributing, pricing), + error_signatures: None, + event_source: Some(coalesce_event_source(streak)), + }; + let call_refs = event_refs_to_tool_call_refs(streak); + let sigs = failure_run_signatures(&call_refs, content_index); + if !sigs.is_empty() { + run.error_signatures = Some(sigs); + } + Some(run) + }, + ) } fn detect_graph_cancellation_runs_for_session<'a>( @@ -729,44 +773,40 @@ fn detect_graph_cancellation_runs_for_session<'a>( refs: &[ToolResultEventRef<'a>], pricing: &PricingTable, ) -> Vec { - let mut runs: Vec = Vec::new(); - let mut streak: Vec> = Vec::new(); - - let commit = |streak: &mut Vec>, out: &mut Vec| { - if streak.is_empty() { - return; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - let mut tools: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for r in streak.iter() { - if seen.insert(r.tool.clone()) { - tools.push(r.tool.clone()); + detect_streaks( + refs.iter().cloned(), + |_head, r| { + if matches!(r.event.status, ToolResultStatus::Cancelled) { + StreakOp::Extend + } else { + StreakOp::Break } - } - let contributing = dedup_defined_turns(streak); - out.push(CancellationRun { - session_id: session_id.to_string(), - length: streak.len() as u64, - start_turn_index: first.turn_index, - end_turn_index: last.turn_index, - tools_involved: tools, - cost: sum_cost_for_turns(&contributing, pricing), - event_source: coalesce_event_source(streak), - }); - }; - - for r in refs { - if matches!(r.event.status, ToolResultStatus::Cancelled) { - streak.push(r.clone()); - } else { - commit(&mut streak, &mut runs); - streak.clear(); - } - } - commit(&mut streak, &mut runs); - runs + }, + |streak| { + if streak.is_empty() { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + let mut tools: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for r in streak.iter() { + if seen.insert(r.tool.clone()) { + tools.push(r.tool.clone()); + } + } + let contributing = dedup_defined_turns(streak); + Some(CancellationRun { + session_id: session_id.to_string(), + length: streak.len() as u64, + start_turn_index: first.turn_index, + end_turn_index: last.turn_index, + tools_involved: tools, + cost: sum_cost_for_turns(&contributing, pricing), + event_source: coalesce_event_source(streak), + }) + }, + ) } fn status_pattern_key(r: &ToolResultEventRef<'_>) -> String { @@ -787,58 +827,47 @@ pub(crate) fn detect_retry_loops_for_session<'a>( pricing: &PricingTable, content_index: Option<&ContentIndex>, ) -> Vec { - let flat = flatten_tool_calls(turns); - let mut loops: Vec = Vec::new(); - let mut streak: Vec> = Vec::new(); - - let commit = |streak: &mut Vec>, out: &mut Vec| { - if streak.len() < MIN_RETRY_LEN { - return; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - let turns_in_streak: Vec<&TurnRecord> = streak.iter().map(|r| r.turn).collect(); - let contributing = dedup_turns(turns_in_streak); - let mut loop_ = RetryLoop { - session_id: session_id.to_string(), - tool: first.call.name.clone(), - target: first.call.target.clone(), - args_hash: first.call.args_hash.clone(), - attempts: streak.len() as u64, - start_turn_index: first.turn.turn_index, - end_turn_index: last.turn.turn_index, - cost: sum_cost_for_turns(&contributing, pricing), - error_signature: None, - event_source: None, - }; - if let Some(sig) = retry_loop_signature(streak, content_index) { - loop_.error_signature = Some(sig); - } - out.push(loop_); - }; - - for r in &flat { - let is_errored = r.call.is_error == Some(true); - if !is_errored { - commit(&mut streak, &mut loops); - streak.clear(); - continue; - } - if streak.is_empty() { - streak.push(*r); - continue; - } - let head = streak.first().unwrap().call; - if head.name == r.call.name && head.args_hash == r.call.args_hash { - streak.push(*r); - } else { - commit(&mut streak, &mut loops); - streak.clear(); - streak.push(*r); - } - } - commit(&mut streak, &mut loops); - loops + detect_streaks( + flatten_tool_calls(turns), + |head, r| { + if r.call.is_error != Some(true) { + StreakOp::Break + } else if let Some(head) = head { + if head.call.name == r.call.name && head.call.args_hash == r.call.args_hash { + StreakOp::Extend + } else { + StreakOp::Rotate + } + } else { + StreakOp::Extend + } + }, + |streak| { + if streak.len() < MIN_RETRY_LEN { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + let turns_in_streak: Vec<&TurnRecord> = streak.iter().map(|r| r.turn).collect(); + let contributing = dedup_turns(turns_in_streak); + let mut loop_ = RetryLoop { + session_id: session_id.to_string(), + tool: first.call.name.clone(), + target: first.call.target.clone(), + args_hash: first.call.args_hash.clone(), + attempts: streak.len() as u64, + start_turn_index: first.turn.turn_index, + end_turn_index: last.turn.turn_index, + cost: sum_cost_for_turns(&contributing, pricing), + error_signature: None, + event_source: None, + }; + if let Some(sig) = retry_loop_signature(streak, content_index) { + loop_.error_signature = Some(sig); + } + Some(loop_) + }, + ) } fn retry_loop_signature( @@ -876,61 +905,56 @@ pub(crate) fn detect_failure_runs_for_session<'a>( pricing: &PricingTable, content_index: Option<&ContentIndex>, ) -> Vec { - let flat = flatten_tool_calls(turns); - let mut runs: Vec = Vec::new(); - let mut streak: Vec> = Vec::new(); - - let commit = |streak: &mut Vec>, out: &mut Vec| { - if streak.len() < MIN_FAILURE_RUN_LEN { - return; - } - let mut keys: HashSet = HashSet::new(); - for r in streak.iter() { - keys.insert(format!("{}|{}", r.call.name, r.call.args_hash)); - } - // Same-(tool,args) run is a retry loop, not a failure run. See - // patterns.ts:868-872. - if keys.len() < 2 { - return; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - let mut tools: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for r in streak.iter() { - if seen.insert(r.call.name.clone()) { - tools.push(r.call.name.clone()); + detect_streaks( + flatten_tool_calls(turns), + |_head, r| { + if r.call.is_error == Some(true) { + StreakOp::Extend + } else { + StreakOp::Break } - } - let turns_in_streak: Vec<&TurnRecord> = streak.iter().map(|r| r.turn).collect(); - let contributing = dedup_turns(turns_in_streak); - let mut run = FailureRun { - session_id: session_id.to_string(), - length: streak.len() as u64, - start_turn_index: first.turn.turn_index, - end_turn_index: last.turn.turn_index, - tools_involved: tools, - cost: sum_cost_for_turns(&contributing, pricing), - error_signatures: None, - event_source: None, - }; - let sigs = failure_run_signatures(streak, content_index); - if !sigs.is_empty() { - run.error_signatures = Some(sigs); - } - out.push(run); - }; - - for r in &flat { - if r.call.is_error == Some(true) { - streak.push(*r); - } else { - commit(&mut streak, &mut runs); - streak.clear(); - } - } - commit(&mut streak, &mut runs); - runs + }, + |streak| { + if streak.len() < MIN_FAILURE_RUN_LEN { + return None; + } + let mut keys: HashSet = HashSet::new(); + for r in streak.iter() { + keys.insert(format!("{}|{}", r.call.name, r.call.args_hash)); + } + // Same-(tool,args) run is a retry loop, not a failure run. See + // patterns.ts:868-872. + if keys.len() < 2 { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + let mut tools: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for r in streak.iter() { + if seen.insert(r.call.name.clone()) { + tools.push(r.call.name.clone()); + } + } + let turns_in_streak: Vec<&TurnRecord> = streak.iter().map(|r| r.turn).collect(); + let contributing = dedup_turns(turns_in_streak); + let mut run = FailureRun { + session_id: session_id.to_string(), + length: streak.len() as u64, + start_turn_index: first.turn.turn_index, + end_turn_index: last.turn.turn_index, + tools_involved: tools, + cost: sum_cost_for_turns(&contributing, pricing), + error_signatures: None, + event_source: None, + }; + let sigs = failure_run_signatures(streak, content_index); + if !sigs.is_empty() { + run.error_signatures = Some(sigs); + } + Some(run) + }, + ) } fn failure_run_signatures( From 4ce12294ad92f4bd71829a94994f68af68e831fb Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 19:11:28 -0400 Subject: [PATCH 05/22] style(sdk/analyze): rustfmt the session_cost finding adapters Line-wrapping cleanup for the WasteFinding::session_cost calls introduced in the builder commit; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/findings.rs | 37 ++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/findings.rs b/crates/relayburn-sdk/src/analyze/findings.rs index 10c290e..4998b52 100644 --- a/crates/relayburn-sdk/src/analyze/findings.rs +++ b/crates/relayburn-sdk/src/analyze/findings.rs @@ -478,8 +478,13 @@ Pre-compact cacheRead cost {cost} — that cache won't be reused on subsequent t cost = fmt_usd(loss.cache_lost_cost), lost = lost_work_detail, ); - let finding = - WasteFinding::session_cost("compaction-loss", &loss.session_id, loss.cache_lost_cost, title, detail); + let finding = WasteFinding::session_cost( + "compaction-loss", + &loss.session_id, + loss.cache_lost_cost, + title, + detail, + ); if loss.tokens_before_compact > 0 { finding.with_tokens_per_session(loss.tokens_before_compact) } else { @@ -544,8 +549,14 @@ Edit-bearing turn cost {cost} — careless editing without first reading surroun retries = session.likely_retries, cost = fmt_usd(session.cost), ); - WasteFinding::session_cost("edit-heavy", &session.session_id, session.cost, title, detail) - .with_severity(severity) + WasteFinding::session_cost( + "edit-heavy", + &session.session_id, + session.cost, + title, + detail, + ) + .with_severity(severity) } pub fn skill_recall_dup_to_finding(dup: &SkillRecallDup) -> WasteFinding { @@ -580,7 +591,13 @@ Invoke + riding-turn cost {cost}.", last = prot.last_cached_turn_index, cost = fmt_usd(prot.cost), ); - WasteFinding::session_cost("skill-pruning-protection", &prot.session_id, prot.cost, title, detail) + WasteFinding::session_cost( + "skill-pruning-protection", + &prot.session_id, + prot.cost, + title, + detail, + ) } pub fn system_prompt_tax_to_finding(tax: &SystemPromptTax) -> WasteFinding { @@ -602,8 +619,14 @@ Total cost {cost}.", turns = tax.riding_turns, cost = fmt_usd(tax.total_cost), ); - WasteFinding::session_cost("system-prompt-tax", &tax.session_id, tax.total_cost, title, detail) - .with_tokens_per_session(riding_tokens) + WasteFinding::session_cost( + "system-prompt-tax", + &tax.session_id, + tax.total_cost, + title, + detail, + ) + .with_tokens_per_session(riding_tokens) } /// Roll the full PatternsResult into a single severity-ranked list. Within From ed38eb5eee0513d2094a69d6beffdf45c037b3db Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 19:11:28 -0400 Subject: [PATCH 06/22] refactor(sdk): consume analyze via its public surface, not submodule paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit query_verbs/flow.rs, the reader span_tree builders, and the query_verbs tests reached into crate::analyze::span_tree:: and crate::analyze::context_delta:: directly, bypassing the curated re-export surface in analyze.rs. Route them through crate::analyze::{TurnSpanTree, OwnerRail, ContextDelta, ContextDeltaOpts, deltas_for_session, AttrValue, SpanKind, SpanNode, SpanStatus} instead, adding the missing names to the shared query_verbs import block. No external code references analyze submodule paths anymore — the re-export list is now the single entry point. Pure path change; all 992 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/query_verbs/flow.rs | 41 ++++++------------- crates/relayburn-sdk/src/query_verbs/mod.rs | 28 +++++++------ crates/relayburn-sdk/src/query_verbs/tests.rs | 8 ++-- .../src/reader/claude/span_tree.rs | 2 +- .../src/reader/codex/span_tree.rs | 2 +- 5 files changed, 33 insertions(+), 48 deletions(-) diff --git a/crates/relayburn-sdk/src/query_verbs/flow.rs b/crates/relayburn-sdk/src/query_verbs/flow.rs index 6ba44ef..3c276c7 100644 --- a/crates/relayburn-sdk/src/query_verbs/flow.rs +++ b/crates/relayburn-sdk/src/query_verbs/flow.rs @@ -33,11 +33,7 @@ impl LedgerHandle { /// 5. Dispatching to the per-harness builder. /// /// Returns an error when the requested turn isn't on the ledger. - pub fn turn_span_tree( - &self, - session_id: &str, - turn_id: &str, - ) -> Result { + pub fn turn_span_tree(&self, session_id: &str, turn_id: &str) -> Result { let trees = self.session_span_trees(session_id)?; trees .into_iter() @@ -54,10 +50,7 @@ impl LedgerHandle { /// than calling [`Self::turn_span_tree`] in a loop when the caller /// wants the whole session. Identical contract otherwise: pure /// derivation, no caching, no writes. - pub fn session_span_trees( - &self, - session_id: &str, - ) -> Result> { + pub fn session_span_trees(&self, session_id: &str) -> Result> { let session_q = Query { session_id: Some(session_id.to_string()), ..Default::default() @@ -359,7 +352,7 @@ pub fn turn_span_tree( session_id: &str, turn_id: &str, ledger_home: Option, -) -> Result { +) -> Result { let handle = open_with(ledger_home.as_deref())?; handle.turn_span_tree(session_id, turn_id) } @@ -368,7 +361,7 @@ pub fn turn_span_tree( pub fn session_span_trees( session_id: &str, ledger_home: Option, -) -> Result> { +) -> Result> { let handle = open_with(ledger_home.as_deref())?; handle.session_span_trees(session_id) } @@ -415,12 +408,10 @@ pub(crate) fn duration_to_since_iso(d: std::time::Duration) -> String { /// Lex key for sorting cross-session [`ContextDelta`] rows by owner_rail /// when other tie-breakers are equal. Mirrors the per-session helper in /// `analyze::context_delta`. -fn owner_rail_str(rail: &crate::analyze::context_delta::OwnerRail) -> (&str, &str) { +fn owner_rail_str(rail: &OwnerRail) -> (&str, &str) { match rail { - crate::analyze::context_delta::OwnerRail::Main => ("main", ""), - crate::analyze::context_delta::OwnerRail::Subagent { agent_id } => { - ("subagent", agent_id.as_str()) - } + OwnerRail::Main => ("main", ""), + OwnerRail::Subagent { agent_id } => ("subagent", agent_id.as_str()), } } @@ -441,10 +432,7 @@ impl LedgerHandle { /// whose latest activity falls outside the window are skipped before any /// span trees get loaded. The same window is then applied to the /// returned [`Vec`] cap. - pub fn context_delta( - &self, - opts: crate::analyze::context_delta::ContextDeltaOpts, - ) -> Result> { + pub fn context_delta(&self, opts: ContextDeltaOpts) -> Result> { let pricing = load_pricing(None); // Build the seed `since` filter from `opts.since`. We always have a @@ -475,7 +463,7 @@ impl LedgerHandle { } }; - let mut out: Vec = Vec::new(); + let mut out: Vec = Vec::new(); for session_id in session_ids { let trees = self.session_span_trees(&session_id)?; if trees.is_empty() { @@ -485,12 +473,7 @@ impl LedgerHandle { session_id: Some(session_id.clone()), ..Default::default() })?; - let per_session = crate::analyze::context_delta::deltas_for_session( - &trees, - &compactions, - &pricing, - &opts, - ); + let per_session = deltas_for_session(&trees, &compactions, &pricing, &opts); out.extend(per_session); } @@ -517,9 +500,9 @@ impl LedgerHandle { /// Free-function form of [`LedgerHandle::context_delta`]. pub fn context_delta( - opts: crate::analyze::context_delta::ContextDeltaOpts, + opts: ContextDeltaOpts, ledger_home: Option, -) -> Result> { +) -> Result> { let handle = open_with(ledger_home.as_deref())?; handle.context_delta(opts) } diff --git a/crates/relayburn-sdk/src/query_verbs/mod.rs b/crates/relayburn-sdk/src/query_verbs/mod.rs index 79d4bd6..d3e3522 100644 --- a/crates/relayburn-sdk/src/query_verbs/mod.rs +++ b/crates/relayburn-sdk/src/query_verbs/mod.rs @@ -22,22 +22,24 @@ use crate::analyze::{ aggregate_by_provider, aggregate_by_subagent, aggregate_subagent_type_stats, attribute_hotspots, attribute_overhead, build_compare_table, build_ghost_surface_inputs, build_subagent_tree, build_trim_recommendations, compute_quality, cost_for_turn, - detect_ghost_surface, detect_patterns, detect_tool_call_patterns, detect_tool_output_bloat, - find_overhead_files, findings_from_patterns, ghost_surface_to_finding, has_minimum_fidelity, - load_claude_settings, load_overhead_file, load_pricing, project_claude_settings_path, - provider_for, render_unified_diff_for_recommendation, sort_findings, sum_costs, - summarize_fidelity, summarize_fidelity_from_iter, summarize_replacement_savings, - tally_unpriced, tool_call_pattern_to_finding, tool_output_bloat_to_finding, - user_claude_settings_path, AggregateByProviderOptions, AttributeOverheadInput, - AttributionMethod, BashAggregation, BashVerbAggregation, BuildSubagentTreeOptions, - CompareOptions as AnalyzeCompareOptions, CompareTable, ComputeQualityOptions, CostBreakdown, + deltas_for_session, detect_ghost_surface, detect_patterns, detect_tool_call_patterns, + detect_tool_output_bloat, find_overhead_files, findings_from_patterns, + ghost_surface_to_finding, has_minimum_fidelity, load_claude_settings, load_overhead_file, + load_pricing, project_claude_settings_path, provider_for, + render_unified_diff_for_recommendation, sort_findings, sum_costs, summarize_fidelity, + summarize_fidelity_from_iter, summarize_replacement_savings, tally_unpriced, + tool_call_pattern_to_finding, tool_output_bloat_to_finding, user_claude_settings_path, + AggregateByProviderOptions, AttributeOverheadInput, AttributionMethod, BashAggregation, + BashVerbAggregation, BuildSubagentTreeOptions, CompareOptions as AnalyzeCompareOptions, + CompareTable, ComputeQualityOptions, ContextDelta, ContextDeltaOpts, CostBreakdown, CoverageField, DetectPatternsOptions, DetectToolCallPatternsOptions, DetectToolOutputBloatOptions, FidelitySummary, FieldCoverage, FileAggregation, GhostSurfaceFindingOptions, HotspotsOptions as AnalyzeHotspotsOptions, LoadedClaudeSettings, - MarkdownSection, McpServerAggregation, OverheadFile, OverheadFileKind, ParsedOverheadFile, - PricingTable, ProviderAggregateRow, ProviderFilter, QualityResult, ReplacementSavingsSummary, - RowCoverage, SessionClaudeMdCost, SubagentAggregation, SubagentTreeNode, SubagentTypeStats, - ToolSavingsAggregate, UsageCostAggregateRow, WasteFinding, + MarkdownSection, McpServerAggregation, OverheadFile, OverheadFileKind, OwnerRail, + ParsedOverheadFile, PricingTable, ProviderAggregateRow, ProviderFilter, QualityResult, + ReplacementSavingsSummary, RowCoverage, SessionClaudeMdCost, SubagentAggregation, + SubagentTreeNode, SubagentTypeStats, ToolSavingsAggregate, TurnSpanTree, UsageCostAggregateRow, + WasteFinding, }; use crate::ledger::{EnrichedTurn, Enrichment, Query}; use crate::reader::{ diff --git a/crates/relayburn-sdk/src/query_verbs/tests.rs b/crates/relayburn-sdk/src/query_verbs/tests.rs index d9ca3b4..e7aaad6 100644 --- a/crates/relayburn-sdk/src/query_verbs/tests.rs +++ b/crates/relayburn-sdk/src/query_verbs/tests.rs @@ -1806,7 +1806,7 @@ fn duration_to_since_iso_emits_canonical_zulu_ms() { /// asserts the seed `query_turns(&since_scoped)` actually narrows. #[test] fn context_delta_since_filter_excludes_old_sessions() { - use crate::analyze::context_delta::ContextDeltaOpts; + use crate::analyze::ContextDeltaOpts; let (_dir, handle) = multi_session_handle(); let opts = ContextDeltaOpts { since: Some(std::time::Duration::from_secs(1)), @@ -2194,7 +2194,7 @@ fn fingerprint_perf_target_under_10ms_on_100k_rows() { /// Claude turns into the test ledger. #[test] fn session_span_trees_round_trips_two_turn_fixture() { - use crate::analyze::span_tree::{SpanKind, SpanStatus}; + use crate::analyze::{SpanKind, SpanStatus}; let (_dir, handle) = fixture_handle(); let trees = handle.session_span_trees("sess-a").expect("trees"); @@ -2390,7 +2390,7 @@ fn bucket_subagents_paired_and_orphan_each_land_in_one_turn() { /// orchestration path. #[test] fn session_span_trees_orphan_subagent_not_duplicated_across_turns() { - use crate::analyze::span_tree::SpanKind; + use crate::analyze::SpanKind; let (_dir, handle) = fixture_handle(); // Both fixture turns have no Task tool_use ids matching the @@ -2423,7 +2423,7 @@ fn session_span_trees_orphan_subagent_not_duplicated_across_turns() { c.kind == SpanKind::Subagent && matches!( c.attributes.get("unattached"), - Some(crate::analyze::span_tree::AttrValue::Bool(true)) + Some(crate::analyze::AttrValue::Bool(true)) ) }) .count() diff --git a/crates/relayburn-sdk/src/reader/claude/span_tree.rs b/crates/relayburn-sdk/src/reader/claude/span_tree.rs index a6e0ffb..e99238e 100644 --- a/crates/relayburn-sdk/src/reader/claude/span_tree.rs +++ b/crates/relayburn-sdk/src/reader/claude/span_tree.rs @@ -46,7 +46,7 @@ use std::collections::HashMap; -use crate::analyze::span_tree::{AttrValue, SpanKind, SpanNode, SpanStatus, TurnSpanTree}; +use crate::analyze::{AttrValue, SpanKind, SpanNode, SpanStatus, TurnSpanTree}; use crate::reader::claude::subagents::SubagentTranscript; use crate::reader::inference::Inference; use crate::reader::types::{ diff --git a/crates/relayburn-sdk/src/reader/codex/span_tree.rs b/crates/relayburn-sdk/src/reader/codex/span_tree.rs index 0541061..63673ee 100644 --- a/crates/relayburn-sdk/src/reader/codex/span_tree.rs +++ b/crates/relayburn-sdk/src/reader/codex/span_tree.rs @@ -36,7 +36,7 @@ use std::collections::HashMap; -use crate::analyze::span_tree::{AttrValue, SpanKind, SpanNode, SpanStatus, TurnSpanTree}; +use crate::analyze::{AttrValue, SpanKind, SpanNode, SpanStatus, TurnSpanTree}; use crate::reader::inference::{Inference, InferenceKeySource, InferenceKind, ToolUseRef}; use crate::reader::types::{ StopReason, ToolCall, ToolResultEventRecord, ToolResultStatus, TurnRecord, From 5eb24f1c856498a2facca8fc7aa388e294a59712 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 20:40:43 -0400 Subject: [PATCH 07/22] refactor(sdk/analyze): split patterns.rs into per-family detector submodules patterns.rs had grown to 1564 lines bundling ~10 independent detectors plus shared plumbing. Split the detector families into cohesive submodules under patterns/, leaving the shared plumbing and the detect_patterns orchestrator in the parent: patterns.rs 755 consts, tool-name helpers, DetectPatternsOptions, detect_patterns, ref structs, ContentIndex, flatten/dedup/cost helpers, detect_streaks/StreakOp, graph event helpers, build_summaries patterns/streaks.rs 372 retry / failure / cancellation (graph + flat) + signatures patterns/edits.rs 157 edit-revert, edit-heavy patterns/compaction.rs 150 compaction-loss + window summary patterns/skills.rs 157 skill recall-dup, pruning-protection, system-prompt-tax Pure code movement. Each submodule reaches the parent's shared plumbing via `use super::*`. The only non-movement edits are widening GraphStatusPatterns / detect_graph_status_patterns_for_session / detect_compaction_losses from private to pub(super) so the parent orchestrator can call across the boundary. detect_patterns's body is unchanged. All 992 tests pass, clippy clean, build warning-free; the moved function bodies are byte-identical to the originals. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/patterns.rs | 851 +----------------- .../src/analyze/patterns/compaction.rs | 150 +++ .../src/analyze/patterns/edits.rs | 157 ++++ .../src/analyze/patterns/skills.rs | 157 ++++ .../src/analyze/patterns/streaks.rs | 372 ++++++++ 5 files changed, 857 insertions(+), 830 deletions(-) create mode 100644 crates/relayburn-sdk/src/analyze/patterns/compaction.rs create mode 100644 crates/relayburn-sdk/src/analyze/patterns/edits.rs create mode 100644 crates/relayburn-sdk/src/analyze/patterns/skills.rs create mode 100644 crates/relayburn-sdk/src/analyze/patterns/streaks.rs diff --git a/crates/relayburn-sdk/src/analyze/patterns.rs b/crates/relayburn-sdk/src/analyze/patterns.rs index 46ed517..fee0d77 100644 --- a/crates/relayburn-sdk/src/analyze/patterns.rs +++ b/crates/relayburn-sdk/src/analyze/patterns.rs @@ -13,21 +13,17 @@ //! in `findings.rs` per AgentWorkforce/burn#268's deferred-types decision, //! so this module re-exports them rather than redefining the same shapes. -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use crate::reader::{ - count_retries, normalize_tool_name, CompactionEvent, ContentKind, ContentRecord, - ContentToolResult, ContentToolUse, SourceKind, ToolCall, ToolResultEventRecord, - ToolResultEventSource, ToolResultStatus, TurnRecord, UserTurnRecord, + CompactionEvent, ContentKind, ContentRecord, ContentToolResult, ContentToolUse, ToolCall, + ToolResultEventRecord, ToolResultEventSource, ToolResultStatus, TurnRecord, UserTurnRecord, }; use serde_json::Value; -use crate::analyze::cost::{ - cost_for_usage, sum_turn_costs, total_cost_for_turn, CostForUsageOptions, -}; +use crate::analyze::cost::sum_turn_costs; use crate::analyze::findings::{ - CancellationRun, CompactionLoss, CompactionLostWork, EditHeavySession, EditPreview, - EditRevertCycle, EditRevertSamplePreview, FailureRun, FailureRunErrorSignature, + CancellationRun, CompactionLoss, EditHeavySession, EditPreview, EditRevertCycle, FailureRun, PatternEventSource, PatternsResult, RetryLoop, SessionPatternSummary, SkillPruningProtection, SkillRecallDup, SystemPromptTax, }; @@ -35,7 +31,22 @@ use crate::analyze::pricing::PricingTable; use crate::analyze::util::{group_turns_by_session, stringify_tool_result}; mod shell; -use shell::shell_command_has_file_read; + +mod compaction; +mod edits; +mod skills; +mod streaks; + +use compaction::detect_compaction_losses; +use edits::{detect_edit_heavy_for_session, detect_edit_reverts_for_session}; +use skills::{ + detect_skill_pruning_protection_for_session, detect_skill_recall_dups_for_session, + detect_system_prompt_tax_for_session, +}; +use streaks::{ + detect_failure_runs_for_session, detect_graph_status_patterns_for_session, + detect_retry_loops_for_session, +}; // --------------------------------------------------------------------------- // Hardcoded thresholds. Each constant cites the TS source line so future @@ -298,41 +309,6 @@ struct ToolResultEventRef<'a> { turn_index: u64, } -struct GraphStatusPatterns { - retry_loops: Vec, - failure_runs: Vec, - cancelled_runs: Vec, -} - -fn detect_graph_status_patterns_for_session<'a>( - session_id: &str, - turns: &[&'a TurnRecord], - events: &[&'a ToolResultEventRecord], - pricing: &PricingTable, - content_index: Option<&ContentIndex>, -) -> GraphStatusPatterns { - let terminal_refs = build_terminal_event_refs(session_id, turns, events); - GraphStatusPatterns { - retry_loops: detect_graph_retry_loops_for_session( - session_id, - &terminal_refs, - pricing, - content_index, - ), - failure_runs: detect_graph_failure_runs_for_session( - session_id, - &terminal_refs, - pricing, - content_index, - ), - cancelled_runs: detect_graph_cancellation_runs_for_session( - session_id, - &terminal_refs, - pricing, - ), - } -} - fn build_terminal_event_refs<'a>( session_id: &str, turns: &[&'a TurnRecord], @@ -650,791 +626,6 @@ fn detect_streaks( out } -// --------------------------------------------------------------------------- -// Graph-backed detectors -// --------------------------------------------------------------------------- - -fn detect_graph_retry_loops_for_session<'a>( - session_id: &str, - refs: &[ToolResultEventRef<'a>], - pricing: &PricingTable, - content_index: Option<&ContentIndex>, -) -> Vec { - detect_streaks( - refs.iter().cloned(), - |head, r| { - let is_errored = matches!(r.event.status, ToolResultStatus::Errored); - if !is_errored || r.call.is_none() || r.args_hash.is_none() { - StreakOp::Break - } else if let Some(head) = head { - if head.tool == r.tool && head.args_hash == r.args_hash { - StreakOp::Extend - } else { - StreakOp::Rotate - } - } else { - StreakOp::Extend - } - }, - |streak| { - if streak.len() < MIN_RETRY_LEN { - return None; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - let contributing = dedup_defined_turns(streak); - let mut loop_ = RetryLoop { - session_id: session_id.to_string(), - tool: first.tool.clone(), - target: first.target.clone(), - args_hash: first.args_hash.clone().unwrap_or_default(), - attempts: streak.len() as u64, - start_turn_index: first.turn_index, - end_turn_index: last.turn_index, - cost: sum_cost_for_turns(&contributing, pricing), - error_signature: None, - event_source: Some(coalesce_event_source(streak)), - }; - let call_refs = event_refs_to_tool_call_refs(streak); - if let Some(sig) = retry_loop_signature(&call_refs, content_index) { - loop_.error_signature = Some(sig); - } - Some(loop_) - }, - ) -} - -fn detect_graph_failure_runs_for_session<'a>( - session_id: &str, - refs: &[ToolResultEventRef<'a>], - pricing: &PricingTable, - content_index: Option<&ContentIndex>, -) -> Vec { - detect_streaks( - refs.iter().cloned(), - |_head, r| { - if matches!(r.event.status, ToolResultStatus::Errored) { - StreakOp::Extend - } else { - StreakOp::Break - } - }, - |streak| { - if streak.len() < MIN_FAILURE_RUN_LEN { - return None; - } - let mut keys: HashSet = HashSet::new(); - for r in streak.iter() { - keys.insert(status_pattern_key(r)); - } - let has_non_tool_result = streak - .iter() - .any(|r| !matches!(r.event.event_source, ToolResultEventSource::ToolResult)); - // A same-(tool,args) tool_result run is a retry loop. Non-tool_result - // terminal events (notably subagent notifications) remain failure - // runs — they represent child invocations ending badly, not a parent - // retry loop. Mirrors patterns.ts:706-710. - if keys.len() < 2 && !has_non_tool_result { - return None; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - // First-seen unique tool order. - let mut tools: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for r in streak.iter() { - if seen.insert(r.tool.clone()) { - tools.push(r.tool.clone()); - } - } - let contributing = dedup_defined_turns(streak); - let mut run = FailureRun { - session_id: session_id.to_string(), - length: streak.len() as u64, - start_turn_index: first.turn_index, - end_turn_index: last.turn_index, - tools_involved: tools, - cost: sum_cost_for_turns(&contributing, pricing), - error_signatures: None, - event_source: Some(coalesce_event_source(streak)), - }; - let call_refs = event_refs_to_tool_call_refs(streak); - let sigs = failure_run_signatures(&call_refs, content_index); - if !sigs.is_empty() { - run.error_signatures = Some(sigs); - } - Some(run) - }, - ) -} - -fn detect_graph_cancellation_runs_for_session<'a>( - session_id: &str, - refs: &[ToolResultEventRef<'a>], - pricing: &PricingTable, -) -> Vec { - detect_streaks( - refs.iter().cloned(), - |_head, r| { - if matches!(r.event.status, ToolResultStatus::Cancelled) { - StreakOp::Extend - } else { - StreakOp::Break - } - }, - |streak| { - if streak.is_empty() { - return None; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - let mut tools: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for r in streak.iter() { - if seen.insert(r.tool.clone()) { - tools.push(r.tool.clone()); - } - } - let contributing = dedup_defined_turns(streak); - Some(CancellationRun { - session_id: session_id.to_string(), - length: streak.len() as u64, - start_turn_index: first.turn_index, - end_turn_index: last.turn_index, - tools_involved: tools, - cost: sum_cost_for_turns(&contributing, pricing), - event_source: coalesce_event_source(streak), - }) - }, - ) -} - -fn status_pattern_key(r: &ToolResultEventRef<'_>) -> String { - let args = r - .args_hash - .clone() - .unwrap_or_else(|| r.event.tool_use_id.clone()); - format!("{}|{}", r.tool, args) -} - -// --------------------------------------------------------------------------- -// Legacy fallback detectors (no event chronology) -// --------------------------------------------------------------------------- - -pub(crate) fn detect_retry_loops_for_session<'a>( - session_id: &str, - turns: &'a [&'a TurnRecord], - pricing: &PricingTable, - content_index: Option<&ContentIndex>, -) -> Vec { - detect_streaks( - flatten_tool_calls(turns), - |head, r| { - if r.call.is_error != Some(true) { - StreakOp::Break - } else if let Some(head) = head { - if head.call.name == r.call.name && head.call.args_hash == r.call.args_hash { - StreakOp::Extend - } else { - StreakOp::Rotate - } - } else { - StreakOp::Extend - } - }, - |streak| { - if streak.len() < MIN_RETRY_LEN { - return None; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - let turns_in_streak: Vec<&TurnRecord> = streak.iter().map(|r| r.turn).collect(); - let contributing = dedup_turns(turns_in_streak); - let mut loop_ = RetryLoop { - session_id: session_id.to_string(), - tool: first.call.name.clone(), - target: first.call.target.clone(), - args_hash: first.call.args_hash.clone(), - attempts: streak.len() as u64, - start_turn_index: first.turn.turn_index, - end_turn_index: last.turn.turn_index, - cost: sum_cost_for_turns(&contributing, pricing), - error_signature: None, - event_source: None, - }; - if let Some(sig) = retry_loop_signature(streak, content_index) { - loop_.error_signature = Some(sig); - } - Some(loop_) - }, - ) -} - -fn retry_loop_signature( - streak: &[ToolCallRef<'_>], - content_index: Option<&ContentIndex>, -) -> Option { - let idx = content_index?; - let mut first_sig: Option = None; - let mut diverged = false; - for r in streak { - let result = idx.tool_results.get(&r.call.id); - let sig = extract_error_signature(result); - let Some(sig) = sig else { continue }; - match &first_sig { - None => first_sig = Some(sig), - Some(existing) => { - if existing != &sig { - diverged = true; - break; - } - } - } - } - let first = first_sig?; - if diverged { - Some(format!("{first} (signatures diverged)")) - } else { - Some(first) - } -} - -pub(crate) fn detect_failure_runs_for_session<'a>( - session_id: &str, - turns: &'a [&'a TurnRecord], - pricing: &PricingTable, - content_index: Option<&ContentIndex>, -) -> Vec { - detect_streaks( - flatten_tool_calls(turns), - |_head, r| { - if r.call.is_error == Some(true) { - StreakOp::Extend - } else { - StreakOp::Break - } - }, - |streak| { - if streak.len() < MIN_FAILURE_RUN_LEN { - return None; - } - let mut keys: HashSet = HashSet::new(); - for r in streak.iter() { - keys.insert(format!("{}|{}", r.call.name, r.call.args_hash)); - } - // Same-(tool,args) run is a retry loop, not a failure run. See - // patterns.ts:868-872. - if keys.len() < 2 { - return None; - } - let first = streak.first().unwrap(); - let last = streak.last().unwrap(); - let mut tools: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for r in streak.iter() { - if seen.insert(r.call.name.clone()) { - tools.push(r.call.name.clone()); - } - } - let turns_in_streak: Vec<&TurnRecord> = streak.iter().map(|r| r.turn).collect(); - let contributing = dedup_turns(turns_in_streak); - let mut run = FailureRun { - session_id: session_id.to_string(), - length: streak.len() as u64, - start_turn_index: first.turn.turn_index, - end_turn_index: last.turn.turn_index, - tools_involved: tools, - cost: sum_cost_for_turns(&contributing, pricing), - error_signatures: None, - event_source: None, - }; - let sigs = failure_run_signatures(streak, content_index); - if !sigs.is_empty() { - run.error_signatures = Some(sigs); - } - Some(run) - }, - ) -} - -fn failure_run_signatures( - streak: &[ToolCallRef<'_>], - content_index: Option<&ContentIndex>, -) -> Vec { - let Some(idx) = content_index else { - return Vec::new(); - }; - let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for r in streak { - if seen.contains(&r.call.name) { - continue; - } - let result = idx.tool_results.get(&r.call.id); - let Some(sig) = extract_error_signature(result) else { - continue; - }; - out.push(FailureRunErrorSignature { - tool: r.call.name.clone(), - first_line: sig, - }); - seen.insert(r.call.name.clone()); - } - out -} - -// --------------------------------------------------------------------------- -// Edit revert detector -// --------------------------------------------------------------------------- - -pub(crate) fn detect_edit_reverts_for_session<'a>( - session_id: &str, - turns: &'a [&'a TurnRecord], - pricing: &PricingTable, - content_index: Option<&ContentIndex>, -) -> Vec { - struct EditSlot<'a> { - pre_hash: Option, - post_hash: Option, - turn: &'a TurnRecord, - tool_use_id: String, - } - let mut by_file: HashMap>> = HashMap::new(); - let mut cycles: Vec = Vec::new(); - - let flat = flatten_tool_calls(turns); - for r in &flat { - let call = r.call; - let Some(target) = call.target.as_deref() else { - continue; - }; - if call.name != "Edit" && call.name != "Write" && call.name != "NotebookEdit" { - continue; - } - // Failed edits don't actually change file state. Mirrors patterns.ts:951-952. - if call.is_error == Some(true) { - continue; - } - let slot = EditSlot { - pre_hash: call.edit_pre_hash.clone(), - post_hash: call.edit_post_hash.clone(), - turn: r.turn, - tool_use_id: call.id.clone(), - }; - let history = by_file.entry(target.to_string()).or_default(); - if let Some(post_hash) = &slot.post_hash { - let match_idx = history - .iter() - .position(|prior| prior.pre_hash.as_deref() == Some(post_hash.as_str())); - if let Some(idx) = match_idx { - let first = &history[idx]; - let mut cycle = EditRevertCycle { - session_id: session_id.to_string(), - file_path: target.to_string(), - first_edit_turn_index: first.turn.turn_index, - revert_turn_index: r.turn.turn_index, - span_turns: r.turn.turn_index - first.turn.turn_index, - cost: sum_cost_for_turns(&dedup_turns(vec![first.turn, r.turn]), pricing), - sample_preview: None, - }; - if let Some(content_idx) = content_index { - let first_edit = extract_edit_preview( - content_idx - .tool_uses - .get(&first.tool_use_id) - .map(|tu| &tu.input), - ); - let revert = extract_edit_preview( - content_idx - .tool_uses - .get(&slot.tool_use_id) - .map(|tu| &tu.input), - ); - if let (Some(first_edit), Some(revert)) = (first_edit, revert) { - cycle.sample_preview = Some(EditRevertSamplePreview { first_edit, revert }); - } - } - cycles.push(cycle); - // Reset the file's history. patterns.ts:982-984. - by_file.insert(target.to_string(), Vec::new()); - continue; - } - } - history.push(slot); - } - cycles -} - -// --------------------------------------------------------------------------- -// Compaction loss detector -// --------------------------------------------------------------------------- - -fn detect_compaction_losses( - events: &[CompactionEvent], - turns: &[TurnRecord], - pricing: &PricingTable, - content_by_session: Option<&HashMap>>, -) -> Vec { - // turn_by_message_id over the full input for cache pricing lookup. - let mut turn_by_message_id: HashMap<&str, &TurnRecord> = HashMap::new(); - for t in turns { - turn_by_message_id.insert(t.message_id.as_str(), t); - } - - // Group events by session in arrival order. - let mut events_order: Vec = Vec::new(); - let mut events_by_session: HashMap> = HashMap::new(); - for e in events { - if !events_by_session.contains_key(&e.session_id) { - events_order.push(e.session_id.clone()); - } - events_by_session - .entry(e.session_id.clone()) - .or_default() - .push(e); - } - for list in events_by_session.values_mut() { - list.sort_by(|a, b| a.ts.cmp(&b.ts)); - } - - // Sort turns by session, then turn_index. - let mut turns_by_session: HashMap> = HashMap::new(); - for t in turns { - turns_by_session - .entry(t.session_id.clone()) - .or_default() - .push(t); - } - for list in turns_by_session.values_mut() { - list.sort_by_key(|t| t.turn_index); - } - - let mut prev_boundary_ts: HashMap = HashMap::new(); - let mut out: Vec = Vec::new(); - - for sid in &events_order { - let session_events = events_by_session.get(sid).unwrap(); - for e in session_events { - let tokens = e.tokens_before_compact.unwrap_or(0); - let mut cache_lost_cost = 0.0_f64; - if tokens > 0 { - if let Some(precid) = e.preceding_message_id.as_deref() { - if let Some(preceding) = turn_by_message_id.get(precid) { - let usage = crate::reader::Usage { - input: 0, - output: 0, - reasoning: 0, - cache_read: tokens, - cache_create_5m: 0, - cache_create_1h: 0, - }; - if let Some(priced) = cost_for_usage( - &usage, - &preceding.model, - pricing, - CostForUsageOptions::default(), - ) { - cache_lost_cost = priced.total; - } - } - } - } - let mut loss = CompactionLoss { - session_id: e.session_id.clone(), - ts: e.ts.clone(), - preceding_message_id: e.preceding_message_id.clone(), - tokens_before_compact: tokens, - cache_lost_cost, - lost_work: None, - }; - // Gate on content-sidecar presence — `lost_work` is the "with - // content" enrichment. Mirrors patterns.ts:1066-1074. - if let Some(map) = content_by_session { - if map.contains_key(&e.session_id) { - let session_turns = turns_by_session - .get(&e.session_id) - .map(|v| v.as_slice()) - .unwrap_or(&[]); - let window_start = prev_boundary_ts.get(&e.session_id).cloned(); - loss.lost_work = Some(summarize_compacted_window( - session_turns, - window_start.as_deref(), - &e.ts, - )); - } - } - out.push(loss); - prev_boundary_ts.insert(e.session_id.clone(), e.ts.clone()); - } - } - out -} - -fn summarize_compacted_window( - session_turns: &[&TurnRecord], - window_start: Option<&str>, - boundary_ts: &str, -) -> CompactionLostWork { - let mut bash_count: u64 = 0; - let mut edit_count: u64 = 0; - let mut read_count: u64 = 0; - let mut files: BTreeSet = BTreeSet::new(); - for t in session_turns { - if let Some(ws) = window_start { - if t.ts.as_str() <= ws { - continue; - } - } - if t.ts.as_str() > boundary_ts { - continue; - } - for call in &t.tool_calls { - let name = normalize_tool_name(&call.name); - if name == "Bash" { - bash_count += 1; - } else if is_edit_tool(name) { - edit_count += 1; - if let Some(target) = &call.target { - files.insert(target.clone()); - } - } else if is_read_tool(name) { - read_count += 1; - } - } - } - CompactionLostWork { - files: files.into_iter().collect(), - bash_count, - edit_count, - read_count, - } -} - -// --------------------------------------------------------------------------- -// OpenCode skill detectors -// --------------------------------------------------------------------------- - -pub(crate) fn detect_skill_recall_dups_for_session( - session_id: &str, - turns: &[&TurnRecord], - pricing: &PricingTable, -) -> Vec { - if turns.is_empty() || turns[0].source != SourceKind::Opencode { - return Vec::new(); - } - let mut order: Vec = Vec::new(); - let mut by_name: HashMap>> = HashMap::new(); - let flat = flatten_tool_calls(turns); - for r in &flat { - if r.call.name != "skill" { - continue; - } - let Some(skill_name) = r.call.skill_name.as_deref() else { - continue; - }; - if !by_name.contains_key(skill_name) { - order.push(skill_name.to_string()); - } - by_name.entry(skill_name.to_string()).or_default().push(*r); - } - let mut out: Vec = Vec::new(); - for name in order { - let refs = by_name.get(&name).unwrap(); - if refs.len() < 2 { - continue; - } - let first = refs.first().unwrap(); - let last = refs.last().unwrap(); - let turns_in_streak: Vec<&TurnRecord> = refs.iter().map(|r| r.turn).collect(); - let contributing = dedup_turns(turns_in_streak); - out.push(SkillRecallDup { - session_id: session_id.to_string(), - skill_name: name, - call_count: refs.len() as u64, - first_turn_index: first.turn.turn_index, - last_turn_index: last.turn.turn_index, - cost: sum_cost_for_turns(&contributing, pricing), - }); - } - out -} - -pub(crate) fn detect_skill_pruning_protection_for_session( - session_id: &str, - turns: &[&TurnRecord], - pricing: &PricingTable, -) -> Vec { - if turns.is_empty() || turns[0].source != SourceKind::Opencode { - return Vec::new(); - } - let mut out: Vec = Vec::new(); - let flat = flatten_tool_calls(turns); - for r in &flat { - if r.call.name != "skill" { - continue; - } - let Some(skill_name) = r.call.skill_name.clone() else { - continue; - }; - let invoke_index = r.turn.turn_index; - let mut riding_turns = 0_u64; - let mut last_cached_turn_index = invoke_index; - let mut riding_cost = 0.0_f64; - for t in turns { - if t.turn_index <= invoke_index { - continue; - } - if t.usage.cache_read > 0 { - riding_turns += 1; - last_cached_turn_index = t.turn_index; - riding_cost += total_cost_for_turn(t, pricing); - } - } - if riding_turns == 0 { - continue; - } - let invoke_cost = total_cost_for_turn(r.turn, pricing); - out.push(SkillPruningProtection { - session_id: session_id.to_string(), - skill_name, - invoked_turn_index: invoke_index, - riding_turns, - last_cached_turn_index, - cost: invoke_cost + riding_cost, - }); - } - out -} - -pub(crate) fn detect_system_prompt_tax_for_session( - session_id: &str, - turns: &[&TurnRecord], - pricing: &PricingTable, - user_turns: Option<&[UserTurnRecord]>, -) -> Vec { - if turns.is_empty() || turns[0].source != SourceKind::Opencode { - return Vec::new(); - } - let first_turn = turns[0]; - let first_cache_create = first_turn.usage.cache_create_5m + first_turn.usage.cache_create_1h; - if first_cache_create == 0 { - return Vec::new(); - } - let mut first_user_tokens = 0_u64; - if let Some(ut) = user_turns { - if let Some(first_user_turn) = ut.first() { - for block in &first_user_turn.blocks { - first_user_tokens += block.approx_tokens; - } - } - } - if first_user_tokens == 0 { - return Vec::new(); - } - let system_prompt_tokens = first_cache_create.saturating_sub(first_user_tokens); - if system_prompt_tokens == 0 { - return Vec::new(); - } - - let mut riding_turns = 0_u64; - let mut total_cost = 0.0_f64; - for t in turns { - // Skip the first turn — its cost is the cacheCreate, not the riding - // tax (patterns.ts:1241-1243). - if t.message_id == first_turn.message_id && t.turn_index == first_turn.turn_index { - continue; - } - if t.usage.cache_read > 0 { - riding_turns += 1; - total_cost += total_cost_for_turn(t, pricing); - } - } - if riding_turns == 0 { - return Vec::new(); - } - vec![SystemPromptTax { - session_id: session_id.to_string(), - first_turn_cache_create: first_cache_create, - first_user_message_tokens: first_user_tokens, - estimated_system_prompt_tokens: system_prompt_tokens, - riding_turns, - total_cost, - }] -} - -// --------------------------------------------------------------------------- -// Edit-heavy detector -// --------------------------------------------------------------------------- - -pub(crate) fn detect_edit_heavy_for_session( - session_id: &str, - turns: &[&TurnRecord], - pricing: &PricingTable, -) -> Vec { - if turns.is_empty() { - return Vec::new(); - } - let mut read_count: u64 = 0; - let mut edit_count: u64 = 0; - let mut likely_retries: u64 = 0; - let mut edit_turns: Vec<&TurnRecord> = Vec::new(); - - for t in turns { - let mut turn_has_edit = false; - for call in &t.tool_calls { - let name = normalize_tool_name(&call.name); - if is_read_for_edit_heavy(call, t.source) { - read_count += 1; - } else if is_edit_tool(name) { - edit_count += 1; - turn_has_edit = true; - } - } - if turn_has_edit { - edit_turns.push(*t); - } - likely_retries += count_retries(&t.tool_calls); - } - - if edit_count < EDIT_HEAVY_MIN_EDITS { - return Vec::new(); - } - let ratio = if read_count == 0 { - f64::INFINITY - } else { - edit_count as f64 / read_count as f64 - }; - if ratio <= EDIT_HEAVY_RATIO { - return Vec::new(); - } - vec![EditHeavySession { - source: turns[0].source, - session_id: session_id.to_string(), - read_count, - edit_count, - ratio, - likely_retries, - cost: sum_cost_for_turns(&dedup_turns(edit_turns), pricing), - }] -} - -fn is_read_for_edit_heavy(call: &ToolCall, source: SourceKind) -> bool { - if is_read_tool(normalize_tool_name(&call.name)) { - return true; - } - source == SourceKind::Codex && is_codex_shell_file_read(call) -} - -fn is_codex_shell_file_read(call: &ToolCall) -> bool { - if !is_codex_shell_name(&call.name) { - return false; - } - let Some(target) = call.target.as_deref() else { - return false; - }; - shell_command_has_file_read(target) -} - // --------------------------------------------------------------------------- // Misc helpers // --------------------------------------------------------------------------- diff --git a/crates/relayburn-sdk/src/analyze/patterns/compaction.rs b/crates/relayburn-sdk/src/analyze/patterns/compaction.rs new file mode 100644 index 0000000..8275641 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/patterns/compaction.rs @@ -0,0 +1,150 @@ +use super::*; + +use std::collections::{BTreeSet, HashMap}; + +use crate::reader::{normalize_tool_name, CompactionEvent, ContentRecord, TurnRecord}; + +use crate::analyze::cost::{cost_for_usage, CostForUsageOptions}; +use crate::analyze::findings::{CompactionLoss, CompactionLostWork}; +use crate::analyze::pricing::PricingTable; + +pub(super) fn detect_compaction_losses( + events: &[CompactionEvent], + turns: &[TurnRecord], + pricing: &PricingTable, + content_by_session: Option<&HashMap>>, +) -> Vec { + // turn_by_message_id over the full input for cache pricing lookup. + let mut turn_by_message_id: HashMap<&str, &TurnRecord> = HashMap::new(); + for t in turns { + turn_by_message_id.insert(t.message_id.as_str(), t); + } + + // Group events by session in arrival order. + let mut events_order: Vec = Vec::new(); + let mut events_by_session: HashMap> = HashMap::new(); + for e in events { + if !events_by_session.contains_key(&e.session_id) { + events_order.push(e.session_id.clone()); + } + events_by_session + .entry(e.session_id.clone()) + .or_default() + .push(e); + } + for list in events_by_session.values_mut() { + list.sort_by(|a, b| a.ts.cmp(&b.ts)); + } + + // Sort turns by session, then turn_index. + let mut turns_by_session: HashMap> = HashMap::new(); + for t in turns { + turns_by_session + .entry(t.session_id.clone()) + .or_default() + .push(t); + } + for list in turns_by_session.values_mut() { + list.sort_by_key(|t| t.turn_index); + } + + let mut prev_boundary_ts: HashMap = HashMap::new(); + let mut out: Vec = Vec::new(); + + for sid in &events_order { + let session_events = events_by_session.get(sid).unwrap(); + for e in session_events { + let tokens = e.tokens_before_compact.unwrap_or(0); + let mut cache_lost_cost = 0.0_f64; + if tokens > 0 { + if let Some(precid) = e.preceding_message_id.as_deref() { + if let Some(preceding) = turn_by_message_id.get(precid) { + let usage = crate::reader::Usage { + input: 0, + output: 0, + reasoning: 0, + cache_read: tokens, + cache_create_5m: 0, + cache_create_1h: 0, + }; + if let Some(priced) = cost_for_usage( + &usage, + &preceding.model, + pricing, + CostForUsageOptions::default(), + ) { + cache_lost_cost = priced.total; + } + } + } + } + let mut loss = CompactionLoss { + session_id: e.session_id.clone(), + ts: e.ts.clone(), + preceding_message_id: e.preceding_message_id.clone(), + tokens_before_compact: tokens, + cache_lost_cost, + lost_work: None, + }; + // Gate on content-sidecar presence — `lost_work` is the "with + // content" enrichment. Mirrors patterns.ts:1066-1074. + if let Some(map) = content_by_session { + if map.contains_key(&e.session_id) { + let session_turns = turns_by_session + .get(&e.session_id) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + let window_start = prev_boundary_ts.get(&e.session_id).cloned(); + loss.lost_work = Some(summarize_compacted_window( + session_turns, + window_start.as_deref(), + &e.ts, + )); + } + } + out.push(loss); + prev_boundary_ts.insert(e.session_id.clone(), e.ts.clone()); + } + } + out +} + +fn summarize_compacted_window( + session_turns: &[&TurnRecord], + window_start: Option<&str>, + boundary_ts: &str, +) -> CompactionLostWork { + let mut bash_count: u64 = 0; + let mut edit_count: u64 = 0; + let mut read_count: u64 = 0; + let mut files: BTreeSet = BTreeSet::new(); + for t in session_turns { + if let Some(ws) = window_start { + if t.ts.as_str() <= ws { + continue; + } + } + if t.ts.as_str() > boundary_ts { + continue; + } + for call in &t.tool_calls { + let name = normalize_tool_name(&call.name); + if name == "Bash" { + bash_count += 1; + } else if is_edit_tool(name) { + edit_count += 1; + if let Some(target) = &call.target { + files.insert(target.clone()); + } + } else if is_read_tool(name) { + read_count += 1; + } + } + } + CompactionLostWork { + files: files.into_iter().collect(), + bash_count, + edit_count, + read_count, + } +} diff --git a/crates/relayburn-sdk/src/analyze/patterns/edits.rs b/crates/relayburn-sdk/src/analyze/patterns/edits.rs new file mode 100644 index 0000000..fc3ecf2 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/patterns/edits.rs @@ -0,0 +1,157 @@ +use super::*; + +use std::collections::HashMap; + +use crate::reader::{count_retries, normalize_tool_name, SourceKind, ToolCall, TurnRecord}; + +use crate::analyze::findings::{EditHeavySession, EditRevertCycle, EditRevertSamplePreview}; +use crate::analyze::pricing::PricingTable; + +use super::shell::shell_command_has_file_read; + +pub(crate) fn detect_edit_reverts_for_session<'a>( + session_id: &str, + turns: &'a [&'a TurnRecord], + pricing: &PricingTable, + content_index: Option<&ContentIndex>, +) -> Vec { + struct EditSlot<'a> { + pre_hash: Option, + post_hash: Option, + turn: &'a TurnRecord, + tool_use_id: String, + } + let mut by_file: HashMap>> = HashMap::new(); + let mut cycles: Vec = Vec::new(); + + let flat = flatten_tool_calls(turns); + for r in &flat { + let call = r.call; + let Some(target) = call.target.as_deref() else { + continue; + }; + if call.name != "Edit" && call.name != "Write" && call.name != "NotebookEdit" { + continue; + } + // Failed edits don't actually change file state. Mirrors patterns.ts:951-952. + if call.is_error == Some(true) { + continue; + } + let slot = EditSlot { + pre_hash: call.edit_pre_hash.clone(), + post_hash: call.edit_post_hash.clone(), + turn: r.turn, + tool_use_id: call.id.clone(), + }; + let history = by_file.entry(target.to_string()).or_default(); + if let Some(post_hash) = &slot.post_hash { + let match_idx = history + .iter() + .position(|prior| prior.pre_hash.as_deref() == Some(post_hash.as_str())); + if let Some(idx) = match_idx { + let first = &history[idx]; + let mut cycle = EditRevertCycle { + session_id: session_id.to_string(), + file_path: target.to_string(), + first_edit_turn_index: first.turn.turn_index, + revert_turn_index: r.turn.turn_index, + span_turns: r.turn.turn_index - first.turn.turn_index, + cost: sum_cost_for_turns(&dedup_turns(vec![first.turn, r.turn]), pricing), + sample_preview: None, + }; + if let Some(content_idx) = content_index { + let first_edit = extract_edit_preview( + content_idx + .tool_uses + .get(&first.tool_use_id) + .map(|tu| &tu.input), + ); + let revert = extract_edit_preview( + content_idx + .tool_uses + .get(&slot.tool_use_id) + .map(|tu| &tu.input), + ); + if let (Some(first_edit), Some(revert)) = (first_edit, revert) { + cycle.sample_preview = Some(EditRevertSamplePreview { first_edit, revert }); + } + } + cycles.push(cycle); + // Reset the file's history. patterns.ts:982-984. + by_file.insert(target.to_string(), Vec::new()); + continue; + } + } + history.push(slot); + } + cycles +} + +pub(crate) fn detect_edit_heavy_for_session( + session_id: &str, + turns: &[&TurnRecord], + pricing: &PricingTable, +) -> Vec { + if turns.is_empty() { + return Vec::new(); + } + let mut read_count: u64 = 0; + let mut edit_count: u64 = 0; + let mut likely_retries: u64 = 0; + let mut edit_turns: Vec<&TurnRecord> = Vec::new(); + + for t in turns { + let mut turn_has_edit = false; + for call in &t.tool_calls { + let name = normalize_tool_name(&call.name); + if is_read_for_edit_heavy(call, t.source) { + read_count += 1; + } else if is_edit_tool(name) { + edit_count += 1; + turn_has_edit = true; + } + } + if turn_has_edit { + edit_turns.push(*t); + } + likely_retries += count_retries(&t.tool_calls); + } + + if edit_count < EDIT_HEAVY_MIN_EDITS { + return Vec::new(); + } + let ratio = if read_count == 0 { + f64::INFINITY + } else { + edit_count as f64 / read_count as f64 + }; + if ratio <= EDIT_HEAVY_RATIO { + return Vec::new(); + } + vec![EditHeavySession { + source: turns[0].source, + session_id: session_id.to_string(), + read_count, + edit_count, + ratio, + likely_retries, + cost: sum_cost_for_turns(&dedup_turns(edit_turns), pricing), + }] +} + +fn is_read_for_edit_heavy(call: &ToolCall, source: SourceKind) -> bool { + if is_read_tool(normalize_tool_name(&call.name)) { + return true; + } + source == SourceKind::Codex && is_codex_shell_file_read(call) +} + +fn is_codex_shell_file_read(call: &ToolCall) -> bool { + if !is_codex_shell_name(&call.name) { + return false; + } + let Some(target) = call.target.as_deref() else { + return false; + }; + shell_command_has_file_read(target) +} diff --git a/crates/relayburn-sdk/src/analyze/patterns/skills.rs b/crates/relayburn-sdk/src/analyze/patterns/skills.rs new file mode 100644 index 0000000..4e0de94 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/patterns/skills.rs @@ -0,0 +1,157 @@ +use super::*; + +use std::collections::HashMap; + +use crate::reader::{SourceKind, TurnRecord, UserTurnRecord}; + +use crate::analyze::cost::total_cost_for_turn; +use crate::analyze::findings::{SkillPruningProtection, SkillRecallDup, SystemPromptTax}; +use crate::analyze::pricing::PricingTable; + +pub(crate) fn detect_skill_recall_dups_for_session( + session_id: &str, + turns: &[&TurnRecord], + pricing: &PricingTable, +) -> Vec { + if turns.is_empty() || turns[0].source != SourceKind::Opencode { + return Vec::new(); + } + let mut order: Vec = Vec::new(); + let mut by_name: HashMap>> = HashMap::new(); + let flat = flatten_tool_calls(turns); + for r in &flat { + if r.call.name != "skill" { + continue; + } + let Some(skill_name) = r.call.skill_name.as_deref() else { + continue; + }; + if !by_name.contains_key(skill_name) { + order.push(skill_name.to_string()); + } + by_name.entry(skill_name.to_string()).or_default().push(*r); + } + let mut out: Vec = Vec::new(); + for name in order { + let refs = by_name.get(&name).unwrap(); + if refs.len() < 2 { + continue; + } + let first = refs.first().unwrap(); + let last = refs.last().unwrap(); + let turns_in_streak: Vec<&TurnRecord> = refs.iter().map(|r| r.turn).collect(); + let contributing = dedup_turns(turns_in_streak); + out.push(SkillRecallDup { + session_id: session_id.to_string(), + skill_name: name, + call_count: refs.len() as u64, + first_turn_index: first.turn.turn_index, + last_turn_index: last.turn.turn_index, + cost: sum_cost_for_turns(&contributing, pricing), + }); + } + out +} + +pub(crate) fn detect_skill_pruning_protection_for_session( + session_id: &str, + turns: &[&TurnRecord], + pricing: &PricingTable, +) -> Vec { + if turns.is_empty() || turns[0].source != SourceKind::Opencode { + return Vec::new(); + } + let mut out: Vec = Vec::new(); + let flat = flatten_tool_calls(turns); + for r in &flat { + if r.call.name != "skill" { + continue; + } + let Some(skill_name) = r.call.skill_name.clone() else { + continue; + }; + let invoke_index = r.turn.turn_index; + let mut riding_turns = 0_u64; + let mut last_cached_turn_index = invoke_index; + let mut riding_cost = 0.0_f64; + for t in turns { + if t.turn_index <= invoke_index { + continue; + } + if t.usage.cache_read > 0 { + riding_turns += 1; + last_cached_turn_index = t.turn_index; + riding_cost += total_cost_for_turn(t, pricing); + } + } + if riding_turns == 0 { + continue; + } + let invoke_cost = total_cost_for_turn(r.turn, pricing); + out.push(SkillPruningProtection { + session_id: session_id.to_string(), + skill_name, + invoked_turn_index: invoke_index, + riding_turns, + last_cached_turn_index, + cost: invoke_cost + riding_cost, + }); + } + out +} + +pub(crate) fn detect_system_prompt_tax_for_session( + session_id: &str, + turns: &[&TurnRecord], + pricing: &PricingTable, + user_turns: Option<&[UserTurnRecord]>, +) -> Vec { + if turns.is_empty() || turns[0].source != SourceKind::Opencode { + return Vec::new(); + } + let first_turn = turns[0]; + let first_cache_create = first_turn.usage.cache_create_5m + first_turn.usage.cache_create_1h; + if first_cache_create == 0 { + return Vec::new(); + } + let mut first_user_tokens = 0_u64; + if let Some(ut) = user_turns { + if let Some(first_user_turn) = ut.first() { + for block in &first_user_turn.blocks { + first_user_tokens += block.approx_tokens; + } + } + } + if first_user_tokens == 0 { + return Vec::new(); + } + let system_prompt_tokens = first_cache_create.saturating_sub(first_user_tokens); + if system_prompt_tokens == 0 { + return Vec::new(); + } + + let mut riding_turns = 0_u64; + let mut total_cost = 0.0_f64; + for t in turns { + // Skip the first turn — its cost is the cacheCreate, not the riding + // tax (patterns.ts:1241-1243). + if t.message_id == first_turn.message_id && t.turn_index == first_turn.turn_index { + continue; + } + if t.usage.cache_read > 0 { + riding_turns += 1; + total_cost += total_cost_for_turn(t, pricing); + } + } + if riding_turns == 0 { + return Vec::new(); + } + vec![SystemPromptTax { + session_id: session_id.to_string(), + first_turn_cache_create: first_cache_create, + first_user_message_tokens: first_user_tokens, + estimated_system_prompt_tokens: system_prompt_tokens, + riding_turns, + total_cost, + }] +} diff --git a/crates/relayburn-sdk/src/analyze/patterns/streaks.rs b/crates/relayburn-sdk/src/analyze/patterns/streaks.rs new file mode 100644 index 0000000..51a58f1 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/patterns/streaks.rs @@ -0,0 +1,372 @@ +use super::*; + +use std::collections::HashSet; + +use crate::reader::{ + ToolResultEventRecord, ToolResultEventSource, ToolResultStatus, TurnRecord, +}; + +use crate::analyze::findings::{ + CancellationRun, FailureRun, FailureRunErrorSignature, RetryLoop, +}; +use crate::analyze::pricing::PricingTable; + +pub(super) struct GraphStatusPatterns { + pub(super) retry_loops: Vec, + pub(super) failure_runs: Vec, + pub(super) cancelled_runs: Vec, +} + +pub(super) fn detect_graph_status_patterns_for_session<'a>( + session_id: &str, + turns: &[&'a TurnRecord], + events: &[&'a ToolResultEventRecord], + pricing: &PricingTable, + content_index: Option<&ContentIndex>, +) -> GraphStatusPatterns { + let terminal_refs = build_terminal_event_refs(session_id, turns, events); + GraphStatusPatterns { + retry_loops: detect_graph_retry_loops_for_session( + session_id, + &terminal_refs, + pricing, + content_index, + ), + failure_runs: detect_graph_failure_runs_for_session( + session_id, + &terminal_refs, + pricing, + content_index, + ), + cancelled_runs: detect_graph_cancellation_runs_for_session( + session_id, + &terminal_refs, + pricing, + ), + } +} + +fn detect_graph_retry_loops_for_session<'a>( + session_id: &str, + refs: &[ToolResultEventRef<'a>], + pricing: &PricingTable, + content_index: Option<&ContentIndex>, +) -> Vec { + detect_streaks( + refs.iter().cloned(), + |head, r| { + let is_errored = matches!(r.event.status, ToolResultStatus::Errored); + if !is_errored || r.call.is_none() || r.args_hash.is_none() { + StreakOp::Break + } else if let Some(head) = head { + if head.tool == r.tool && head.args_hash == r.args_hash { + StreakOp::Extend + } else { + StreakOp::Rotate + } + } else { + StreakOp::Extend + } + }, + |streak| { + if streak.len() < MIN_RETRY_LEN { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + let contributing = dedup_defined_turns(streak); + let mut loop_ = RetryLoop { + session_id: session_id.to_string(), + tool: first.tool.clone(), + target: first.target.clone(), + args_hash: first.args_hash.clone().unwrap_or_default(), + attempts: streak.len() as u64, + start_turn_index: first.turn_index, + end_turn_index: last.turn_index, + cost: sum_cost_for_turns(&contributing, pricing), + error_signature: None, + event_source: Some(coalesce_event_source(streak)), + }; + let call_refs = event_refs_to_tool_call_refs(streak); + if let Some(sig) = retry_loop_signature(&call_refs, content_index) { + loop_.error_signature = Some(sig); + } + Some(loop_) + }, + ) +} + +fn detect_graph_failure_runs_for_session<'a>( + session_id: &str, + refs: &[ToolResultEventRef<'a>], + pricing: &PricingTable, + content_index: Option<&ContentIndex>, +) -> Vec { + detect_streaks( + refs.iter().cloned(), + |_head, r| { + if matches!(r.event.status, ToolResultStatus::Errored) { + StreakOp::Extend + } else { + StreakOp::Break + } + }, + |streak| { + if streak.len() < MIN_FAILURE_RUN_LEN { + return None; + } + let mut keys: HashSet = HashSet::new(); + for r in streak.iter() { + keys.insert(status_pattern_key(r)); + } + let has_non_tool_result = streak + .iter() + .any(|r| !matches!(r.event.event_source, ToolResultEventSource::ToolResult)); + // A same-(tool,args) tool_result run is a retry loop. Non-tool_result + // terminal events (notably subagent notifications) remain failure + // runs — they represent child invocations ending badly, not a parent + // retry loop. Mirrors patterns.ts:706-710. + if keys.len() < 2 && !has_non_tool_result { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + // First-seen unique tool order. + let mut tools: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for r in streak.iter() { + if seen.insert(r.tool.clone()) { + tools.push(r.tool.clone()); + } + } + let contributing = dedup_defined_turns(streak); + let mut run = FailureRun { + session_id: session_id.to_string(), + length: streak.len() as u64, + start_turn_index: first.turn_index, + end_turn_index: last.turn_index, + tools_involved: tools, + cost: sum_cost_for_turns(&contributing, pricing), + error_signatures: None, + event_source: Some(coalesce_event_source(streak)), + }; + let call_refs = event_refs_to_tool_call_refs(streak); + let sigs = failure_run_signatures(&call_refs, content_index); + if !sigs.is_empty() { + run.error_signatures = Some(sigs); + } + Some(run) + }, + ) +} + +fn detect_graph_cancellation_runs_for_session<'a>( + session_id: &str, + refs: &[ToolResultEventRef<'a>], + pricing: &PricingTable, +) -> Vec { + detect_streaks( + refs.iter().cloned(), + |_head, r| { + if matches!(r.event.status, ToolResultStatus::Cancelled) { + StreakOp::Extend + } else { + StreakOp::Break + } + }, + |streak| { + if streak.is_empty() { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + let mut tools: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for r in streak.iter() { + if seen.insert(r.tool.clone()) { + tools.push(r.tool.clone()); + } + } + let contributing = dedup_defined_turns(streak); + Some(CancellationRun { + session_id: session_id.to_string(), + length: streak.len() as u64, + start_turn_index: first.turn_index, + end_turn_index: last.turn_index, + tools_involved: tools, + cost: sum_cost_for_turns(&contributing, pricing), + event_source: coalesce_event_source(streak), + }) + }, + ) +} + +fn status_pattern_key(r: &ToolResultEventRef<'_>) -> String { + let args = r + .args_hash + .clone() + .unwrap_or_else(|| r.event.tool_use_id.clone()); + format!("{}|{}", r.tool, args) +} + +pub(crate) fn detect_retry_loops_for_session<'a>( + session_id: &str, + turns: &'a [&'a TurnRecord], + pricing: &PricingTable, + content_index: Option<&ContentIndex>, +) -> Vec { + detect_streaks( + flatten_tool_calls(turns), + |head, r| { + if r.call.is_error != Some(true) { + StreakOp::Break + } else if let Some(head) = head { + if head.call.name == r.call.name && head.call.args_hash == r.call.args_hash { + StreakOp::Extend + } else { + StreakOp::Rotate + } + } else { + StreakOp::Extend + } + }, + |streak| { + if streak.len() < MIN_RETRY_LEN { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + let turns_in_streak: Vec<&TurnRecord> = streak.iter().map(|r| r.turn).collect(); + let contributing = dedup_turns(turns_in_streak); + let mut loop_ = RetryLoop { + session_id: session_id.to_string(), + tool: first.call.name.clone(), + target: first.call.target.clone(), + args_hash: first.call.args_hash.clone(), + attempts: streak.len() as u64, + start_turn_index: first.turn.turn_index, + end_turn_index: last.turn.turn_index, + cost: sum_cost_for_turns(&contributing, pricing), + error_signature: None, + event_source: None, + }; + if let Some(sig) = retry_loop_signature(streak, content_index) { + loop_.error_signature = Some(sig); + } + Some(loop_) + }, + ) +} + +fn retry_loop_signature( + streak: &[ToolCallRef<'_>], + content_index: Option<&ContentIndex>, +) -> Option { + let idx = content_index?; + let mut first_sig: Option = None; + let mut diverged = false; + for r in streak { + let result = idx.tool_results.get(&r.call.id); + let sig = extract_error_signature(result); + let Some(sig) = sig else { continue }; + match &first_sig { + None => first_sig = Some(sig), + Some(existing) => { + if existing != &sig { + diverged = true; + break; + } + } + } + } + let first = first_sig?; + if diverged { + Some(format!("{first} (signatures diverged)")) + } else { + Some(first) + } +} + +pub(crate) fn detect_failure_runs_for_session<'a>( + session_id: &str, + turns: &'a [&'a TurnRecord], + pricing: &PricingTable, + content_index: Option<&ContentIndex>, +) -> Vec { + detect_streaks( + flatten_tool_calls(turns), + |_head, r| { + if r.call.is_error == Some(true) { + StreakOp::Extend + } else { + StreakOp::Break + } + }, + |streak| { + if streak.len() < MIN_FAILURE_RUN_LEN { + return None; + } + let mut keys: HashSet = HashSet::new(); + for r in streak.iter() { + keys.insert(format!("{}|{}", r.call.name, r.call.args_hash)); + } + // Same-(tool,args) run is a retry loop, not a failure run. See + // patterns.ts:868-872. + if keys.len() < 2 { + return None; + } + let first = streak.first().unwrap(); + let last = streak.last().unwrap(); + let mut tools: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for r in streak.iter() { + if seen.insert(r.call.name.clone()) { + tools.push(r.call.name.clone()); + } + } + let turns_in_streak: Vec<&TurnRecord> = streak.iter().map(|r| r.turn).collect(); + let contributing = dedup_turns(turns_in_streak); + let mut run = FailureRun { + session_id: session_id.to_string(), + length: streak.len() as u64, + start_turn_index: first.turn.turn_index, + end_turn_index: last.turn.turn_index, + tools_involved: tools, + cost: sum_cost_for_turns(&contributing, pricing), + error_signatures: None, + event_source: None, + }; + let sigs = failure_run_signatures(streak, content_index); + if !sigs.is_empty() { + run.error_signatures = Some(sigs); + } + Some(run) + }, + ) +} + +fn failure_run_signatures( + streak: &[ToolCallRef<'_>], + content_index: Option<&ContentIndex>, +) -> Vec { + let Some(idx) = content_index else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for r in streak { + if seen.contains(&r.call.name) { + continue; + } + let result = idx.tool_results.get(&r.call.id); + let Some(sig) = extract_error_signature(result) else { + continue; + }; + out.push(FailureRunErrorSignature { + tool: r.call.name.clone(), + first_line: sig, + }); + seen.insert(r.call.name.clone()); + } + out +} From a37b6a1dba9796e097e64f091b9c084508e8b84f Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 20:44:29 -0400 Subject: [PATCH 08/22] refactor(sdk/analyze): externalize hotspots tests to hotspots_tests.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hotspots.rs was 2451 lines, but ~1426 of those were the inline `#[cfg(test)] mod tests` block — the code itself is a cohesive ~1024-line attribution + aggregation module. Move the test block verbatim into hotspots_tests.rs, included via `#[cfg(test)] #[path = "hotspots_tests.rs"] mod tests;`, mirroring the existing patterns_tests.rs convention. The source file is now 1027 lines of pure code. The module name stays `tests` so test paths (analyze::hotspots::tests::*) are unchanged; the 24 hotspots tests pass identically. Pure relocation — no code or test logic touched. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/hotspots.rs | 1428 +---------------- .../src/analyze/hotspots_tests.rs | 1427 ++++++++++++++++ 2 files changed, 1429 insertions(+), 1426 deletions(-) create mode 100644 crates/relayburn-sdk/src/analyze/hotspots_tests.rs diff --git a/crates/relayburn-sdk/src/analyze/hotspots.rs b/crates/relayburn-sdk/src/analyze/hotspots.rs index 2f49c78..8ea1af5 100644 --- a/crates/relayburn-sdk/src/analyze/hotspots.rs +++ b/crates/relayburn-sdk/src/analyze/hotspots.rs @@ -1023,1429 +1023,5 @@ pub fn aggregate_by_mcp_server(attributions: &[ToolAttribution]) -> Vec Usage { - Usage { - input: 0, - output: 0, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - } - } - - #[allow(clippy::too_many_arguments)] - fn turn( - session_id: &str, - message_id: &str, - turn_index: u64, - ts: &str, - model: &str, - usage: Usage, - tool_calls: Vec, - source: SourceKind, - ) -> TurnRecord { - TurnRecord { - v: 1, - source, - session_id: session_id.into(), - session_path: None, - message_id: message_id.into(), - turn_index, - ts: ts.into(), - model: model.into(), - project: None, - project_key: None, - usage, - tool_calls, - files_touched: None, - subagent: None, - stop_reason: None, - activity: None, - retries: None, - has_edits: None, - fidelity: None, - } - } - - fn tc(id: &str, name: &str, target: Option<&str>) -> ToolCall { - let target_part = target.unwrap_or(id); - ToolCall { - id: id.into(), - name: name.into(), - target: target.map(String::from), - args_hash: format!("{name}:{target_part}"), - is_error: None, - edit_pre_hash: None, - edit_post_hash: None, - skill_name: None, - replaced_tools: None, - collapsed_calls: None, - } - } - - fn tc_with_hash(id: &str, name: &str, target: &str, args_hash: &str) -> ToolCall { - ToolCall { - id: id.into(), - name: name.into(), - target: Some(target.into()), - args_hash: args_hash.into(), - is_error: None, - edit_pre_hash: None, - edit_post_hash: None, - skill_name: None, - replaced_tools: None, - collapsed_calls: None, - } - } - - fn tool_result_content( - session_id: &str, - tool_use_id: &str, - text: &str, - ts: &str, - ) -> ContentRecord { - ContentRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.into(), - message_id: format!("m-{tool_use_id}"), - ts: ts.into(), - role: ContentRole::ToolResult, - kind: ContentKind::ToolResult, - text: None, - tool_use: None, - tool_result: Some(ContentToolResult { - tool_use_id: tool_use_id.into(), - content: json!(text), - is_error: None, - }), - } - } - - fn user_turn(session_id: &str, user_uuid: &str, blocks: Vec) -> UserTurnRecord { - UserTurnRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.into(), - user_uuid: user_uuid.into(), - ts: "2026-04-20T00:00:00.500Z".into(), - preceding_message_id: Some("msg-0".into()), - following_message_id: Some("msg-1".into()), - blocks, - } - } - - fn tool_result_block(tool_use_id: &str, byte_len: u64, approx_tokens: u64) -> UserTurnBlock { - UserTurnBlock { - kind: UserTurnBlockKind::ToolResult, - tool_use_id: Some(tool_use_id.into()), - byte_len, - approx_tokens, - is_error: None, - } - } - - fn bash_attribution( - command: &str, - args_hash: &str, - total_cost: f64, - initial_tokens: f64, - persistence_tokens: f64, - riding_turns: u64, - ) -> ToolAttribution { - ToolAttribution { - tool_use_id: format!("tu-{args_hash}"), - tool_name: "Bash".into(), - target: Some(command.into()), - args_hash: args_hash.into(), - session_id: "s-bash-verb".into(), - emit_turn_index: 0, - emit_ts: "2026-04-20T00:00:00.000Z".into(), - model: "claude-sonnet-4-6".into(), - project: None, - project_key: None, - subagent_type: None, - result_tokens: 0, - result_bytes_estimated: true, - output_bytes: None, - output_truncated: None, - initial_cost: total_cost, - initial_tokens, - persistence_cost: 0.0, - persistence_tokens, - riding_turns, - total_cost, - } - } - - #[test] - fn attributes_persistence_of_8k_read_across_20_ride_along_turns_within_10_pct() { - let pricing = load_builtin_pricing(); - let rate = pricing - .get("claude-sonnet-4-6") - .expect("sonnet present") - .clone(); - const READ_TOKENS: u64 = 8000; - let read_text: String = "x".repeat((READ_TOKENS as usize) * 4); - - let session_id = "s-hotspots-1"; - let mut turns: Vec = Vec::new(); - - // Turn 0: assistant emits the Read tool_use. - turns.push(turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 200, - output: 50, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![tc("tu_read_1", "Read", Some("/src/big.ts"))], - SourceKind::ClaudeCode, - )); - - // Turn 1 pays initial: 8000 tokens enter as fresh input. - turns.push(turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:01.000Z", - "claude-sonnet-4-6", - Usage { - input: READ_TOKENS, - output: 30, - reasoning: 0, - cache_read: 250, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - )); - - // Turns 2..=21: 20 ride-along turns each with cacheRead >= READ_TOKENS. - for i in 2..=21u64 { - turns.push(turn( - session_id, - &format!("msg-{i}"), - i, - &format!("2026-04-20T00:00:{:02}.000Z", i), - "claude-sonnet-4-6", - Usage { - input: 50, - output: 30, - reasoning: 0, - cache_read: READ_TOKENS + 2000, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - )); - } - - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( - session_id, - "tu_read_1", - &read_text, - "2026-04-20T00:00:00.500Z", - )], - ); - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - assert_eq!(result.attributions.len(), 1); - let a = &result.attributions[0]; - assert_eq!(a.tool_use_id, "tu_read_1"); - - let expected_initial = (READ_TOKENS as f64 / 1_000_000.0) * rate.input; - let expected_persistence = 20.0 * (READ_TOKENS as f64 / 1_000_000.0) * rate.cache_read; - let expected_total = expected_initial + expected_persistence; - assert!( - (a.total_cost - expected_total).abs() <= expected_total * 0.10, - "total={} expected={} diff>10%", - a.total_cost, - expected_total - ); - assert_eq!(a.riding_turns, 20); - } - - #[test] - fn aggregates_by_file_and_ranks_most_expensive_read_first() { - let pricing = load_builtin_pricing(); - let session_id = "s-files"; - const READ_TOKENS: u64 = 5000; - const SMALL_TOKENS: u64 = 200; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_a", "Read", Some("/big.ts")), - tc("tu_b", "Read", Some("/small.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: READ_TOKENS + SMALL_TOKENS, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-2", - 2, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 100, - output: 5, - reasoning: 0, - cache_read: READ_TOKENS + SMALL_TOKENS + 500, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-3", - 3, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 100, - output: 5, - reasoning: 0, - cache_read: READ_TOKENS + SMALL_TOKENS + 500, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![ - tool_result_content( - session_id, - "tu_a", - &"x".repeat((READ_TOKENS as usize) * 4), - "2026-04-20T00:00:00.100Z", - ), - tool_result_content( - session_id, - "tu_b", - &"y".repeat((SMALL_TOKENS as usize) * 4), - "2026-04-20T00:00:00.101Z", - ), - ], - ); - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - let files = aggregate_by_file(&result.attributions); - assert_eq!(files.len(), 2); - assert_eq!(files[0].path, "/big.ts"); - assert_eq!(files[1].path, "/small.ts"); - assert!(files[0].total_cost > files[1].total_cost); - } - - #[test] - fn aggregates_by_bash_args_hash_so_repeated_commands_collapse() { - let pricing = load_builtin_pricing(); - let session_id = "s-bash"; - let mut turns: Vec = Vec::new(); - let mut ts = 0u64; - for i in 0..3 { - turns.push(turn( - session_id, - &format!("msg-emit-{i}"), - ts, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![tc_with_hash( - &format!("tu_b_{i}"), - "Bash", - "ls -la", - "Bash:ls", - )], - SourceKind::ClaudeCode, - )); - ts += 1; - turns.push(turn( - session_id, - &format!("msg-pay-{i}"), - ts, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 1000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - )); - ts += 1; - } - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![ - tool_result_content( - session_id, - "tu_b_0", - &"x".repeat(4000), - "2026-04-20T00:00:00.100Z", - ), - tool_result_content( - session_id, - "tu_b_1", - &"x".repeat(4000), - "2026-04-20T00:00:00.200Z", - ), - tool_result_content( - session_id, - "tu_b_2", - &"x".repeat(4000), - "2026-04-20T00:00:00.300Z", - ), - ], - ); - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - let bash = aggregate_by_bash(&result.attributions); - assert_eq!(bash.len(), 1); - assert_eq!(bash[0].call_count, 3); - } - - #[test] - fn aggregates_bash_cost_by_normalized_verb_with_distinct_command_and_examples() { - let attrs = vec![ - bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), - bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), - bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), - bash_attribution("git diff src/a.ts", "git:diff:a", 5.0, 100.0, 10.0, 1), - bash_attribution("git diff src/a.ts", "git:diff:a", 5.0, 100.0, 10.0, 1), - bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), - bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), - bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), - bash_attribution("pnpm run test", "pnpm:test", 4.0, 40.0, 8.0, 1), - ]; - - let verbs = aggregate_by_bash_verb(&attrs, parse_bash_command); - assert_eq!(verbs[0].verb, "git diff"); - assert_eq!(verbs[0].call_count, 5); - assert_eq!(verbs[0].distinct_commands, 2); - assert!((verbs[0].total_cost - 31.0).abs() < 1e-9); - assert!((verbs[0].initial_tokens - 500.0).abs() < 1e-9); - assert!((verbs[0].persistence_tokens - 80.0).abs() < 1e-9); - assert!((verbs[0].avg_persistence_turns - 1.6).abs() < 1e-9); - assert_eq!( - verbs[0].top_examples, - vec!["git diff src/b.ts", "git diff src/a.ts"] - ); - - assert_eq!(verbs[1].verb, "git status"); - assert_eq!(verbs[1].call_count, 3); - assert_eq!(verbs[1].distinct_commands, 1); - assert_eq!(verbs[2].verb, "pnpm test"); - } - - #[test] - fn aggregates_subagent_calls_by_subagent_type() { - let pricing = load_builtin_pricing(); - let session_id = "s-agent"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![tc_with_hash( - "tu_a1", - "Agent", - "general-purpose", - "Agent:gp", - )], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 2000, - output: 10, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( - session_id, - "tu_a1", - &"z".repeat(8000), - "2026-04-20T00:00:00.100Z", - )], - ); - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - let subagents = aggregate_by_subagent(&result.attributions); - assert_eq!(subagents.len(), 1); - assert_eq!(subagents[0].subagent_type, "general-purpose"); - assert_eq!(subagents[0].call_count, 1); - assert!(subagents[0].total_cost > 0.0); - } - - fn mcp_attribution(tool_name: &str, total_cost: f64, riding_turns: u64) -> ToolAttribution { - ToolAttribution { - tool_use_id: format!("tu-{tool_name}"), - tool_name: tool_name.into(), - target: None, - args_hash: format!("{tool_name}:0"), - session_id: "s-mcp".into(), - emit_turn_index: 0, - emit_ts: "2026-04-20T00:00:00.000Z".into(), - model: "claude-sonnet-4-6".into(), - project: None, - project_key: None, - subagent_type: None, - result_tokens: 0, - result_bytes_estimated: true, - initial_cost: total_cost, - initial_tokens: total_cost * 100.0, - persistence_cost: 0.0, - persistence_tokens: total_cost * 50.0, - riding_turns, - total_cost, - output_bytes: None, - output_truncated: None, - } - } - - #[test] - fn aggregates_by_mcp_server_groups_by_server_segment_and_sorts_by_cost() { - // Two MCP servers + a non-MCP tool + a malformed mcp__ name. The - // non-MCP + malformed rows must NOT show up; the relaycast roll-up - // must collapse all three relaycast tools into a single row with - // top_tools sorted by cost desc. - let attrs = vec![ - mcp_attribution("mcp__relaycast__send_dm", 2.0, 1), - mcp_attribution("mcp__relaycast__send_dm", 1.5, 0), - mcp_attribution("mcp__relaycast__list_channels", 0.5, 0), - mcp_attribution("mcp__relaycast__react_to_message", 0.25, 0), - mcp_attribution("mcp__github__get_file_contents", 1.0, 2), - mcp_attribution("mcp__github__create_pull_request", 0.1, 0), - // Non-MCP — must be skipped. - mcp_attribution("Read", 99.0, 5), - // Malformed: missing tool segment. - mcp_attribution("mcp__only_server__", 50.0, 0), - // Malformed: missing server segment. - mcp_attribution("mcp____tool_only", 50.0, 0), - // Malformed: not enough separators. - mcp_attribution("mcp__no_double_separator", 50.0, 0), - ]; - - let rows = aggregate_by_mcp_server(&attrs); - assert_eq!( - rows.len(), - 2, - "only the two well-formed mcp__ servers should aggregate" - ); - - // relaycast wins on cumulative cost (2.0 + 1.5 + 0.5 + 0.25 = 4.25) - // vs github (1.0 + 0.1 = 1.1). - let relaycast = &rows[0]; - assert_eq!(relaycast.server, "relaycast"); - assert_eq!(relaycast.call_count, 4); - assert!((relaycast.total_cost - 4.25).abs() < 1e-9); - assert!((relaycast.initial_tokens - 4.25 * 100.0).abs() < 1e-9); - assert!((relaycast.persistence_tokens - 4.25 * 50.0).abs() < 1e-9); - assert_eq!(relaycast.riding_turns, 1); - assert_eq!( - relaycast.top_tools, - vec!["send_dm", "list_channels", "react_to_message"], - ); - - let github = &rows[1]; - assert_eq!(github.server, "github"); - assert_eq!(github.call_count, 2); - assert!((github.total_cost - 1.1).abs() < 1e-9); - assert_eq!(github.riding_turns, 2); - assert_eq!( - github.top_tools, - vec!["get_file_contents", "create_pull_request"], - ); - } - - #[test] - fn falls_back_to_even_split_when_no_content_is_provided() { - let pricing = load_builtin_pricing(); - let session_id = "s-fallback"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_x", "Read", Some("/a.ts")), - tc("tu_y", "Read", Some("/b.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 4000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: None, - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - assert_eq!(result.attributions.len(), 2); - let rate = pricing.get("claude-sonnet-4-6").unwrap(); - let expected = ((4000.0 / 1_000_000.0) * rate.input) / 2.0; - for a in &result.attributions { - assert!((a.initial_cost - expected).abs() < 1e-9); - assert_eq!(a.persistence_cost, 0.0); - } - assert_eq!( - result.session_totals[0].attribution_method, - AttributionMethod::EvenSplit - ); - } - - #[test] - fn uses_user_turn_block_sizes_when_content_sidecar_is_unavailable() { - let pricing = load_builtin_pricing(); - let session_id = "s-user-turn-fallback"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_big", "Read", Some("/big.ts")), - tc("tu_small", "Read", Some("/small.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 4000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-2", - 2, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 100, - output: 5, - reasoning: 0, - cache_read: 4500, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - - let mut user_turns_by_session: HashMap> = HashMap::new(); - user_turns_by_session.insert( - session_id.into(), - vec![user_turn( - session_id, - "u-1", - vec![ - tool_result_block("tu_big", 12_000, 3000), - tool_result_block("tu_small", 4000, 1000), - ], - )], - ); - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: None, - user_turns_by_session: Some(&user_turns_by_session), - tool_result_events_by_session: None, - }, - ); - let by_id: HashMap<&str, &ToolAttribution> = result - .attributions - .iter() - .map(|a| (a.tool_use_id.as_str(), a)) - .collect(); - assert_eq!( - result.session_totals[0].attribution_method, - AttributionMethod::Sized - ); - assert!((by_id["tu_big"].initial_tokens - 3000.0).abs() < 1e-9); - assert!((by_id["tu_small"].initial_tokens - 1000.0).abs() < 1e-9); - assert!((by_id["tu_big"].persistence_tokens - 3000.0).abs() < 1e-9); - assert!((by_id["tu_small"].persistence_tokens - 1000.0).abs() < 1e-9); - assert!(by_id["tu_big"].total_cost > by_id["tu_small"].total_cost); - } - - #[test] - fn prefers_user_turn_block_sizes_over_content_sidecar_estimates() { - let pricing = load_builtin_pricing(); - let session_id = "s-sidecar-primary"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![tc("tu_read", "Read", Some("/file.ts"))], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 10_000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( - session_id, - "tu_read", - &"x".repeat(1000 * 4), - "2026-04-20T00:00:00.100Z", - )], - ); - let mut user_turns_by_session: HashMap> = HashMap::new(); - user_turns_by_session.insert( - session_id.into(), - vec![user_turn( - session_id, - "u-1", - vec![tool_result_block("tu_read", 36_000, 9000)], - )], - ); - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: Some(&user_turns_by_session), - tool_result_events_by_session: None, - }, - ); - assert_eq!( - result.session_totals[0].attribution_method, - AttributionMethod::Sized - ); - assert!((result.attributions[0].initial_tokens - 9000.0).abs() < 1e-9); - } - - #[test] - fn caps_sibling_initial_cost_at_next_turns_actual_new_content() { - let pricing = load_builtin_pricing(); - let session_id = "s-cap"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_big", "Read", Some("/big.ts")), - tc("tu_med", "Read", Some("/med.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 5000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![ - tool_result_content( - session_id, - "tu_big", - &"x".repeat(6000 * 4), - "2026-04-20T00:00:00.100Z", - ), - tool_result_content( - session_id, - "tu_med", - &"y".repeat(4000 * 4), - "2026-04-20T00:00:00.101Z", - ), - ], - ); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - let summed: f64 = result.attributions.iter().map(|a| a.initial_tokens).sum(); - assert!(summed <= 5000.0 + 1e-6, "summed={summed} > newContent=5000"); - let big = result - .attributions - .iter() - .find(|a| a.tool_use_id == "tu_big") - .unwrap(); - let med = result - .attributions - .iter() - .find(|a| a.tool_use_id == "tu_med") - .unwrap(); - assert!((big.initial_tokens - 3000.0).abs() < 1e-6); - assert!((med.initial_tokens - 2000.0).abs() < 1e-6); - } - - #[test] - fn caps_sibling_persistence_at_turns_actual_cache_read() { - let pricing = load_builtin_pricing(); - let session_id = "s-persist-cap"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_a", "Read", Some("/a.ts")), - tc("tu_b", "Read", Some("/b.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 8000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-2", - 2, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 50, - output: 5, - reasoning: 0, - cache_read: 5000, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![ - tool_result_content( - session_id, - "tu_a", - &"x".repeat(4000 * 4), - "2026-04-20T00:00:00.100Z", - ), - tool_result_content( - session_id, - "tu_b", - &"y".repeat(4000 * 4), - "2026-04-20T00:00:00.101Z", - ), - ], - ); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - let summed_persist: f64 = result - .attributions - .iter() - .map(|a| a.persistence_tokens) - .sum(); - assert!( - summed_persist <= 5000.0 + 1e-6, - "summedPersist={summed_persist} > cacheRead=5000" - ); - for a in &result.attributions { - assert!((a.persistence_tokens - 2500.0).abs() < 1e-6); - } - } - - #[test] - fn uses_paying_turns_model_rate_not_emit_turns() { - let pricing = load_builtin_pricing(); - let sonnet = pricing.get("claude-sonnet-4-6").unwrap().clone(); - let haiku = pricing.get("claude-haiku-4-5").unwrap().clone(); - assert_ne!(haiku.input, sonnet.input, "test prerequisite: rates differ"); - - let session_id = "s-cross-model"; - const TOK: u64 = 4000; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![tc("tu_x", "Read", Some("/x.ts"))], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-haiku-4-5", - Usage { - input: TOK, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-2", - 2, - "2026-04-20T00:00:00.000Z", - "claude-haiku-4-5", - Usage { - input: 50, - output: 5, - reasoning: 0, - cache_read: TOK + 100, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( - session_id, - "tu_x", - &"z".repeat((TOK as usize) * 4), - "2026-04-20T00:00:00.100Z", - )], - ); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - let a = &result.attributions[0]; - let expected_initial = (TOK as f64 / 1_000_000.0) * haiku.input; - let expected_persistence = (TOK as f64 / 1_000_000.0) * haiku.cache_read; - assert!( - (a.initial_cost - expected_initial).abs() < 1e-9, - "initial_cost={} expected={}", - a.initial_cost, - expected_initial - ); - assert!( - (a.persistence_cost - expected_persistence).abs() < 1e-9, - "persistence_cost={} expected={}", - a.persistence_cost, - expected_persistence - ); - } - - #[test] - fn session_grand_honors_source_aware_reasoning_for_codex() { - // Regression: hotspots must use `cost_for_turn` so its `session_grand` - // inherits Codex's `included_in_output` reasoning semantics. Otherwise - // it overstates by `reasoning × output_rate` and drifts away from the - // canonical `cost.rs` totals. - let pricing = load_builtin_pricing(); - let codex_model = if pricing.contains_key("gpt-5-codex") { - "gpt-5-codex" - } else { - "claude-sonnet-4-6" - }; - let session_id = "s-codex-reasoning"; - let turns = vec![turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - codex_model, - Usage { - input: 1000, - // Codex `output_tokens` already includes reasoning. - output: 500, - reasoning: 200, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::Codex, - )]; - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: None, - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - - let rate = pricing.get(codex_model).unwrap(); - let expected = (1000.0 / 1_000_000.0) * rate.input + (500.0 / 1_000_000.0) * rate.output; - assert!( - (result.grand_total - expected).abs() < 1e-9, - "Codex sessionGrand should not bill reasoning at output rate: got={} expected={}", - result.grand_total, - expected - ); - } - - #[test] - fn grand_total_plus_unattributed_equals_session_grand_within_rounding() { - let pricing = load_builtin_pricing(); - let session_id = "s-totals"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 100, - output: 50, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![tc("tu_z", "Read", Some("/z.ts"))], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 2000, - output: 30, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( - session_id, - "tu_z", - &"q".repeat(2000 * 4), - "2026-04-20T00:00:00.500Z", - )], - ); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - assert!( - (result.attributed_total + result.unattributed_total - result.grand_total).abs() < 1e-9 - ); - } - - #[test] - fn attribution_method_serializes_to_kebab_case() { - // The CLI/MCP presenters round-trip these enums through JSON, so the - // wire format must match the TS string union ('sized' | 'even-split'). - assert_eq!( - serde_json::to_string(&AttributionMethod::Sized).unwrap(), - "\"sized\"" - ); - assert_eq!( - serde_json::to_string(&AttributionMethod::EvenSplit).unwrap(), - "\"even-split\"" - ); - } - - /// Regression for #436: a 1 MB Bash result that gets truncated to a - /// small token count must rank above a small-bytes / large-tokens - /// Read when sorted by `total_output_bytes`. The bash row also has - /// to flag `truncated_count > 0` from the propagated - /// `output_truncated`. - #[test] - fn aggregations_track_output_bytes_so_byte_ranking_inverts_token_ranking() { - use crate::reader::{ToolResultEventRecord, ToolResultEventSource, ToolResultStatus}; - - let pricing = load_builtin_pricing(); - let session_id = "s-bytes"; - - // Emit a Bash call and a Read call on turn 0. Turn 1 pays for - // both. The Bash payload is 1 MB raw bytes but the user-turn - // block reports a small post-truncation token count; the Read - // payload is tiny but the user-turn block reports a large token - // count. Token-sort puts Read first; byte-sort must put Bash - // first. - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-05-25T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc_with_hash("tu_bash", "Bash", "find / -name foo", "Bash:find"), - tc("tu_read", "Read", Some("/big.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-05-25T00:00:01.000Z", - "claude-sonnet-4-6", - Usage { - input: 5000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - - // User-turn block sizes drive the token ranking: Read is "big" - // in tokens (4000), Bash is "small" in tokens (200) because - // Claude truncated it before tokenizing. - let mut user_turns_by_session: HashMap> = HashMap::new(); - user_turns_by_session.insert( - session_id.into(), - vec![user_turn( - session_id, - "u-1", - vec![ - tool_result_block("tu_bash", 800, 200), - tool_result_block("tu_read", 16_000, 4000), - ], - )], - ); - - // Tool-result event payload sizes drive the byte ranking: Bash - // is 1 MB (pre-token-truncation raw stdout), Read is 1 KB. - const BASH_BYTES: u64 = 1_000_000; - const READ_BYTES: u64 = 1_000; - let bash_event = ToolResultEventRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.into(), - message_id: Some("msg-0".into()), - tool_use_id: "tu_bash".into(), - call_index: Some(0), - event_index: 0, - ts: Some("2026-05-25T00:00:00.500Z".into()), - status: ToolResultStatus::Completed, - event_source: ToolResultEventSource::ToolResult, - content_length: Some(BASH_BYTES), - output_bytes: Some(BASH_BYTES), - output_truncated: Some(true), - content_hash: None, - is_error: None, - usage: None, - usage_attribution: None, - subagent_session_id: None, - agent_id: None, - replaced_tools: None, - collapsed_calls: None, - }; - let read_event = ToolResultEventRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.into(), - message_id: Some("msg-0".into()), - tool_use_id: "tu_read".into(), - call_index: Some(0), - event_index: 1, - ts: Some("2026-05-25T00:00:00.500Z".into()), - status: ToolResultStatus::Completed, - event_source: ToolResultEventSource::ToolResult, - content_length: Some(READ_BYTES), - output_bytes: Some(READ_BYTES), - output_truncated: Some(false), - content_hash: None, - is_error: None, - usage: None, - usage_attribution: None, - subagent_session_id: None, - agent_id: None, - replaced_tools: None, - collapsed_calls: None, - }; - let mut events_by_session: HashMap> = HashMap::new(); - events_by_session.insert(session_id.into(), vec![bash_event, read_event]); - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: None, - user_turns_by_session: Some(&user_turns_by_session), - tool_result_events_by_session: Some(&events_by_session), - }, - ); - - // Sanity: bytes / truncation rode through to ToolAttribution. - let by_id: HashMap<&str, &ToolAttribution> = result - .attributions - .iter() - .map(|a| (a.tool_use_id.as_str(), a)) - .collect(); - assert_eq!(by_id["tu_bash"].output_bytes, Some(BASH_BYTES)); - assert_eq!(by_id["tu_bash"].output_truncated, Some(true)); - assert_eq!(by_id["tu_read"].output_bytes, Some(READ_BYTES)); - assert_eq!(by_id["tu_read"].output_truncated, Some(false)); - - // Token-driven cost ranks Read first (4000 tok > 200 tok). - let files = aggregate_by_file(&result.attributions); - assert_eq!(files.len(), 1, "Read is the only file-touching tool"); - let bash = aggregate_by_bash(&result.attributions); - assert_eq!(bash.len(), 1); - let read_file = &files[0]; - let bash_row = &bash[0]; - // The Read row out-costs the Bash row (sized attribution). - assert!( - read_file.total_cost > bash_row.total_cost, - "expected Read cost > Bash cost in token-sized attribution; got read={} bash={}", - read_file.total_cost, - bash_row.total_cost, - ); - - // Bytes plumbing populated on both aggregations. - assert_eq!(read_file.total_output_bytes, READ_BYTES); - assert_eq!(read_file.max_output_bytes, READ_BYTES); - assert_eq!(read_file.truncated_count, 0); - assert_eq!(bash_row.total_output_bytes, BASH_BYTES); - assert_eq!(bash_row.max_output_bytes, BASH_BYTES); - assert_eq!(bash_row.truncated_count, 1); - - // Byte ranking inverts the cost ranking: Bash should win when - // we sort by total_output_bytes. The SDK's default sort is by - // cost; we just confirm the underlying counter inverts. - assert!( - bash_row.total_output_bytes > read_file.total_output_bytes, - "byte ranking should put Bash (1 MB) ahead of Read (1 KB)" - ); - } -} +#[path = "hotspots_tests.rs"] +mod tests; diff --git a/crates/relayburn-sdk/src/analyze/hotspots_tests.rs b/crates/relayburn-sdk/src/analyze/hotspots_tests.rs new file mode 100644 index 0000000..3cf3205 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/hotspots_tests.rs @@ -0,0 +1,1427 @@ +//! Conformance tests for the hotspots module — extracted verbatim from the +//! former inline `#[cfg(test)] mod tests` block (included via `#[path]`). + + use super::*; + use crate::analyze::pricing::load_builtin_pricing; + use crate::reader::{ + parse_bash_command, ContentRole, ContentToolResult, SourceKind, ToolCall, Usage, + UserTurnBlock, + }; + use serde_json::json; + + fn empty_usage() -> Usage { + Usage { + input: 0, + output: 0, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + } + } + + #[allow(clippy::too_many_arguments)] + fn turn( + session_id: &str, + message_id: &str, + turn_index: u64, + ts: &str, + model: &str, + usage: Usage, + tool_calls: Vec, + source: SourceKind, + ) -> TurnRecord { + TurnRecord { + v: 1, + source, + session_id: session_id.into(), + session_path: None, + message_id: message_id.into(), + turn_index, + ts: ts.into(), + model: model.into(), + project: None, + project_key: None, + usage, + tool_calls, + files_touched: None, + subagent: None, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, + } + } + + fn tc(id: &str, name: &str, target: Option<&str>) -> ToolCall { + let target_part = target.unwrap_or(id); + ToolCall { + id: id.into(), + name: name.into(), + target: target.map(String::from), + args_hash: format!("{name}:{target_part}"), + is_error: None, + edit_pre_hash: None, + edit_post_hash: None, + skill_name: None, + replaced_tools: None, + collapsed_calls: None, + } + } + + fn tc_with_hash(id: &str, name: &str, target: &str, args_hash: &str) -> ToolCall { + ToolCall { + id: id.into(), + name: name.into(), + target: Some(target.into()), + args_hash: args_hash.into(), + is_error: None, + edit_pre_hash: None, + edit_post_hash: None, + skill_name: None, + replaced_tools: None, + collapsed_calls: None, + } + } + + fn tool_result_content( + session_id: &str, + tool_use_id: &str, + text: &str, + ts: &str, + ) -> ContentRecord { + ContentRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.into(), + message_id: format!("m-{tool_use_id}"), + ts: ts.into(), + role: ContentRole::ToolResult, + kind: ContentKind::ToolResult, + text: None, + tool_use: None, + tool_result: Some(ContentToolResult { + tool_use_id: tool_use_id.into(), + content: json!(text), + is_error: None, + }), + } + } + + fn user_turn(session_id: &str, user_uuid: &str, blocks: Vec) -> UserTurnRecord { + UserTurnRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.into(), + user_uuid: user_uuid.into(), + ts: "2026-04-20T00:00:00.500Z".into(), + preceding_message_id: Some("msg-0".into()), + following_message_id: Some("msg-1".into()), + blocks, + } + } + + fn tool_result_block(tool_use_id: &str, byte_len: u64, approx_tokens: u64) -> UserTurnBlock { + UserTurnBlock { + kind: UserTurnBlockKind::ToolResult, + tool_use_id: Some(tool_use_id.into()), + byte_len, + approx_tokens, + is_error: None, + } + } + + fn bash_attribution( + command: &str, + args_hash: &str, + total_cost: f64, + initial_tokens: f64, + persistence_tokens: f64, + riding_turns: u64, + ) -> ToolAttribution { + ToolAttribution { + tool_use_id: format!("tu-{args_hash}"), + tool_name: "Bash".into(), + target: Some(command.into()), + args_hash: args_hash.into(), + session_id: "s-bash-verb".into(), + emit_turn_index: 0, + emit_ts: "2026-04-20T00:00:00.000Z".into(), + model: "claude-sonnet-4-6".into(), + project: None, + project_key: None, + subagent_type: None, + result_tokens: 0, + result_bytes_estimated: true, + output_bytes: None, + output_truncated: None, + initial_cost: total_cost, + initial_tokens, + persistence_cost: 0.0, + persistence_tokens, + riding_turns, + total_cost, + } + } + + #[test] + fn attributes_persistence_of_8k_read_across_20_ride_along_turns_within_10_pct() { + let pricing = load_builtin_pricing(); + let rate = pricing + .get("claude-sonnet-4-6") + .expect("sonnet present") + .clone(); + const READ_TOKENS: u64 = 8000; + let read_text: String = "x".repeat((READ_TOKENS as usize) * 4); + + let session_id = "s-hotspots-1"; + let mut turns: Vec = Vec::new(); + + // Turn 0: assistant emits the Read tool_use. + turns.push(turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 200, + output: 50, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![tc("tu_read_1", "Read", Some("/src/big.ts"))], + SourceKind::ClaudeCode, + )); + + // Turn 1 pays initial: 8000 tokens enter as fresh input. + turns.push(turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:01.000Z", + "claude-sonnet-4-6", + Usage { + input: READ_TOKENS, + output: 30, + reasoning: 0, + cache_read: 250, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + )); + + // Turns 2..=21: 20 ride-along turns each with cacheRead >= READ_TOKENS. + for i in 2..=21u64 { + turns.push(turn( + session_id, + &format!("msg-{i}"), + i, + &format!("2026-04-20T00:00:{:02}.000Z", i), + "claude-sonnet-4-6", + Usage { + input: 50, + output: 30, + reasoning: 0, + cache_read: READ_TOKENS + 2000, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + )); + } + + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_read_1", + &read_text, + "2026-04-20T00:00:00.500Z", + )], + ); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + assert_eq!(result.attributions.len(), 1); + let a = &result.attributions[0]; + assert_eq!(a.tool_use_id, "tu_read_1"); + + let expected_initial = (READ_TOKENS as f64 / 1_000_000.0) * rate.input; + let expected_persistence = 20.0 * (READ_TOKENS as f64 / 1_000_000.0) * rate.cache_read; + let expected_total = expected_initial + expected_persistence; + assert!( + (a.total_cost - expected_total).abs() <= expected_total * 0.10, + "total={} expected={} diff>10%", + a.total_cost, + expected_total + ); + assert_eq!(a.riding_turns, 20); + } + + #[test] + fn aggregates_by_file_and_ranks_most_expensive_read_first() { + let pricing = load_builtin_pricing(); + let session_id = "s-files"; + const READ_TOKENS: u64 = 5000; + const SMALL_TOKENS: u64 = 200; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc("tu_a", "Read", Some("/big.ts")), + tc("tu_b", "Read", Some("/small.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: READ_TOKENS + SMALL_TOKENS, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-2", + 2, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 100, + output: 5, + reasoning: 0, + cache_read: READ_TOKENS + SMALL_TOKENS + 500, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-3", + 3, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 100, + output: 5, + reasoning: 0, + cache_read: READ_TOKENS + SMALL_TOKENS + 500, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![ + tool_result_content( + session_id, + "tu_a", + &"x".repeat((READ_TOKENS as usize) * 4), + "2026-04-20T00:00:00.100Z", + ), + tool_result_content( + session_id, + "tu_b", + &"y".repeat((SMALL_TOKENS as usize) * 4), + "2026-04-20T00:00:00.101Z", + ), + ], + ); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let files = aggregate_by_file(&result.attributions); + assert_eq!(files.len(), 2); + assert_eq!(files[0].path, "/big.ts"); + assert_eq!(files[1].path, "/small.ts"); + assert!(files[0].total_cost > files[1].total_cost); + } + + #[test] + fn aggregates_by_bash_args_hash_so_repeated_commands_collapse() { + let pricing = load_builtin_pricing(); + let session_id = "s-bash"; + let mut turns: Vec = Vec::new(); + let mut ts = 0u64; + for i in 0..3 { + turns.push(turn( + session_id, + &format!("msg-emit-{i}"), + ts, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![tc_with_hash( + &format!("tu_b_{i}"), + "Bash", + "ls -la", + "Bash:ls", + )], + SourceKind::ClaudeCode, + )); + ts += 1; + turns.push(turn( + session_id, + &format!("msg-pay-{i}"), + ts, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 1000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + )); + ts += 1; + } + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![ + tool_result_content( + session_id, + "tu_b_0", + &"x".repeat(4000), + "2026-04-20T00:00:00.100Z", + ), + tool_result_content( + session_id, + "tu_b_1", + &"x".repeat(4000), + "2026-04-20T00:00:00.200Z", + ), + tool_result_content( + session_id, + "tu_b_2", + &"x".repeat(4000), + "2026-04-20T00:00:00.300Z", + ), + ], + ); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let bash = aggregate_by_bash(&result.attributions); + assert_eq!(bash.len(), 1); + assert_eq!(bash[0].call_count, 3); + } + + #[test] + fn aggregates_bash_cost_by_normalized_verb_with_distinct_command_and_examples() { + let attrs = vec![ + bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), + bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), + bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), + bash_attribution("git diff src/a.ts", "git:diff:a", 5.0, 100.0, 10.0, 1), + bash_attribution("git diff src/a.ts", "git:diff:a", 5.0, 100.0, 10.0, 1), + bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), + bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), + bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), + bash_attribution("pnpm run test", "pnpm:test", 4.0, 40.0, 8.0, 1), + ]; + + let verbs = aggregate_by_bash_verb(&attrs, parse_bash_command); + assert_eq!(verbs[0].verb, "git diff"); + assert_eq!(verbs[0].call_count, 5); + assert_eq!(verbs[0].distinct_commands, 2); + assert!((verbs[0].total_cost - 31.0).abs() < 1e-9); + assert!((verbs[0].initial_tokens - 500.0).abs() < 1e-9); + assert!((verbs[0].persistence_tokens - 80.0).abs() < 1e-9); + assert!((verbs[0].avg_persistence_turns - 1.6).abs() < 1e-9); + assert_eq!( + verbs[0].top_examples, + vec!["git diff src/b.ts", "git diff src/a.ts"] + ); + + assert_eq!(verbs[1].verb, "git status"); + assert_eq!(verbs[1].call_count, 3); + assert_eq!(verbs[1].distinct_commands, 1); + assert_eq!(verbs[2].verb, "pnpm test"); + } + + #[test] + fn aggregates_subagent_calls_by_subagent_type() { + let pricing = load_builtin_pricing(); + let session_id = "s-agent"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![tc_with_hash( + "tu_a1", + "Agent", + "general-purpose", + "Agent:gp", + )], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 2000, + output: 10, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_a1", + &"z".repeat(8000), + "2026-04-20T00:00:00.100Z", + )], + ); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let subagents = aggregate_by_subagent(&result.attributions); + assert_eq!(subagents.len(), 1); + assert_eq!(subagents[0].subagent_type, "general-purpose"); + assert_eq!(subagents[0].call_count, 1); + assert!(subagents[0].total_cost > 0.0); + } + + fn mcp_attribution(tool_name: &str, total_cost: f64, riding_turns: u64) -> ToolAttribution { + ToolAttribution { + tool_use_id: format!("tu-{tool_name}"), + tool_name: tool_name.into(), + target: None, + args_hash: format!("{tool_name}:0"), + session_id: "s-mcp".into(), + emit_turn_index: 0, + emit_ts: "2026-04-20T00:00:00.000Z".into(), + model: "claude-sonnet-4-6".into(), + project: None, + project_key: None, + subagent_type: None, + result_tokens: 0, + result_bytes_estimated: true, + initial_cost: total_cost, + initial_tokens: total_cost * 100.0, + persistence_cost: 0.0, + persistence_tokens: total_cost * 50.0, + riding_turns, + total_cost, + output_bytes: None, + output_truncated: None, + } + } + + #[test] + fn aggregates_by_mcp_server_groups_by_server_segment_and_sorts_by_cost() { + // Two MCP servers + a non-MCP tool + a malformed mcp__ name. The + // non-MCP + malformed rows must NOT show up; the relaycast roll-up + // must collapse all three relaycast tools into a single row with + // top_tools sorted by cost desc. + let attrs = vec![ + mcp_attribution("mcp__relaycast__send_dm", 2.0, 1), + mcp_attribution("mcp__relaycast__send_dm", 1.5, 0), + mcp_attribution("mcp__relaycast__list_channels", 0.5, 0), + mcp_attribution("mcp__relaycast__react_to_message", 0.25, 0), + mcp_attribution("mcp__github__get_file_contents", 1.0, 2), + mcp_attribution("mcp__github__create_pull_request", 0.1, 0), + // Non-MCP — must be skipped. + mcp_attribution("Read", 99.0, 5), + // Malformed: missing tool segment. + mcp_attribution("mcp__only_server__", 50.0, 0), + // Malformed: missing server segment. + mcp_attribution("mcp____tool_only", 50.0, 0), + // Malformed: not enough separators. + mcp_attribution("mcp__no_double_separator", 50.0, 0), + ]; + + let rows = aggregate_by_mcp_server(&attrs); + assert_eq!( + rows.len(), + 2, + "only the two well-formed mcp__ servers should aggregate" + ); + + // relaycast wins on cumulative cost (2.0 + 1.5 + 0.5 + 0.25 = 4.25) + // vs github (1.0 + 0.1 = 1.1). + let relaycast = &rows[0]; + assert_eq!(relaycast.server, "relaycast"); + assert_eq!(relaycast.call_count, 4); + assert!((relaycast.total_cost - 4.25).abs() < 1e-9); + assert!((relaycast.initial_tokens - 4.25 * 100.0).abs() < 1e-9); + assert!((relaycast.persistence_tokens - 4.25 * 50.0).abs() < 1e-9); + assert_eq!(relaycast.riding_turns, 1); + assert_eq!( + relaycast.top_tools, + vec!["send_dm", "list_channels", "react_to_message"], + ); + + let github = &rows[1]; + assert_eq!(github.server, "github"); + assert_eq!(github.call_count, 2); + assert!((github.total_cost - 1.1).abs() < 1e-9); + assert_eq!(github.riding_turns, 2); + assert_eq!( + github.top_tools, + vec!["get_file_contents", "create_pull_request"], + ); + } + + #[test] + fn falls_back_to_even_split_when_no_content_is_provided() { + let pricing = load_builtin_pricing(); + let session_id = "s-fallback"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc("tu_x", "Read", Some("/a.ts")), + tc("tu_y", "Read", Some("/b.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 4000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: None, + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + assert_eq!(result.attributions.len(), 2); + let rate = pricing.get("claude-sonnet-4-6").unwrap(); + let expected = ((4000.0 / 1_000_000.0) * rate.input) / 2.0; + for a in &result.attributions { + assert!((a.initial_cost - expected).abs() < 1e-9); + assert_eq!(a.persistence_cost, 0.0); + } + assert_eq!( + result.session_totals[0].attribution_method, + AttributionMethod::EvenSplit + ); + } + + #[test] + fn uses_user_turn_block_sizes_when_content_sidecar_is_unavailable() { + let pricing = load_builtin_pricing(); + let session_id = "s-user-turn-fallback"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc("tu_big", "Read", Some("/big.ts")), + tc("tu_small", "Read", Some("/small.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 4000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-2", + 2, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 100, + output: 5, + reasoning: 0, + cache_read: 4500, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + + let mut user_turns_by_session: HashMap> = HashMap::new(); + user_turns_by_session.insert( + session_id.into(), + vec![user_turn( + session_id, + "u-1", + vec![ + tool_result_block("tu_big", 12_000, 3000), + tool_result_block("tu_small", 4000, 1000), + ], + )], + ); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: None, + user_turns_by_session: Some(&user_turns_by_session), + tool_result_events_by_session: None, + }, + ); + let by_id: HashMap<&str, &ToolAttribution> = result + .attributions + .iter() + .map(|a| (a.tool_use_id.as_str(), a)) + .collect(); + assert_eq!( + result.session_totals[0].attribution_method, + AttributionMethod::Sized + ); + assert!((by_id["tu_big"].initial_tokens - 3000.0).abs() < 1e-9); + assert!((by_id["tu_small"].initial_tokens - 1000.0).abs() < 1e-9); + assert!((by_id["tu_big"].persistence_tokens - 3000.0).abs() < 1e-9); + assert!((by_id["tu_small"].persistence_tokens - 1000.0).abs() < 1e-9); + assert!(by_id["tu_big"].total_cost > by_id["tu_small"].total_cost); + } + + #[test] + fn prefers_user_turn_block_sizes_over_content_sidecar_estimates() { + let pricing = load_builtin_pricing(); + let session_id = "s-sidecar-primary"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![tc("tu_read", "Read", Some("/file.ts"))], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 10_000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_read", + &"x".repeat(1000 * 4), + "2026-04-20T00:00:00.100Z", + )], + ); + let mut user_turns_by_session: HashMap> = HashMap::new(); + user_turns_by_session.insert( + session_id.into(), + vec![user_turn( + session_id, + "u-1", + vec![tool_result_block("tu_read", 36_000, 9000)], + )], + ); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: Some(&user_turns_by_session), + tool_result_events_by_session: None, + }, + ); + assert_eq!( + result.session_totals[0].attribution_method, + AttributionMethod::Sized + ); + assert!((result.attributions[0].initial_tokens - 9000.0).abs() < 1e-9); + } + + #[test] + fn caps_sibling_initial_cost_at_next_turns_actual_new_content() { + let pricing = load_builtin_pricing(); + let session_id = "s-cap"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc("tu_big", "Read", Some("/big.ts")), + tc("tu_med", "Read", Some("/med.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 5000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![ + tool_result_content( + session_id, + "tu_big", + &"x".repeat(6000 * 4), + "2026-04-20T00:00:00.100Z", + ), + tool_result_content( + session_id, + "tu_med", + &"y".repeat(4000 * 4), + "2026-04-20T00:00:00.101Z", + ), + ], + ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let summed: f64 = result.attributions.iter().map(|a| a.initial_tokens).sum(); + assert!(summed <= 5000.0 + 1e-6, "summed={summed} > newContent=5000"); + let big = result + .attributions + .iter() + .find(|a| a.tool_use_id == "tu_big") + .unwrap(); + let med = result + .attributions + .iter() + .find(|a| a.tool_use_id == "tu_med") + .unwrap(); + assert!((big.initial_tokens - 3000.0).abs() < 1e-6); + assert!((med.initial_tokens - 2000.0).abs() < 1e-6); + } + + #[test] + fn caps_sibling_persistence_at_turns_actual_cache_read() { + let pricing = load_builtin_pricing(); + let session_id = "s-persist-cap"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc("tu_a", "Read", Some("/a.ts")), + tc("tu_b", "Read", Some("/b.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 8000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-2", + 2, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 50, + output: 5, + reasoning: 0, + cache_read: 5000, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![ + tool_result_content( + session_id, + "tu_a", + &"x".repeat(4000 * 4), + "2026-04-20T00:00:00.100Z", + ), + tool_result_content( + session_id, + "tu_b", + &"y".repeat(4000 * 4), + "2026-04-20T00:00:00.101Z", + ), + ], + ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let summed_persist: f64 = result + .attributions + .iter() + .map(|a| a.persistence_tokens) + .sum(); + assert!( + summed_persist <= 5000.0 + 1e-6, + "summedPersist={summed_persist} > cacheRead=5000" + ); + for a in &result.attributions { + assert!((a.persistence_tokens - 2500.0).abs() < 1e-6); + } + } + + #[test] + fn uses_paying_turns_model_rate_not_emit_turns() { + let pricing = load_builtin_pricing(); + let sonnet = pricing.get("claude-sonnet-4-6").unwrap().clone(); + let haiku = pricing.get("claude-haiku-4-5").unwrap().clone(); + assert_ne!(haiku.input, sonnet.input, "test prerequisite: rates differ"); + + let session_id = "s-cross-model"; + const TOK: u64 = 4000; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![tc("tu_x", "Read", Some("/x.ts"))], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-haiku-4-5", + Usage { + input: TOK, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-2", + 2, + "2026-04-20T00:00:00.000Z", + "claude-haiku-4-5", + Usage { + input: 50, + output: 5, + reasoning: 0, + cache_read: TOK + 100, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_x", + &"z".repeat((TOK as usize) * 4), + "2026-04-20T00:00:00.100Z", + )], + ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let a = &result.attributions[0]; + let expected_initial = (TOK as f64 / 1_000_000.0) * haiku.input; + let expected_persistence = (TOK as f64 / 1_000_000.0) * haiku.cache_read; + assert!( + (a.initial_cost - expected_initial).abs() < 1e-9, + "initial_cost={} expected={}", + a.initial_cost, + expected_initial + ); + assert!( + (a.persistence_cost - expected_persistence).abs() < 1e-9, + "persistence_cost={} expected={}", + a.persistence_cost, + expected_persistence + ); + } + + #[test] + fn session_grand_honors_source_aware_reasoning_for_codex() { + // Regression: hotspots must use `cost_for_turn` so its `session_grand` + // inherits Codex's `included_in_output` reasoning semantics. Otherwise + // it overstates by `reasoning × output_rate` and drifts away from the + // canonical `cost.rs` totals. + let pricing = load_builtin_pricing(); + let codex_model = if pricing.contains_key("gpt-5-codex") { + "gpt-5-codex" + } else { + "claude-sonnet-4-6" + }; + let session_id = "s-codex-reasoning"; + let turns = vec![turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + codex_model, + Usage { + input: 1000, + // Codex `output_tokens` already includes reasoning. + output: 500, + reasoning: 200, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::Codex, + )]; + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: None, + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + + let rate = pricing.get(codex_model).unwrap(); + let expected = (1000.0 / 1_000_000.0) * rate.input + (500.0 / 1_000_000.0) * rate.output; + assert!( + (result.grand_total - expected).abs() < 1e-9, + "Codex sessionGrand should not bill reasoning at output rate: got={} expected={}", + result.grand_total, + expected + ); + } + + #[test] + fn grand_total_plus_unattributed_equals_session_grand_within_rounding() { + let pricing = load_builtin_pricing(); + let session_id = "s-totals"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 100, + output: 50, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![tc("tu_z", "Read", Some("/z.ts"))], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 2000, + output: 30, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_z", + &"q".repeat(2000 * 4), + "2026-04-20T00:00:00.500Z", + )], + ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + assert!( + (result.attributed_total + result.unattributed_total - result.grand_total).abs() < 1e-9 + ); + } + + #[test] + fn attribution_method_serializes_to_kebab_case() { + // The CLI/MCP presenters round-trip these enums through JSON, so the + // wire format must match the TS string union ('sized' | 'even-split'). + assert_eq!( + serde_json::to_string(&AttributionMethod::Sized).unwrap(), + "\"sized\"" + ); + assert_eq!( + serde_json::to_string(&AttributionMethod::EvenSplit).unwrap(), + "\"even-split\"" + ); + } + + /// Regression for #436: a 1 MB Bash result that gets truncated to a + /// small token count must rank above a small-bytes / large-tokens + /// Read when sorted by `total_output_bytes`. The bash row also has + /// to flag `truncated_count > 0` from the propagated + /// `output_truncated`. + #[test] + fn aggregations_track_output_bytes_so_byte_ranking_inverts_token_ranking() { + use crate::reader::{ToolResultEventRecord, ToolResultEventSource, ToolResultStatus}; + + let pricing = load_builtin_pricing(); + let session_id = "s-bytes"; + + // Emit a Bash call and a Read call on turn 0. Turn 1 pays for + // both. The Bash payload is 1 MB raw bytes but the user-turn + // block reports a small post-truncation token count; the Read + // payload is tiny but the user-turn block reports a large token + // count. Token-sort puts Read first; byte-sort must put Bash + // first. + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-05-25T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc_with_hash("tu_bash", "Bash", "find / -name foo", "Bash:find"), + tc("tu_read", "Read", Some("/big.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-05-25T00:00:01.000Z", + "claude-sonnet-4-6", + Usage { + input: 5000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + + // User-turn block sizes drive the token ranking: Read is "big" + // in tokens (4000), Bash is "small" in tokens (200) because + // Claude truncated it before tokenizing. + let mut user_turns_by_session: HashMap> = HashMap::new(); + user_turns_by_session.insert( + session_id.into(), + vec![user_turn( + session_id, + "u-1", + vec![ + tool_result_block("tu_bash", 800, 200), + tool_result_block("tu_read", 16_000, 4000), + ], + )], + ); + + // Tool-result event payload sizes drive the byte ranking: Bash + // is 1 MB (pre-token-truncation raw stdout), Read is 1 KB. + const BASH_BYTES: u64 = 1_000_000; + const READ_BYTES: u64 = 1_000; + let bash_event = ToolResultEventRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.into(), + message_id: Some("msg-0".into()), + tool_use_id: "tu_bash".into(), + call_index: Some(0), + event_index: 0, + ts: Some("2026-05-25T00:00:00.500Z".into()), + status: ToolResultStatus::Completed, + event_source: ToolResultEventSource::ToolResult, + content_length: Some(BASH_BYTES), + output_bytes: Some(BASH_BYTES), + output_truncated: Some(true), + content_hash: None, + is_error: None, + usage: None, + usage_attribution: None, + subagent_session_id: None, + agent_id: None, + replaced_tools: None, + collapsed_calls: None, + }; + let read_event = ToolResultEventRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.into(), + message_id: Some("msg-0".into()), + tool_use_id: "tu_read".into(), + call_index: Some(0), + event_index: 1, + ts: Some("2026-05-25T00:00:00.500Z".into()), + status: ToolResultStatus::Completed, + event_source: ToolResultEventSource::ToolResult, + content_length: Some(READ_BYTES), + output_bytes: Some(READ_BYTES), + output_truncated: Some(false), + content_hash: None, + is_error: None, + usage: None, + usage_attribution: None, + subagent_session_id: None, + agent_id: None, + replaced_tools: None, + collapsed_calls: None, + }; + let mut events_by_session: HashMap> = HashMap::new(); + events_by_session.insert(session_id.into(), vec![bash_event, read_event]); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: None, + user_turns_by_session: Some(&user_turns_by_session), + tool_result_events_by_session: Some(&events_by_session), + }, + ); + + // Sanity: bytes / truncation rode through to ToolAttribution. + let by_id: HashMap<&str, &ToolAttribution> = result + .attributions + .iter() + .map(|a| (a.tool_use_id.as_str(), a)) + .collect(); + assert_eq!(by_id["tu_bash"].output_bytes, Some(BASH_BYTES)); + assert_eq!(by_id["tu_bash"].output_truncated, Some(true)); + assert_eq!(by_id["tu_read"].output_bytes, Some(READ_BYTES)); + assert_eq!(by_id["tu_read"].output_truncated, Some(false)); + + // Token-driven cost ranks Read first (4000 tok > 200 tok). + let files = aggregate_by_file(&result.attributions); + assert_eq!(files.len(), 1, "Read is the only file-touching tool"); + let bash = aggregate_by_bash(&result.attributions); + assert_eq!(bash.len(), 1); + let read_file = &files[0]; + let bash_row = &bash[0]; + // The Read row out-costs the Bash row (sized attribution). + assert!( + read_file.total_cost > bash_row.total_cost, + "expected Read cost > Bash cost in token-sized attribution; got read={} bash={}", + read_file.total_cost, + bash_row.total_cost, + ); + + // Bytes plumbing populated on both aggregations. + assert_eq!(read_file.total_output_bytes, READ_BYTES); + assert_eq!(read_file.max_output_bytes, READ_BYTES); + assert_eq!(read_file.truncated_count, 0); + assert_eq!(bash_row.total_output_bytes, BASH_BYTES); + assert_eq!(bash_row.max_output_bytes, BASH_BYTES); + assert_eq!(bash_row.truncated_count, 1); + + // Byte ranking inverts the cost ranking: Bash should win when + // we sort by total_output_bytes. The SDK's default sort is by + // cost; we just confirm the underlying counter inverts. + assert!( + bash_row.total_output_bytes > read_file.total_output_bytes, + "byte ranking should put Bash (1 MB) ahead of Read (1 KB)" + ); + } From 0cfb7842f8d5a4e3f2d9fe179c6ee6c7cc4aa746 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 20:46:54 -0400 Subject: [PATCH 09/22] refactor(sdk/analyze): externalize tool_output_bloat tests tool_output_bloat.rs was 1676 lines, ~1072 of them the inline test block. Move the tests verbatim into tool_output_bloat_tests.rs via `#[cfg(test)] #[path = ...] mod tests;`, matching the patterns_tests.rs / hotspots_tests.rs convention. Source file is now 606 lines of pure code; the 29 tests pass identically (paths unchanged). Pure relocation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/analyze/tool_output_bloat.rs | 1074 +---------------- .../src/analyze/tool_output_bloat_tests.rs | 1073 ++++++++++++++++ 2 files changed, 1075 insertions(+), 1072 deletions(-) create mode 100644 crates/relayburn-sdk/src/analyze/tool_output_bloat_tests.rs diff --git a/crates/relayburn-sdk/src/analyze/tool_output_bloat.rs b/crates/relayburn-sdk/src/analyze/tool_output_bloat.rs index e38ed1e..f80c52e 100644 --- a/crates/relayburn-sdk/src/analyze/tool_output_bloat.rs +++ b/crates/relayburn-sdk/src/analyze/tool_output_bloat.rs @@ -602,1075 +602,5 @@ Estimated next-turn carry cost {usd}. {advice}", } #[cfg(test)] -mod tests { - use super::*; - use crate::analyze::pricing::load_builtin_pricing; - use crate::reader::{ToolCall, ToolResultEventSource, ToolResultStatus, Usage, UserTurnBlock}; - use serde_json::json; - use std::path::PathBuf; - use tempfile::tempdir; - - fn loaded(path: &str, env: serde_json::Value) -> LoadedClaudeSettings { - let settings: ClaudeSettings = serde_json::from_value(json!({ "env": env })).unwrap(); - LoadedClaudeSettings { - path: PathBuf::from(path), - settings, - } - } - - fn loaded_no_env(path: &str) -> LoadedClaudeSettings { - LoadedClaudeSettings { - path: PathBuf::from(path), - settings: ClaudeSettings::default(), - } - } - - fn evt( - session_id: &str, - tool_use_id: &str, - event_index: u64, - message_id: Option<&str>, - ) -> ToolResultEventRecord { - ToolResultEventRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.to_string(), - message_id: message_id.map(String::from), - tool_use_id: tool_use_id.to_string(), - call_index: None, - event_index, - ts: None, - status: ToolResultStatus::Completed, - event_source: ToolResultEventSource::ToolResult, - content_length: None, - output_bytes: None, - output_truncated: None, - content_hash: None, - is_error: None, - usage: None, - usage_attribution: None, - subagent_session_id: None, - agent_id: None, - replaced_tools: None, - collapsed_calls: None, - } - } - - #[allow(clippy::too_many_arguments)] - fn evt_with( - source: SourceKind, - session_id: &str, - tool_use_id: &str, - event_index: u64, - message_id: Option<&str>, - event_source: ToolResultEventSource, - content_length: Option, - call_index: Option, - ) -> ToolResultEventRecord { - ToolResultEventRecord { - v: 1, - source, - session_id: session_id.to_string(), - message_id: message_id.map(String::from), - tool_use_id: tool_use_id.to_string(), - call_index, - event_index, - ts: None, - status: ToolResultStatus::Completed, - event_source, - content_length, - output_bytes: content_length, - output_truncated: None, - content_hash: None, - is_error: None, - usage: None, - usage_attribution: None, - subagent_session_id: None, - agent_id: None, - replaced_tools: None, - collapsed_calls: None, - } - } - - #[allow(clippy::too_many_arguments)] - fn user_turn_with( - source: SourceKind, - session_id: &str, - user_uuid: &str, - preceding: &str, - following: &str, - tool_use_id: &str, - byte_len: u64, - approx_tokens: u64, - ) -> UserTurnRecord { - UserTurnRecord { - v: 1, - source, - session_id: session_id.to_string(), - user_uuid: user_uuid.to_string(), - ts: "2026-04-20T00:00:00.500Z".to_string(), - preceding_message_id: Some(preceding.to_string()), - following_message_id: Some(following.to_string()), - blocks: vec![UserTurnBlock { - kind: UserTurnBlockKind::ToolResult, - tool_use_id: Some(tool_use_id.to_string()), - byte_len, - approx_tokens, - is_error: None, - }], - } - } - - fn turn_with( - source: SourceKind, - session_id: &str, - message_id: &str, - turn_index: u64, - tool_calls: Vec, - ) -> TurnRecord { - TurnRecord { - v: 1, - source, - session_id: session_id.to_string(), - session_path: None, - message_id: message_id.to_string(), - turn_index, - ts: "2026-04-20T00:00:00.000Z".to_string(), - model: "claude-sonnet-4-6".to_string(), - project: None, - project_key: None, - usage: Usage { - input: 10, - output: 5, - reasoning: 0, - cache_read: 100, - cache_create_5m: 50, - cache_create_1h: 0, - }, - tool_calls, - files_touched: None, - subagent: None, - stop_reason: None, - activity: None, - retries: None, - has_edits: None, - fidelity: None, - } - } - - fn tc(id: &str, name: &str) -> ToolCall { - ToolCall { - id: id.to_string(), - name: name.to_string(), - target: None, - args_hash: "hash".to_string(), - is_error: None, - edit_pre_hash: None, - edit_post_hash: None, - skill_name: None, - replaced_tools: None, - collapsed_calls: None, - } - } - - // ------------------------------------------------------------------- - // Signal A — static-config check - // ------------------------------------------------------------------- - - #[test] - fn signal_a_flags_oversized_bash_max_output_length() { - let settings = vec![loaded( - "/home/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), - )]; - let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }); - assert_eq!(out.len(), 1); - let f = &out[0]; - assert_eq!(f.kind, ToolOutputBloatKind::StaticConfig); - assert_eq!(f.source, SourceKind::ClaudeCode); - assert_eq!(f.tool_name, "Bash"); - assert_eq!(f.configured_limit, Some(80_000)); - assert_eq!(f.evidenced_max_output, 20_000); - assert_eq!(f.occurrence_count, 1); - assert_eq!(f.cost, 0.0); - assert_eq!( - f.evidence, - vec!["/home/u/.claude/settings.json".to_string()] - ); - } - - #[test] - fn signal_a_does_not_flag_at_threshold() { - let settings = vec![loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "60000" }), - )]; - assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }) - .is_empty()); - } - - #[test] - fn signal_a_unit_conversion_under_threshold() { - let settings = vec![loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "50000" }), - )]; - assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }) - .is_empty()); - } - - #[test] - fn signal_a_no_env_block() { - let settings = vec![loaded_no_env("/u/.claude/settings.json")]; - assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }) - .is_empty()); - } - - #[test] - fn signal_a_project_overrides_user() { - let settings = vec![ - loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), - ), - loaded( - "/cwd/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "60000" }), - ), - ]; - assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }) - .is_empty()); - } - - #[test] - fn signal_a_project_path_reported_when_project_overrides_to_oversized() { - let settings = vec![ - loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "15000" }), - ), - loaded( - "/cwd/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "99999" }), - ), - ]; - let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }); - assert_eq!(out.len(), 1); - assert_eq!( - out[0].evidence, - vec!["/cwd/.claude/settings.json".to_string()] - ); - assert_eq!(out[0].configured_limit, Some(99_999)); - } - - #[test] - fn signal_a_honors_custom_threshold() { - let settings = vec![loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "5000" }), - )]; - let tight = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: Some(1_000), - settings: settings.clone(), - }); - assert_eq!(tight.len(), 1); - let loose = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: Some(10_000), - settings, - }); - assert!(loose.is_empty()); - } - - // ------------------------------------------------------------------- - // Filesystem loader - // ------------------------------------------------------------------- - - #[test] - fn load_settings_returns_none_for_missing_file() { - let dir = tempdir().unwrap(); - assert!(load_claude_settings(dir.path().join("nope.json")).is_none()); - } - - #[test] - fn load_settings_returns_none_for_malformed_json() { - let dir = tempdir().unwrap(); - let p = dir.path().join("bad.json"); - std::fs::write(&p, "{not json").unwrap(); - assert!(load_claude_settings(&p).is_none()); - } - - #[test] - fn load_settings_reads_env_block() { - let dir = tempdir().unwrap(); - let p = dir.path().join("settings.json"); - std::fs::write( - &p, - json!({ "env": { BASH_MAX_OUTPUT_ENV_KEY: "80000" } }).to_string(), - ) - .unwrap(); - let loaded = load_claude_settings(&p).expect("loads"); - assert_eq!(loaded.path, p); - let env = loaded.settings.env.as_ref().expect("env present"); - assert_eq!( - env.get(BASH_MAX_OUTPUT_ENV_KEY).and_then(|v| v.as_str()), - Some("80000"), - ); - } - - #[test] - fn load_and_detect_end_to_end() { - let dir = tempdir().unwrap(); - let claude_dir = dir.path().join(".claude"); - std::fs::create_dir_all(&claude_dir).unwrap(); - let p = claude_dir.join("settings.json"); - std::fs::write( - &p, - json!({ "env": { BASH_MAX_OUTPUT_ENV_KEY: "80000" } }).to_string(), - ) - .unwrap(); - let loaded = load_claude_settings(&p).expect("loads"); - let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings: vec![loaded], - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].configured_limit, Some(80_000)); - } - - // ------------------------------------------------------------------- - // Signal B — observed bloat across sessions - // ------------------------------------------------------------------- - - #[test] - fn signal_b_flags_bash_above_threshold() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 80_000, - 20_000, - )]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - let b = &out[0]; - assert_eq!(b.kind, ToolOutputBloatKind::ObservedBloat); - assert_eq!(b.source, SourceKind::ClaudeCode); - assert_eq!(b.tool_name, "Bash"); - assert_eq!(b.occurrence_count, 1); - assert_eq!(b.evidenced_max_output, 20_000); - assert_eq!(b.evidence, vec!["s1".to_string()]); - assert!(b.cost > 0.0, "cost should be priced via the model rate"); - } - - #[test] - fn signal_b_does_not_flag_below_threshold() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 40_000, - 10_000, - )]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert!(out.is_empty()); - } - - #[test] - fn signal_b_aggregates_into_single_bucket() { - let pricing = load_builtin_pricing(); - let events = vec![ - evt("s1", "tu_a", 0, Some("m1")), - evt("s2", "tu_b", 0, Some("m2")), - evt("s3", "tu_c", 0, Some("m3")), - ]; - let user_turns = vec![ - user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 80_000, - 20_000, - ), - user_turn_with( - SourceKind::ClaudeCode, - "s2", - "u2", - "m2", - "m3", - "tu_b", - 100_000, - 25_000, - ), - user_turn_with( - SourceKind::ClaudeCode, - "s3", - "u3", - "m3", - "m4", - "tu_c", - 120_000, - 30_000, - ), - ]; - let turns = vec![ - turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - ), - turn_with( - SourceKind::ClaudeCode, - "s2", - "m2", - 0, - vec![tc("tu_b", "Bash")], - ), - turn_with( - SourceKind::ClaudeCode, - "s3", - "m3", - 0, - vec![tc("tu_c", "Bash")], - ), - ]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - let b = &out[0]; - assert_eq!(b.occurrence_count, 3); - assert_eq!(b.evidenced_max_output, 30_000); - assert_eq!(b.evidence.len(), 3); - } - - #[test] - fn signal_b_emits_one_bucket_per_source_tool_pair() { - let pricing = load_builtin_pricing(); - let events = vec![ - evt_with( - SourceKind::ClaudeCode, - "s1", - "tu_a", - 0, - Some("m1"), - ToolResultEventSource::ToolResult, - None, - None, - ), - evt_with( - SourceKind::Codex, - "s2", - "call_b", - 0, - Some("m2"), - ToolResultEventSource::ToolResult, - None, - None, - ), - evt_with( - SourceKind::Opencode, - "s3", - "opc_c", - 0, - Some("m3"), - ToolResultEventSource::ToolResult, - None, - None, - ), - ]; - let user_turns = vec![ - user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 80_000, - 20_000, - ), - user_turn_with( - SourceKind::Codex, - "s2", - "u2", - "m2", - "m3", - "call_b", - 90_000, - 22_500, - ), - user_turn_with( - SourceKind::Opencode, - "s3", - "u3", - "m3", - "m4", - "opc_c", - 85_000, - 21_250, - ), - ]; - let turns = vec![ - turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - ), - turn_with( - SourceKind::Codex, - "s2", - "m2", - 0, - vec![tc("call_b", "shell")], - ), - turn_with( - SourceKind::Opencode, - "s3", - "m3", - 0, - vec![tc("opc_c", "bash")], - ), - ]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 3); - let mut sources: Vec = out.iter().map(|b| b.source).collect(); - sources.sort_by_key(|s| match s { - SourceKind::ClaudeCode => 0, - SourceKind::Codex => 1, - SourceKind::Opencode => 2, - _ => 3, - }); - assert_eq!( - sources, - vec![ - SourceKind::ClaudeCode, - SourceKind::Codex, - SourceKind::Opencode - ] - ); - for b in &out { - assert_eq!(b.tool_name, "Bash"); - } - } - - #[test] - fn signal_b_skips_events_without_user_turn_blocks() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &[], - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert!(out.is_empty()); - } - - #[test] - fn signal_b_honors_custom_threshold() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 4_000, - 1_000, - )]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let def = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert!(def.is_empty()); - let tight = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: Some(500), - min_occurrences: None, - }); - assert_eq!(tight.len(), 1); - assert_eq!(tight[0].evidenced_max_output, 1_000); - } - - #[test] - fn signal_b_falls_back_to_unknown_tool_name() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "orphan", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "orphan", - 80_000, - 20_000, - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &[], - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].tool_name, ""); - assert_eq!(out[0].cost, 0.0); - } - - #[test] - fn signal_b_does_not_double_count_carrier_plus_subagent_notification() { - let pricing = load_builtin_pricing(); - let events = vec![ - evt_with( - SourceKind::ClaudeCode, - "s1", - "tu_a", - 0, - Some("m1"), - ToolResultEventSource::ToolResult, - None, - Some(0), - ), - evt_with( - SourceKind::ClaudeCode, - "s1", - "tu_a", - 1, - Some("m1"), - ToolResultEventSource::SubagentNotification, - Some(200), - Some(1), - ), - ]; - let user_turns = vec![user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 80_000, - 20_000, - )]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].occurrence_count, 1); - assert_eq!(out[0].evidenced_max_output, 20_000); - } - - // ------------------------------------------------------------------- - // Top-level orchestration - // ------------------------------------------------------------------- - - #[test] - fn orchestration_runs_both_signals() { - let pricing = load_builtin_pricing(); - let settings = vec![loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), - )]; - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 80_000, - 20_000, - )]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { - settings: &settings, - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 2); - let mut kinds: Vec = out.iter().map(|b| b.kind).collect(); - kinds.sort_by_key(|k| match k { - ToolOutputBloatKind::ObservedBloat => 0, - ToolOutputBloatKind::StaticConfig => 1, - }); - assert_eq!( - kinds, - vec![ - ToolOutputBloatKind::ObservedBloat, - ToolOutputBloatKind::StaticConfig, - ] - ); - } - - #[test] - fn orchestration_signal_a_only() { - let pricing = load_builtin_pricing(); - let settings = vec![loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), - )]; - let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { - settings: &settings, - tool_result_events: &[], - user_turns: &[], - turns: &[], - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].kind, ToolOutputBloatKind::StaticConfig); - } - - #[test] - fn orchestration_signal_b_only() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 80_000, - 20_000, - )]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { - settings: &[], - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].kind, ToolOutputBloatKind::ObservedBloat); - } - - // ------------------------------------------------------------------- - // WasteFinding adapter - // ------------------------------------------------------------------- - - #[test] - fn finding_adapter_signal_a_paste_targets_settings_json() { - let f = tool_output_bloat_to_finding(&ToolOutputBloat { - source: SourceKind::ClaudeCode, - kind: ToolOutputBloatKind::StaticConfig, - tool_name: "Bash".to_string(), - configured_limit: Some(80_000), - evidenced_max_output: 20_000, - evidenced_p95_output: None, - occurrence_count: 1, - cost: 0.0, - evidence: vec!["/u/.claude/settings.json".to_string()], - }); - assert_eq!(f.kind, "tool-output-bloat"); - assert_eq!(f.actions.len(), 1); - match &f.actions[0] { - WasteAction::Paste { label, text } => { - assert!(label.contains("settings.json"), "label: {label}"); - assert!(text.contains(BASH_MAX_OUTPUT_ENV_KEY), "text: {text}"); - assert!( - text.contains("\"60000\""), - "text should target 60000 chars: {text}" - ); - } - other => panic!("expected Paste action, got {other:?}"), - } - assert_eq!(f.estimated_savings.tokens_per_session, Some(20_000)); - } - - #[test] - fn finding_adapter_signal_b_emits_instruction_paste() { - let f = tool_output_bloat_to_finding(&ToolOutputBloat { - source: SourceKind::Codex, - kind: ToolOutputBloatKind::ObservedBloat, - tool_name: "shell".to_string(), - configured_limit: None, - evidenced_max_output: 25_000, - evidenced_p95_output: Some(24_000), - occurrence_count: 4, - cost: 0.07, - evidence: vec!["s1".to_string(), "s2".to_string()], - }); - assert_eq!(f.kind, "tool-output-bloat"); - assert_eq!(f.severity, WasteSeverity::Warn); - assert!(f.title.contains("codex shell"), "title: {}", f.title); - assert!(f.title.contains("4×"), "title: {}", f.title); - assert!(f.detail.contains("head"), "detail: {}", f.detail); - assert!(f.detail.contains("tail"), "detail: {}", f.detail); - assert!(f.detail.contains("grep"), "detail: {}", f.detail); - assert!(matches!(f.actions[0], WasteAction::Paste { .. })); - } - - // ------------------------------------------------------------------- - // Fixture-driven integration coverage - // ------------------------------------------------------------------- - - fn workspace_fixture(rel: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("tests") - .join("fixtures") - .join(rel) - } - - #[test] - fn fixture_settings_json_oversized_bash_output_length() { - let path = workspace_fixture("claude/settings/oversized-bash-output-length.json"); - let loaded = load_claude_settings(&path).expect("fixture loads"); - let result = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings: vec![loaded], - }); - assert_eq!(result.len(), 1); - assert_eq!(result[0].configured_limit, Some(80_000)); - assert_eq!( - result[0].evidence, - vec![path.to_string_lossy().into_owned()] - ); - } - - #[test] - fn fixture_claude_oversized_bash_output_enriched_path() { - use crate::reader::{parse_claude_session, ClaudeParseOptions}; - let pricing = load_builtin_pricing(); - let path = workspace_fixture("claude/oversized-bash-output.jsonl"); - let parsed = parse_claude_session(&path, &ClaudeParseOptions::default()).expect("parses"); - // cl100k tokenizes repeated single-char content far below the - // bytes/4 heuristic; we don't have cl100k wired here so the - // detector falls back to bytes/4 either way. Use a low threshold - // so the assertion still trips on the synthetic content. - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &parsed.tool_result_events, - user_turns: &parsed.user_turns, - turns: &parsed.turns, - pricing: &pricing, - threshold: Some(5_000), - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].source, SourceKind::ClaudeCode); - assert_eq!(out[0].tool_name, "Bash"); - assert!(out[0].evidenced_max_output > 5_000); - } - - #[test] - fn fixture_claude_oversized_bash_output_content_length_fallback() { - use crate::reader::{parse_claude_session, ClaudeParseOptions}; - let pricing = load_builtin_pricing(); - let path = workspace_fixture("claude/oversized-bash-output.jsonl"); - let parsed = parse_claude_session(&path, &ClaudeParseOptions::default()).expect("parses"); - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &parsed.tool_result_events, - user_turns: &[], - turns: &parsed.turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].source, SourceKind::ClaudeCode); - assert_eq!(out[0].tool_name, "Bash"); - assert!(out[0].evidenced_max_output >= DEFAULT_BLOAT_TOKEN_THRESHOLD); - } - - #[test] - fn fixture_codex_oversized_shell_output() { - use crate::reader::codex::{parse_codex_session, ParseCodexOptions}; - let pricing = load_builtin_pricing(); - let path = workspace_fixture("codex/oversized-shell-output.jsonl"); - let parsed = parse_codex_session(&path, &ParseCodexOptions::default()).expect("parses"); - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &parsed.tool_result_events, - user_turns: &parsed.user_turns, - turns: &parsed.turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].source, SourceKind::Codex); - // Codex `shell` normalizes to canonical `Bash`. - assert_eq!(out[0].tool_name, "Bash"); - assert!(out[0].evidenced_max_output >= DEFAULT_BLOAT_TOKEN_THRESHOLD); - } - - #[test] - fn fixture_opencode_synthesized_bash() { - let pricing = load_builtin_pricing(); - let events = vec![evt_with( - SourceKind::Opencode, - "ses_bloat", - "opc_bash_1", - 0, - Some("msg_bloat"), - ToolResultEventSource::ToolResult, - None, - None, - )]; - let user_turns = vec![user_turn_with( - SourceKind::Opencode, - "ses_bloat", - "u_bloat", - "msg_bloat", - "msg_bloat_next", - "opc_bash_1", - 80_000, - 20_000, - )]; - let turns = vec![turn_with( - SourceKind::Opencode, - "ses_bloat", - "msg_bloat", - 0, - vec![tc("opc_bash_1", "bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].source, SourceKind::Opencode); - assert_eq!(out[0].tool_name, "Bash"); - } -} +#[path = "tool_output_bloat_tests.rs"] +mod tests; diff --git a/crates/relayburn-sdk/src/analyze/tool_output_bloat_tests.rs b/crates/relayburn-sdk/src/analyze/tool_output_bloat_tests.rs new file mode 100644 index 0000000..ed02b2d --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/tool_output_bloat_tests.rs @@ -0,0 +1,1073 @@ +//! Conformance tests for the tool_output_bloat module — extracted verbatim from +//! the former inline `#[cfg(test)] mod tests` block (included via `#[path]`). + + use super::*; + use crate::analyze::pricing::load_builtin_pricing; + use crate::reader::{ToolCall, ToolResultEventSource, ToolResultStatus, Usage, UserTurnBlock}; + use serde_json::json; + use std::path::PathBuf; + use tempfile::tempdir; + + fn loaded(path: &str, env: serde_json::Value) -> LoadedClaudeSettings { + let settings: ClaudeSettings = serde_json::from_value(json!({ "env": env })).unwrap(); + LoadedClaudeSettings { + path: PathBuf::from(path), + settings, + } + } + + fn loaded_no_env(path: &str) -> LoadedClaudeSettings { + LoadedClaudeSettings { + path: PathBuf::from(path), + settings: ClaudeSettings::default(), + } + } + + fn evt( + session_id: &str, + tool_use_id: &str, + event_index: u64, + message_id: Option<&str>, + ) -> ToolResultEventRecord { + ToolResultEventRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.to_string(), + message_id: message_id.map(String::from), + tool_use_id: tool_use_id.to_string(), + call_index: None, + event_index, + ts: None, + status: ToolResultStatus::Completed, + event_source: ToolResultEventSource::ToolResult, + content_length: None, + output_bytes: None, + output_truncated: None, + content_hash: None, + is_error: None, + usage: None, + usage_attribution: None, + subagent_session_id: None, + agent_id: None, + replaced_tools: None, + collapsed_calls: None, + } + } + + #[allow(clippy::too_many_arguments)] + fn evt_with( + source: SourceKind, + session_id: &str, + tool_use_id: &str, + event_index: u64, + message_id: Option<&str>, + event_source: ToolResultEventSource, + content_length: Option, + call_index: Option, + ) -> ToolResultEventRecord { + ToolResultEventRecord { + v: 1, + source, + session_id: session_id.to_string(), + message_id: message_id.map(String::from), + tool_use_id: tool_use_id.to_string(), + call_index, + event_index, + ts: None, + status: ToolResultStatus::Completed, + event_source, + content_length, + output_bytes: content_length, + output_truncated: None, + content_hash: None, + is_error: None, + usage: None, + usage_attribution: None, + subagent_session_id: None, + agent_id: None, + replaced_tools: None, + collapsed_calls: None, + } + } + + #[allow(clippy::too_many_arguments)] + fn user_turn_with( + source: SourceKind, + session_id: &str, + user_uuid: &str, + preceding: &str, + following: &str, + tool_use_id: &str, + byte_len: u64, + approx_tokens: u64, + ) -> UserTurnRecord { + UserTurnRecord { + v: 1, + source, + session_id: session_id.to_string(), + user_uuid: user_uuid.to_string(), + ts: "2026-04-20T00:00:00.500Z".to_string(), + preceding_message_id: Some(preceding.to_string()), + following_message_id: Some(following.to_string()), + blocks: vec![UserTurnBlock { + kind: UserTurnBlockKind::ToolResult, + tool_use_id: Some(tool_use_id.to_string()), + byte_len, + approx_tokens, + is_error: None, + }], + } + } + + fn turn_with( + source: SourceKind, + session_id: &str, + message_id: &str, + turn_index: u64, + tool_calls: Vec, + ) -> TurnRecord { + TurnRecord { + v: 1, + source, + session_id: session_id.to_string(), + session_path: None, + message_id: message_id.to_string(), + turn_index, + ts: "2026-04-20T00:00:00.000Z".to_string(), + model: "claude-sonnet-4-6".to_string(), + project: None, + project_key: None, + usage: Usage { + input: 10, + output: 5, + reasoning: 0, + cache_read: 100, + cache_create_5m: 50, + cache_create_1h: 0, + }, + tool_calls, + files_touched: None, + subagent: None, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, + } + } + + fn tc(id: &str, name: &str) -> ToolCall { + ToolCall { + id: id.to_string(), + name: name.to_string(), + target: None, + args_hash: "hash".to_string(), + is_error: None, + edit_pre_hash: None, + edit_post_hash: None, + skill_name: None, + replaced_tools: None, + collapsed_calls: None, + } + } + + // ------------------------------------------------------------------- + // Signal A — static-config check + // ------------------------------------------------------------------- + + #[test] + fn signal_a_flags_oversized_bash_max_output_length() { + let settings = vec![loaded( + "/home/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), + )]; + let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }); + assert_eq!(out.len(), 1); + let f = &out[0]; + assert_eq!(f.kind, ToolOutputBloatKind::StaticConfig); + assert_eq!(f.source, SourceKind::ClaudeCode); + assert_eq!(f.tool_name, "Bash"); + assert_eq!(f.configured_limit, Some(80_000)); + assert_eq!(f.evidenced_max_output, 20_000); + assert_eq!(f.occurrence_count, 1); + assert_eq!(f.cost, 0.0); + assert_eq!( + f.evidence, + vec!["/home/u/.claude/settings.json".to_string()] + ); + } + + #[test] + fn signal_a_does_not_flag_at_threshold() { + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "60000" }), + )]; + assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }) + .is_empty()); + } + + #[test] + fn signal_a_unit_conversion_under_threshold() { + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "50000" }), + )]; + assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }) + .is_empty()); + } + + #[test] + fn signal_a_no_env_block() { + let settings = vec![loaded_no_env("/u/.claude/settings.json")]; + assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }) + .is_empty()); + } + + #[test] + fn signal_a_project_overrides_user() { + let settings = vec![ + loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), + ), + loaded( + "/cwd/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "60000" }), + ), + ]; + assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }) + .is_empty()); + } + + #[test] + fn signal_a_project_path_reported_when_project_overrides_to_oversized() { + let settings = vec![ + loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "15000" }), + ), + loaded( + "/cwd/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "99999" }), + ), + ]; + let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }); + assert_eq!(out.len(), 1); + assert_eq!( + out[0].evidence, + vec!["/cwd/.claude/settings.json".to_string()] + ); + assert_eq!(out[0].configured_limit, Some(99_999)); + } + + #[test] + fn signal_a_honors_custom_threshold() { + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "5000" }), + )]; + let tight = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: Some(1_000), + settings: settings.clone(), + }); + assert_eq!(tight.len(), 1); + let loose = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: Some(10_000), + settings, + }); + assert!(loose.is_empty()); + } + + // ------------------------------------------------------------------- + // Filesystem loader + // ------------------------------------------------------------------- + + #[test] + fn load_settings_returns_none_for_missing_file() { + let dir = tempdir().unwrap(); + assert!(load_claude_settings(dir.path().join("nope.json")).is_none()); + } + + #[test] + fn load_settings_returns_none_for_malformed_json() { + let dir = tempdir().unwrap(); + let p = dir.path().join("bad.json"); + std::fs::write(&p, "{not json").unwrap(); + assert!(load_claude_settings(&p).is_none()); + } + + #[test] + fn load_settings_reads_env_block() { + let dir = tempdir().unwrap(); + let p = dir.path().join("settings.json"); + std::fs::write( + &p, + json!({ "env": { BASH_MAX_OUTPUT_ENV_KEY: "80000" } }).to_string(), + ) + .unwrap(); + let loaded = load_claude_settings(&p).expect("loads"); + assert_eq!(loaded.path, p); + let env = loaded.settings.env.as_ref().expect("env present"); + assert_eq!( + env.get(BASH_MAX_OUTPUT_ENV_KEY).and_then(|v| v.as_str()), + Some("80000"), + ); + } + + #[test] + fn load_and_detect_end_to_end() { + let dir = tempdir().unwrap(); + let claude_dir = dir.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + let p = claude_dir.join("settings.json"); + std::fs::write( + &p, + json!({ "env": { BASH_MAX_OUTPUT_ENV_KEY: "80000" } }).to_string(), + ) + .unwrap(); + let loaded = load_claude_settings(&p).expect("loads"); + let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings: vec![loaded], + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].configured_limit, Some(80_000)); + } + + // ------------------------------------------------------------------- + // Signal B — observed bloat across sessions + // ------------------------------------------------------------------- + + #[test] + fn signal_b_flags_bash_above_threshold() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + let b = &out[0]; + assert_eq!(b.kind, ToolOutputBloatKind::ObservedBloat); + assert_eq!(b.source, SourceKind::ClaudeCode); + assert_eq!(b.tool_name, "Bash"); + assert_eq!(b.occurrence_count, 1); + assert_eq!(b.evidenced_max_output, 20_000); + assert_eq!(b.evidence, vec!["s1".to_string()]); + assert!(b.cost > 0.0, "cost should be priced via the model rate"); + } + + #[test] + fn signal_b_does_not_flag_below_threshold() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 40_000, + 10_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert!(out.is_empty()); + } + + #[test] + fn signal_b_aggregates_into_single_bucket() { + let pricing = load_builtin_pricing(); + let events = vec![ + evt("s1", "tu_a", 0, Some("m1")), + evt("s2", "tu_b", 0, Some("m2")), + evt("s3", "tu_c", 0, Some("m3")), + ]; + let user_turns = vec![ + user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + ), + user_turn_with( + SourceKind::ClaudeCode, + "s2", + "u2", + "m2", + "m3", + "tu_b", + 100_000, + 25_000, + ), + user_turn_with( + SourceKind::ClaudeCode, + "s3", + "u3", + "m3", + "m4", + "tu_c", + 120_000, + 30_000, + ), + ]; + let turns = vec![ + turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + ), + turn_with( + SourceKind::ClaudeCode, + "s2", + "m2", + 0, + vec![tc("tu_b", "Bash")], + ), + turn_with( + SourceKind::ClaudeCode, + "s3", + "m3", + 0, + vec![tc("tu_c", "Bash")], + ), + ]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + let b = &out[0]; + assert_eq!(b.occurrence_count, 3); + assert_eq!(b.evidenced_max_output, 30_000); + assert_eq!(b.evidence.len(), 3); + } + + #[test] + fn signal_b_emits_one_bucket_per_source_tool_pair() { + let pricing = load_builtin_pricing(); + let events = vec![ + evt_with( + SourceKind::ClaudeCode, + "s1", + "tu_a", + 0, + Some("m1"), + ToolResultEventSource::ToolResult, + None, + None, + ), + evt_with( + SourceKind::Codex, + "s2", + "call_b", + 0, + Some("m2"), + ToolResultEventSource::ToolResult, + None, + None, + ), + evt_with( + SourceKind::Opencode, + "s3", + "opc_c", + 0, + Some("m3"), + ToolResultEventSource::ToolResult, + None, + None, + ), + ]; + let user_turns = vec![ + user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + ), + user_turn_with( + SourceKind::Codex, + "s2", + "u2", + "m2", + "m3", + "call_b", + 90_000, + 22_500, + ), + user_turn_with( + SourceKind::Opencode, + "s3", + "u3", + "m3", + "m4", + "opc_c", + 85_000, + 21_250, + ), + ]; + let turns = vec![ + turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + ), + turn_with( + SourceKind::Codex, + "s2", + "m2", + 0, + vec![tc("call_b", "shell")], + ), + turn_with( + SourceKind::Opencode, + "s3", + "m3", + 0, + vec![tc("opc_c", "bash")], + ), + ]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 3); + let mut sources: Vec = out.iter().map(|b| b.source).collect(); + sources.sort_by_key(|s| match s { + SourceKind::ClaudeCode => 0, + SourceKind::Codex => 1, + SourceKind::Opencode => 2, + _ => 3, + }); + assert_eq!( + sources, + vec![ + SourceKind::ClaudeCode, + SourceKind::Codex, + SourceKind::Opencode + ] + ); + for b in &out { + assert_eq!(b.tool_name, "Bash"); + } + } + + #[test] + fn signal_b_skips_events_without_user_turn_blocks() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &[], + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert!(out.is_empty()); + } + + #[test] + fn signal_b_honors_custom_threshold() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 4_000, + 1_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let def = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert!(def.is_empty()); + let tight = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: Some(500), + min_occurrences: None, + }); + assert_eq!(tight.len(), 1); + assert_eq!(tight[0].evidenced_max_output, 1_000); + } + + #[test] + fn signal_b_falls_back_to_unknown_tool_name() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "orphan", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "orphan", + 80_000, + 20_000, + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &[], + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].tool_name, ""); + assert_eq!(out[0].cost, 0.0); + } + + #[test] + fn signal_b_does_not_double_count_carrier_plus_subagent_notification() { + let pricing = load_builtin_pricing(); + let events = vec![ + evt_with( + SourceKind::ClaudeCode, + "s1", + "tu_a", + 0, + Some("m1"), + ToolResultEventSource::ToolResult, + None, + Some(0), + ), + evt_with( + SourceKind::ClaudeCode, + "s1", + "tu_a", + 1, + Some("m1"), + ToolResultEventSource::SubagentNotification, + Some(200), + Some(1), + ), + ]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].occurrence_count, 1); + assert_eq!(out[0].evidenced_max_output, 20_000); + } + + // ------------------------------------------------------------------- + // Top-level orchestration + // ------------------------------------------------------------------- + + #[test] + fn orchestration_runs_both_signals() { + let pricing = load_builtin_pricing(); + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), + )]; + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { + settings: &settings, + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 2); + let mut kinds: Vec = out.iter().map(|b| b.kind).collect(); + kinds.sort_by_key(|k| match k { + ToolOutputBloatKind::ObservedBloat => 0, + ToolOutputBloatKind::StaticConfig => 1, + }); + assert_eq!( + kinds, + vec![ + ToolOutputBloatKind::ObservedBloat, + ToolOutputBloatKind::StaticConfig, + ] + ); + } + + #[test] + fn orchestration_signal_a_only() { + let pricing = load_builtin_pricing(); + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), + )]; + let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { + settings: &settings, + tool_result_events: &[], + user_turns: &[], + turns: &[], + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].kind, ToolOutputBloatKind::StaticConfig); + } + + #[test] + fn orchestration_signal_b_only() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { + settings: &[], + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].kind, ToolOutputBloatKind::ObservedBloat); + } + + // ------------------------------------------------------------------- + // WasteFinding adapter + // ------------------------------------------------------------------- + + #[test] + fn finding_adapter_signal_a_paste_targets_settings_json() { + let f = tool_output_bloat_to_finding(&ToolOutputBloat { + source: SourceKind::ClaudeCode, + kind: ToolOutputBloatKind::StaticConfig, + tool_name: "Bash".to_string(), + configured_limit: Some(80_000), + evidenced_max_output: 20_000, + evidenced_p95_output: None, + occurrence_count: 1, + cost: 0.0, + evidence: vec!["/u/.claude/settings.json".to_string()], + }); + assert_eq!(f.kind, "tool-output-bloat"); + assert_eq!(f.actions.len(), 1); + match &f.actions[0] { + WasteAction::Paste { label, text } => { + assert!(label.contains("settings.json"), "label: {label}"); + assert!(text.contains(BASH_MAX_OUTPUT_ENV_KEY), "text: {text}"); + assert!( + text.contains("\"60000\""), + "text should target 60000 chars: {text}" + ); + } + other => panic!("expected Paste action, got {other:?}"), + } + assert_eq!(f.estimated_savings.tokens_per_session, Some(20_000)); + } + + #[test] + fn finding_adapter_signal_b_emits_instruction_paste() { + let f = tool_output_bloat_to_finding(&ToolOutputBloat { + source: SourceKind::Codex, + kind: ToolOutputBloatKind::ObservedBloat, + tool_name: "shell".to_string(), + configured_limit: None, + evidenced_max_output: 25_000, + evidenced_p95_output: Some(24_000), + occurrence_count: 4, + cost: 0.07, + evidence: vec!["s1".to_string(), "s2".to_string()], + }); + assert_eq!(f.kind, "tool-output-bloat"); + assert_eq!(f.severity, WasteSeverity::Warn); + assert!(f.title.contains("codex shell"), "title: {}", f.title); + assert!(f.title.contains("4×"), "title: {}", f.title); + assert!(f.detail.contains("head"), "detail: {}", f.detail); + assert!(f.detail.contains("tail"), "detail: {}", f.detail); + assert!(f.detail.contains("grep"), "detail: {}", f.detail); + assert!(matches!(f.actions[0], WasteAction::Paste { .. })); + } + + // ------------------------------------------------------------------- + // Fixture-driven integration coverage + // ------------------------------------------------------------------- + + fn workspace_fixture(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("tests") + .join("fixtures") + .join(rel) + } + + #[test] + fn fixture_settings_json_oversized_bash_output_length() { + let path = workspace_fixture("claude/settings/oversized-bash-output-length.json"); + let loaded = load_claude_settings(&path).expect("fixture loads"); + let result = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings: vec![loaded], + }); + assert_eq!(result.len(), 1); + assert_eq!(result[0].configured_limit, Some(80_000)); + assert_eq!( + result[0].evidence, + vec![path.to_string_lossy().into_owned()] + ); + } + + #[test] + fn fixture_claude_oversized_bash_output_enriched_path() { + use crate::reader::{parse_claude_session, ClaudeParseOptions}; + let pricing = load_builtin_pricing(); + let path = workspace_fixture("claude/oversized-bash-output.jsonl"); + let parsed = parse_claude_session(&path, &ClaudeParseOptions::default()).expect("parses"); + // cl100k tokenizes repeated single-char content far below the + // bytes/4 heuristic; we don't have cl100k wired here so the + // detector falls back to bytes/4 either way. Use a low threshold + // so the assertion still trips on the synthetic content. + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &parsed.tool_result_events, + user_turns: &parsed.user_turns, + turns: &parsed.turns, + pricing: &pricing, + threshold: Some(5_000), + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].source, SourceKind::ClaudeCode); + assert_eq!(out[0].tool_name, "Bash"); + assert!(out[0].evidenced_max_output > 5_000); + } + + #[test] + fn fixture_claude_oversized_bash_output_content_length_fallback() { + use crate::reader::{parse_claude_session, ClaudeParseOptions}; + let pricing = load_builtin_pricing(); + let path = workspace_fixture("claude/oversized-bash-output.jsonl"); + let parsed = parse_claude_session(&path, &ClaudeParseOptions::default()).expect("parses"); + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &parsed.tool_result_events, + user_turns: &[], + turns: &parsed.turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].source, SourceKind::ClaudeCode); + assert_eq!(out[0].tool_name, "Bash"); + assert!(out[0].evidenced_max_output >= DEFAULT_BLOAT_TOKEN_THRESHOLD); + } + + #[test] + fn fixture_codex_oversized_shell_output() { + use crate::reader::codex::{parse_codex_session, ParseCodexOptions}; + let pricing = load_builtin_pricing(); + let path = workspace_fixture("codex/oversized-shell-output.jsonl"); + let parsed = parse_codex_session(&path, &ParseCodexOptions::default()).expect("parses"); + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &parsed.tool_result_events, + user_turns: &parsed.user_turns, + turns: &parsed.turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].source, SourceKind::Codex); + // Codex `shell` normalizes to canonical `Bash`. + assert_eq!(out[0].tool_name, "Bash"); + assert!(out[0].evidenced_max_output >= DEFAULT_BLOAT_TOKEN_THRESHOLD); + } + + #[test] + fn fixture_opencode_synthesized_bash() { + let pricing = load_builtin_pricing(); + let events = vec![evt_with( + SourceKind::Opencode, + "ses_bloat", + "opc_bash_1", + 0, + Some("msg_bloat"), + ToolResultEventSource::ToolResult, + None, + None, + )]; + let user_turns = vec![user_turn_with( + SourceKind::Opencode, + "ses_bloat", + "u_bloat", + "msg_bloat", + "msg_bloat_next", + "opc_bash_1", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::Opencode, + "ses_bloat", + "msg_bloat", + 0, + vec![tc("opc_bash_1", "bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].source, SourceKind::Opencode); + assert_eq!(out[0].tool_name, "Bash"); + } From cb5c70a99ba080f2afadd02875a0ebad130cee12 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:06:16 -0400 Subject: [PATCH 10/22] refactor(sdk/analyze): externalize subagent_tree + context_delta tests Apply the patterns_tests.rs / hotspots_tests.rs convention to the two remaining large analyze files with a clean single inline test block: subagent_tree.rs 1277 -> 826 code (tests -> subagent_tree_tests.rs) context_delta.rs 1077 -> 658 code (tests -> context_delta_tests.rs) Tests moved verbatim, module name kept `tests` so paths are unchanged. All tests pass, clippy clean. Pure relocation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/analyze/context_delta.rs | 423 +--------------- .../src/analyze/context_delta_tests.rs | 422 ++++++++++++++++ .../src/analyze/subagent_tree.rs | 455 +----------------- .../src/analyze/subagent_tree_tests.rs | 454 +++++++++++++++++ 4 files changed, 880 insertions(+), 874 deletions(-) create mode 100644 crates/relayburn-sdk/src/analyze/context_delta_tests.rs create mode 100644 crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs diff --git a/crates/relayburn-sdk/src/analyze/context_delta.rs b/crates/relayburn-sdk/src/analyze/context_delta.rs index 5e197ee..ce9a9ba 100644 --- a/crates/relayburn-sdk/src/analyze/context_delta.rs +++ b/crates/relayburn-sdk/src/analyze/context_delta.rs @@ -654,424 +654,5 @@ fn attr_int(node: &SpanNode, key: &str) -> Option { // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - use crate::analyze::span_tree::{SpanKind, SpanNode, SpanStatus, TurnSpanTree}; - use crate::reader::{CompactionEvent, SourceKind}; - - fn make_inf( - req_id: &str, - model: &str, - input: i64, - cache_read: i64, - cache_write: i64, - ) -> SpanNode { - let mut n = SpanNode::new(SpanKind::Inference, model); - n.set_attr("model", AttrValue::str(model)); - n.set_attr("request_id", AttrValue::str(req_id)); - n.set_attr("tokens.input", AttrValue::Int(input)); - n.set_attr("tokens.output", AttrValue::Int(0)); - n.set_attr("tokens.cache_read", AttrValue::Int(cache_read)); - n.set_attr("tokens.cache_write", AttrValue::Int(cache_write)); - n.set_attr("tokens.reasoning", AttrValue::Int(0)); - n - } - - fn make_tool_use(name: &str, tool_use_id: &str) -> SpanNode { - let mut n = SpanNode::new(SpanKind::ToolUse, name); - n.set_attr("tool_use_id", AttrValue::str(tool_use_id)); - n - } - - fn make_tool_result(tool_use_id: &str, bytes: i64, truncated: bool) -> SpanNode { - let mut n = SpanNode::new(SpanKind::ToolResult, "tool-result"); - n.set_attr("tool_use_id", AttrValue::str(tool_use_id)); - n.set_attr("output_bytes", AttrValue::Int(bytes)); - if truncated { - n.set_attr("output_truncated", AttrValue::Bool(true)); - } - n - } - - fn make_user_prompt() -> SpanNode { - SpanNode::new(SpanKind::UserPrompt, "user-prompt") - } - - fn turn_tree(session: &str, turn: &str, root: SpanNode) -> TurnSpanTree { - TurnSpanTree { - session_id: session.to_string(), - turn_id: turn.to_string(), - turn_number: 0, - root, - } - } - - /// Build a single-turn fixture: two inferences on the main rail - /// with a Bash tool_result between them whose 40_000 bytes - /// translates to a ~10k token jump. The delta should surface as - /// the top row with Bash as the driver. - #[test] - fn bash_blowup_surfaces_as_top_delta_with_bash_driver() { - // inference #1: context = 1000 - let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); - let mut bash_use = make_tool_use("Bash", "tu-1"); - bash_use - .children - .push(make_tool_result("tu-1", 40_000, false)); - let mut inf1 = inf1; - inf1.children.push(bash_use); - - // inference #2: context jumped to 12_000 — delta of 11_000. - let inf2 = make_inf("req-2", "claude-sonnet-4-6", 12_000, 0, 0); - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.status = SpanStatus::Ok; - root.children.push(make_user_prompt()); - root.children.push(inf1); - root.children.push(inf2); - - let tree = turn_tree("sess-1", "msg-1", root); - let pricing = crate::analyze::pricing::load_builtin_pricing(); - let opts = ContextDeltaOpts::default(); - let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); - assert_eq!(deltas.len(), 1, "one pairwise delta expected"); - let d = &deltas[0]; - assert_eq!(d.session_id, "sess-1"); - assert_eq!(d.turn_id, "msg-1"); - assert_eq!(d.owner_rail, OwnerRail::Main); - assert_eq!(d.prior_context_tokens, 1000); - assert_eq!(d.current_context_tokens, 12_000); - assert_eq!(d.delta_tokens, 11_000); - assert_eq!(d.intervening.len(), 1); - match &d.intervening[0] { - InterveningStep::ToolResult { - tool_name, - approx_bytes, - approx_tokens, - truncated, - .. - } => { - assert_eq!(tool_name, "Bash"); - assert_eq!(*approx_bytes, 40_000); - assert_eq!(*approx_tokens, 10_000); - assert!(!*truncated); - } - other => panic!("expected ToolResult step, got {other:?}"), - } - // The driver_label helper should mention Bash. - assert!(d.intervening[0].driver_label().contains("Bash")); - // Cost is non-negative. - assert!(d.attributed_cost_usd >= 0.0); - } - - /// Compaction handling: a CompactionEvent between two inferences - /// where the second has *less* context than the first must surface - /// as `Compaction { tokens_freed }`, NOT a negative `delta_tokens`. - #[test] - fn compaction_replaces_negative_delta() { - let inf1 = make_inf("req-1", "claude-sonnet-4-6", 50_000, 0, 0); - // After compaction, context drops to 8_000. - let inf2 = make_inf("req-2", "claude-sonnet-4-6", 8_000, 0, 0); - - // Stamp timestamps so the compaction event sits between them. - let mut inf1 = inf1; - inf1.start_ms = 1_776_643_201_000; - inf1.end_ms = 1_776_643_202_000; - let mut inf2 = inf2; - inf2.start_ms = 1_776_643_204_000; - inf2.end_ms = 1_776_643_205_000; - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(inf1); - root.children.push(inf2); - let tree = turn_tree("sess-1", "msg-1", root); - - let compaction = CompactionEvent { - v: 1, - source: SourceKind::ClaudeCode, - session_id: "sess-1".into(), - ts: "2026-04-20T00:00:03.000Z".into(), - preceding_message_id: Some("msg-1".into()), - tokens_before_compact: Some(50_000), - }; - - let pricing = crate::analyze::pricing::load_builtin_pricing(); - // Opts: min_delta 0 so the row isn't filtered out (delta_tokens is 0). - let opts = ContextDeltaOpts { - min_delta: Some(0), - ..ContextDeltaOpts::default() - }; - let deltas = deltas_for_session(&[tree], &[compaction], &pricing, &opts); - assert_eq!(deltas.len(), 1); - let d = &deltas[0]; - assert_eq!(d.delta_tokens, 0, "compaction clamps to 0"); - let has_compaction = d - .intervening - .iter() - .any(|s| matches!(s, InterveningStep::Compaction { tokens_freed } if *tokens_freed == 42_000)); - assert!( - has_compaction, - "expected Compaction step with tokens_freed=42000, got {:?}", - d.intervening - ); - } - - /// Subagent isolation: a main-rail Inference and a subagent - /// Inference both happen, with a subagent tool_result between - /// them. The main-rail delta must NOT include the subagent's - /// tool_result. - #[test] - fn subagent_isolation_main_rail_excludes_subagent_results() { - // Main rail: two inferences with a 10k context jump. - let mut main_inf1 = make_inf("req-main-1", "claude-sonnet-4-6", 1000, 0, 0); - // Add a Task tool_use that fans out to a Subagent with its own - // inference + tool_result. The subagent's tool_result has - // 40k bytes (~10k tokens) — and the main rail's tool_use also - // gets a small result so the main delta is non-zero. - let mut task_use = make_tool_use("Task", "tu-task"); - - let mut sub_node = SpanNode::new(SpanKind::Subagent, "general-purpose"); - sub_node.set_attr("agent_id", AttrValue::str("agent-a")); - // Subagent inferences: - let sub_inf1 = make_inf("req-sub-1", "claude-sonnet-4-6", 2000, 0, 0); - let mut sub_bash = make_tool_use("Bash", "tu-sub-bash"); - sub_bash - .children - .push(make_tool_result("tu-sub-bash", 40_000, false)); - let mut sub_inf1 = sub_inf1; - sub_inf1.children.push(sub_bash); - let sub_inf2 = make_inf("req-sub-2", "claude-sonnet-4-6", 12_000, 0, 0); - sub_node.children.push(sub_inf1); - sub_node.children.push(sub_inf2); - task_use.children.push(sub_node); - main_inf1.children.push(task_use); - - // Main rail #2: small jump only (no big tool_result on its own). - let main_inf2 = make_inf("req-main-2", "claude-sonnet-4-6", 3000, 0, 0); - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(main_inf1); - root.children.push(main_inf2); - let tree = turn_tree("sess-1", "msg-1", root); - - let pricing = crate::analyze::pricing::load_builtin_pricing(); - let opts = ContextDeltaOpts { - min_delta: Some(0), - ..ContextDeltaOpts::default() - }; - let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); - - // We expect one main-rail delta and one subagent-rail delta. - let main_delta = deltas - .iter() - .find(|d| d.owner_rail == OwnerRail::Main) - .expect("main-rail delta missing"); - let sub_delta = deltas - .iter() - .find(|d| matches!(&d.owner_rail, OwnerRail::Subagent { agent_id } if agent_id == "agent-a")) - .expect("subagent-rail delta missing"); - - // Main delta intervening must NOT include the subagent's tool_result. - for step in &main_delta.intervening { - if let InterveningStep::ToolResult { tool_use_id, .. } = step { - assert_ne!( - tool_use_id, "tu-sub-bash", - "main rail must NOT see the subagent's tool_result" - ); - } - } - - // Subagent delta intervening SHOULD include its own Bash result. - let sub_has_bash = sub_delta.intervening.iter().any(|s| match s { - InterveningStep::ToolResult { tool_use_id, .. } => tool_use_id == "tu-sub-bash", - _ => false, - }); - assert!( - sub_has_bash, - "subagent rail must see its own Bash tool_result" - ); - } - - /// Empty rail (single inference, no prev) → no delta emitted, no - /// panic. - #[test] - fn single_inference_yields_no_delta() { - let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(inf1); - let tree = turn_tree("sess-1", "msg-1", root); - let pricing = crate::analyze::pricing::load_builtin_pricing(); - let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); - assert!( - deltas.is_empty(), - "single inference must not emit a pairwise delta" - ); - } - - /// `min_delta` filters out small jumps. - #[test] - fn min_delta_filters_small_jumps() { - let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); - // Small jump: +500 tokens. - let inf2 = make_inf("req-2", "claude-sonnet-4-6", 1500, 0, 0); - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(inf1); - root.children.push(inf2); - let tree = turn_tree("sess-1", "msg-1", root); - let pricing = crate::analyze::pricing::load_builtin_pricing(); - // Default min_delta is 1000; 500 < 1000 → filtered out. - let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); - assert!(deltas.is_empty(), "500 token jump must be filtered"); - - // Lower the threshold to 100 → row appears. - let opts = ContextDeltaOpts { - min_delta: Some(100), - ..ContextDeltaOpts::default() - }; - let deltas = deltas_for_session( - &[turn_tree("sess-1", "msg-1", root_with_two_infs(1000, 1500))], - &[], - &pricing, - &opts, - ); - assert_eq!(deltas.len(), 1); - assert_eq!(deltas[0].delta_tokens, 500); - } - - fn root_with_two_infs(ctx1: i64, ctx2: i64) -> SpanNode { - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children - .push(make_inf("req-1", "claude-sonnet-4-6", ctx1, 0, 0)); - root.children - .push(make_inf("req-2", "claude-sonnet-4-6", ctx2, 0, 0)); - root - } - - /// `--top N` caps output. - #[test] - fn top_caps_output() { - // Build a tree with 5 inferences, each adding 5000 tokens. - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - let ctx_steps = [1000, 6000, 11_000, 16_000, 21_000]; - for (i, c) in ctx_steps.iter().enumerate() { - root.children - .push(make_inf(&format!("req-{i}"), "claude-sonnet-4-6", *c, 0, 0)); - } - let tree = turn_tree("sess-1", "msg-1", root); - let pricing = crate::analyze::pricing::load_builtin_pricing(); - - // No cap → 4 pairwise deltas (5 inferences = 4 windows). - let opts = ContextDeltaOpts { - min_delta: Some(0), - ..ContextDeltaOpts::default() - }; - let all = deltas_for_session(std::slice::from_ref(&tree), &[], &pricing, &opts); - assert_eq!(all.len(), 4); - - // Cap at 2 → only the top 2 deltas. - let opts = ContextDeltaOpts { - min_delta: Some(0), - top: Some(2), - ..ContextDeltaOpts::default() - }; - let top2 = deltas_for_session(&[tree], &[], &pricing, &opts); - assert_eq!(top2.len(), 2); - } - - /// `--owner main` filter excludes subagent rails. - #[test] - fn owner_filter_main_excludes_subagent_rail() { - // Reuse the subagent-isolation fixture shape. - let mut main_inf1 = make_inf("req-main-1", "claude-sonnet-4-6", 1000, 0, 0); - let mut task_use = make_tool_use("Task", "tu-task"); - let mut sub_node = SpanNode::new(SpanKind::Subagent, "general-purpose"); - sub_node.set_attr("agent_id", AttrValue::str("agent-a")); - sub_node - .children - .push(make_inf("req-sub-1", "claude-sonnet-4-6", 2000, 0, 0)); - sub_node - .children - .push(make_inf("req-sub-2", "claude-sonnet-4-6", 22_000, 0, 0)); - task_use.children.push(sub_node); - main_inf1.children.push(task_use); - let main_inf2 = make_inf("req-main-2", "claude-sonnet-4-6", 3000, 0, 0); - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(main_inf1); - root.children.push(main_inf2); - let tree = turn_tree("sess-1", "msg-1", root); - - let pricing = crate::analyze::pricing::load_builtin_pricing(); - let opts = ContextDeltaOpts { - min_delta: Some(0), - owner: OwnerFilter::Main, - ..ContextDeltaOpts::default() - }; - let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); - for d in &deltas { - assert_eq!( - d.owner_rail, - OwnerRail::Main, - "owner filter Main must exclude subagent rails" - ); - } - assert!(!deltas.is_empty(), "expected at least one main-rail delta"); - } - - /// JSON shape: rail serializes with a `kind` discriminant, steps - /// keep their kebab-case kind tag. Catch wire-format drift early. - #[test] - fn json_shape_uses_kebab_case_discriminants() { - let d = ContextDelta { - session_id: "s".into(), - turn_id: "t".into(), - inference_idx: 2, - owner_rail: OwnerRail::Subagent { - agent_id: "agent-x".into(), - }, - prior_context_tokens: 10, - current_context_tokens: 20, - delta_tokens: 10, - intervening: vec![InterveningStep::ToolResult { - tool_use_id: "tu-1".into(), - tool_name: "Bash".into(), - approx_tokens: 5, - approx_bytes: 20, - truncated: false, - }], - attributed_cost_usd: 0.0, - }; - let s = serde_json::to_string(&d).unwrap(); - assert!(s.contains("\"kind\":\"subagent\""), "got {s}"); - assert!(s.contains("\"agentId\":\"agent-x\""), "got {s}"); - assert!(s.contains("\"kind\":\"tool-result\""), "got {s}"); - assert!(s.contains("\"toolUseId\":\"tu-1\""), "got {s}"); - let back: ContextDelta = serde_json::from_str(&s).unwrap(); - assert_eq!(back, d); - } - - /// System reminder step surfaces as its own intervening step row. - /// We construct one directly (the timeline builder doesn't yet - /// synthesize SystemReminder spans from content sidecars; first- - /// cut behavior per the issue). - #[test] - fn system_reminder_step_round_trips_in_json() { - let step = InterveningStep::SystemReminder { - source: ReminderSource::Other, - approx_tokens: 250, - }; - let s = serde_json::to_string(&step).unwrap(); - assert!(s.contains("\"kind\":\"system-reminder\""), "got {s}"); - assert!(s.contains("\"source\":\"other\""), "got {s}"); - let back: InterveningStep = serde_json::from_str(&s).unwrap(); - assert_eq!(back, step); - } -} +#[path = "context_delta_tests.rs"] +mod tests; diff --git a/crates/relayburn-sdk/src/analyze/context_delta_tests.rs b/crates/relayburn-sdk/src/analyze/context_delta_tests.rs new file mode 100644 index 0000000..cf9405d --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/context_delta_tests.rs @@ -0,0 +1,422 @@ +//! Conformance tests for the context_delta module — extracted verbatim from the +//! former inline `#[cfg(test)] mod tests` block (included via `#[path]`). + + use super::*; + use crate::analyze::span_tree::{SpanKind, SpanNode, SpanStatus, TurnSpanTree}; + use crate::reader::{CompactionEvent, SourceKind}; + + fn make_inf( + req_id: &str, + model: &str, + input: i64, + cache_read: i64, + cache_write: i64, + ) -> SpanNode { + let mut n = SpanNode::new(SpanKind::Inference, model); + n.set_attr("model", AttrValue::str(model)); + n.set_attr("request_id", AttrValue::str(req_id)); + n.set_attr("tokens.input", AttrValue::Int(input)); + n.set_attr("tokens.output", AttrValue::Int(0)); + n.set_attr("tokens.cache_read", AttrValue::Int(cache_read)); + n.set_attr("tokens.cache_write", AttrValue::Int(cache_write)); + n.set_attr("tokens.reasoning", AttrValue::Int(0)); + n + } + + fn make_tool_use(name: &str, tool_use_id: &str) -> SpanNode { + let mut n = SpanNode::new(SpanKind::ToolUse, name); + n.set_attr("tool_use_id", AttrValue::str(tool_use_id)); + n + } + + fn make_tool_result(tool_use_id: &str, bytes: i64, truncated: bool) -> SpanNode { + let mut n = SpanNode::new(SpanKind::ToolResult, "tool-result"); + n.set_attr("tool_use_id", AttrValue::str(tool_use_id)); + n.set_attr("output_bytes", AttrValue::Int(bytes)); + if truncated { + n.set_attr("output_truncated", AttrValue::Bool(true)); + } + n + } + + fn make_user_prompt() -> SpanNode { + SpanNode::new(SpanKind::UserPrompt, "user-prompt") + } + + fn turn_tree(session: &str, turn: &str, root: SpanNode) -> TurnSpanTree { + TurnSpanTree { + session_id: session.to_string(), + turn_id: turn.to_string(), + turn_number: 0, + root, + } + } + + /// Build a single-turn fixture: two inferences on the main rail + /// with a Bash tool_result between them whose 40_000 bytes + /// translates to a ~10k token jump. The delta should surface as + /// the top row with Bash as the driver. + #[test] + fn bash_blowup_surfaces_as_top_delta_with_bash_driver() { + // inference #1: context = 1000 + let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); + let mut bash_use = make_tool_use("Bash", "tu-1"); + bash_use + .children + .push(make_tool_result("tu-1", 40_000, false)); + let mut inf1 = inf1; + inf1.children.push(bash_use); + + // inference #2: context jumped to 12_000 — delta of 11_000. + let inf2 = make_inf("req-2", "claude-sonnet-4-6", 12_000, 0, 0); + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.status = SpanStatus::Ok; + root.children.push(make_user_prompt()); + root.children.push(inf1); + root.children.push(inf2); + + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let opts = ContextDeltaOpts::default(); + let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); + assert_eq!(deltas.len(), 1, "one pairwise delta expected"); + let d = &deltas[0]; + assert_eq!(d.session_id, "sess-1"); + assert_eq!(d.turn_id, "msg-1"); + assert_eq!(d.owner_rail, OwnerRail::Main); + assert_eq!(d.prior_context_tokens, 1000); + assert_eq!(d.current_context_tokens, 12_000); + assert_eq!(d.delta_tokens, 11_000); + assert_eq!(d.intervening.len(), 1); + match &d.intervening[0] { + InterveningStep::ToolResult { + tool_name, + approx_bytes, + approx_tokens, + truncated, + .. + } => { + assert_eq!(tool_name, "Bash"); + assert_eq!(*approx_bytes, 40_000); + assert_eq!(*approx_tokens, 10_000); + assert!(!*truncated); + } + other => panic!("expected ToolResult step, got {other:?}"), + } + // The driver_label helper should mention Bash. + assert!(d.intervening[0].driver_label().contains("Bash")); + // Cost is non-negative. + assert!(d.attributed_cost_usd >= 0.0); + } + + /// Compaction handling: a CompactionEvent between two inferences + /// where the second has *less* context than the first must surface + /// as `Compaction { tokens_freed }`, NOT a negative `delta_tokens`. + #[test] + fn compaction_replaces_negative_delta() { + let inf1 = make_inf("req-1", "claude-sonnet-4-6", 50_000, 0, 0); + // After compaction, context drops to 8_000. + let inf2 = make_inf("req-2", "claude-sonnet-4-6", 8_000, 0, 0); + + // Stamp timestamps so the compaction event sits between them. + let mut inf1 = inf1; + inf1.start_ms = 1_776_643_201_000; + inf1.end_ms = 1_776_643_202_000; + let mut inf2 = inf2; + inf2.start_ms = 1_776_643_204_000; + inf2.end_ms = 1_776_643_205_000; + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(inf1); + root.children.push(inf2); + let tree = turn_tree("sess-1", "msg-1", root); + + let compaction = CompactionEvent { + v: 1, + source: SourceKind::ClaudeCode, + session_id: "sess-1".into(), + ts: "2026-04-20T00:00:03.000Z".into(), + preceding_message_id: Some("msg-1".into()), + tokens_before_compact: Some(50_000), + }; + + let pricing = crate::analyze::pricing::load_builtin_pricing(); + // Opts: min_delta 0 so the row isn't filtered out (delta_tokens is 0). + let opts = ContextDeltaOpts { + min_delta: Some(0), + ..ContextDeltaOpts::default() + }; + let deltas = deltas_for_session(&[tree], &[compaction], &pricing, &opts); + assert_eq!(deltas.len(), 1); + let d = &deltas[0]; + assert_eq!(d.delta_tokens, 0, "compaction clamps to 0"); + let has_compaction = d + .intervening + .iter() + .any(|s| matches!(s, InterveningStep::Compaction { tokens_freed } if *tokens_freed == 42_000)); + assert!( + has_compaction, + "expected Compaction step with tokens_freed=42000, got {:?}", + d.intervening + ); + } + + /// Subagent isolation: a main-rail Inference and a subagent + /// Inference both happen, with a subagent tool_result between + /// them. The main-rail delta must NOT include the subagent's + /// tool_result. + #[test] + fn subagent_isolation_main_rail_excludes_subagent_results() { + // Main rail: two inferences with a 10k context jump. + let mut main_inf1 = make_inf("req-main-1", "claude-sonnet-4-6", 1000, 0, 0); + // Add a Task tool_use that fans out to a Subagent with its own + // inference + tool_result. The subagent's tool_result has + // 40k bytes (~10k tokens) — and the main rail's tool_use also + // gets a small result so the main delta is non-zero. + let mut task_use = make_tool_use("Task", "tu-task"); + + let mut sub_node = SpanNode::new(SpanKind::Subagent, "general-purpose"); + sub_node.set_attr("agent_id", AttrValue::str("agent-a")); + // Subagent inferences: + let sub_inf1 = make_inf("req-sub-1", "claude-sonnet-4-6", 2000, 0, 0); + let mut sub_bash = make_tool_use("Bash", "tu-sub-bash"); + sub_bash + .children + .push(make_tool_result("tu-sub-bash", 40_000, false)); + let mut sub_inf1 = sub_inf1; + sub_inf1.children.push(sub_bash); + let sub_inf2 = make_inf("req-sub-2", "claude-sonnet-4-6", 12_000, 0, 0); + sub_node.children.push(sub_inf1); + sub_node.children.push(sub_inf2); + task_use.children.push(sub_node); + main_inf1.children.push(task_use); + + // Main rail #2: small jump only (no big tool_result on its own). + let main_inf2 = make_inf("req-main-2", "claude-sonnet-4-6", 3000, 0, 0); + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(main_inf1); + root.children.push(main_inf2); + let tree = turn_tree("sess-1", "msg-1", root); + + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let opts = ContextDeltaOpts { + min_delta: Some(0), + ..ContextDeltaOpts::default() + }; + let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); + + // We expect one main-rail delta and one subagent-rail delta. + let main_delta = deltas + .iter() + .find(|d| d.owner_rail == OwnerRail::Main) + .expect("main-rail delta missing"); + let sub_delta = deltas + .iter() + .find(|d| matches!(&d.owner_rail, OwnerRail::Subagent { agent_id } if agent_id == "agent-a")) + .expect("subagent-rail delta missing"); + + // Main delta intervening must NOT include the subagent's tool_result. + for step in &main_delta.intervening { + if let InterveningStep::ToolResult { tool_use_id, .. } = step { + assert_ne!( + tool_use_id, "tu-sub-bash", + "main rail must NOT see the subagent's tool_result" + ); + } + } + + // Subagent delta intervening SHOULD include its own Bash result. + let sub_has_bash = sub_delta.intervening.iter().any(|s| match s { + InterveningStep::ToolResult { tool_use_id, .. } => tool_use_id == "tu-sub-bash", + _ => false, + }); + assert!( + sub_has_bash, + "subagent rail must see its own Bash tool_result" + ); + } + + /// Empty rail (single inference, no prev) → no delta emitted, no + /// panic. + #[test] + fn single_inference_yields_no_delta() { + let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(inf1); + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); + assert!( + deltas.is_empty(), + "single inference must not emit a pairwise delta" + ); + } + + /// `min_delta` filters out small jumps. + #[test] + fn min_delta_filters_small_jumps() { + let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); + // Small jump: +500 tokens. + let inf2 = make_inf("req-2", "claude-sonnet-4-6", 1500, 0, 0); + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(inf1); + root.children.push(inf2); + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + // Default min_delta is 1000; 500 < 1000 → filtered out. + let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); + assert!(deltas.is_empty(), "500 token jump must be filtered"); + + // Lower the threshold to 100 → row appears. + let opts = ContextDeltaOpts { + min_delta: Some(100), + ..ContextDeltaOpts::default() + }; + let deltas = deltas_for_session( + &[turn_tree("sess-1", "msg-1", root_with_two_infs(1000, 1500))], + &[], + &pricing, + &opts, + ); + assert_eq!(deltas.len(), 1); + assert_eq!(deltas[0].delta_tokens, 500); + } + + fn root_with_two_infs(ctx1: i64, ctx2: i64) -> SpanNode { + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children + .push(make_inf("req-1", "claude-sonnet-4-6", ctx1, 0, 0)); + root.children + .push(make_inf("req-2", "claude-sonnet-4-6", ctx2, 0, 0)); + root + } + + /// `--top N` caps output. + #[test] + fn top_caps_output() { + // Build a tree with 5 inferences, each adding 5000 tokens. + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + let ctx_steps = [1000, 6000, 11_000, 16_000, 21_000]; + for (i, c) in ctx_steps.iter().enumerate() { + root.children + .push(make_inf(&format!("req-{i}"), "claude-sonnet-4-6", *c, 0, 0)); + } + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + + // No cap → 4 pairwise deltas (5 inferences = 4 windows). + let opts = ContextDeltaOpts { + min_delta: Some(0), + ..ContextDeltaOpts::default() + }; + let all = deltas_for_session(std::slice::from_ref(&tree), &[], &pricing, &opts); + assert_eq!(all.len(), 4); + + // Cap at 2 → only the top 2 deltas. + let opts = ContextDeltaOpts { + min_delta: Some(0), + top: Some(2), + ..ContextDeltaOpts::default() + }; + let top2 = deltas_for_session(&[tree], &[], &pricing, &opts); + assert_eq!(top2.len(), 2); + } + + /// `--owner main` filter excludes subagent rails. + #[test] + fn owner_filter_main_excludes_subagent_rail() { + // Reuse the subagent-isolation fixture shape. + let mut main_inf1 = make_inf("req-main-1", "claude-sonnet-4-6", 1000, 0, 0); + let mut task_use = make_tool_use("Task", "tu-task"); + let mut sub_node = SpanNode::new(SpanKind::Subagent, "general-purpose"); + sub_node.set_attr("agent_id", AttrValue::str("agent-a")); + sub_node + .children + .push(make_inf("req-sub-1", "claude-sonnet-4-6", 2000, 0, 0)); + sub_node + .children + .push(make_inf("req-sub-2", "claude-sonnet-4-6", 22_000, 0, 0)); + task_use.children.push(sub_node); + main_inf1.children.push(task_use); + let main_inf2 = make_inf("req-main-2", "claude-sonnet-4-6", 3000, 0, 0); + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(main_inf1); + root.children.push(main_inf2); + let tree = turn_tree("sess-1", "msg-1", root); + + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let opts = ContextDeltaOpts { + min_delta: Some(0), + owner: OwnerFilter::Main, + ..ContextDeltaOpts::default() + }; + let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); + for d in &deltas { + assert_eq!( + d.owner_rail, + OwnerRail::Main, + "owner filter Main must exclude subagent rails" + ); + } + assert!(!deltas.is_empty(), "expected at least one main-rail delta"); + } + + /// JSON shape: rail serializes with a `kind` discriminant, steps + /// keep their kebab-case kind tag. Catch wire-format drift early. + #[test] + fn json_shape_uses_kebab_case_discriminants() { + let d = ContextDelta { + session_id: "s".into(), + turn_id: "t".into(), + inference_idx: 2, + owner_rail: OwnerRail::Subagent { + agent_id: "agent-x".into(), + }, + prior_context_tokens: 10, + current_context_tokens: 20, + delta_tokens: 10, + intervening: vec![InterveningStep::ToolResult { + tool_use_id: "tu-1".into(), + tool_name: "Bash".into(), + approx_tokens: 5, + approx_bytes: 20, + truncated: false, + }], + attributed_cost_usd: 0.0, + }; + let s = serde_json::to_string(&d).unwrap(); + assert!(s.contains("\"kind\":\"subagent\""), "got {s}"); + assert!(s.contains("\"agentId\":\"agent-x\""), "got {s}"); + assert!(s.contains("\"kind\":\"tool-result\""), "got {s}"); + assert!(s.contains("\"toolUseId\":\"tu-1\""), "got {s}"); + let back: ContextDelta = serde_json::from_str(&s).unwrap(); + assert_eq!(back, d); + } + + /// System reminder step surfaces as its own intervening step row. + /// We construct one directly (the timeline builder doesn't yet + /// synthesize SystemReminder spans from content sidecars; first- + /// cut behavior per the issue). + #[test] + fn system_reminder_step_round_trips_in_json() { + let step = InterveningStep::SystemReminder { + source: ReminderSource::Other, + approx_tokens: 250, + }; + let s = serde_json::to_string(&step).unwrap(); + assert!(s.contains("\"kind\":\"system-reminder\""), "got {s}"); + assert!(s.contains("\"source\":\"other\""), "got {s}"); + let back: InterveningStep = serde_json::from_str(&s).unwrap(); + assert_eq!(back, step); + } diff --git a/crates/relayburn-sdk/src/analyze/subagent_tree.rs b/crates/relayburn-sdk/src/analyze/subagent_tree.rs index 1bfe3f3..9486365 100644 --- a/crates/relayburn-sdk/src/analyze/subagent_tree.rs +++ b/crates/relayburn-sdk/src/analyze/subagent_tree.rs @@ -822,456 +822,5 @@ pub fn aggregate_subagent_type_stats( } #[cfg(test)] -mod tests { - use super::*; - use crate::analyze::pricing::load_builtin_pricing; - use crate::reader::{ - RelationshipSourceKind, RelationshipType, SourceKind, Subagent, ToolCall, TurnRecord, Usage, - }; - - fn make_turn( - session_id: &str, - message_id: &str, - model: &str, - turn_index: u64, - source: SourceKind, - subagent: Option, - ) -> TurnRecord { - TurnRecord { - v: 1, - source, - session_id: session_id.into(), - session_path: None, - message_id: message_id.into(), - turn_index, - ts: "2026-04-20T00:00:00.000Z".into(), - model: model.into(), - project: None, - project_key: None, - usage: Usage { - input: 1000, - output: 1000, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - tool_calls: Vec::::new(), - files_touched: None, - subagent, - stop_reason: None, - activity: None, - retries: None, - has_edits: None, - fidelity: None, - } - } - - fn sub( - agent_id: Option<&str>, - parent_agent_id: Option<&str>, - subagent_type: Option<&str>, - description: Option<&str>, - ) -> Subagent { - Subagent { - is_sidechain: true, - parent_tool_use_id: None, - agent_id: agent_id.map(String::from), - parent_agent_id: parent_agent_id.map(String::from), - subagent_type: subagent_type.map(String::from), - description: description.map(String::from), - } - } - - fn rel( - session_id: &str, - rel_type: RelationshipType, - related: Option<&str>, - agent_id: Option<&str>, - subagent_type: Option<&str>, - description: Option<&str>, - source: RelationshipSourceKind, - ) -> SessionRelationshipRecord { - SessionRelationshipRecord { - v: 1, - source, - session_id: session_id.into(), - related_session_id: related.map(String::from), - relationship_type: rel_type, - ts: None, - source_session_id: None, - source_version: None, - parent_tool_use_id: None, - agent_id: agent_id.map(String::from), - subagent_type: subagent_type.map(String::from), - description: description.map(String::from), - } - } - - #[test] - fn folds_cumulative_cost_from_nested_subagents_up_to_the_main_root() { - let pricing = load_builtin_pricing(); - let session_id = "sess-1"; - let turns = vec![ - make_turn( - session_id, - "m1", - "claude-sonnet-4-6", - 0, - SourceKind::ClaudeCode, - None, - ), - make_turn( - session_id, - "m2", - "claude-sonnet-4-6", - 1, - SourceKind::ClaudeCode, - None, - ), - make_turn( - session_id, - "o1", - "claude-haiku-4-5", - 2, - SourceKind::ClaudeCode, - Some(sub( - Some("u-outer"), - Some(session_id), - Some("Explore"), - Some("Research"), - )), - ), - make_turn( - session_id, - "o2", - "claude-haiku-4-5", - 3, - SourceKind::ClaudeCode, - Some(sub( - Some("u-outer"), - Some(session_id), - Some("Explore"), - None, - )), - ), - make_turn( - session_id, - "i1", - "claude-haiku-4-5", - 4, - SourceKind::ClaudeCode, - Some(sub( - Some("u-inner"), - Some("u-outer"), - Some("code-reviewer"), - None, - )), - ), - ]; - - let opts = BuildSubagentTreeOptions::new(&pricing); - let trees = build_subagent_tree(&turns, &opts); - let root = trees.get(session_id).expect("root"); - assert_eq!(root.label, "main"); - assert_eq!(root.depth, 0); - assert_eq!(root.self_turns, 2); - assert_eq!(root.cumulative_turns, 5); - assert!(root.cumulative_cost > root.self_cost); - - assert_eq!(root.children.len(), 1); - let outer = &root.children[0]; - assert_eq!(outer.label, "Explore"); - assert_eq!(outer.depth, 1); - assert_eq!(outer.self_turns, 2); - assert_eq!(outer.cumulative_turns, 3); - assert_eq!(outer.children.len(), 1); - - let inner = &outer.children[0]; - assert_eq!(inner.label, "code-reviewer"); - assert_eq!(inner.depth, 2); - assert_eq!(inner.self_turns, 1); - assert_eq!(inner.cumulative_turns, 1); - assert!((inner.cumulative_cost - inner.self_cost).abs() < 1e-12); - - assert!( - (outer.cumulative_cost - (outer.self_cost + inner.cumulative_cost)).abs() < 1e-12, - "outer cumulative is selfCost + inner.cumulativeCost" - ); - } - - #[test] - fn buckets_sidechain_turns_without_agent_id_under_an_unresolved_node() { - let pricing = load_builtin_pricing(); - let session_id = "sess-2"; - let turns = vec![ - make_turn( - session_id, - "m1", - "claude-sonnet-4-6", - 0, - SourceKind::ClaudeCode, - None, - ), - make_turn( - session_id, - "s1", - "claude-haiku-4-5", - 1, - SourceKind::ClaudeCode, - Some(Subagent { - is_sidechain: true, - parent_tool_use_id: None, - agent_id: None, - parent_agent_id: None, - subagent_type: None, - description: None, - }), - ), - ]; - let opts = BuildSubagentTreeOptions::new(&pricing); - let trees = build_subagent_tree(&turns, &opts); - let root = trees.get(session_id).unwrap(); - assert_eq!(root.children.len(), 1); - assert_eq!(root.children[0].label, "(unresolved)"); - assert_eq!(root.children[0].self_turns, 1); - } - - #[test] - fn builds_the_same_claude_tree_from_session_relationship_records() { - let pricing = load_builtin_pricing(); - let session_id = "sess-graph"; - let turns = vec![ - make_turn( - session_id, - "m1", - "claude-sonnet-4-6", - 0, - SourceKind::ClaudeCode, - None, - ), - make_turn( - session_id, - "o1", - "claude-haiku-4-5", - 1, - SourceKind::ClaudeCode, - Some(sub( - Some("u-outer"), - Some(session_id), - Some("Explore"), - Some("Research"), - )), - ), - make_turn( - session_id, - "i1", - "claude-haiku-4-5", - 2, - SourceKind::ClaudeCode, - Some(sub( - Some("u-inner"), - Some("u-outer"), - Some("code-reviewer"), - None, - )), - ), - ]; - let relationships = vec![ - rel( - session_id, - RelationshipType::Root, - None, - None, - None, - None, - RelationshipSourceKind::ClaudeCode, - ), - rel( - session_id, - RelationshipType::Subagent, - Some(session_id), - Some("u-outer"), - Some("Explore"), - Some("Research"), - RelationshipSourceKind::NativeClaude, - ), - rel( - session_id, - RelationshipType::Subagent, - Some("u-outer"), - Some("u-inner"), - Some("code-reviewer"), - None, - RelationshipSourceKind::NativeClaude, - ), - ]; - - let legacy_opts = BuildSubagentTreeOptions::new(&pricing); - let legacy = build_subagent_tree(&turns, &legacy_opts) - .get(session_id) - .unwrap() - .clone(); - let graph_opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); - let graph = build_subagent_tree(&turns, &graph_opts) - .get(session_id) - .unwrap() - .clone(); - assert_eq!(graph, legacy); - assert_eq!(graph.relationship_type, RelationshipType::Root); - assert_eq!( - graph.children[0].relationship_type, - RelationshipType::Subagent - ); - } - - #[test] - fn joins_child_session_relationship_rows_to_turns_without_per_turn_subagent_metadata() { - let pricing = load_builtin_pricing(); - let turns = vec![ - make_turn( - "parent-session", - "parent-1", - "gpt-5.1-codex", - 0, - SourceKind::Codex, - None, - ), - make_turn( - "child-session", - "child-1", - "gpt-5.1-codex", - 0, - SourceKind::Codex, - None, - ), - ]; - let relationships = vec![ - rel( - "parent-session", - RelationshipType::Root, - None, - None, - None, - None, - RelationshipSourceKind::Codex, - ), - rel( - "child-session", - RelationshipType::Subagent, - Some("parent-session"), - Some("agent-child"), - Some("worker"), - None, - RelationshipSourceKind::Codex, - ), - ]; - - let opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); - let root = build_subagent_tree(&turns, &opts) - .get("parent-session") - .unwrap() - .clone(); - assert_eq!(root.self_turns, 1); - assert_eq!(root.cumulative_turns, 2); - assert_eq!(root.children.len(), 1); - assert_eq!(root.children[0].label, "worker"); - assert_eq!(root.children[0].node_id, "child-session"); - assert_eq!( - root.children[0].relationship_type, - RelationshipType::Subagent - ); - assert_eq!(root.children[0].self_turns, 1); - } - - #[test] - fn does_not_alias_native_sidechain_session_roots_onto_agent_ids_when_turns_lack_subagent_fields( - ) { - let pricing = load_builtin_pricing(); - let session_id = "partial-claude"; - let turns = vec![make_turn( - session_id, - "main-1", - "claude-sonnet-4-6", - 0, - SourceKind::ClaudeCode, - None, - )]; - let relationships = vec![ - rel( - session_id, - RelationshipType::Root, - None, - None, - None, - None, - RelationshipSourceKind::ClaudeCode, - ), - rel( - session_id, - RelationshipType::Subagent, - Some(session_id), - Some("u-outer"), - Some("Explore"), - None, - RelationshipSourceKind::NativeClaude, - ), - ]; - let opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); - let root = build_subagent_tree(&turns, &opts) - .get(session_id) - .unwrap() - .clone(); - assert_eq!(root.node_id, session_id); - assert_eq!(root.label, "main"); - assert_eq!(root.self_turns, 1); - assert_eq!(root.children.len(), 1); - assert_eq!(root.children[0].node_id, "u-outer"); - assert_eq!(root.children[0].self_turns, 0); - } - - #[test] - fn reports_median_p95_mean_total_per_subagent_type_across_invocations() { - let pricing = load_builtin_pricing(); - let mut turns: Vec = Vec::new(); - for i in 0..3 { - let agent_id = format!("u-exp-{i}"); - for j in 0..=i { - turns.push(make_turn( - &format!("sess-{i}"), - &format!("m-{i}-{j}"), - "claude-haiku-4-5", - j as u64, - SourceKind::ClaudeCode, - Some(sub(Some(&agent_id), None, Some("Explore"), None)), - )); - } - } - turns.push(make_turn( - "sess-rev", - "mr", - "claude-haiku-4-5", - 0, - SourceKind::ClaudeCode, - Some(sub(Some("u-rev"), None, Some("code-reviewer"), None)), - )); - - let opts = BuildSubagentTreeOptions::new(&pricing); - let stats = aggregate_subagent_type_stats(&turns, &opts); - let explore = stats.iter().find(|s| s.subagent_type == "Explore").unwrap(); - assert_eq!(explore.invocations, 3); - assert_eq!(explore.turns, 6); - assert!(explore.median_cost > 0.0); - assert!(explore.p95_cost >= explore.median_cost); - assert!((explore.mean_cost - explore.total_cost / 3.0).abs() < 1e-12); - - let rev = stats - .iter() - .find(|s| s.subagent_type == "code-reviewer") - .unwrap(); - assert_eq!(rev.invocations, 1); - assert_eq!(rev.turns, 1); - assert!((rev.median_cost - rev.total_cost).abs() < 1e-12); - assert!((rev.p95_cost - rev.total_cost).abs() < 1e-12); - } -} +#[path = "subagent_tree_tests.rs"] +mod tests; diff --git a/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs b/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs new file mode 100644 index 0000000..2333a74 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs @@ -0,0 +1,454 @@ +//! Conformance tests for the subagent_tree module — extracted verbatim from the +//! former inline `#[cfg(test)] mod tests` block (included via `#[path]`). + + use super::*; + use crate::analyze::pricing::load_builtin_pricing; + use crate::reader::{ + RelationshipSourceKind, RelationshipType, SourceKind, Subagent, ToolCall, TurnRecord, Usage, + }; + + fn make_turn( + session_id: &str, + message_id: &str, + model: &str, + turn_index: u64, + source: SourceKind, + subagent: Option, + ) -> TurnRecord { + TurnRecord { + v: 1, + source, + session_id: session_id.into(), + session_path: None, + message_id: message_id.into(), + turn_index, + ts: "2026-04-20T00:00:00.000Z".into(), + model: model.into(), + project: None, + project_key: None, + usage: Usage { + input: 1000, + output: 1000, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + tool_calls: Vec::::new(), + files_touched: None, + subagent, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, + } + } + + fn sub( + agent_id: Option<&str>, + parent_agent_id: Option<&str>, + subagent_type: Option<&str>, + description: Option<&str>, + ) -> Subagent { + Subagent { + is_sidechain: true, + parent_tool_use_id: None, + agent_id: agent_id.map(String::from), + parent_agent_id: parent_agent_id.map(String::from), + subagent_type: subagent_type.map(String::from), + description: description.map(String::from), + } + } + + fn rel( + session_id: &str, + rel_type: RelationshipType, + related: Option<&str>, + agent_id: Option<&str>, + subagent_type: Option<&str>, + description: Option<&str>, + source: RelationshipSourceKind, + ) -> SessionRelationshipRecord { + SessionRelationshipRecord { + v: 1, + source, + session_id: session_id.into(), + related_session_id: related.map(String::from), + relationship_type: rel_type, + ts: None, + source_session_id: None, + source_version: None, + parent_tool_use_id: None, + agent_id: agent_id.map(String::from), + subagent_type: subagent_type.map(String::from), + description: description.map(String::from), + } + } + + #[test] + fn folds_cumulative_cost_from_nested_subagents_up_to_the_main_root() { + let pricing = load_builtin_pricing(); + let session_id = "sess-1"; + let turns = vec![ + make_turn( + session_id, + "m1", + "claude-sonnet-4-6", + 0, + SourceKind::ClaudeCode, + None, + ), + make_turn( + session_id, + "m2", + "claude-sonnet-4-6", + 1, + SourceKind::ClaudeCode, + None, + ), + make_turn( + session_id, + "o1", + "claude-haiku-4-5", + 2, + SourceKind::ClaudeCode, + Some(sub( + Some("u-outer"), + Some(session_id), + Some("Explore"), + Some("Research"), + )), + ), + make_turn( + session_id, + "o2", + "claude-haiku-4-5", + 3, + SourceKind::ClaudeCode, + Some(sub( + Some("u-outer"), + Some(session_id), + Some("Explore"), + None, + )), + ), + make_turn( + session_id, + "i1", + "claude-haiku-4-5", + 4, + SourceKind::ClaudeCode, + Some(sub( + Some("u-inner"), + Some("u-outer"), + Some("code-reviewer"), + None, + )), + ), + ]; + + let opts = BuildSubagentTreeOptions::new(&pricing); + let trees = build_subagent_tree(&turns, &opts); + let root = trees.get(session_id).expect("root"); + assert_eq!(root.label, "main"); + assert_eq!(root.depth, 0); + assert_eq!(root.self_turns, 2); + assert_eq!(root.cumulative_turns, 5); + assert!(root.cumulative_cost > root.self_cost); + + assert_eq!(root.children.len(), 1); + let outer = &root.children[0]; + assert_eq!(outer.label, "Explore"); + assert_eq!(outer.depth, 1); + assert_eq!(outer.self_turns, 2); + assert_eq!(outer.cumulative_turns, 3); + assert_eq!(outer.children.len(), 1); + + let inner = &outer.children[0]; + assert_eq!(inner.label, "code-reviewer"); + assert_eq!(inner.depth, 2); + assert_eq!(inner.self_turns, 1); + assert_eq!(inner.cumulative_turns, 1); + assert!((inner.cumulative_cost - inner.self_cost).abs() < 1e-12); + + assert!( + (outer.cumulative_cost - (outer.self_cost + inner.cumulative_cost)).abs() < 1e-12, + "outer cumulative is selfCost + inner.cumulativeCost" + ); + } + + #[test] + fn buckets_sidechain_turns_without_agent_id_under_an_unresolved_node() { + let pricing = load_builtin_pricing(); + let session_id = "sess-2"; + let turns = vec![ + make_turn( + session_id, + "m1", + "claude-sonnet-4-6", + 0, + SourceKind::ClaudeCode, + None, + ), + make_turn( + session_id, + "s1", + "claude-haiku-4-5", + 1, + SourceKind::ClaudeCode, + Some(Subagent { + is_sidechain: true, + parent_tool_use_id: None, + agent_id: None, + parent_agent_id: None, + subagent_type: None, + description: None, + }), + ), + ]; + let opts = BuildSubagentTreeOptions::new(&pricing); + let trees = build_subagent_tree(&turns, &opts); + let root = trees.get(session_id).unwrap(); + assert_eq!(root.children.len(), 1); + assert_eq!(root.children[0].label, "(unresolved)"); + assert_eq!(root.children[0].self_turns, 1); + } + + #[test] + fn builds_the_same_claude_tree_from_session_relationship_records() { + let pricing = load_builtin_pricing(); + let session_id = "sess-graph"; + let turns = vec![ + make_turn( + session_id, + "m1", + "claude-sonnet-4-6", + 0, + SourceKind::ClaudeCode, + None, + ), + make_turn( + session_id, + "o1", + "claude-haiku-4-5", + 1, + SourceKind::ClaudeCode, + Some(sub( + Some("u-outer"), + Some(session_id), + Some("Explore"), + Some("Research"), + )), + ), + make_turn( + session_id, + "i1", + "claude-haiku-4-5", + 2, + SourceKind::ClaudeCode, + Some(sub( + Some("u-inner"), + Some("u-outer"), + Some("code-reviewer"), + None, + )), + ), + ]; + let relationships = vec![ + rel( + session_id, + RelationshipType::Root, + None, + None, + None, + None, + RelationshipSourceKind::ClaudeCode, + ), + rel( + session_id, + RelationshipType::Subagent, + Some(session_id), + Some("u-outer"), + Some("Explore"), + Some("Research"), + RelationshipSourceKind::NativeClaude, + ), + rel( + session_id, + RelationshipType::Subagent, + Some("u-outer"), + Some("u-inner"), + Some("code-reviewer"), + None, + RelationshipSourceKind::NativeClaude, + ), + ]; + + let legacy_opts = BuildSubagentTreeOptions::new(&pricing); + let legacy = build_subagent_tree(&turns, &legacy_opts) + .get(session_id) + .unwrap() + .clone(); + let graph_opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); + let graph = build_subagent_tree(&turns, &graph_opts) + .get(session_id) + .unwrap() + .clone(); + assert_eq!(graph, legacy); + assert_eq!(graph.relationship_type, RelationshipType::Root); + assert_eq!( + graph.children[0].relationship_type, + RelationshipType::Subagent + ); + } + + #[test] + fn joins_child_session_relationship_rows_to_turns_without_per_turn_subagent_metadata() { + let pricing = load_builtin_pricing(); + let turns = vec![ + make_turn( + "parent-session", + "parent-1", + "gpt-5.1-codex", + 0, + SourceKind::Codex, + None, + ), + make_turn( + "child-session", + "child-1", + "gpt-5.1-codex", + 0, + SourceKind::Codex, + None, + ), + ]; + let relationships = vec![ + rel( + "parent-session", + RelationshipType::Root, + None, + None, + None, + None, + RelationshipSourceKind::Codex, + ), + rel( + "child-session", + RelationshipType::Subagent, + Some("parent-session"), + Some("agent-child"), + Some("worker"), + None, + RelationshipSourceKind::Codex, + ), + ]; + + let opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); + let root = build_subagent_tree(&turns, &opts) + .get("parent-session") + .unwrap() + .clone(); + assert_eq!(root.self_turns, 1); + assert_eq!(root.cumulative_turns, 2); + assert_eq!(root.children.len(), 1); + assert_eq!(root.children[0].label, "worker"); + assert_eq!(root.children[0].node_id, "child-session"); + assert_eq!( + root.children[0].relationship_type, + RelationshipType::Subagent + ); + assert_eq!(root.children[0].self_turns, 1); + } + + #[test] + fn does_not_alias_native_sidechain_session_roots_onto_agent_ids_when_turns_lack_subagent_fields( + ) { + let pricing = load_builtin_pricing(); + let session_id = "partial-claude"; + let turns = vec![make_turn( + session_id, + "main-1", + "claude-sonnet-4-6", + 0, + SourceKind::ClaudeCode, + None, + )]; + let relationships = vec![ + rel( + session_id, + RelationshipType::Root, + None, + None, + None, + None, + RelationshipSourceKind::ClaudeCode, + ), + rel( + session_id, + RelationshipType::Subagent, + Some(session_id), + Some("u-outer"), + Some("Explore"), + None, + RelationshipSourceKind::NativeClaude, + ), + ]; + let opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); + let root = build_subagent_tree(&turns, &opts) + .get(session_id) + .unwrap() + .clone(); + assert_eq!(root.node_id, session_id); + assert_eq!(root.label, "main"); + assert_eq!(root.self_turns, 1); + assert_eq!(root.children.len(), 1); + assert_eq!(root.children[0].node_id, "u-outer"); + assert_eq!(root.children[0].self_turns, 0); + } + + #[test] + fn reports_median_p95_mean_total_per_subagent_type_across_invocations() { + let pricing = load_builtin_pricing(); + let mut turns: Vec = Vec::new(); + for i in 0..3 { + let agent_id = format!("u-exp-{i}"); + for j in 0..=i { + turns.push(make_turn( + &format!("sess-{i}"), + &format!("m-{i}-{j}"), + "claude-haiku-4-5", + j as u64, + SourceKind::ClaudeCode, + Some(sub(Some(&agent_id), None, Some("Explore"), None)), + )); + } + } + turns.push(make_turn( + "sess-rev", + "mr", + "claude-haiku-4-5", + 0, + SourceKind::ClaudeCode, + Some(sub(Some("u-rev"), None, Some("code-reviewer"), None)), + )); + + let opts = BuildSubagentTreeOptions::new(&pricing); + let stats = aggregate_subagent_type_stats(&turns, &opts); + let explore = stats.iter().find(|s| s.subagent_type == "Explore").unwrap(); + assert_eq!(explore.invocations, 3); + assert_eq!(explore.turns, 6); + assert!(explore.median_cost > 0.0); + assert!(explore.p95_cost >= explore.median_cost); + assert!((explore.mean_cost - explore.total_cost / 3.0).abs() < 1e-12); + + let rev = stats + .iter() + .find(|s| s.subagent_type == "code-reviewer") + .unwrap(); + assert_eq!(rev.invocations, 1); + assert_eq!(rev.turns, 1); + assert!((rev.median_cost - rev.total_cost).abs() < 1e-12); + assert!((rev.p95_cost - rev.total_cost).abs() < 1e-12); + } From ca4d96855f11441f7e5a917d61c7ff70c221b269 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:14:06 -0400 Subject: [PATCH 11/22] refactor(sdk/analyze): consolidate three duplicated helpers - ghost_surface::source_kind_str was byte-identical to the existing public SourceKind::wire_str(); delete it and call wire_str() at the two sites. - strip_provider_prefix was defined verbatim in both cost.rs and provider.rs; hoist a single pub(crate) copy into provider_reattribution.rs (the provider- normalization module both already import from) and route both callers there. - extract_error_signature and truncate_for_preview shared an identical "char-count, else take(max-1) + ellipsis" tail; extract util::truncate_chars and delegate from both. Behavior-preserving (identical outputs); all 992 tests pass, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/cost.rs | 9 +-------- .../relayburn-sdk/src/analyze/ghost_surface.rs | 15 ++------------- crates/relayburn-sdk/src/analyze/patterns.rs | 16 +++------------- crates/relayburn-sdk/src/analyze/provider.rs | 9 +-------- .../src/analyze/provider_reattribution.rs | 11 +++++++++++ crates/relayburn-sdk/src/analyze/util.rs | 13 +++++++++++++ 6 files changed, 31 insertions(+), 42 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/cost.rs b/crates/relayburn-sdk/src/analyze/cost.rs index 5ff2dd7..fff140b 100644 --- a/crates/relayburn-sdk/src/analyze/cost.rs +++ b/crates/relayburn-sdk/src/analyze/cost.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::reader::{SourceKind, TurnRecord, Usage}; use crate::analyze::pricing::{ModelCost, PricingTable, ReasoningMode}; -use crate::analyze::provider_reattribution::resolve_provider; +use crate::analyze::provider_reattribution::{resolve_provider, strip_provider_prefix}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -163,13 +163,6 @@ fn pricing_model_alias(model: &str) -> Option<&'static str> { } } -fn strip_provider_prefix(model: &str) -> &str { - match model.find('/') { - Some(i) => &model[i + 1..], - None => model, - } -} - /// Count turns whose model has no pricing entry, and collect the distinct /// model names, first-seen order. Used by summary surfaces to make pricing /// gaps visible instead of silently folding them in at $0. diff --git a/crates/relayburn-sdk/src/analyze/ghost_surface.rs b/crates/relayburn-sdk/src/analyze/ghost_surface.rs index 9acd557..7e73f13 100644 --- a/crates/relayburn-sdk/src/analyze/ghost_surface.rs +++ b/crates/relayburn-sdk/src/analyze/ghost_surface.rs @@ -482,7 +482,7 @@ pub fn ghost_surface_to_finding( "" }; let sessions_clause = if ghost.session_count > 0 { - let source_str = source_kind_str(ghost.source); + let source_str = ghost.source.wire_str(); format!( " Observed across {n} {src} session(s) in the lookback window.", n = ghost.session_count, @@ -493,7 +493,7 @@ pub fn ghost_surface_to_finding( }; let title_basename = basename_of(&ghost.path); let title_basename = title_basename.split('#').next().unwrap_or(&title_basename); - let source_str = source_kind_str(ghost.source); + let source_str = ghost.source.wire_str(); WasteFinding { kind: kind_label.to_string(), severity, @@ -524,17 +524,6 @@ pub fn ghost_surface_to_finding( } } -fn source_kind_str(source: SourceKind) -> &'static str { - match source { - SourceKind::ClaudeCode => "claude-code", - SourceKind::Codex => "codex", - SourceKind::Opencode => "opencode", - SourceKind::AnthropicApi => "anthropic-api", - SourceKind::OpenaiApi => "openai-api", - SourceKind::GeminiApi => "gemini-api", - } -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/crates/relayburn-sdk/src/analyze/patterns.rs b/crates/relayburn-sdk/src/analyze/patterns.rs index fee0d77..7c6b28e 100644 --- a/crates/relayburn-sdk/src/analyze/patterns.rs +++ b/crates/relayburn-sdk/src/analyze/patterns.rs @@ -28,7 +28,7 @@ use crate::analyze::findings::{ SkillRecallDup, SystemPromptTax, }; use crate::analyze::pricing::PricingTable; -use crate::analyze::util::{group_turns_by_session, stringify_tool_result}; +use crate::analyze::util::{group_turns_by_session, stringify_tool_result, truncate_chars}; mod shell; @@ -519,23 +519,13 @@ fn extract_error_signature(tool_result: Option<&ContentToolResult>) -> Option String { - let char_count = s.chars().count(); - if char_count <= SAMPLE_PREVIEW_MAX_CHARS { - return s.to_string(); - } - let truncated: String = s.chars().take(SAMPLE_PREVIEW_MAX_CHARS - 1).collect(); - format!("{truncated}…") + truncate_chars(s, SAMPLE_PREVIEW_MAX_CHARS) } fn extract_edit_preview(input: Option<&BTreeMap>) -> Option { diff --git a/crates/relayburn-sdk/src/analyze/provider.rs b/crates/relayburn-sdk/src/analyze/provider.rs index 4a500f3..ae22f49 100644 --- a/crates/relayburn-sdk/src/analyze/provider.rs +++ b/crates/relayburn-sdk/src/analyze/provider.rs @@ -20,7 +20,7 @@ use crate::reader::{Coverage, SourceKind, TurnRecord, Usage}; use crate::analyze::cost::{cost_for_turn, CostBreakdown}; use crate::analyze::pricing::PricingTable; use crate::analyze::provider_reattribution::{ - default_rules, resolve_provider_with_rules, ProviderRule, + default_rules, resolve_provider_with_rules, strip_provider_prefix, ProviderRule, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -329,13 +329,6 @@ fn provider_from_model_prefix(model: &str) -> Option { Some(model[..i].to_lowercase()) } -fn strip_provider_prefix(model: &str) -> &str { - match model.find('/') { - Some(i) => &model[i + 1..], - None => model, - } -} - fn provider_from_source(source: SourceKind) -> String { match source { SourceKind::ClaudeCode | SourceKind::AnthropicApi => "anthropic".into(), diff --git a/crates/relayburn-sdk/src/analyze/provider_reattribution.rs b/crates/relayburn-sdk/src/analyze/provider_reattribution.rs index 5b13f57..554bfb9 100644 --- a/crates/relayburn-sdk/src/analyze/provider_reattribution.rs +++ b/crates/relayburn-sdk/src/analyze/provider_reattribution.rs @@ -148,6 +148,17 @@ pub fn resolve_provider(model: &str) -> ProviderResolution { resolve_provider_with_rules(model, default_rules()) } +/// Strip a leading `provider/` prefix from a model id, returning the bare +/// model name. `anthropic/claude-x` → `claude-x`; an id with no `/` is +/// returned unchanged. Shared by the cost lookup and provider aggregation +/// paths so both strip prefixes identically. +pub(crate) fn strip_provider_prefix(model: &str) -> &str { + match model.find('/') { + Some(i) => &model[i + 1..], + None => model, + } +} + /// Like [`resolve_provider`] but uses an explicit rule set — used by the /// public analyzer surface to thread `AggregateByProviderOptions::rules` /// through, and by tests to exercise extension scenarios. diff --git a/crates/relayburn-sdk/src/analyze/util.rs b/crates/relayburn-sdk/src/analyze/util.rs index 78b770e..cf0d23e 100644 --- a/crates/relayburn-sdk/src/analyze/util.rs +++ b/crates/relayburn-sdk/src/analyze/util.rs @@ -69,6 +69,19 @@ pub(crate) fn bytes_from_tokens(tokens: u64) -> u64 { tokens * APPROX_BYTES_PER_TOKEN } +/// Truncate `s` to at most `max` characters, appending `…` when it was longer +/// (the ellipsis takes the `max`-th slot, so the result is exactly `max` +/// chars). Counts by `char` to avoid splitting a multi-byte sequence mid- +/// codepoint; for the ASCII fixtures this matches the TS `string.length` / +/// `slice` truncation semantics. +pub(crate) fn truncate_chars(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let truncated: String = s.chars().take(max - 1).collect(); + format!("{truncated}…") +} + /// Nearest-rank percentile over an already-sorted slice, with `p` expressed as /// a fraction in `[0, 1]` (e.g. `0.95` for p95). Empty input yields the type's /// default (`0` / `0.0`); a single element is returned as-is. The index is From 1bda2bca266ce819bf604bc33f36cdd72c23a4f8 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:18:26 -0400 Subject: [PATCH 12/22] refactor(sdk/analyze): add group_turns_by_session_sorted, fold the post-group sort Five detectors (patterns, hotspots, quality, tool_call_patterns, claude_md) grouped turns by session and then repeated `bucket.sort_by_key(|t| t.turn_index)` inside the loop. Add a group_turns_by_session_sorted util variant that does the stable sort once and route all five through it. The sort is stable, so per-session ordering is bit-identical; all 992 tests pass, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/claude_md.rs | 6 +- .../src/analyze/context_delta_tests.rs | 813 +++-- crates/relayburn-sdk/src/analyze/hotspots.rs | 11 +- .../src/analyze/hotspots_tests.rs | 2610 ++++++++--------- crates/relayburn-sdk/src/analyze/patterns.rs | 9 +- .../src/analyze/patterns/streaks.rs | 8 +- crates/relayburn-sdk/src/analyze/quality.rs | 7 +- .../src/analyze/subagent_tree_tests.rs | 839 +++--- .../src/analyze/tool_call_patterns.rs | 5 +- .../src/analyze/tool_output_bloat_tests.rs | 1962 ++++++------- crates/relayburn-sdk/src/analyze/util.rs | 19 + 11 files changed, 3142 insertions(+), 3147 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/claude_md.rs b/crates/relayburn-sdk/src/analyze/claude_md.rs index 3ed1003..d782b2c 100644 --- a/crates/relayburn-sdk/src/analyze/claude_md.rs +++ b/crates/relayburn-sdk/src/analyze/claude_md.rs @@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize}; use crate::analyze::cost::{lookup_model_rate, PER_MILLION}; use crate::analyze::pricing::PricingTable; -use crate::analyze::util::{group_turns_by_session, percentile, tokens_from_bytes}; +use crate::analyze::util::{group_turns_by_session_sorted, percentile, tokens_from_bytes}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -325,13 +325,11 @@ pub(crate) fn attribute_claude_md_refs( }; } - let by_session = group_turns_by_session(turns.iter().copied()); + let by_session = group_turns_by_session_sorted(turns.iter().copied()); let mut session_costs: Vec = Vec::new(); let mut total_cost = 0.0_f64; for (session_id, turns) in by_session { - let mut turns = turns; - turns.sort_by_key(|t| t.turn_index); let mut cost = 0.0_f64; let mut riding_turns: u64 = 0; let mut model_counts: IndexMap = IndexMap::new(); diff --git a/crates/relayburn-sdk/src/analyze/context_delta_tests.rs b/crates/relayburn-sdk/src/analyze/context_delta_tests.rs index cf9405d..3939eee 100644 --- a/crates/relayburn-sdk/src/analyze/context_delta_tests.rs +++ b/crates/relayburn-sdk/src/analyze/context_delta_tests.rs @@ -1,422 +1,417 @@ //! Conformance tests for the context_delta module — extracted verbatim from the //! former inline `#[cfg(test)] mod tests` block (included via `#[path]`). - use super::*; - use crate::analyze::span_tree::{SpanKind, SpanNode, SpanStatus, TurnSpanTree}; - use crate::reader::{CompactionEvent, SourceKind}; - - fn make_inf( - req_id: &str, - model: &str, - input: i64, - cache_read: i64, - cache_write: i64, - ) -> SpanNode { - let mut n = SpanNode::new(SpanKind::Inference, model); - n.set_attr("model", AttrValue::str(model)); - n.set_attr("request_id", AttrValue::str(req_id)); - n.set_attr("tokens.input", AttrValue::Int(input)); - n.set_attr("tokens.output", AttrValue::Int(0)); - n.set_attr("tokens.cache_read", AttrValue::Int(cache_read)); - n.set_attr("tokens.cache_write", AttrValue::Int(cache_write)); - n.set_attr("tokens.reasoning", AttrValue::Int(0)); - n +use super::*; +use crate::analyze::span_tree::{SpanKind, SpanNode, SpanStatus, TurnSpanTree}; +use crate::reader::{CompactionEvent, SourceKind}; + +fn make_inf(req_id: &str, model: &str, input: i64, cache_read: i64, cache_write: i64) -> SpanNode { + let mut n = SpanNode::new(SpanKind::Inference, model); + n.set_attr("model", AttrValue::str(model)); + n.set_attr("request_id", AttrValue::str(req_id)); + n.set_attr("tokens.input", AttrValue::Int(input)); + n.set_attr("tokens.output", AttrValue::Int(0)); + n.set_attr("tokens.cache_read", AttrValue::Int(cache_read)); + n.set_attr("tokens.cache_write", AttrValue::Int(cache_write)); + n.set_attr("tokens.reasoning", AttrValue::Int(0)); + n +} + +fn make_tool_use(name: &str, tool_use_id: &str) -> SpanNode { + let mut n = SpanNode::new(SpanKind::ToolUse, name); + n.set_attr("tool_use_id", AttrValue::str(tool_use_id)); + n +} + +fn make_tool_result(tool_use_id: &str, bytes: i64, truncated: bool) -> SpanNode { + let mut n = SpanNode::new(SpanKind::ToolResult, "tool-result"); + n.set_attr("tool_use_id", AttrValue::str(tool_use_id)); + n.set_attr("output_bytes", AttrValue::Int(bytes)); + if truncated { + n.set_attr("output_truncated", AttrValue::Bool(true)); } - - fn make_tool_use(name: &str, tool_use_id: &str) -> SpanNode { - let mut n = SpanNode::new(SpanKind::ToolUse, name); - n.set_attr("tool_use_id", AttrValue::str(tool_use_id)); - n - } - - fn make_tool_result(tool_use_id: &str, bytes: i64, truncated: bool) -> SpanNode { - let mut n = SpanNode::new(SpanKind::ToolResult, "tool-result"); - n.set_attr("tool_use_id", AttrValue::str(tool_use_id)); - n.set_attr("output_bytes", AttrValue::Int(bytes)); - if truncated { - n.set_attr("output_truncated", AttrValue::Bool(true)); - } - n - } - - fn make_user_prompt() -> SpanNode { - SpanNode::new(SpanKind::UserPrompt, "user-prompt") - } - - fn turn_tree(session: &str, turn: &str, root: SpanNode) -> TurnSpanTree { - TurnSpanTree { - session_id: session.to_string(), - turn_id: turn.to_string(), - turn_number: 0, - root, - } + n +} + +fn make_user_prompt() -> SpanNode { + SpanNode::new(SpanKind::UserPrompt, "user-prompt") +} + +fn turn_tree(session: &str, turn: &str, root: SpanNode) -> TurnSpanTree { + TurnSpanTree { + session_id: session.to_string(), + turn_id: turn.to_string(), + turn_number: 0, + root, } - - /// Build a single-turn fixture: two inferences on the main rail - /// with a Bash tool_result between them whose 40_000 bytes - /// translates to a ~10k token jump. The delta should surface as - /// the top row with Bash as the driver. - #[test] - fn bash_blowup_surfaces_as_top_delta_with_bash_driver() { - // inference #1: context = 1000 - let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); - let mut bash_use = make_tool_use("Bash", "tu-1"); - bash_use - .children - .push(make_tool_result("tu-1", 40_000, false)); - let mut inf1 = inf1; - inf1.children.push(bash_use); - - // inference #2: context jumped to 12_000 — delta of 11_000. - let inf2 = make_inf("req-2", "claude-sonnet-4-6", 12_000, 0, 0); - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.status = SpanStatus::Ok; - root.children.push(make_user_prompt()); - root.children.push(inf1); - root.children.push(inf2); - - let tree = turn_tree("sess-1", "msg-1", root); - let pricing = crate::analyze::pricing::load_builtin_pricing(); - let opts = ContextDeltaOpts::default(); - let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); - assert_eq!(deltas.len(), 1, "one pairwise delta expected"); - let d = &deltas[0]; - assert_eq!(d.session_id, "sess-1"); - assert_eq!(d.turn_id, "msg-1"); - assert_eq!(d.owner_rail, OwnerRail::Main); - assert_eq!(d.prior_context_tokens, 1000); - assert_eq!(d.current_context_tokens, 12_000); - assert_eq!(d.delta_tokens, 11_000); - assert_eq!(d.intervening.len(), 1); - match &d.intervening[0] { - InterveningStep::ToolResult { - tool_name, - approx_bytes, - approx_tokens, - truncated, - .. - } => { - assert_eq!(tool_name, "Bash"); - assert_eq!(*approx_bytes, 40_000); - assert_eq!(*approx_tokens, 10_000); - assert!(!*truncated); - } - other => panic!("expected ToolResult step, got {other:?}"), +} + +/// Build a single-turn fixture: two inferences on the main rail +/// with a Bash tool_result between them whose 40_000 bytes +/// translates to a ~10k token jump. The delta should surface as +/// the top row with Bash as the driver. +#[test] +fn bash_blowup_surfaces_as_top_delta_with_bash_driver() { + // inference #1: context = 1000 + let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); + let mut bash_use = make_tool_use("Bash", "tu-1"); + bash_use + .children + .push(make_tool_result("tu-1", 40_000, false)); + let mut inf1 = inf1; + inf1.children.push(bash_use); + + // inference #2: context jumped to 12_000 — delta of 11_000. + let inf2 = make_inf("req-2", "claude-sonnet-4-6", 12_000, 0, 0); + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.status = SpanStatus::Ok; + root.children.push(make_user_prompt()); + root.children.push(inf1); + root.children.push(inf2); + + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let opts = ContextDeltaOpts::default(); + let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); + assert_eq!(deltas.len(), 1, "one pairwise delta expected"); + let d = &deltas[0]; + assert_eq!(d.session_id, "sess-1"); + assert_eq!(d.turn_id, "msg-1"); + assert_eq!(d.owner_rail, OwnerRail::Main); + assert_eq!(d.prior_context_tokens, 1000); + assert_eq!(d.current_context_tokens, 12_000); + assert_eq!(d.delta_tokens, 11_000); + assert_eq!(d.intervening.len(), 1); + match &d.intervening[0] { + InterveningStep::ToolResult { + tool_name, + approx_bytes, + approx_tokens, + truncated, + .. + } => { + assert_eq!(tool_name, "Bash"); + assert_eq!(*approx_bytes, 40_000); + assert_eq!(*approx_tokens, 10_000); + assert!(!*truncated); } - // The driver_label helper should mention Bash. - assert!(d.intervening[0].driver_label().contains("Bash")); - // Cost is non-negative. - assert!(d.attributed_cost_usd >= 0.0); - } - - /// Compaction handling: a CompactionEvent between two inferences - /// where the second has *less* context than the first must surface - /// as `Compaction { tokens_freed }`, NOT a negative `delta_tokens`. - #[test] - fn compaction_replaces_negative_delta() { - let inf1 = make_inf("req-1", "claude-sonnet-4-6", 50_000, 0, 0); - // After compaction, context drops to 8_000. - let inf2 = make_inf("req-2", "claude-sonnet-4-6", 8_000, 0, 0); - - // Stamp timestamps so the compaction event sits between them. - let mut inf1 = inf1; - inf1.start_ms = 1_776_643_201_000; - inf1.end_ms = 1_776_643_202_000; - let mut inf2 = inf2; - inf2.start_ms = 1_776_643_204_000; - inf2.end_ms = 1_776_643_205_000; - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(inf1); - root.children.push(inf2); - let tree = turn_tree("sess-1", "msg-1", root); - - let compaction = CompactionEvent { - v: 1, - source: SourceKind::ClaudeCode, - session_id: "sess-1".into(), - ts: "2026-04-20T00:00:03.000Z".into(), - preceding_message_id: Some("msg-1".into()), - tokens_before_compact: Some(50_000), - }; - - let pricing = crate::analyze::pricing::load_builtin_pricing(); - // Opts: min_delta 0 so the row isn't filtered out (delta_tokens is 0). - let opts = ContextDeltaOpts { - min_delta: Some(0), - ..ContextDeltaOpts::default() - }; - let deltas = deltas_for_session(&[tree], &[compaction], &pricing, &opts); - assert_eq!(deltas.len(), 1); - let d = &deltas[0]; - assert_eq!(d.delta_tokens, 0, "compaction clamps to 0"); - let has_compaction = d - .intervening - .iter() - .any(|s| matches!(s, InterveningStep::Compaction { tokens_freed } if *tokens_freed == 42_000)); - assert!( - has_compaction, - "expected Compaction step with tokens_freed=42000, got {:?}", - d.intervening - ); - } - - /// Subagent isolation: a main-rail Inference and a subagent - /// Inference both happen, with a subagent tool_result between - /// them. The main-rail delta must NOT include the subagent's - /// tool_result. - #[test] - fn subagent_isolation_main_rail_excludes_subagent_results() { - // Main rail: two inferences with a 10k context jump. - let mut main_inf1 = make_inf("req-main-1", "claude-sonnet-4-6", 1000, 0, 0); - // Add a Task tool_use that fans out to a Subagent with its own - // inference + tool_result. The subagent's tool_result has - // 40k bytes (~10k tokens) — and the main rail's tool_use also - // gets a small result so the main delta is non-zero. - let mut task_use = make_tool_use("Task", "tu-task"); - - let mut sub_node = SpanNode::new(SpanKind::Subagent, "general-purpose"); - sub_node.set_attr("agent_id", AttrValue::str("agent-a")); - // Subagent inferences: - let sub_inf1 = make_inf("req-sub-1", "claude-sonnet-4-6", 2000, 0, 0); - let mut sub_bash = make_tool_use("Bash", "tu-sub-bash"); - sub_bash - .children - .push(make_tool_result("tu-sub-bash", 40_000, false)); - let mut sub_inf1 = sub_inf1; - sub_inf1.children.push(sub_bash); - let sub_inf2 = make_inf("req-sub-2", "claude-sonnet-4-6", 12_000, 0, 0); - sub_node.children.push(sub_inf1); - sub_node.children.push(sub_inf2); - task_use.children.push(sub_node); - main_inf1.children.push(task_use); - - // Main rail #2: small jump only (no big tool_result on its own). - let main_inf2 = make_inf("req-main-2", "claude-sonnet-4-6", 3000, 0, 0); - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(main_inf1); - root.children.push(main_inf2); - let tree = turn_tree("sess-1", "msg-1", root); - - let pricing = crate::analyze::pricing::load_builtin_pricing(); - let opts = ContextDeltaOpts { - min_delta: Some(0), - ..ContextDeltaOpts::default() - }; - let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); - - // We expect one main-rail delta and one subagent-rail delta. - let main_delta = deltas - .iter() - .find(|d| d.owner_rail == OwnerRail::Main) - .expect("main-rail delta missing"); - let sub_delta = deltas - .iter() - .find(|d| matches!(&d.owner_rail, OwnerRail::Subagent { agent_id } if agent_id == "agent-a")) - .expect("subagent-rail delta missing"); - - // Main delta intervening must NOT include the subagent's tool_result. - for step in &main_delta.intervening { - if let InterveningStep::ToolResult { tool_use_id, .. } = step { - assert_ne!( - tool_use_id, "tu-sub-bash", - "main rail must NOT see the subagent's tool_result" - ); - } - } - - // Subagent delta intervening SHOULD include its own Bash result. - let sub_has_bash = sub_delta.intervening.iter().any(|s| match s { - InterveningStep::ToolResult { tool_use_id, .. } => tool_use_id == "tu-sub-bash", - _ => false, - }); - assert!( - sub_has_bash, - "subagent rail must see its own Bash tool_result" - ); + other => panic!("expected ToolResult step, got {other:?}"), } - - /// Empty rail (single inference, no prev) → no delta emitted, no - /// panic. - #[test] - fn single_inference_yields_no_delta() { - let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(inf1); - let tree = turn_tree("sess-1", "msg-1", root); - let pricing = crate::analyze::pricing::load_builtin_pricing(); - let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); - assert!( - deltas.is_empty(), - "single inference must not emit a pairwise delta" - ); - } - - /// `min_delta` filters out small jumps. - #[test] - fn min_delta_filters_small_jumps() { - let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); - // Small jump: +500 tokens. - let inf2 = make_inf("req-2", "claude-sonnet-4-6", 1500, 0, 0); - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(inf1); - root.children.push(inf2); - let tree = turn_tree("sess-1", "msg-1", root); - let pricing = crate::analyze::pricing::load_builtin_pricing(); - // Default min_delta is 1000; 500 < 1000 → filtered out. - let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); - assert!(deltas.is_empty(), "500 token jump must be filtered"); - - // Lower the threshold to 100 → row appears. - let opts = ContextDeltaOpts { - min_delta: Some(100), - ..ContextDeltaOpts::default() - }; - let deltas = deltas_for_session( - &[turn_tree("sess-1", "msg-1", root_with_two_infs(1000, 1500))], - &[], - &pricing, - &opts, - ); - assert_eq!(deltas.len(), 1); - assert_eq!(deltas[0].delta_tokens, 500); - } - - fn root_with_two_infs(ctx1: i64, ctx2: i64) -> SpanNode { - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children - .push(make_inf("req-1", "claude-sonnet-4-6", ctx1, 0, 0)); - root.children - .push(make_inf("req-2", "claude-sonnet-4-6", ctx2, 0, 0)); - root - } - - /// `--top N` caps output. - #[test] - fn top_caps_output() { - // Build a tree with 5 inferences, each adding 5000 tokens. - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - let ctx_steps = [1000, 6000, 11_000, 16_000, 21_000]; - for (i, c) in ctx_steps.iter().enumerate() { - root.children - .push(make_inf(&format!("req-{i}"), "claude-sonnet-4-6", *c, 0, 0)); - } - let tree = turn_tree("sess-1", "msg-1", root); - let pricing = crate::analyze::pricing::load_builtin_pricing(); - - // No cap → 4 pairwise deltas (5 inferences = 4 windows). - let opts = ContextDeltaOpts { - min_delta: Some(0), - ..ContextDeltaOpts::default() - }; - let all = deltas_for_session(std::slice::from_ref(&tree), &[], &pricing, &opts); - assert_eq!(all.len(), 4); - - // Cap at 2 → only the top 2 deltas. - let opts = ContextDeltaOpts { - min_delta: Some(0), - top: Some(2), - ..ContextDeltaOpts::default() - }; - let top2 = deltas_for_session(&[tree], &[], &pricing, &opts); - assert_eq!(top2.len(), 2); - } - - /// `--owner main` filter excludes subagent rails. - #[test] - fn owner_filter_main_excludes_subagent_rail() { - // Reuse the subagent-isolation fixture shape. - let mut main_inf1 = make_inf("req-main-1", "claude-sonnet-4-6", 1000, 0, 0); - let mut task_use = make_tool_use("Task", "tu-task"); - let mut sub_node = SpanNode::new(SpanKind::Subagent, "general-purpose"); - sub_node.set_attr("agent_id", AttrValue::str("agent-a")); - sub_node - .children - .push(make_inf("req-sub-1", "claude-sonnet-4-6", 2000, 0, 0)); - sub_node - .children - .push(make_inf("req-sub-2", "claude-sonnet-4-6", 22_000, 0, 0)); - task_use.children.push(sub_node); - main_inf1.children.push(task_use); - let main_inf2 = make_inf("req-main-2", "claude-sonnet-4-6", 3000, 0, 0); - - let mut root = SpanNode::new(SpanKind::Turn, "turn"); - root.children.push(make_user_prompt()); - root.children.push(main_inf1); - root.children.push(main_inf2); - let tree = turn_tree("sess-1", "msg-1", root); - - let pricing = crate::analyze::pricing::load_builtin_pricing(); - let opts = ContextDeltaOpts { - min_delta: Some(0), - owner: OwnerFilter::Main, - ..ContextDeltaOpts::default() - }; - let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); - for d in &deltas { - assert_eq!( - d.owner_rail, - OwnerRail::Main, - "owner filter Main must exclude subagent rails" + // The driver_label helper should mention Bash. + assert!(d.intervening[0].driver_label().contains("Bash")); + // Cost is non-negative. + assert!(d.attributed_cost_usd >= 0.0); +} + +/// Compaction handling: a CompactionEvent between two inferences +/// where the second has *less* context than the first must surface +/// as `Compaction { tokens_freed }`, NOT a negative `delta_tokens`. +#[test] +fn compaction_replaces_negative_delta() { + let inf1 = make_inf("req-1", "claude-sonnet-4-6", 50_000, 0, 0); + // After compaction, context drops to 8_000. + let inf2 = make_inf("req-2", "claude-sonnet-4-6", 8_000, 0, 0); + + // Stamp timestamps so the compaction event sits between them. + let mut inf1 = inf1; + inf1.start_ms = 1_776_643_201_000; + inf1.end_ms = 1_776_643_202_000; + let mut inf2 = inf2; + inf2.start_ms = 1_776_643_204_000; + inf2.end_ms = 1_776_643_205_000; + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(inf1); + root.children.push(inf2); + let tree = turn_tree("sess-1", "msg-1", root); + + let compaction = CompactionEvent { + v: 1, + source: SourceKind::ClaudeCode, + session_id: "sess-1".into(), + ts: "2026-04-20T00:00:03.000Z".into(), + preceding_message_id: Some("msg-1".into()), + tokens_before_compact: Some(50_000), + }; + + let pricing = crate::analyze::pricing::load_builtin_pricing(); + // Opts: min_delta 0 so the row isn't filtered out (delta_tokens is 0). + let opts = ContextDeltaOpts { + min_delta: Some(0), + ..ContextDeltaOpts::default() + }; + let deltas = deltas_for_session(&[tree], &[compaction], &pricing, &opts); + assert_eq!(deltas.len(), 1); + let d = &deltas[0]; + assert_eq!(d.delta_tokens, 0, "compaction clamps to 0"); + let has_compaction = d.intervening.iter().any( + |s| matches!(s, InterveningStep::Compaction { tokens_freed } if *tokens_freed == 42_000), + ); + assert!( + has_compaction, + "expected Compaction step with tokens_freed=42000, got {:?}", + d.intervening + ); +} + +/// Subagent isolation: a main-rail Inference and a subagent +/// Inference both happen, with a subagent tool_result between +/// them. The main-rail delta must NOT include the subagent's +/// tool_result. +#[test] +fn subagent_isolation_main_rail_excludes_subagent_results() { + // Main rail: two inferences with a 10k context jump. + let mut main_inf1 = make_inf("req-main-1", "claude-sonnet-4-6", 1000, 0, 0); + // Add a Task tool_use that fans out to a Subagent with its own + // inference + tool_result. The subagent's tool_result has + // 40k bytes (~10k tokens) — and the main rail's tool_use also + // gets a small result so the main delta is non-zero. + let mut task_use = make_tool_use("Task", "tu-task"); + + let mut sub_node = SpanNode::new(SpanKind::Subagent, "general-purpose"); + sub_node.set_attr("agent_id", AttrValue::str("agent-a")); + // Subagent inferences: + let sub_inf1 = make_inf("req-sub-1", "claude-sonnet-4-6", 2000, 0, 0); + let mut sub_bash = make_tool_use("Bash", "tu-sub-bash"); + sub_bash + .children + .push(make_tool_result("tu-sub-bash", 40_000, false)); + let mut sub_inf1 = sub_inf1; + sub_inf1.children.push(sub_bash); + let sub_inf2 = make_inf("req-sub-2", "claude-sonnet-4-6", 12_000, 0, 0); + sub_node.children.push(sub_inf1); + sub_node.children.push(sub_inf2); + task_use.children.push(sub_node); + main_inf1.children.push(task_use); + + // Main rail #2: small jump only (no big tool_result on its own). + let main_inf2 = make_inf("req-main-2", "claude-sonnet-4-6", 3000, 0, 0); + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(main_inf1); + root.children.push(main_inf2); + let tree = turn_tree("sess-1", "msg-1", root); + + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let opts = ContextDeltaOpts { + min_delta: Some(0), + ..ContextDeltaOpts::default() + }; + let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); + + // We expect one main-rail delta and one subagent-rail delta. + let main_delta = deltas + .iter() + .find(|d| d.owner_rail == OwnerRail::Main) + .expect("main-rail delta missing"); + let sub_delta = deltas + .iter() + .find( + |d| matches!(&d.owner_rail, OwnerRail::Subagent { agent_id } if agent_id == "agent-a"), + ) + .expect("subagent-rail delta missing"); + + // Main delta intervening must NOT include the subagent's tool_result. + for step in &main_delta.intervening { + if let InterveningStep::ToolResult { tool_use_id, .. } = step { + assert_ne!( + tool_use_id, "tu-sub-bash", + "main rail must NOT see the subagent's tool_result" ); } - assert!(!deltas.is_empty(), "expected at least one main-rail delta"); } - /// JSON shape: rail serializes with a `kind` discriminant, steps - /// keep their kebab-case kind tag. Catch wire-format drift early. - #[test] - fn json_shape_uses_kebab_case_discriminants() { - let d = ContextDelta { - session_id: "s".into(), - turn_id: "t".into(), - inference_idx: 2, - owner_rail: OwnerRail::Subagent { - agent_id: "agent-x".into(), - }, - prior_context_tokens: 10, - current_context_tokens: 20, - delta_tokens: 10, - intervening: vec![InterveningStep::ToolResult { - tool_use_id: "tu-1".into(), - tool_name: "Bash".into(), - approx_tokens: 5, - approx_bytes: 20, - truncated: false, - }], - attributed_cost_usd: 0.0, - }; - let s = serde_json::to_string(&d).unwrap(); - assert!(s.contains("\"kind\":\"subagent\""), "got {s}"); - assert!(s.contains("\"agentId\":\"agent-x\""), "got {s}"); - assert!(s.contains("\"kind\":\"tool-result\""), "got {s}"); - assert!(s.contains("\"toolUseId\":\"tu-1\""), "got {s}"); - let back: ContextDelta = serde_json::from_str(&s).unwrap(); - assert_eq!(back, d); + // Subagent delta intervening SHOULD include its own Bash result. + let sub_has_bash = sub_delta.intervening.iter().any(|s| match s { + InterveningStep::ToolResult { tool_use_id, .. } => tool_use_id == "tu-sub-bash", + _ => false, + }); + assert!( + sub_has_bash, + "subagent rail must see its own Bash tool_result" + ); +} + +/// Empty rail (single inference, no prev) → no delta emitted, no +/// panic. +#[test] +fn single_inference_yields_no_delta() { + let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(inf1); + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); + assert!( + deltas.is_empty(), + "single inference must not emit a pairwise delta" + ); +} + +/// `min_delta` filters out small jumps. +#[test] +fn min_delta_filters_small_jumps() { + let inf1 = make_inf("req-1", "claude-sonnet-4-6", 1000, 0, 0); + // Small jump: +500 tokens. + let inf2 = make_inf("req-2", "claude-sonnet-4-6", 1500, 0, 0); + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(inf1); + root.children.push(inf2); + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + // Default min_delta is 1000; 500 < 1000 → filtered out. + let deltas = deltas_for_session(&[tree], &[], &pricing, &ContextDeltaOpts::default()); + assert!(deltas.is_empty(), "500 token jump must be filtered"); + + // Lower the threshold to 100 → row appears. + let opts = ContextDeltaOpts { + min_delta: Some(100), + ..ContextDeltaOpts::default() + }; + let deltas = deltas_for_session( + &[turn_tree("sess-1", "msg-1", root_with_two_infs(1000, 1500))], + &[], + &pricing, + &opts, + ); + assert_eq!(deltas.len(), 1); + assert_eq!(deltas[0].delta_tokens, 500); +} + +fn root_with_two_infs(ctx1: i64, ctx2: i64) -> SpanNode { + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children + .push(make_inf("req-1", "claude-sonnet-4-6", ctx1, 0, 0)); + root.children + .push(make_inf("req-2", "claude-sonnet-4-6", ctx2, 0, 0)); + root +} + +/// `--top N` caps output. +#[test] +fn top_caps_output() { + // Build a tree with 5 inferences, each adding 5000 tokens. + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + let ctx_steps = [1000, 6000, 11_000, 16_000, 21_000]; + for (i, c) in ctx_steps.iter().enumerate() { + root.children + .push(make_inf(&format!("req-{i}"), "claude-sonnet-4-6", *c, 0, 0)); } - - /// System reminder step surfaces as its own intervening step row. - /// We construct one directly (the timeline builder doesn't yet - /// synthesize SystemReminder spans from content sidecars; first- - /// cut behavior per the issue). - #[test] - fn system_reminder_step_round_trips_in_json() { - let step = InterveningStep::SystemReminder { - source: ReminderSource::Other, - approx_tokens: 250, - }; - let s = serde_json::to_string(&step).unwrap(); - assert!(s.contains("\"kind\":\"system-reminder\""), "got {s}"); - assert!(s.contains("\"source\":\"other\""), "got {s}"); - let back: InterveningStep = serde_json::from_str(&s).unwrap(); - assert_eq!(back, step); + let tree = turn_tree("sess-1", "msg-1", root); + let pricing = crate::analyze::pricing::load_builtin_pricing(); + + // No cap → 4 pairwise deltas (5 inferences = 4 windows). + let opts = ContextDeltaOpts { + min_delta: Some(0), + ..ContextDeltaOpts::default() + }; + let all = deltas_for_session(std::slice::from_ref(&tree), &[], &pricing, &opts); + assert_eq!(all.len(), 4); + + // Cap at 2 → only the top 2 deltas. + let opts = ContextDeltaOpts { + min_delta: Some(0), + top: Some(2), + ..ContextDeltaOpts::default() + }; + let top2 = deltas_for_session(&[tree], &[], &pricing, &opts); + assert_eq!(top2.len(), 2); +} + +/// `--owner main` filter excludes subagent rails. +#[test] +fn owner_filter_main_excludes_subagent_rail() { + // Reuse the subagent-isolation fixture shape. + let mut main_inf1 = make_inf("req-main-1", "claude-sonnet-4-6", 1000, 0, 0); + let mut task_use = make_tool_use("Task", "tu-task"); + let mut sub_node = SpanNode::new(SpanKind::Subagent, "general-purpose"); + sub_node.set_attr("agent_id", AttrValue::str("agent-a")); + sub_node + .children + .push(make_inf("req-sub-1", "claude-sonnet-4-6", 2000, 0, 0)); + sub_node + .children + .push(make_inf("req-sub-2", "claude-sonnet-4-6", 22_000, 0, 0)); + task_use.children.push(sub_node); + main_inf1.children.push(task_use); + let main_inf2 = make_inf("req-main-2", "claude-sonnet-4-6", 3000, 0, 0); + + let mut root = SpanNode::new(SpanKind::Turn, "turn"); + root.children.push(make_user_prompt()); + root.children.push(main_inf1); + root.children.push(main_inf2); + let tree = turn_tree("sess-1", "msg-1", root); + + let pricing = crate::analyze::pricing::load_builtin_pricing(); + let opts = ContextDeltaOpts { + min_delta: Some(0), + owner: OwnerFilter::Main, + ..ContextDeltaOpts::default() + }; + let deltas = deltas_for_session(&[tree], &[], &pricing, &opts); + for d in &deltas { + assert_eq!( + d.owner_rail, + OwnerRail::Main, + "owner filter Main must exclude subagent rails" + ); } + assert!(!deltas.is_empty(), "expected at least one main-rail delta"); +} + +/// JSON shape: rail serializes with a `kind` discriminant, steps +/// keep their kebab-case kind tag. Catch wire-format drift early. +#[test] +fn json_shape_uses_kebab_case_discriminants() { + let d = ContextDelta { + session_id: "s".into(), + turn_id: "t".into(), + inference_idx: 2, + owner_rail: OwnerRail::Subagent { + agent_id: "agent-x".into(), + }, + prior_context_tokens: 10, + current_context_tokens: 20, + delta_tokens: 10, + intervening: vec![InterveningStep::ToolResult { + tool_use_id: "tu-1".into(), + tool_name: "Bash".into(), + approx_tokens: 5, + approx_bytes: 20, + truncated: false, + }], + attributed_cost_usd: 0.0, + }; + let s = serde_json::to_string(&d).unwrap(); + assert!(s.contains("\"kind\":\"subagent\""), "got {s}"); + assert!(s.contains("\"agentId\":\"agent-x\""), "got {s}"); + assert!(s.contains("\"kind\":\"tool-result\""), "got {s}"); + assert!(s.contains("\"toolUseId\":\"tu-1\""), "got {s}"); + let back: ContextDelta = serde_json::from_str(&s).unwrap(); + assert_eq!(back, d); +} + +/// System reminder step surfaces as its own intervening step row. +/// We construct one directly (the timeline builder doesn't yet +/// synthesize SystemReminder spans from content sidecars; first- +/// cut behavior per the issue). +#[test] +fn system_reminder_step_round_trips_in_json() { + let step = InterveningStep::SystemReminder { + source: ReminderSource::Other, + approx_tokens: 250, + }; + let s = serde_json::to_string(&step).unwrap(); + assert!(s.contains("\"kind\":\"system-reminder\""), "got {s}"); + assert!(s.contains("\"source\":\"other\""), "got {s}"); + let back: InterveningStep = serde_json::from_str(&s).unwrap(); + assert_eq!(back, step); +} diff --git a/crates/relayburn-sdk/src/analyze/hotspots.rs b/crates/relayburn-sdk/src/analyze/hotspots.rs index 8ea1af5..1cf6a95 100644 --- a/crates/relayburn-sdk/src/analyze/hotspots.rs +++ b/crates/relayburn-sdk/src/analyze/hotspots.rs @@ -19,7 +19,9 @@ use serde::{Deserialize, Serialize}; use crate::analyze::cost::{cost_for_turn, lookup_model_rate, PER_MILLION}; use crate::analyze::pricing::PricingTable; -use crate::analyze::util::{group_turns_by_session, stringify_tool_result, tokens_from_utf16_len}; +use crate::analyze::util::{ + group_turns_by_session_sorted, stringify_tool_result, tokens_from_utf16_len, +}; /// How a session's attribution loop allocated cost across tool calls. /// @@ -272,17 +274,14 @@ pub fn attribute_hotspots(turns: &[TurnRecord], opts: &HotspotsOptions<'_>) -> H // First-seen session ordering matches the TS `Map` iteration semantics. // Borrow turns rather than cloning — nothing below mutates them and the // input slice outlives every aggregation step. - let by_session = group_turns_by_session(turns); + let by_session = group_turns_by_session_sorted(turns); let mut attributions: Vec = Vec::new(); let mut session_totals: Vec = Vec::new(); let mut grand_total = 0.0_f64; let mut attributed_total = 0.0_f64; - for (session_id, mut session_turns) in by_session.into_iter() { - // Stable sort matches the TS `Array.prototype.sort` contract. - session_turns.sort_by_key(|t| t.turn_index); - + for (session_id, session_turns) in by_session.into_iter() { let session_content = opts.content_by_session.and_then(|m| m.get(&session_id)); let tool_results_by_turn = session_content.map(|content| index_tool_results(content, &session_turns)); diff --git a/crates/relayburn-sdk/src/analyze/hotspots_tests.rs b/crates/relayburn-sdk/src/analyze/hotspots_tests.rs index 3cf3205..1be98f2 100644 --- a/crates/relayburn-sdk/src/analyze/hotspots_tests.rs +++ b/crates/relayburn-sdk/src/analyze/hotspots_tests.rs @@ -1,1427 +1,1421 @@ //! Conformance tests for the hotspots module — extracted verbatim from the //! former inline `#[cfg(test)] mod tests` block (included via `#[path]`). - use super::*; - use crate::analyze::pricing::load_builtin_pricing; - use crate::reader::{ - parse_bash_command, ContentRole, ContentToolResult, SourceKind, ToolCall, Usage, - UserTurnBlock, - }; - use serde_json::json; +use super::*; +use crate::analyze::pricing::load_builtin_pricing; +use crate::reader::{ + parse_bash_command, ContentRole, ContentToolResult, SourceKind, ToolCall, Usage, UserTurnBlock, +}; +use serde_json::json; - fn empty_usage() -> Usage { - Usage { - input: 0, - output: 0, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - } +fn empty_usage() -> Usage { + Usage { + input: 0, + output: 0, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, } +} - #[allow(clippy::too_many_arguments)] - fn turn( - session_id: &str, - message_id: &str, - turn_index: u64, - ts: &str, - model: &str, - usage: Usage, - tool_calls: Vec, - source: SourceKind, - ) -> TurnRecord { - TurnRecord { - v: 1, - source, - session_id: session_id.into(), - session_path: None, - message_id: message_id.into(), - turn_index, - ts: ts.into(), - model: model.into(), - project: None, - project_key: None, - usage, - tool_calls, - files_touched: None, - subagent: None, - stop_reason: None, - activity: None, - retries: None, - has_edits: None, - fidelity: None, - } +#[allow(clippy::too_many_arguments)] +fn turn( + session_id: &str, + message_id: &str, + turn_index: u64, + ts: &str, + model: &str, + usage: Usage, + tool_calls: Vec, + source: SourceKind, +) -> TurnRecord { + TurnRecord { + v: 1, + source, + session_id: session_id.into(), + session_path: None, + message_id: message_id.into(), + turn_index, + ts: ts.into(), + model: model.into(), + project: None, + project_key: None, + usage, + tool_calls, + files_touched: None, + subagent: None, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, } +} - fn tc(id: &str, name: &str, target: Option<&str>) -> ToolCall { - let target_part = target.unwrap_or(id); - ToolCall { - id: id.into(), - name: name.into(), - target: target.map(String::from), - args_hash: format!("{name}:{target_part}"), - is_error: None, - edit_pre_hash: None, - edit_post_hash: None, - skill_name: None, - replaced_tools: None, - collapsed_calls: None, - } +fn tc(id: &str, name: &str, target: Option<&str>) -> ToolCall { + let target_part = target.unwrap_or(id); + ToolCall { + id: id.into(), + name: name.into(), + target: target.map(String::from), + args_hash: format!("{name}:{target_part}"), + is_error: None, + edit_pre_hash: None, + edit_post_hash: None, + skill_name: None, + replaced_tools: None, + collapsed_calls: None, } +} - fn tc_with_hash(id: &str, name: &str, target: &str, args_hash: &str) -> ToolCall { - ToolCall { - id: id.into(), - name: name.into(), - target: Some(target.into()), - args_hash: args_hash.into(), - is_error: None, - edit_pre_hash: None, - edit_post_hash: None, - skill_name: None, - replaced_tools: None, - collapsed_calls: None, - } +fn tc_with_hash(id: &str, name: &str, target: &str, args_hash: &str) -> ToolCall { + ToolCall { + id: id.into(), + name: name.into(), + target: Some(target.into()), + args_hash: args_hash.into(), + is_error: None, + edit_pre_hash: None, + edit_post_hash: None, + skill_name: None, + replaced_tools: None, + collapsed_calls: None, } +} - fn tool_result_content( - session_id: &str, - tool_use_id: &str, - text: &str, - ts: &str, - ) -> ContentRecord { - ContentRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.into(), - message_id: format!("m-{tool_use_id}"), - ts: ts.into(), - role: ContentRole::ToolResult, - kind: ContentKind::ToolResult, - text: None, - tool_use: None, - tool_result: Some(ContentToolResult { - tool_use_id: tool_use_id.into(), - content: json!(text), - is_error: None, - }), - } +fn tool_result_content(session_id: &str, tool_use_id: &str, text: &str, ts: &str) -> ContentRecord { + ContentRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.into(), + message_id: format!("m-{tool_use_id}"), + ts: ts.into(), + role: ContentRole::ToolResult, + kind: ContentKind::ToolResult, + text: None, + tool_use: None, + tool_result: Some(ContentToolResult { + tool_use_id: tool_use_id.into(), + content: json!(text), + is_error: None, + }), } +} - fn user_turn(session_id: &str, user_uuid: &str, blocks: Vec) -> UserTurnRecord { - UserTurnRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.into(), - user_uuid: user_uuid.into(), - ts: "2026-04-20T00:00:00.500Z".into(), - preceding_message_id: Some("msg-0".into()), - following_message_id: Some("msg-1".into()), - blocks, - } +fn user_turn(session_id: &str, user_uuid: &str, blocks: Vec) -> UserTurnRecord { + UserTurnRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.into(), + user_uuid: user_uuid.into(), + ts: "2026-04-20T00:00:00.500Z".into(), + preceding_message_id: Some("msg-0".into()), + following_message_id: Some("msg-1".into()), + blocks, } +} - fn tool_result_block(tool_use_id: &str, byte_len: u64, approx_tokens: u64) -> UserTurnBlock { - UserTurnBlock { - kind: UserTurnBlockKind::ToolResult, - tool_use_id: Some(tool_use_id.into()), - byte_len, - approx_tokens, - is_error: None, - } +fn tool_result_block(tool_use_id: &str, byte_len: u64, approx_tokens: u64) -> UserTurnBlock { + UserTurnBlock { + kind: UserTurnBlockKind::ToolResult, + tool_use_id: Some(tool_use_id.into()), + byte_len, + approx_tokens, + is_error: None, } +} - fn bash_attribution( - command: &str, - args_hash: &str, - total_cost: f64, - initial_tokens: f64, - persistence_tokens: f64, - riding_turns: u64, - ) -> ToolAttribution { - ToolAttribution { - tool_use_id: format!("tu-{args_hash}"), - tool_name: "Bash".into(), - target: Some(command.into()), - args_hash: args_hash.into(), - session_id: "s-bash-verb".into(), - emit_turn_index: 0, - emit_ts: "2026-04-20T00:00:00.000Z".into(), - model: "claude-sonnet-4-6".into(), - project: None, - project_key: None, - subagent_type: None, - result_tokens: 0, - result_bytes_estimated: true, - output_bytes: None, - output_truncated: None, - initial_cost: total_cost, - initial_tokens, - persistence_cost: 0.0, - persistence_tokens, - riding_turns, - total_cost, - } +fn bash_attribution( + command: &str, + args_hash: &str, + total_cost: f64, + initial_tokens: f64, + persistence_tokens: f64, + riding_turns: u64, +) -> ToolAttribution { + ToolAttribution { + tool_use_id: format!("tu-{args_hash}"), + tool_name: "Bash".into(), + target: Some(command.into()), + args_hash: args_hash.into(), + session_id: "s-bash-verb".into(), + emit_turn_index: 0, + emit_ts: "2026-04-20T00:00:00.000Z".into(), + model: "claude-sonnet-4-6".into(), + project: None, + project_key: None, + subagent_type: None, + result_tokens: 0, + result_bytes_estimated: true, + output_bytes: None, + output_truncated: None, + initial_cost: total_cost, + initial_tokens, + persistence_cost: 0.0, + persistence_tokens, + riding_turns, + total_cost, } +} - #[test] - fn attributes_persistence_of_8k_read_across_20_ride_along_turns_within_10_pct() { - let pricing = load_builtin_pricing(); - let rate = pricing - .get("claude-sonnet-4-6") - .expect("sonnet present") - .clone(); - const READ_TOKENS: u64 = 8000; - let read_text: String = "x".repeat((READ_TOKENS as usize) * 4); +#[test] +fn attributes_persistence_of_8k_read_across_20_ride_along_turns_within_10_pct() { + let pricing = load_builtin_pricing(); + let rate = pricing + .get("claude-sonnet-4-6") + .expect("sonnet present") + .clone(); + const READ_TOKENS: u64 = 8000; + let read_text: String = "x".repeat((READ_TOKENS as usize) * 4); + + let session_id = "s-hotspots-1"; + let mut turns: Vec = Vec::new(); + + // Turn 0: assistant emits the Read tool_use. + turns.push(turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 200, + output: 50, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![tc("tu_read_1", "Read", Some("/src/big.ts"))], + SourceKind::ClaudeCode, + )); - let session_id = "s-hotspots-1"; - let mut turns: Vec = Vec::new(); + // Turn 1 pays initial: 8000 tokens enter as fresh input. + turns.push(turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:01.000Z", + "claude-sonnet-4-6", + Usage { + input: READ_TOKENS, + output: 30, + reasoning: 0, + cache_read: 250, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + )); - // Turn 0: assistant emits the Read tool_use. + // Turns 2..=21: 20 ride-along turns each with cacheRead >= READ_TOKENS. + for i in 2..=21u64 { turns.push(turn( session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", + &format!("msg-{i}"), + i, + &format!("2026-04-20T00:00:{:02}.000Z", i), "claude-sonnet-4-6", Usage { - input: 200, - output: 50, + input: 50, + output: 30, reasoning: 0, - cache_read: 0, + cache_read: READ_TOKENS + 2000, cache_create_5m: 0, cache_create_1h: 0, }, - vec![tc("tu_read_1", "Read", Some("/src/big.ts"))], + vec![], SourceKind::ClaudeCode, )); + } - // Turn 1 pays initial: 8000 tokens enter as fresh input. - turns.push(turn( + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_read_1", + &read_text, + "2026-04-20T00:00:00.500Z", + )], + ); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + assert_eq!(result.attributions.len(), 1); + let a = &result.attributions[0]; + assert_eq!(a.tool_use_id, "tu_read_1"); + + let expected_initial = (READ_TOKENS as f64 / 1_000_000.0) * rate.input; + let expected_persistence = 20.0 * (READ_TOKENS as f64 / 1_000_000.0) * rate.cache_read; + let expected_total = expected_initial + expected_persistence; + assert!( + (a.total_cost - expected_total).abs() <= expected_total * 0.10, + "total={} expected={} diff>10%", + a.total_cost, + expected_total + ); + assert_eq!(a.riding_turns, 20); +} + +#[test] +fn aggregates_by_file_and_ranks_most_expensive_read_first() { + let pricing = load_builtin_pricing(); + let session_id = "s-files"; + const READ_TOKENS: u64 = 5000; + const SMALL_TOKENS: u64 = 200; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc("tu_a", "Read", Some("/big.ts")), + tc("tu_b", "Read", Some("/small.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( session_id, "msg-1", 1, - "2026-04-20T00:00:01.000Z", + "2026-04-20T00:00:00.000Z", "claude-sonnet-4-6", Usage { - input: READ_TOKENS, - output: 30, + input: READ_TOKENS + SMALL_TOKENS, + output: 5, reasoning: 0, - cache_read: 250, + cache_read: 0, cache_create_5m: 0, cache_create_1h: 0, }, vec![], SourceKind::ClaudeCode, - )); - - // Turns 2..=21: 20 ride-along turns each with cacheRead >= READ_TOKENS. - for i in 2..=21u64 { - turns.push(turn( + ), + turn( + session_id, + "msg-2", + 2, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 100, + output: 5, + reasoning: 0, + cache_read: READ_TOKENS + SMALL_TOKENS + 500, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-3", + 3, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 100, + output: 5, + reasoning: 0, + cache_read: READ_TOKENS + SMALL_TOKENS + 500, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![ + tool_result_content( session_id, - &format!("msg-{i}"), - i, - &format!("2026-04-20T00:00:{:02}.000Z", i), - "claude-sonnet-4-6", - Usage { - input: 50, - output: 30, - reasoning: 0, - cache_read: READ_TOKENS + 2000, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - )); - } - - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( + "tu_a", + &"x".repeat((READ_TOKENS as usize) * 4), + "2026-04-20T00:00:00.100Z", + ), + tool_result_content( session_id, - "tu_read_1", - &read_text, - "2026-04-20T00:00:00.500Z", - )], - ); + "tu_b", + &"y".repeat((SMALL_TOKENS as usize) * 4), + "2026-04-20T00:00:00.101Z", + ), + ], + ); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - assert_eq!(result.attributions.len(), 1); - let a = &result.attributions[0]; - assert_eq!(a.tool_use_id, "tu_read_1"); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let files = aggregate_by_file(&result.attributions); + assert_eq!(files.len(), 2); + assert_eq!(files[0].path, "/big.ts"); + assert_eq!(files[1].path, "/small.ts"); + assert!(files[0].total_cost > files[1].total_cost); +} - let expected_initial = (READ_TOKENS as f64 / 1_000_000.0) * rate.input; - let expected_persistence = 20.0 * (READ_TOKENS as f64 / 1_000_000.0) * rate.cache_read; - let expected_total = expected_initial + expected_persistence; - assert!( - (a.total_cost - expected_total).abs() <= expected_total * 0.10, - "total={} expected={} diff>10%", - a.total_cost, - expected_total - ); - assert_eq!(a.riding_turns, 20); +#[test] +fn aggregates_by_bash_args_hash_so_repeated_commands_collapse() { + let pricing = load_builtin_pricing(); + let session_id = "s-bash"; + let mut turns: Vec = Vec::new(); + let mut ts = 0u64; + for i in 0..3 { + turns.push(turn( + session_id, + &format!("msg-emit-{i}"), + ts, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![tc_with_hash( + &format!("tu_b_{i}"), + "Bash", + "ls -la", + "Bash:ls", + )], + SourceKind::ClaudeCode, + )); + ts += 1; + turns.push(turn( + session_id, + &format!("msg-pay-{i}"), + ts, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 1000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + )); + ts += 1; } - - #[test] - fn aggregates_by_file_and_ranks_most_expensive_read_first() { - let pricing = load_builtin_pricing(); - let session_id = "s-files"; - const READ_TOKENS: u64 = 5000; - const SMALL_TOKENS: u64 = 200; - let turns = vec![ - turn( + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![ + tool_result_content( session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_a", "Read", Some("/big.ts")), - tc("tu_b", "Read", Some("/small.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: READ_TOKENS + SMALL_TOKENS, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, + "tu_b_0", + &"x".repeat(4000), + "2026-04-20T00:00:00.100Z", ), - turn( + tool_result_content( session_id, - "msg-2", - 2, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 100, - output: 5, - reasoning: 0, - cache_read: READ_TOKENS + SMALL_TOKENS + 500, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, + "tu_b_1", + &"x".repeat(4000), + "2026-04-20T00:00:00.200Z", ), - turn( + tool_result_content( session_id, - "msg-3", - 3, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 100, - output: 5, - reasoning: 0, - cache_read: READ_TOKENS + SMALL_TOKENS + 500, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, + "tu_b_2", + &"x".repeat(4000), + "2026-04-20T00:00:00.300Z", ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![ - tool_result_content( - session_id, - "tu_a", - &"x".repeat((READ_TOKENS as usize) * 4), - "2026-04-20T00:00:00.100Z", - ), - tool_result_content( - session_id, - "tu_b", - &"y".repeat((SMALL_TOKENS as usize) * 4), - "2026-04-20T00:00:00.101Z", - ), - ], - ); - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - let files = aggregate_by_file(&result.attributions); - assert_eq!(files.len(), 2); - assert_eq!(files[0].path, "/big.ts"); - assert_eq!(files[1].path, "/small.ts"); - assert!(files[0].total_cost > files[1].total_cost); - } + ], + ); - #[test] - fn aggregates_by_bash_args_hash_so_repeated_commands_collapse() { - let pricing = load_builtin_pricing(); - let session_id = "s-bash"; - let mut turns: Vec = Vec::new(); - let mut ts = 0u64; - for i in 0..3 { - turns.push(turn( - session_id, - &format!("msg-emit-{i}"), - ts, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![tc_with_hash( - &format!("tu_b_{i}"), - "Bash", - "ls -la", - "Bash:ls", - )], - SourceKind::ClaudeCode, - )); - ts += 1; - turns.push(turn( - session_id, - &format!("msg-pay-{i}"), - ts, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 1000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - )); - ts += 1; - } - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![ - tool_result_content( - session_id, - "tu_b_0", - &"x".repeat(4000), - "2026-04-20T00:00:00.100Z", - ), - tool_result_content( - session_id, - "tu_b_1", - &"x".repeat(4000), - "2026-04-20T00:00:00.200Z", - ), - tool_result_content( - session_id, - "tu_b_2", - &"x".repeat(4000), - "2026-04-20T00:00:00.300Z", - ), - ], - ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let bash = aggregate_by_bash(&result.attributions); + assert_eq!(bash.len(), 1); + assert_eq!(bash[0].call_count, 3); +} - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - let bash = aggregate_by_bash(&result.attributions); - assert_eq!(bash.len(), 1); - assert_eq!(bash[0].call_count, 3); - } - - #[test] - fn aggregates_bash_cost_by_normalized_verb_with_distinct_command_and_examples() { - let attrs = vec![ - bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), - bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), - bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), - bash_attribution("git diff src/a.ts", "git:diff:a", 5.0, 100.0, 10.0, 1), - bash_attribution("git diff src/a.ts", "git:diff:a", 5.0, 100.0, 10.0, 1), - bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), - bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), - bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), - bash_attribution("pnpm run test", "pnpm:test", 4.0, 40.0, 8.0, 1), - ]; +#[test] +fn aggregates_bash_cost_by_normalized_verb_with_distinct_command_and_examples() { + let attrs = vec![ + bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), + bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), + bash_attribution("git status", "git:status", 2.0, 20.0, 5.0, 0), + bash_attribution("git diff src/a.ts", "git:diff:a", 5.0, 100.0, 10.0, 1), + bash_attribution("git diff src/a.ts", "git:diff:a", 5.0, 100.0, 10.0, 1), + bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), + bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), + bash_attribution("git diff src/b.ts", "git:diff:b", 7.0, 100.0, 20.0, 2), + bash_attribution("pnpm run test", "pnpm:test", 4.0, 40.0, 8.0, 1), + ]; - let verbs = aggregate_by_bash_verb(&attrs, parse_bash_command); - assert_eq!(verbs[0].verb, "git diff"); - assert_eq!(verbs[0].call_count, 5); - assert_eq!(verbs[0].distinct_commands, 2); - assert!((verbs[0].total_cost - 31.0).abs() < 1e-9); - assert!((verbs[0].initial_tokens - 500.0).abs() < 1e-9); - assert!((verbs[0].persistence_tokens - 80.0).abs() < 1e-9); - assert!((verbs[0].avg_persistence_turns - 1.6).abs() < 1e-9); - assert_eq!( - verbs[0].top_examples, - vec!["git diff src/b.ts", "git diff src/a.ts"] - ); + let verbs = aggregate_by_bash_verb(&attrs, parse_bash_command); + assert_eq!(verbs[0].verb, "git diff"); + assert_eq!(verbs[0].call_count, 5); + assert_eq!(verbs[0].distinct_commands, 2); + assert!((verbs[0].total_cost - 31.0).abs() < 1e-9); + assert!((verbs[0].initial_tokens - 500.0).abs() < 1e-9); + assert!((verbs[0].persistence_tokens - 80.0).abs() < 1e-9); + assert!((verbs[0].avg_persistence_turns - 1.6).abs() < 1e-9); + assert_eq!( + verbs[0].top_examples, + vec!["git diff src/b.ts", "git diff src/a.ts"] + ); - assert_eq!(verbs[1].verb, "git status"); - assert_eq!(verbs[1].call_count, 3); - assert_eq!(verbs[1].distinct_commands, 1); - assert_eq!(verbs[2].verb, "pnpm test"); - } + assert_eq!(verbs[1].verb, "git status"); + assert_eq!(verbs[1].call_count, 3); + assert_eq!(verbs[1].distinct_commands, 1); + assert_eq!(verbs[2].verb, "pnpm test"); +} - #[test] - fn aggregates_subagent_calls_by_subagent_type() { - let pricing = load_builtin_pricing(); - let session_id = "s-agent"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![tc_with_hash( - "tu_a1", - "Agent", - "general-purpose", - "Agent:gp", - )], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 2000, - output: 10, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( - session_id, +#[test] +fn aggregates_subagent_calls_by_subagent_type() { + let pricing = load_builtin_pricing(); + let session_id = "s-agent"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![tc_with_hash( "tu_a1", - &"z".repeat(8000), - "2026-04-20T00:00:00.100Z", + "Agent", + "general-purpose", + "Agent:gp", )], - ); - - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 2000, + output: 10, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, }, - ); - let subagents = aggregate_by_subagent(&result.attributions); - assert_eq!(subagents.len(), 1); - assert_eq!(subagents[0].subagent_type, "general-purpose"); - assert_eq!(subagents[0].call_count, 1); - assert!(subagents[0].total_cost > 0.0); - } + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_a1", + &"z".repeat(8000), + "2026-04-20T00:00:00.100Z", + )], + ); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let subagents = aggregate_by_subagent(&result.attributions); + assert_eq!(subagents.len(), 1); + assert_eq!(subagents[0].subagent_type, "general-purpose"); + assert_eq!(subagents[0].call_count, 1); + assert!(subagents[0].total_cost > 0.0); +} - fn mcp_attribution(tool_name: &str, total_cost: f64, riding_turns: u64) -> ToolAttribution { - ToolAttribution { - tool_use_id: format!("tu-{tool_name}"), - tool_name: tool_name.into(), - target: None, - args_hash: format!("{tool_name}:0"), - session_id: "s-mcp".into(), - emit_turn_index: 0, - emit_ts: "2026-04-20T00:00:00.000Z".into(), - model: "claude-sonnet-4-6".into(), - project: None, - project_key: None, - subagent_type: None, - result_tokens: 0, - result_bytes_estimated: true, - initial_cost: total_cost, - initial_tokens: total_cost * 100.0, - persistence_cost: 0.0, - persistence_tokens: total_cost * 50.0, - riding_turns, - total_cost, - output_bytes: None, - output_truncated: None, - } +fn mcp_attribution(tool_name: &str, total_cost: f64, riding_turns: u64) -> ToolAttribution { + ToolAttribution { + tool_use_id: format!("tu-{tool_name}"), + tool_name: tool_name.into(), + target: None, + args_hash: format!("{tool_name}:0"), + session_id: "s-mcp".into(), + emit_turn_index: 0, + emit_ts: "2026-04-20T00:00:00.000Z".into(), + model: "claude-sonnet-4-6".into(), + project: None, + project_key: None, + subagent_type: None, + result_tokens: 0, + result_bytes_estimated: true, + initial_cost: total_cost, + initial_tokens: total_cost * 100.0, + persistence_cost: 0.0, + persistence_tokens: total_cost * 50.0, + riding_turns, + total_cost, + output_bytes: None, + output_truncated: None, } +} - #[test] - fn aggregates_by_mcp_server_groups_by_server_segment_and_sorts_by_cost() { - // Two MCP servers + a non-MCP tool + a malformed mcp__ name. The - // non-MCP + malformed rows must NOT show up; the relaycast roll-up - // must collapse all three relaycast tools into a single row with - // top_tools sorted by cost desc. - let attrs = vec![ - mcp_attribution("mcp__relaycast__send_dm", 2.0, 1), - mcp_attribution("mcp__relaycast__send_dm", 1.5, 0), - mcp_attribution("mcp__relaycast__list_channels", 0.5, 0), - mcp_attribution("mcp__relaycast__react_to_message", 0.25, 0), - mcp_attribution("mcp__github__get_file_contents", 1.0, 2), - mcp_attribution("mcp__github__create_pull_request", 0.1, 0), - // Non-MCP — must be skipped. - mcp_attribution("Read", 99.0, 5), - // Malformed: missing tool segment. - mcp_attribution("mcp__only_server__", 50.0, 0), - // Malformed: missing server segment. - mcp_attribution("mcp____tool_only", 50.0, 0), - // Malformed: not enough separators. - mcp_attribution("mcp__no_double_separator", 50.0, 0), - ]; +#[test] +fn aggregates_by_mcp_server_groups_by_server_segment_and_sorts_by_cost() { + // Two MCP servers + a non-MCP tool + a malformed mcp__ name. The + // non-MCP + malformed rows must NOT show up; the relaycast roll-up + // must collapse all three relaycast tools into a single row with + // top_tools sorted by cost desc. + let attrs = vec![ + mcp_attribution("mcp__relaycast__send_dm", 2.0, 1), + mcp_attribution("mcp__relaycast__send_dm", 1.5, 0), + mcp_attribution("mcp__relaycast__list_channels", 0.5, 0), + mcp_attribution("mcp__relaycast__react_to_message", 0.25, 0), + mcp_attribution("mcp__github__get_file_contents", 1.0, 2), + mcp_attribution("mcp__github__create_pull_request", 0.1, 0), + // Non-MCP — must be skipped. + mcp_attribution("Read", 99.0, 5), + // Malformed: missing tool segment. + mcp_attribution("mcp__only_server__", 50.0, 0), + // Malformed: missing server segment. + mcp_attribution("mcp____tool_only", 50.0, 0), + // Malformed: not enough separators. + mcp_attribution("mcp__no_double_separator", 50.0, 0), + ]; - let rows = aggregate_by_mcp_server(&attrs); - assert_eq!( - rows.len(), - 2, - "only the two well-formed mcp__ servers should aggregate" - ); + let rows = aggregate_by_mcp_server(&attrs); + assert_eq!( + rows.len(), + 2, + "only the two well-formed mcp__ servers should aggregate" + ); - // relaycast wins on cumulative cost (2.0 + 1.5 + 0.5 + 0.25 = 4.25) - // vs github (1.0 + 0.1 = 1.1). - let relaycast = &rows[0]; - assert_eq!(relaycast.server, "relaycast"); - assert_eq!(relaycast.call_count, 4); - assert!((relaycast.total_cost - 4.25).abs() < 1e-9); - assert!((relaycast.initial_tokens - 4.25 * 100.0).abs() < 1e-9); - assert!((relaycast.persistence_tokens - 4.25 * 50.0).abs() < 1e-9); - assert_eq!(relaycast.riding_turns, 1); - assert_eq!( - relaycast.top_tools, - vec!["send_dm", "list_channels", "react_to_message"], - ); + // relaycast wins on cumulative cost (2.0 + 1.5 + 0.5 + 0.25 = 4.25) + // vs github (1.0 + 0.1 = 1.1). + let relaycast = &rows[0]; + assert_eq!(relaycast.server, "relaycast"); + assert_eq!(relaycast.call_count, 4); + assert!((relaycast.total_cost - 4.25).abs() < 1e-9); + assert!((relaycast.initial_tokens - 4.25 * 100.0).abs() < 1e-9); + assert!((relaycast.persistence_tokens - 4.25 * 50.0).abs() < 1e-9); + assert_eq!(relaycast.riding_turns, 1); + assert_eq!( + relaycast.top_tools, + vec!["send_dm", "list_channels", "react_to_message"], + ); - let github = &rows[1]; - assert_eq!(github.server, "github"); - assert_eq!(github.call_count, 2); - assert!((github.total_cost - 1.1).abs() < 1e-9); - assert_eq!(github.riding_turns, 2); - assert_eq!( - github.top_tools, - vec!["get_file_contents", "create_pull_request"], - ); - } - - #[test] - fn falls_back_to_even_split_when_no_content_is_provided() { - let pricing = load_builtin_pricing(); - let session_id = "s-fallback"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_x", "Read", Some("/a.ts")), - tc("tu_y", "Read", Some("/b.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 4000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; + let github = &rows[1]; + assert_eq!(github.server, "github"); + assert_eq!(github.call_count, 2); + assert!((github.total_cost - 1.1).abs() < 1e-9); + assert_eq!(github.riding_turns, 2); + assert_eq!( + github.top_tools, + vec!["get_file_contents", "create_pull_request"], + ); +} - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: None, - user_turns_by_session: None, - tool_result_events_by_session: None, +#[test] +fn falls_back_to_even_split_when_no_content_is_provided() { + let pricing = load_builtin_pricing(); + let session_id = "s-fallback"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc("tu_x", "Read", Some("/a.ts")), + tc("tu_y", "Read", Some("/b.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 4000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, }, - ); - assert_eq!(result.attributions.len(), 2); - let rate = pricing.get("claude-sonnet-4-6").unwrap(); - let expected = ((4000.0 / 1_000_000.0) * rate.input) / 2.0; - for a in &result.attributions { - assert!((a.initial_cost - expected).abs() < 1e-9); - assert_eq!(a.persistence_cost, 0.0); - } - assert_eq!( - result.session_totals[0].attribution_method, - AttributionMethod::EvenSplit - ); - } - - #[test] - fn uses_user_turn_block_sizes_when_content_sidecar_is_unavailable() { - let pricing = load_builtin_pricing(); - let session_id = "s-user-turn-fallback"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_big", "Read", Some("/big.ts")), - tc("tu_small", "Read", Some("/small.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 4000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-2", - 2, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 100, - output: 5, - reasoning: 0, - cache_read: 4500, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; + vec![], + SourceKind::ClaudeCode, + ), + ]; - let mut user_turns_by_session: HashMap> = HashMap::new(); - user_turns_by_session.insert( - session_id.into(), - vec![user_turn( - session_id, - "u-1", - vec![ - tool_result_block("tu_big", 12_000, 3000), - tool_result_block("tu_small", 4000, 1000), - ], - )], - ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: None, + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + assert_eq!(result.attributions.len(), 2); + let rate = pricing.get("claude-sonnet-4-6").unwrap(); + let expected = ((4000.0 / 1_000_000.0) * rate.input) / 2.0; + for a in &result.attributions { + assert!((a.initial_cost - expected).abs() < 1e-9); + assert_eq!(a.persistence_cost, 0.0); + } + assert_eq!( + result.session_totals[0].attribution_method, + AttributionMethod::EvenSplit + ); +} - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: None, - user_turns_by_session: Some(&user_turns_by_session), - tool_result_events_by_session: None, +#[test] +fn uses_user_turn_block_sizes_when_content_sidecar_is_unavailable() { + let pricing = load_builtin_pricing(); + let session_id = "s-user-turn-fallback"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc("tu_big", "Read", Some("/big.ts")), + tc("tu_small", "Read", Some("/small.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 4000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, }, - ); - let by_id: HashMap<&str, &ToolAttribution> = result - .attributions - .iter() - .map(|a| (a.tool_use_id.as_str(), a)) - .collect(); - assert_eq!( - result.session_totals[0].attribution_method, - AttributionMethod::Sized - ); - assert!((by_id["tu_big"].initial_tokens - 3000.0).abs() < 1e-9); - assert!((by_id["tu_small"].initial_tokens - 1000.0).abs() < 1e-9); - assert!((by_id["tu_big"].persistence_tokens - 3000.0).abs() < 1e-9); - assert!((by_id["tu_small"].persistence_tokens - 1000.0).abs() < 1e-9); - assert!(by_id["tu_big"].total_cost > by_id["tu_small"].total_cost); - } + vec![], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-2", + 2, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 100, + output: 5, + reasoning: 0, + cache_read: 4500, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; - #[test] - fn prefers_user_turn_block_sizes_over_content_sidecar_estimates() { - let pricing = load_builtin_pricing(); - let session_id = "s-sidecar-primary"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![tc("tu_read", "Read", Some("/file.ts"))], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 10_000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( - session_id, - "tu_read", - &"x".repeat(1000 * 4), - "2026-04-20T00:00:00.100Z", - )], - ); - let mut user_turns_by_session: HashMap> = HashMap::new(); - user_turns_by_session.insert( - session_id.into(), - vec![user_turn( - session_id, - "u-1", - vec![tool_result_block("tu_read", 36_000, 9000)], - )], - ); + let mut user_turns_by_session: HashMap> = HashMap::new(); + user_turns_by_session.insert( + session_id.into(), + vec![user_turn( + session_id, + "u-1", + vec![ + tool_result_block("tu_big", 12_000, 3000), + tool_result_block("tu_small", 4000, 1000), + ], + )], + ); + + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: None, + user_turns_by_session: Some(&user_turns_by_session), + tool_result_events_by_session: None, + }, + ); + let by_id: HashMap<&str, &ToolAttribution> = result + .attributions + .iter() + .map(|a| (a.tool_use_id.as_str(), a)) + .collect(); + assert_eq!( + result.session_totals[0].attribution_method, + AttributionMethod::Sized + ); + assert!((by_id["tu_big"].initial_tokens - 3000.0).abs() < 1e-9); + assert!((by_id["tu_small"].initial_tokens - 1000.0).abs() < 1e-9); + assert!((by_id["tu_big"].persistence_tokens - 3000.0).abs() < 1e-9); + assert!((by_id["tu_small"].persistence_tokens - 1000.0).abs() < 1e-9); + assert!(by_id["tu_big"].total_cost > by_id["tu_small"].total_cost); +} - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: Some(&user_turns_by_session), - tool_result_events_by_session: None, +#[test] +fn prefers_user_turn_block_sizes_over_content_sidecar_estimates() { + let pricing = load_builtin_pricing(); + let session_id = "s-sidecar-primary"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![tc("tu_read", "Read", Some("/file.ts"))], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 10_000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, }, - ); - assert_eq!( - result.session_totals[0].attribution_method, - AttributionMethod::Sized - ); - assert!((result.attributions[0].initial_tokens - 9000.0).abs() < 1e-9); - } + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_read", + &"x".repeat(1000 * 4), + "2026-04-20T00:00:00.100Z", + )], + ); + let mut user_turns_by_session: HashMap> = HashMap::new(); + user_turns_by_session.insert( + session_id.into(), + vec![user_turn( + session_id, + "u-1", + vec![tool_result_block("tu_read", 36_000, 9000)], + )], + ); - #[test] - fn caps_sibling_initial_cost_at_next_turns_actual_new_content() { - let pricing = load_builtin_pricing(); - let session_id = "s-cap"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_big", "Read", Some("/big.ts")), - tc("tu_med", "Read", Some("/med.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 5000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: Some(&user_turns_by_session), + tool_result_events_by_session: None, + }, + ); + assert_eq!( + result.session_totals[0].attribution_method, + AttributionMethod::Sized + ); + assert!((result.attributions[0].initial_tokens - 9000.0).abs() < 1e-9); +} + +#[test] +fn caps_sibling_initial_cost_at_next_turns_actual_new_content() { + let pricing = load_builtin_pricing(); + let session_id = "s-cap"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), vec![ - tool_result_content( - session_id, - "tu_big", - &"x".repeat(6000 * 4), - "2026-04-20T00:00:00.100Z", - ), - tool_result_content( - session_id, - "tu_med", - &"y".repeat(4000 * 4), - "2026-04-20T00:00:00.101Z", - ), + tc("tu_big", "Read", Some("/big.ts")), + tc("tu_med", "Read", Some("/med.ts")), ], - ); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 5000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, }, - ); - let summed: f64 = result.attributions.iter().map(|a| a.initial_tokens).sum(); - assert!(summed <= 5000.0 + 1e-6, "summed={summed} > newContent=5000"); - let big = result - .attributions - .iter() - .find(|a| a.tool_use_id == "tu_big") - .unwrap(); - let med = result - .attributions - .iter() - .find(|a| a.tool_use_id == "tu_med") - .unwrap(); - assert!((big.initial_tokens - 3000.0).abs() < 1e-6); - assert!((med.initial_tokens - 2000.0).abs() < 1e-6); - } - - #[test] - fn caps_sibling_persistence_at_turns_actual_cache_read() { - let pricing = load_builtin_pricing(); - let session_id = "s-persist-cap"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc("tu_a", "Read", Some("/a.ts")), - tc("tu_b", "Read", Some("/b.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![ + tool_result_content( session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 8000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, + "tu_big", + &"x".repeat(6000 * 4), + "2026-04-20T00:00:00.100Z", ), - turn( + tool_result_content( session_id, - "msg-2", - 2, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 50, - output: 5, - reasoning: 0, - cache_read: 5000, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, + "tu_med", + &"y".repeat(4000 * 4), + "2026-04-20T00:00:00.101Z", ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), + ], + ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let summed: f64 = result.attributions.iter().map(|a| a.initial_tokens).sum(); + assert!(summed <= 5000.0 + 1e-6, "summed={summed} > newContent=5000"); + let big = result + .attributions + .iter() + .find(|a| a.tool_use_id == "tu_big") + .unwrap(); + let med = result + .attributions + .iter() + .find(|a| a.tool_use_id == "tu_med") + .unwrap(); + assert!((big.initial_tokens - 3000.0).abs() < 1e-6); + assert!((med.initial_tokens - 2000.0).abs() < 1e-6); +} + +#[test] +fn caps_sibling_persistence_at_turns_actual_cache_read() { + let pricing = load_builtin_pricing(); + let session_id = "s-persist-cap"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), vec![ - tool_result_content( - session_id, - "tu_a", - &"x".repeat(4000 * 4), - "2026-04-20T00:00:00.100Z", - ), - tool_result_content( - session_id, - "tu_b", - &"y".repeat(4000 * 4), - "2026-04-20T00:00:00.101Z", - ), + tc("tu_a", "Read", Some("/a.ts")), + tc("tu_b", "Read", Some("/b.ts")), ], - ); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 8000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, }, - ); - let summed_persist: f64 = result - .attributions - .iter() - .map(|a| a.persistence_tokens) - .sum(); - assert!( - summed_persist <= 5000.0 + 1e-6, - "summedPersist={summed_persist} > cacheRead=5000" - ); - for a in &result.attributions { - assert!((a.persistence_tokens - 2500.0).abs() < 1e-6); - } - } - - #[test] - fn uses_paying_turns_model_rate_not_emit_turns() { - let pricing = load_builtin_pricing(); - let sonnet = pricing.get("claude-sonnet-4-6").unwrap().clone(); - let haiku = pricing.get("claude-haiku-4-5").unwrap().clone(); - assert_ne!(haiku.input, sonnet.input, "test prerequisite: rates differ"); - - let session_id = "s-cross-model"; - const TOK: u64 = 4000; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![tc("tu_x", "Read", Some("/x.ts"))], - SourceKind::ClaudeCode, - ), - turn( + vec![], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-2", + 2, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 50, + output: 5, + reasoning: 0, + cache_read: 5000, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![ + tool_result_content( session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-haiku-4-5", - Usage { - input: TOK, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, + "tu_a", + &"x".repeat(4000 * 4), + "2026-04-20T00:00:00.100Z", ), - turn( + tool_result_content( session_id, - "msg-2", - 2, - "2026-04-20T00:00:00.000Z", - "claude-haiku-4-5", - Usage { - input: 50, - output: 5, - reasoning: 0, - cache_read: TOK + 100, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, + "tu_b", + &"y".repeat(4000 * 4), + "2026-04-20T00:00:00.101Z", ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( - session_id, - "tu_x", - &"z".repeat((TOK as usize) * 4), - "2026-04-20T00:00:00.100Z", - )], - ); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, - }, - ); - let a = &result.attributions[0]; - let expected_initial = (TOK as f64 / 1_000_000.0) * haiku.input; - let expected_persistence = (TOK as f64 / 1_000_000.0) * haiku.cache_read; - assert!( - (a.initial_cost - expected_initial).abs() < 1e-9, - "initial_cost={} expected={}", - a.initial_cost, - expected_initial - ); - assert!( - (a.persistence_cost - expected_persistence).abs() < 1e-9, - "persistence_cost={} expected={}", - a.persistence_cost, - expected_persistence - ); + ], + ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let summed_persist: f64 = result + .attributions + .iter() + .map(|a| a.persistence_tokens) + .sum(); + assert!( + summed_persist <= 5000.0 + 1e-6, + "summedPersist={summed_persist} > cacheRead=5000" + ); + for a in &result.attributions { + assert!((a.persistence_tokens - 2500.0).abs() < 1e-6); } +} + +#[test] +fn uses_paying_turns_model_rate_not_emit_turns() { + let pricing = load_builtin_pricing(); + let sonnet = pricing.get("claude-sonnet-4-6").unwrap().clone(); + let haiku = pricing.get("claude-haiku-4-5").unwrap().clone(); + assert_ne!(haiku.input, sonnet.input, "test prerequisite: rates differ"); - #[test] - fn session_grand_honors_source_aware_reasoning_for_codex() { - // Regression: hotspots must use `cost_for_turn` so its `session_grand` - // inherits Codex's `included_in_output` reasoning semantics. Otherwise - // it overstates by `reasoning × output_rate` and drifts away from the - // canonical `cost.rs` totals. - let pricing = load_builtin_pricing(); - let codex_model = if pricing.contains_key("gpt-5-codex") { - "gpt-5-codex" - } else { - "claude-sonnet-4-6" - }; - let session_id = "s-codex-reasoning"; - let turns = vec![turn( + let session_id = "s-cross-model"; + const TOK: u64 = 4000; + let turns = vec![ + turn( session_id, "msg-0", 0, "2026-04-20T00:00:00.000Z", - codex_model, + "claude-sonnet-4-6", + empty_usage(), + vec![tc("tu_x", "Read", Some("/x.ts"))], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-haiku-4-5", Usage { - input: 1000, - // Codex `output_tokens` already includes reasoning. - output: 500, - reasoning: 200, + input: TOK, + output: 5, + reasoning: 0, cache_read: 0, cache_create_5m: 0, cache_create_1h: 0, }, vec![], - SourceKind::Codex, - )]; - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: None, - user_turns_by_session: None, - tool_result_events_by_session: None, + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-2", + 2, + "2026-04-20T00:00:00.000Z", + "claude-haiku-4-5", + Usage { + input: 50, + output: 5, + reasoning: 0, + cache_read: TOK + 100, + cache_create_5m: 0, + cache_create_1h: 0, }, - ); + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_x", + &"z".repeat((TOK as usize) * 4), + "2026-04-20T00:00:00.100Z", + )], + ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + let a = &result.attributions[0]; + let expected_initial = (TOK as f64 / 1_000_000.0) * haiku.input; + let expected_persistence = (TOK as f64 / 1_000_000.0) * haiku.cache_read; + assert!( + (a.initial_cost - expected_initial).abs() < 1e-9, + "initial_cost={} expected={}", + a.initial_cost, + expected_initial + ); + assert!( + (a.persistence_cost - expected_persistence).abs() < 1e-9, + "persistence_cost={} expected={}", + a.persistence_cost, + expected_persistence + ); +} - let rate = pricing.get(codex_model).unwrap(); - let expected = (1000.0 / 1_000_000.0) * rate.input + (500.0 / 1_000_000.0) * rate.output; - assert!( - (result.grand_total - expected).abs() < 1e-9, - "Codex sessionGrand should not bill reasoning at output rate: got={} expected={}", - result.grand_total, - expected - ); - } +#[test] +fn session_grand_honors_source_aware_reasoning_for_codex() { + // Regression: hotspots must use `cost_for_turn` so its `session_grand` + // inherits Codex's `included_in_output` reasoning semantics. Otherwise + // it overstates by `reasoning × output_rate` and drifts away from the + // canonical `cost.rs` totals. + let pricing = load_builtin_pricing(); + let codex_model = if pricing.contains_key("gpt-5-codex") { + "gpt-5-codex" + } else { + "claude-sonnet-4-6" + }; + let session_id = "s-codex-reasoning"; + let turns = vec![turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + codex_model, + Usage { + input: 1000, + // Codex `output_tokens` already includes reasoning. + output: 500, + reasoning: 200, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::Codex, + )]; + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: None, + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); - #[test] - fn grand_total_plus_unattributed_equals_session_grand_within_rounding() { - let pricing = load_builtin_pricing(); - let session_id = "s-totals"; - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 100, - output: 50, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![tc("tu_z", "Read", Some("/z.ts"))], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-04-20T00:00:00.000Z", - "claude-sonnet-4-6", - Usage { - input: 2000, - output: 30, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; - let mut content_by_session: HashMap> = HashMap::new(); - content_by_session.insert( - session_id.into(), - vec![tool_result_content( - session_id, - "tu_z", - &"q".repeat(2000 * 4), - "2026-04-20T00:00:00.500Z", - )], - ); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: Some(&content_by_session), - user_turns_by_session: None, - tool_result_events_by_session: None, + let rate = pricing.get(codex_model).unwrap(); + let expected = (1000.0 / 1_000_000.0) * rate.input + (500.0 / 1_000_000.0) * rate.output; + assert!( + (result.grand_total - expected).abs() < 1e-9, + "Codex sessionGrand should not bill reasoning at output rate: got={} expected={}", + result.grand_total, + expected + ); +} + +#[test] +fn grand_total_plus_unattributed_equals_session_grand_within_rounding() { + let pricing = load_builtin_pricing(); + let session_id = "s-totals"; + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 100, + output: 50, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, }, - ); - assert!( - (result.attributed_total + result.unattributed_total - result.grand_total).abs() < 1e-9 - ); - } + vec![tc("tu_z", "Read", Some("/z.ts"))], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-04-20T00:00:00.000Z", + "claude-sonnet-4-6", + Usage { + input: 2000, + output: 30, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; + let mut content_by_session: HashMap> = HashMap::new(); + content_by_session.insert( + session_id.into(), + vec![tool_result_content( + session_id, + "tu_z", + &"q".repeat(2000 * 4), + "2026-04-20T00:00:00.500Z", + )], + ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: Some(&content_by_session), + user_turns_by_session: None, + tool_result_events_by_session: None, + }, + ); + assert!( + (result.attributed_total + result.unattributed_total - result.grand_total).abs() < 1e-9 + ); +} - #[test] - fn attribution_method_serializes_to_kebab_case() { - // The CLI/MCP presenters round-trip these enums through JSON, so the - // wire format must match the TS string union ('sized' | 'even-split'). - assert_eq!( - serde_json::to_string(&AttributionMethod::Sized).unwrap(), - "\"sized\"" - ); - assert_eq!( - serde_json::to_string(&AttributionMethod::EvenSplit).unwrap(), - "\"even-split\"" - ); - } +#[test] +fn attribution_method_serializes_to_kebab_case() { + // The CLI/MCP presenters round-trip these enums through JSON, so the + // wire format must match the TS string union ('sized' | 'even-split'). + assert_eq!( + serde_json::to_string(&AttributionMethod::Sized).unwrap(), + "\"sized\"" + ); + assert_eq!( + serde_json::to_string(&AttributionMethod::EvenSplit).unwrap(), + "\"even-split\"" + ); +} - /// Regression for #436: a 1 MB Bash result that gets truncated to a - /// small token count must rank above a small-bytes / large-tokens - /// Read when sorted by `total_output_bytes`. The bash row also has - /// to flag `truncated_count > 0` from the propagated - /// `output_truncated`. - #[test] - fn aggregations_track_output_bytes_so_byte_ranking_inverts_token_ranking() { - use crate::reader::{ToolResultEventRecord, ToolResultEventSource, ToolResultStatus}; +/// Regression for #436: a 1 MB Bash result that gets truncated to a +/// small token count must rank above a small-bytes / large-tokens +/// Read when sorted by `total_output_bytes`. The bash row also has +/// to flag `truncated_count > 0` from the propagated +/// `output_truncated`. +#[test] +fn aggregations_track_output_bytes_so_byte_ranking_inverts_token_ranking() { + use crate::reader::{ToolResultEventRecord, ToolResultEventSource, ToolResultStatus}; - let pricing = load_builtin_pricing(); - let session_id = "s-bytes"; + let pricing = load_builtin_pricing(); + let session_id = "s-bytes"; - // Emit a Bash call and a Read call on turn 0. Turn 1 pays for - // both. The Bash payload is 1 MB raw bytes but the user-turn - // block reports a small post-truncation token count; the Read - // payload is tiny but the user-turn block reports a large token - // count. Token-sort puts Read first; byte-sort must put Bash - // first. - let turns = vec![ - turn( - session_id, - "msg-0", - 0, - "2026-05-25T00:00:00.000Z", - "claude-sonnet-4-6", - empty_usage(), - vec![ - tc_with_hash("tu_bash", "Bash", "find / -name foo", "Bash:find"), - tc("tu_read", "Read", Some("/big.ts")), - ], - SourceKind::ClaudeCode, - ), - turn( - session_id, - "msg-1", - 1, - "2026-05-25T00:00:01.000Z", - "claude-sonnet-4-6", - Usage { - input: 5000, - output: 5, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - vec![], - SourceKind::ClaudeCode, - ), - ]; + // Emit a Bash call and a Read call on turn 0. Turn 1 pays for + // both. The Bash payload is 1 MB raw bytes but the user-turn + // block reports a small post-truncation token count; the Read + // payload is tiny but the user-turn block reports a large token + // count. Token-sort puts Read first; byte-sort must put Bash + // first. + let turns = vec![ + turn( + session_id, + "msg-0", + 0, + "2026-05-25T00:00:00.000Z", + "claude-sonnet-4-6", + empty_usage(), + vec![ + tc_with_hash("tu_bash", "Bash", "find / -name foo", "Bash:find"), + tc("tu_read", "Read", Some("/big.ts")), + ], + SourceKind::ClaudeCode, + ), + turn( + session_id, + "msg-1", + 1, + "2026-05-25T00:00:01.000Z", + "claude-sonnet-4-6", + Usage { + input: 5000, + output: 5, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + vec![], + SourceKind::ClaudeCode, + ), + ]; - // User-turn block sizes drive the token ranking: Read is "big" - // in tokens (4000), Bash is "small" in tokens (200) because - // Claude truncated it before tokenizing. - let mut user_turns_by_session: HashMap> = HashMap::new(); - user_turns_by_session.insert( - session_id.into(), - vec![user_turn( - session_id, - "u-1", - vec![ - tool_result_block("tu_bash", 800, 200), - tool_result_block("tu_read", 16_000, 4000), - ], - )], - ); + // User-turn block sizes drive the token ranking: Read is "big" + // in tokens (4000), Bash is "small" in tokens (200) because + // Claude truncated it before tokenizing. + let mut user_turns_by_session: HashMap> = HashMap::new(); + user_turns_by_session.insert( + session_id.into(), + vec![user_turn( + session_id, + "u-1", + vec![ + tool_result_block("tu_bash", 800, 200), + tool_result_block("tu_read", 16_000, 4000), + ], + )], + ); - // Tool-result event payload sizes drive the byte ranking: Bash - // is 1 MB (pre-token-truncation raw stdout), Read is 1 KB. - const BASH_BYTES: u64 = 1_000_000; - const READ_BYTES: u64 = 1_000; - let bash_event = ToolResultEventRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.into(), - message_id: Some("msg-0".into()), - tool_use_id: "tu_bash".into(), - call_index: Some(0), - event_index: 0, - ts: Some("2026-05-25T00:00:00.500Z".into()), - status: ToolResultStatus::Completed, - event_source: ToolResultEventSource::ToolResult, - content_length: Some(BASH_BYTES), - output_bytes: Some(BASH_BYTES), - output_truncated: Some(true), - content_hash: None, - is_error: None, - usage: None, - usage_attribution: None, - subagent_session_id: None, - agent_id: None, - replaced_tools: None, - collapsed_calls: None, - }; - let read_event = ToolResultEventRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.into(), - message_id: Some("msg-0".into()), - tool_use_id: "tu_read".into(), - call_index: Some(0), - event_index: 1, - ts: Some("2026-05-25T00:00:00.500Z".into()), - status: ToolResultStatus::Completed, - event_source: ToolResultEventSource::ToolResult, - content_length: Some(READ_BYTES), - output_bytes: Some(READ_BYTES), - output_truncated: Some(false), - content_hash: None, - is_error: None, - usage: None, - usage_attribution: None, - subagent_session_id: None, - agent_id: None, - replaced_tools: None, - collapsed_calls: None, - }; - let mut events_by_session: HashMap> = HashMap::new(); - events_by_session.insert(session_id.into(), vec![bash_event, read_event]); + // Tool-result event payload sizes drive the byte ranking: Bash + // is 1 MB (pre-token-truncation raw stdout), Read is 1 KB. + const BASH_BYTES: u64 = 1_000_000; + const READ_BYTES: u64 = 1_000; + let bash_event = ToolResultEventRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.into(), + message_id: Some("msg-0".into()), + tool_use_id: "tu_bash".into(), + call_index: Some(0), + event_index: 0, + ts: Some("2026-05-25T00:00:00.500Z".into()), + status: ToolResultStatus::Completed, + event_source: ToolResultEventSource::ToolResult, + content_length: Some(BASH_BYTES), + output_bytes: Some(BASH_BYTES), + output_truncated: Some(true), + content_hash: None, + is_error: None, + usage: None, + usage_attribution: None, + subagent_session_id: None, + agent_id: None, + replaced_tools: None, + collapsed_calls: None, + }; + let read_event = ToolResultEventRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.into(), + message_id: Some("msg-0".into()), + tool_use_id: "tu_read".into(), + call_index: Some(0), + event_index: 1, + ts: Some("2026-05-25T00:00:00.500Z".into()), + status: ToolResultStatus::Completed, + event_source: ToolResultEventSource::ToolResult, + content_length: Some(READ_BYTES), + output_bytes: Some(READ_BYTES), + output_truncated: Some(false), + content_hash: None, + is_error: None, + usage: None, + usage_attribution: None, + subagent_session_id: None, + agent_id: None, + replaced_tools: None, + collapsed_calls: None, + }; + let mut events_by_session: HashMap> = HashMap::new(); + events_by_session.insert(session_id.into(), vec![bash_event, read_event]); - let result = attribute_hotspots( - &turns, - &HotspotsOptions { - pricing: &pricing, - content_by_session: None, - user_turns_by_session: Some(&user_turns_by_session), - tool_result_events_by_session: Some(&events_by_session), - }, - ); + let result = attribute_hotspots( + &turns, + &HotspotsOptions { + pricing: &pricing, + content_by_session: None, + user_turns_by_session: Some(&user_turns_by_session), + tool_result_events_by_session: Some(&events_by_session), + }, + ); - // Sanity: bytes / truncation rode through to ToolAttribution. - let by_id: HashMap<&str, &ToolAttribution> = result - .attributions - .iter() - .map(|a| (a.tool_use_id.as_str(), a)) - .collect(); - assert_eq!(by_id["tu_bash"].output_bytes, Some(BASH_BYTES)); - assert_eq!(by_id["tu_bash"].output_truncated, Some(true)); - assert_eq!(by_id["tu_read"].output_bytes, Some(READ_BYTES)); - assert_eq!(by_id["tu_read"].output_truncated, Some(false)); + // Sanity: bytes / truncation rode through to ToolAttribution. + let by_id: HashMap<&str, &ToolAttribution> = result + .attributions + .iter() + .map(|a| (a.tool_use_id.as_str(), a)) + .collect(); + assert_eq!(by_id["tu_bash"].output_bytes, Some(BASH_BYTES)); + assert_eq!(by_id["tu_bash"].output_truncated, Some(true)); + assert_eq!(by_id["tu_read"].output_bytes, Some(READ_BYTES)); + assert_eq!(by_id["tu_read"].output_truncated, Some(false)); - // Token-driven cost ranks Read first (4000 tok > 200 tok). - let files = aggregate_by_file(&result.attributions); - assert_eq!(files.len(), 1, "Read is the only file-touching tool"); - let bash = aggregate_by_bash(&result.attributions); - assert_eq!(bash.len(), 1); - let read_file = &files[0]; - let bash_row = &bash[0]; - // The Read row out-costs the Bash row (sized attribution). - assert!( - read_file.total_cost > bash_row.total_cost, - "expected Read cost > Bash cost in token-sized attribution; got read={} bash={}", - read_file.total_cost, - bash_row.total_cost, - ); + // Token-driven cost ranks Read first (4000 tok > 200 tok). + let files = aggregate_by_file(&result.attributions); + assert_eq!(files.len(), 1, "Read is the only file-touching tool"); + let bash = aggregate_by_bash(&result.attributions); + assert_eq!(bash.len(), 1); + let read_file = &files[0]; + let bash_row = &bash[0]; + // The Read row out-costs the Bash row (sized attribution). + assert!( + read_file.total_cost > bash_row.total_cost, + "expected Read cost > Bash cost in token-sized attribution; got read={} bash={}", + read_file.total_cost, + bash_row.total_cost, + ); - // Bytes plumbing populated on both aggregations. - assert_eq!(read_file.total_output_bytes, READ_BYTES); - assert_eq!(read_file.max_output_bytes, READ_BYTES); - assert_eq!(read_file.truncated_count, 0); - assert_eq!(bash_row.total_output_bytes, BASH_BYTES); - assert_eq!(bash_row.max_output_bytes, BASH_BYTES); - assert_eq!(bash_row.truncated_count, 1); + // Bytes plumbing populated on both aggregations. + assert_eq!(read_file.total_output_bytes, READ_BYTES); + assert_eq!(read_file.max_output_bytes, READ_BYTES); + assert_eq!(read_file.truncated_count, 0); + assert_eq!(bash_row.total_output_bytes, BASH_BYTES); + assert_eq!(bash_row.max_output_bytes, BASH_BYTES); + assert_eq!(bash_row.truncated_count, 1); - // Byte ranking inverts the cost ranking: Bash should win when - // we sort by total_output_bytes. The SDK's default sort is by - // cost; we just confirm the underlying counter inverts. - assert!( - bash_row.total_output_bytes > read_file.total_output_bytes, - "byte ranking should put Bash (1 MB) ahead of Read (1 KB)" - ); - } + // Byte ranking inverts the cost ranking: Bash should win when + // we sort by total_output_bytes. The SDK's default sort is by + // cost; we just confirm the underlying counter inverts. + assert!( + bash_row.total_output_bytes > read_file.total_output_bytes, + "byte ranking should put Bash (1 MB) ahead of Read (1 KB)" + ); +} diff --git a/crates/relayburn-sdk/src/analyze/patterns.rs b/crates/relayburn-sdk/src/analyze/patterns.rs index 7c6b28e..644961a 100644 --- a/crates/relayburn-sdk/src/analyze/patterns.rs +++ b/crates/relayburn-sdk/src/analyze/patterns.rs @@ -28,7 +28,7 @@ use crate::analyze::findings::{ SkillRecallDup, SystemPromptTax, }; use crate::analyze::pricing::PricingTable; -use crate::analyze::util::{group_turns_by_session, stringify_tool_result, truncate_chars}; +use crate::analyze::util::{group_turns_by_session_sorted, stringify_tool_result, truncate_chars}; mod shell; @@ -140,7 +140,7 @@ impl<'a> DetectPatternsOptions<'a> { /// Run every detector across the supplied turn stream. Mirrors the TS /// `detectPatterns` orchestrator (patterns.ts:273-345). pub fn detect_patterns(turns: &[TurnRecord], opts: &DetectPatternsOptions<'_>) -> PatternsResult { - let by_session = group_turns_by_session(turns); + let by_session = group_turns_by_session_sorted(turns); let events_by_session = group_tool_result_events_by_session(opts.tool_result_events); let mut retry_loops: Vec = Vec::new(); @@ -154,10 +154,7 @@ pub fn detect_patterns(turns: &[TurnRecord], opts: &DetectPatternsOptions<'_>) - // Iterate sessions in insertion (= first-seen) order so output ordering // matches the TS `Map` iteration contract. - for (session_id, mut session_turns) in by_session { - // TS sorts each per-session bucket by turn_index in place. Mirror that. - session_turns.sort_by_key(|t| t.turn_index); - + for (session_id, session_turns) in by_session { let content_index = build_content_index( opts.content_by_session .and_then(|m| m.get(&session_id)) diff --git a/crates/relayburn-sdk/src/analyze/patterns/streaks.rs b/crates/relayburn-sdk/src/analyze/patterns/streaks.rs index 51a58f1..806d959 100644 --- a/crates/relayburn-sdk/src/analyze/patterns/streaks.rs +++ b/crates/relayburn-sdk/src/analyze/patterns/streaks.rs @@ -2,13 +2,9 @@ use super::*; use std::collections::HashSet; -use crate::reader::{ - ToolResultEventRecord, ToolResultEventSource, ToolResultStatus, TurnRecord, -}; +use crate::reader::{ToolResultEventRecord, ToolResultEventSource, ToolResultStatus, TurnRecord}; -use crate::analyze::findings::{ - CancellationRun, FailureRun, FailureRunErrorSignature, RetryLoop, -}; +use crate::analyze::findings::{CancellationRun, FailureRun, FailureRunErrorSignature, RetryLoop}; use crate::analyze::pricing::PricingTable; pub(super) struct GraphStatusPatterns { diff --git a/crates/relayburn-sdk/src/analyze/quality.rs b/crates/relayburn-sdk/src/analyze/quality.rs index f5446b2..6dcf7b1 100644 --- a/crates/relayburn-sdk/src/analyze/quality.rs +++ b/crates/relayburn-sdk/src/analyze/quality.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; -use crate::analyze::util::group_turns_by_session; +use crate::analyze::util::group_turns_by_session_sorted; use crate::reader::{ContentKind, ContentRecord, ContentRole, StopReason, TurnRecord}; use serde::{Deserialize, Serialize}; @@ -112,14 +112,13 @@ const LONG_CONVERSATION_THRESHOLD: usize = 10; const FAILURE_STREAK_THRESHOLD: u64 = 3; pub fn compute_quality(turns: &[TurnRecord], opts: &ComputeQualityOptions) -> QualityResult { - let by_session = group_turns_by_session(turns); + let by_session = group_turns_by_session_sorted(turns); let now = opts.now_ms.unwrap_or_else(now_ms_system); let mut outcomes = Vec::with_capacity(by_session.len()); let mut one_shot = Vec::with_capacity(by_session.len()); - for (session_id, mut session_turns) in by_session { - session_turns.sort_by_key(|t| t.turn_index); + for (session_id, session_turns) in by_session { outcomes.push(infer_outcome_refs( &session_id, &session_turns, diff --git a/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs b/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs index 2333a74..c1855f6 100644 --- a/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs +++ b/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs @@ -1,454 +1,453 @@ //! Conformance tests for the subagent_tree module — extracted verbatim from the //! former inline `#[cfg(test)] mod tests` block (included via `#[path]`). - use super::*; - use crate::analyze::pricing::load_builtin_pricing; - use crate::reader::{ - RelationshipSourceKind, RelationshipType, SourceKind, Subagent, ToolCall, TurnRecord, Usage, - }; +use super::*; +use crate::analyze::pricing::load_builtin_pricing; +use crate::reader::{ + RelationshipSourceKind, RelationshipType, SourceKind, Subagent, ToolCall, TurnRecord, Usage, +}; - fn make_turn( - session_id: &str, - message_id: &str, - model: &str, - turn_index: u64, - source: SourceKind, - subagent: Option, - ) -> TurnRecord { - TurnRecord { - v: 1, - source, - session_id: session_id.into(), - session_path: None, - message_id: message_id.into(), - turn_index, - ts: "2026-04-20T00:00:00.000Z".into(), - model: model.into(), - project: None, - project_key: None, - usage: Usage { - input: 1000, - output: 1000, - reasoning: 0, - cache_read: 0, - cache_create_5m: 0, - cache_create_1h: 0, - }, - tool_calls: Vec::::new(), - files_touched: None, - subagent, - stop_reason: None, - activity: None, - retries: None, - has_edits: None, - fidelity: None, - } - } - - fn sub( - agent_id: Option<&str>, - parent_agent_id: Option<&str>, - subagent_type: Option<&str>, - description: Option<&str>, - ) -> Subagent { - Subagent { - is_sidechain: true, - parent_tool_use_id: None, - agent_id: agent_id.map(String::from), - parent_agent_id: parent_agent_id.map(String::from), - subagent_type: subagent_type.map(String::from), - description: description.map(String::from), - } +fn make_turn( + session_id: &str, + message_id: &str, + model: &str, + turn_index: u64, + source: SourceKind, + subagent: Option, +) -> TurnRecord { + TurnRecord { + v: 1, + source, + session_id: session_id.into(), + session_path: None, + message_id: message_id.into(), + turn_index, + ts: "2026-04-20T00:00:00.000Z".into(), + model: model.into(), + project: None, + project_key: None, + usage: Usage { + input: 1000, + output: 1000, + reasoning: 0, + cache_read: 0, + cache_create_5m: 0, + cache_create_1h: 0, + }, + tool_calls: Vec::::new(), + files_touched: None, + subagent, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, } +} - fn rel( - session_id: &str, - rel_type: RelationshipType, - related: Option<&str>, - agent_id: Option<&str>, - subagent_type: Option<&str>, - description: Option<&str>, - source: RelationshipSourceKind, - ) -> SessionRelationshipRecord { - SessionRelationshipRecord { - v: 1, - source, - session_id: session_id.into(), - related_session_id: related.map(String::from), - relationship_type: rel_type, - ts: None, - source_session_id: None, - source_version: None, - parent_tool_use_id: None, - agent_id: agent_id.map(String::from), - subagent_type: subagent_type.map(String::from), - description: description.map(String::from), - } - } - - #[test] - fn folds_cumulative_cost_from_nested_subagents_up_to_the_main_root() { - let pricing = load_builtin_pricing(); - let session_id = "sess-1"; - let turns = vec![ - make_turn( - session_id, - "m1", - "claude-sonnet-4-6", - 0, - SourceKind::ClaudeCode, - None, - ), - make_turn( - session_id, - "m2", - "claude-sonnet-4-6", - 1, - SourceKind::ClaudeCode, - None, - ), - make_turn( - session_id, - "o1", - "claude-haiku-4-5", - 2, - SourceKind::ClaudeCode, - Some(sub( - Some("u-outer"), - Some(session_id), - Some("Explore"), - Some("Research"), - )), - ), - make_turn( - session_id, - "o2", - "claude-haiku-4-5", - 3, - SourceKind::ClaudeCode, - Some(sub( - Some("u-outer"), - Some(session_id), - Some("Explore"), - None, - )), - ), - make_turn( - session_id, - "i1", - "claude-haiku-4-5", - 4, - SourceKind::ClaudeCode, - Some(sub( - Some("u-inner"), - Some("u-outer"), - Some("code-reviewer"), - None, - )), - ), - ]; - - let opts = BuildSubagentTreeOptions::new(&pricing); - let trees = build_subagent_tree(&turns, &opts); - let root = trees.get(session_id).expect("root"); - assert_eq!(root.label, "main"); - assert_eq!(root.depth, 0); - assert_eq!(root.self_turns, 2); - assert_eq!(root.cumulative_turns, 5); - assert!(root.cumulative_cost > root.self_cost); - - assert_eq!(root.children.len(), 1); - let outer = &root.children[0]; - assert_eq!(outer.label, "Explore"); - assert_eq!(outer.depth, 1); - assert_eq!(outer.self_turns, 2); - assert_eq!(outer.cumulative_turns, 3); - assert_eq!(outer.children.len(), 1); - - let inner = &outer.children[0]; - assert_eq!(inner.label, "code-reviewer"); - assert_eq!(inner.depth, 2); - assert_eq!(inner.self_turns, 1); - assert_eq!(inner.cumulative_turns, 1); - assert!((inner.cumulative_cost - inner.self_cost).abs() < 1e-12); - - assert!( - (outer.cumulative_cost - (outer.self_cost + inner.cumulative_cost)).abs() < 1e-12, - "outer cumulative is selfCost + inner.cumulativeCost" - ); +fn sub( + agent_id: Option<&str>, + parent_agent_id: Option<&str>, + subagent_type: Option<&str>, + description: Option<&str>, +) -> Subagent { + Subagent { + is_sidechain: true, + parent_tool_use_id: None, + agent_id: agent_id.map(String::from), + parent_agent_id: parent_agent_id.map(String::from), + subagent_type: subagent_type.map(String::from), + description: description.map(String::from), } +} - #[test] - fn buckets_sidechain_turns_without_agent_id_under_an_unresolved_node() { - let pricing = load_builtin_pricing(); - let session_id = "sess-2"; - let turns = vec![ - make_turn( - session_id, - "m1", - "claude-sonnet-4-6", - 0, - SourceKind::ClaudeCode, - None, - ), - make_turn( - session_id, - "s1", - "claude-haiku-4-5", - 1, - SourceKind::ClaudeCode, - Some(Subagent { - is_sidechain: true, - parent_tool_use_id: None, - agent_id: None, - parent_agent_id: None, - subagent_type: None, - description: None, - }), - ), - ]; - let opts = BuildSubagentTreeOptions::new(&pricing); - let trees = build_subagent_tree(&turns, &opts); - let root = trees.get(session_id).unwrap(); - assert_eq!(root.children.len(), 1); - assert_eq!(root.children[0].label, "(unresolved)"); - assert_eq!(root.children[0].self_turns, 1); +fn rel( + session_id: &str, + rel_type: RelationshipType, + related: Option<&str>, + agent_id: Option<&str>, + subagent_type: Option<&str>, + description: Option<&str>, + source: RelationshipSourceKind, +) -> SessionRelationshipRecord { + SessionRelationshipRecord { + v: 1, + source, + session_id: session_id.into(), + related_session_id: related.map(String::from), + relationship_type: rel_type, + ts: None, + source_session_id: None, + source_version: None, + parent_tool_use_id: None, + agent_id: agent_id.map(String::from), + subagent_type: subagent_type.map(String::from), + description: description.map(String::from), } +} - #[test] - fn builds_the_same_claude_tree_from_session_relationship_records() { - let pricing = load_builtin_pricing(); - let session_id = "sess-graph"; - let turns = vec![ - make_turn( - session_id, - "m1", - "claude-sonnet-4-6", - 0, - SourceKind::ClaudeCode, - None, - ), - make_turn( - session_id, - "o1", - "claude-haiku-4-5", - 1, - SourceKind::ClaudeCode, - Some(sub( - Some("u-outer"), - Some(session_id), - Some("Explore"), - Some("Research"), - )), - ), - make_turn( - session_id, - "i1", - "claude-haiku-4-5", - 2, - SourceKind::ClaudeCode, - Some(sub( - Some("u-inner"), - Some("u-outer"), - Some("code-reviewer"), - None, - )), - ), - ]; - let relationships = vec![ - rel( - session_id, - RelationshipType::Root, - None, - None, - None, - None, - RelationshipSourceKind::ClaudeCode, - ), - rel( - session_id, - RelationshipType::Subagent, - Some(session_id), +#[test] +fn folds_cumulative_cost_from_nested_subagents_up_to_the_main_root() { + let pricing = load_builtin_pricing(); + let session_id = "sess-1"; + let turns = vec![ + make_turn( + session_id, + "m1", + "claude-sonnet-4-6", + 0, + SourceKind::ClaudeCode, + None, + ), + make_turn( + session_id, + "m2", + "claude-sonnet-4-6", + 1, + SourceKind::ClaudeCode, + None, + ), + make_turn( + session_id, + "o1", + "claude-haiku-4-5", + 2, + SourceKind::ClaudeCode, + Some(sub( Some("u-outer"), + Some(session_id), Some("Explore"), Some("Research"), - RelationshipSourceKind::NativeClaude, - ), - rel( - session_id, - RelationshipType::Subagent, + )), + ), + make_turn( + session_id, + "o2", + "claude-haiku-4-5", + 3, + SourceKind::ClaudeCode, + Some(sub( Some("u-outer"), + Some(session_id), + Some("Explore"), + None, + )), + ), + make_turn( + session_id, + "i1", + "claude-haiku-4-5", + 4, + SourceKind::ClaudeCode, + Some(sub( Some("u-inner"), + Some("u-outer"), Some("code-reviewer"), None, - RelationshipSourceKind::NativeClaude, - ), - ]; + )), + ), + ]; - let legacy_opts = BuildSubagentTreeOptions::new(&pricing); - let legacy = build_subagent_tree(&turns, &legacy_opts) - .get(session_id) - .unwrap() - .clone(); - let graph_opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); - let graph = build_subagent_tree(&turns, &graph_opts) - .get(session_id) - .unwrap() - .clone(); - assert_eq!(graph, legacy); - assert_eq!(graph.relationship_type, RelationshipType::Root); - assert_eq!( - graph.children[0].relationship_type, - RelationshipType::Subagent - ); - } + let opts = BuildSubagentTreeOptions::new(&pricing); + let trees = build_subagent_tree(&turns, &opts); + let root = trees.get(session_id).expect("root"); + assert_eq!(root.label, "main"); + assert_eq!(root.depth, 0); + assert_eq!(root.self_turns, 2); + assert_eq!(root.cumulative_turns, 5); + assert!(root.cumulative_cost > root.self_cost); - #[test] - fn joins_child_session_relationship_rows_to_turns_without_per_turn_subagent_metadata() { - let pricing = load_builtin_pricing(); - let turns = vec![ - make_turn( - "parent-session", - "parent-1", - "gpt-5.1-codex", - 0, - SourceKind::Codex, - None, - ), - make_turn( - "child-session", - "child-1", - "gpt-5.1-codex", - 0, - SourceKind::Codex, - None, - ), - ]; - let relationships = vec![ - rel( - "parent-session", - RelationshipType::Root, - None, - None, - None, - None, - RelationshipSourceKind::Codex, - ), - rel( - "child-session", - RelationshipType::Subagent, - Some("parent-session"), - Some("agent-child"), - Some("worker"), - None, - RelationshipSourceKind::Codex, - ), - ]; + assert_eq!(root.children.len(), 1); + let outer = &root.children[0]; + assert_eq!(outer.label, "Explore"); + assert_eq!(outer.depth, 1); + assert_eq!(outer.self_turns, 2); + assert_eq!(outer.cumulative_turns, 3); + assert_eq!(outer.children.len(), 1); - let opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); - let root = build_subagent_tree(&turns, &opts) - .get("parent-session") - .unwrap() - .clone(); - assert_eq!(root.self_turns, 1); - assert_eq!(root.cumulative_turns, 2); - assert_eq!(root.children.len(), 1); - assert_eq!(root.children[0].label, "worker"); - assert_eq!(root.children[0].node_id, "child-session"); - assert_eq!( - root.children[0].relationship_type, - RelationshipType::Subagent - ); - assert_eq!(root.children[0].self_turns, 1); - } + let inner = &outer.children[0]; + assert_eq!(inner.label, "code-reviewer"); + assert_eq!(inner.depth, 2); + assert_eq!(inner.self_turns, 1); + assert_eq!(inner.cumulative_turns, 1); + assert!((inner.cumulative_cost - inner.self_cost).abs() < 1e-12); - #[test] - fn does_not_alias_native_sidechain_session_roots_onto_agent_ids_when_turns_lack_subagent_fields( - ) { - let pricing = load_builtin_pricing(); - let session_id = "partial-claude"; - let turns = vec![make_turn( + assert!( + (outer.cumulative_cost - (outer.self_cost + inner.cumulative_cost)).abs() < 1e-12, + "outer cumulative is selfCost + inner.cumulativeCost" + ); +} + +#[test] +fn buckets_sidechain_turns_without_agent_id_under_an_unresolved_node() { + let pricing = load_builtin_pricing(); + let session_id = "sess-2"; + let turns = vec![ + make_turn( session_id, - "main-1", + "m1", "claude-sonnet-4-6", 0, SourceKind::ClaudeCode, None, - )]; - let relationships = vec![ - rel( - session_id, - RelationshipType::Root, - None, - None, - None, - None, - RelationshipSourceKind::ClaudeCode, - ), - rel( - session_id, - RelationshipType::Subagent, - Some(session_id), + ), + make_turn( + session_id, + "s1", + "claude-haiku-4-5", + 1, + SourceKind::ClaudeCode, + Some(Subagent { + is_sidechain: true, + parent_tool_use_id: None, + agent_id: None, + parent_agent_id: None, + subagent_type: None, + description: None, + }), + ), + ]; + let opts = BuildSubagentTreeOptions::new(&pricing); + let trees = build_subagent_tree(&turns, &opts); + let root = trees.get(session_id).unwrap(); + assert_eq!(root.children.len(), 1); + assert_eq!(root.children[0].label, "(unresolved)"); + assert_eq!(root.children[0].self_turns, 1); +} + +#[test] +fn builds_the_same_claude_tree_from_session_relationship_records() { + let pricing = load_builtin_pricing(); + let session_id = "sess-graph"; + let turns = vec![ + make_turn( + session_id, + "m1", + "claude-sonnet-4-6", + 0, + SourceKind::ClaudeCode, + None, + ), + make_turn( + session_id, + "o1", + "claude-haiku-4-5", + 1, + SourceKind::ClaudeCode, + Some(sub( Some("u-outer"), + Some(session_id), Some("Explore"), + Some("Research"), + )), + ), + make_turn( + session_id, + "i1", + "claude-haiku-4-5", + 2, + SourceKind::ClaudeCode, + Some(sub( + Some("u-inner"), + Some("u-outer"), + Some("code-reviewer"), None, - RelationshipSourceKind::NativeClaude, - ), - ]; - let opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); - let root = build_subagent_tree(&turns, &opts) - .get(session_id) - .unwrap() - .clone(); - assert_eq!(root.node_id, session_id); - assert_eq!(root.label, "main"); - assert_eq!(root.self_turns, 1); - assert_eq!(root.children.len(), 1); - assert_eq!(root.children[0].node_id, "u-outer"); - assert_eq!(root.children[0].self_turns, 0); - } + )), + ), + ]; + let relationships = vec![ + rel( + session_id, + RelationshipType::Root, + None, + None, + None, + None, + RelationshipSourceKind::ClaudeCode, + ), + rel( + session_id, + RelationshipType::Subagent, + Some(session_id), + Some("u-outer"), + Some("Explore"), + Some("Research"), + RelationshipSourceKind::NativeClaude, + ), + rel( + session_id, + RelationshipType::Subagent, + Some("u-outer"), + Some("u-inner"), + Some("code-reviewer"), + None, + RelationshipSourceKind::NativeClaude, + ), + ]; - #[test] - fn reports_median_p95_mean_total_per_subagent_type_across_invocations() { - let pricing = load_builtin_pricing(); - let mut turns: Vec = Vec::new(); - for i in 0..3 { - let agent_id = format!("u-exp-{i}"); - for j in 0..=i { - turns.push(make_turn( - &format!("sess-{i}"), - &format!("m-{i}-{j}"), - "claude-haiku-4-5", - j as u64, - SourceKind::ClaudeCode, - Some(sub(Some(&agent_id), None, Some("Explore"), None)), - )); - } - } - turns.push(make_turn( - "sess-rev", - "mr", - "claude-haiku-4-5", + let legacy_opts = BuildSubagentTreeOptions::new(&pricing); + let legacy = build_subagent_tree(&turns, &legacy_opts) + .get(session_id) + .unwrap() + .clone(); + let graph_opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); + let graph = build_subagent_tree(&turns, &graph_opts) + .get(session_id) + .unwrap() + .clone(); + assert_eq!(graph, legacy); + assert_eq!(graph.relationship_type, RelationshipType::Root); + assert_eq!( + graph.children[0].relationship_type, + RelationshipType::Subagent + ); +} + +#[test] +fn joins_child_session_relationship_rows_to_turns_without_per_turn_subagent_metadata() { + let pricing = load_builtin_pricing(); + let turns = vec![ + make_turn( + "parent-session", + "parent-1", + "gpt-5.1-codex", 0, - SourceKind::ClaudeCode, - Some(sub(Some("u-rev"), None, Some("code-reviewer"), None)), - )); + SourceKind::Codex, + None, + ), + make_turn( + "child-session", + "child-1", + "gpt-5.1-codex", + 0, + SourceKind::Codex, + None, + ), + ]; + let relationships = vec![ + rel( + "parent-session", + RelationshipType::Root, + None, + None, + None, + None, + RelationshipSourceKind::Codex, + ), + rel( + "child-session", + RelationshipType::Subagent, + Some("parent-session"), + Some("agent-child"), + Some("worker"), + None, + RelationshipSourceKind::Codex, + ), + ]; + + let opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); + let root = build_subagent_tree(&turns, &opts) + .get("parent-session") + .unwrap() + .clone(); + assert_eq!(root.self_turns, 1); + assert_eq!(root.cumulative_turns, 2); + assert_eq!(root.children.len(), 1); + assert_eq!(root.children[0].label, "worker"); + assert_eq!(root.children[0].node_id, "child-session"); + assert_eq!( + root.children[0].relationship_type, + RelationshipType::Subagent + ); + assert_eq!(root.children[0].self_turns, 1); +} - let opts = BuildSubagentTreeOptions::new(&pricing); - let stats = aggregate_subagent_type_stats(&turns, &opts); - let explore = stats.iter().find(|s| s.subagent_type == "Explore").unwrap(); - assert_eq!(explore.invocations, 3); - assert_eq!(explore.turns, 6); - assert!(explore.median_cost > 0.0); - assert!(explore.p95_cost >= explore.median_cost); - assert!((explore.mean_cost - explore.total_cost / 3.0).abs() < 1e-12); +#[test] +fn does_not_alias_native_sidechain_session_roots_onto_agent_ids_when_turns_lack_subagent_fields() { + let pricing = load_builtin_pricing(); + let session_id = "partial-claude"; + let turns = vec![make_turn( + session_id, + "main-1", + "claude-sonnet-4-6", + 0, + SourceKind::ClaudeCode, + None, + )]; + let relationships = vec![ + rel( + session_id, + RelationshipType::Root, + None, + None, + None, + None, + RelationshipSourceKind::ClaudeCode, + ), + rel( + session_id, + RelationshipType::Subagent, + Some(session_id), + Some("u-outer"), + Some("Explore"), + None, + RelationshipSourceKind::NativeClaude, + ), + ]; + let opts = BuildSubagentTreeOptions::new(&pricing).with_relationships(&relationships); + let root = build_subagent_tree(&turns, &opts) + .get(session_id) + .unwrap() + .clone(); + assert_eq!(root.node_id, session_id); + assert_eq!(root.label, "main"); + assert_eq!(root.self_turns, 1); + assert_eq!(root.children.len(), 1); + assert_eq!(root.children[0].node_id, "u-outer"); + assert_eq!(root.children[0].self_turns, 0); +} - let rev = stats - .iter() - .find(|s| s.subagent_type == "code-reviewer") - .unwrap(); - assert_eq!(rev.invocations, 1); - assert_eq!(rev.turns, 1); - assert!((rev.median_cost - rev.total_cost).abs() < 1e-12); - assert!((rev.p95_cost - rev.total_cost).abs() < 1e-12); +#[test] +fn reports_median_p95_mean_total_per_subagent_type_across_invocations() { + let pricing = load_builtin_pricing(); + let mut turns: Vec = Vec::new(); + for i in 0..3 { + let agent_id = format!("u-exp-{i}"); + for j in 0..=i { + turns.push(make_turn( + &format!("sess-{i}"), + &format!("m-{i}-{j}"), + "claude-haiku-4-5", + j as u64, + SourceKind::ClaudeCode, + Some(sub(Some(&agent_id), None, Some("Explore"), None)), + )); + } } + turns.push(make_turn( + "sess-rev", + "mr", + "claude-haiku-4-5", + 0, + SourceKind::ClaudeCode, + Some(sub(Some("u-rev"), None, Some("code-reviewer"), None)), + )); + + let opts = BuildSubagentTreeOptions::new(&pricing); + let stats = aggregate_subagent_type_stats(&turns, &opts); + let explore = stats.iter().find(|s| s.subagent_type == "Explore").unwrap(); + assert_eq!(explore.invocations, 3); + assert_eq!(explore.turns, 6); + assert!(explore.median_cost > 0.0); + assert!(explore.p95_cost >= explore.median_cost); + assert!((explore.mean_cost - explore.total_cost / 3.0).abs() < 1e-12); + + let rev = stats + .iter() + .find(|s| s.subagent_type == "code-reviewer") + .unwrap(); + assert_eq!(rev.invocations, 1); + assert_eq!(rev.turns, 1); + assert!((rev.median_cost - rev.total_cost).abs() < 1e-12); + assert!((rev.p95_cost - rev.total_cost).abs() < 1e-12); +} diff --git a/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs b/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs index 141e9b5..ebaf1fa 100644 --- a/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs +++ b/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs @@ -73,8 +73,7 @@ pub fn detect_tool_call_patterns( opts: &DetectToolCallPatternsOptions<'_>, ) -> Vec { let mut out: Vec = Vec::new(); - for (sid, mut sess) in group_turns_by_session(turns) { - sess.sort_by_key(|t| t.turn_index); + for (sid, sess) in group_turns_by_session_sorted(turns) { out.extend(detect_for_session(&sid, &sess, opts.pricing)); } // Sort: usd desc, then tokens desc. @@ -448,7 +447,7 @@ only the PR fields the agent reads.", } } -use super::util::{fmt_usd, format_with_commas, group_turns_by_session}; +use super::util::{fmt_usd, format_with_commas, group_turns_by_session_sorted}; pub fn tool_call_pattern_to_finding(finding: &ToolCallPatternFinding) -> WasteFinding { let evidence_str = if finding.evidence.is_empty() { diff --git a/crates/relayburn-sdk/src/analyze/tool_output_bloat_tests.rs b/crates/relayburn-sdk/src/analyze/tool_output_bloat_tests.rs index ed02b2d..5addfb8 100644 --- a/crates/relayburn-sdk/src/analyze/tool_output_bloat_tests.rs +++ b/crates/relayburn-sdk/src/analyze/tool_output_bloat_tests.rs @@ -1,367 +1,444 @@ //! Conformance tests for the tool_output_bloat module — extracted verbatim from //! the former inline `#[cfg(test)] mod tests` block (included via `#[path]`). - use super::*; - use crate::analyze::pricing::load_builtin_pricing; - use crate::reader::{ToolCall, ToolResultEventSource, ToolResultStatus, Usage, UserTurnBlock}; - use serde_json::json; - use std::path::PathBuf; - use tempfile::tempdir; +use super::*; +use crate::analyze::pricing::load_builtin_pricing; +use crate::reader::{ToolCall, ToolResultEventSource, ToolResultStatus, Usage, UserTurnBlock}; +use serde_json::json; +use std::path::PathBuf; +use tempfile::tempdir; - fn loaded(path: &str, env: serde_json::Value) -> LoadedClaudeSettings { - let settings: ClaudeSettings = serde_json::from_value(json!({ "env": env })).unwrap(); - LoadedClaudeSettings { - path: PathBuf::from(path), - settings, - } +fn loaded(path: &str, env: serde_json::Value) -> LoadedClaudeSettings { + let settings: ClaudeSettings = serde_json::from_value(json!({ "env": env })).unwrap(); + LoadedClaudeSettings { + path: PathBuf::from(path), + settings, } +} - fn loaded_no_env(path: &str) -> LoadedClaudeSettings { - LoadedClaudeSettings { - path: PathBuf::from(path), - settings: ClaudeSettings::default(), - } +fn loaded_no_env(path: &str) -> LoadedClaudeSettings { + LoadedClaudeSettings { + path: PathBuf::from(path), + settings: ClaudeSettings::default(), } +} - fn evt( - session_id: &str, - tool_use_id: &str, - event_index: u64, - message_id: Option<&str>, - ) -> ToolResultEventRecord { - ToolResultEventRecord { - v: 1, - source: SourceKind::ClaudeCode, - session_id: session_id.to_string(), - message_id: message_id.map(String::from), - tool_use_id: tool_use_id.to_string(), - call_index: None, - event_index, - ts: None, - status: ToolResultStatus::Completed, - event_source: ToolResultEventSource::ToolResult, - content_length: None, - output_bytes: None, - output_truncated: None, - content_hash: None, - is_error: None, - usage: None, - usage_attribution: None, - subagent_session_id: None, - agent_id: None, - replaced_tools: None, - collapsed_calls: None, - } +fn evt( + session_id: &str, + tool_use_id: &str, + event_index: u64, + message_id: Option<&str>, +) -> ToolResultEventRecord { + ToolResultEventRecord { + v: 1, + source: SourceKind::ClaudeCode, + session_id: session_id.to_string(), + message_id: message_id.map(String::from), + tool_use_id: tool_use_id.to_string(), + call_index: None, + event_index, + ts: None, + status: ToolResultStatus::Completed, + event_source: ToolResultEventSource::ToolResult, + content_length: None, + output_bytes: None, + output_truncated: None, + content_hash: None, + is_error: None, + usage: None, + usage_attribution: None, + subagent_session_id: None, + agent_id: None, + replaced_tools: None, + collapsed_calls: None, } +} - #[allow(clippy::too_many_arguments)] - fn evt_with( - source: SourceKind, - session_id: &str, - tool_use_id: &str, - event_index: u64, - message_id: Option<&str>, - event_source: ToolResultEventSource, - content_length: Option, - call_index: Option, - ) -> ToolResultEventRecord { - ToolResultEventRecord { - v: 1, - source, - session_id: session_id.to_string(), - message_id: message_id.map(String::from), - tool_use_id: tool_use_id.to_string(), - call_index, - event_index, - ts: None, - status: ToolResultStatus::Completed, - event_source, - content_length, - output_bytes: content_length, - output_truncated: None, - content_hash: None, - is_error: None, - usage: None, - usage_attribution: None, - subagent_session_id: None, - agent_id: None, - replaced_tools: None, - collapsed_calls: None, - } +#[allow(clippy::too_many_arguments)] +fn evt_with( + source: SourceKind, + session_id: &str, + tool_use_id: &str, + event_index: u64, + message_id: Option<&str>, + event_source: ToolResultEventSource, + content_length: Option, + call_index: Option, +) -> ToolResultEventRecord { + ToolResultEventRecord { + v: 1, + source, + session_id: session_id.to_string(), + message_id: message_id.map(String::from), + tool_use_id: tool_use_id.to_string(), + call_index, + event_index, + ts: None, + status: ToolResultStatus::Completed, + event_source, + content_length, + output_bytes: content_length, + output_truncated: None, + content_hash: None, + is_error: None, + usage: None, + usage_attribution: None, + subagent_session_id: None, + agent_id: None, + replaced_tools: None, + collapsed_calls: None, } +} - #[allow(clippy::too_many_arguments)] - fn user_turn_with( - source: SourceKind, - session_id: &str, - user_uuid: &str, - preceding: &str, - following: &str, - tool_use_id: &str, - byte_len: u64, - approx_tokens: u64, - ) -> UserTurnRecord { - UserTurnRecord { - v: 1, - source, - session_id: session_id.to_string(), - user_uuid: user_uuid.to_string(), - ts: "2026-04-20T00:00:00.500Z".to_string(), - preceding_message_id: Some(preceding.to_string()), - following_message_id: Some(following.to_string()), - blocks: vec![UserTurnBlock { - kind: UserTurnBlockKind::ToolResult, - tool_use_id: Some(tool_use_id.to_string()), - byte_len, - approx_tokens, - is_error: None, - }], - } +#[allow(clippy::too_many_arguments)] +fn user_turn_with( + source: SourceKind, + session_id: &str, + user_uuid: &str, + preceding: &str, + following: &str, + tool_use_id: &str, + byte_len: u64, + approx_tokens: u64, +) -> UserTurnRecord { + UserTurnRecord { + v: 1, + source, + session_id: session_id.to_string(), + user_uuid: user_uuid.to_string(), + ts: "2026-04-20T00:00:00.500Z".to_string(), + preceding_message_id: Some(preceding.to_string()), + following_message_id: Some(following.to_string()), + blocks: vec![UserTurnBlock { + kind: UserTurnBlockKind::ToolResult, + tool_use_id: Some(tool_use_id.to_string()), + byte_len, + approx_tokens, + is_error: None, + }], } +} - fn turn_with( - source: SourceKind, - session_id: &str, - message_id: &str, - turn_index: u64, - tool_calls: Vec, - ) -> TurnRecord { - TurnRecord { - v: 1, - source, - session_id: session_id.to_string(), - session_path: None, - message_id: message_id.to_string(), - turn_index, - ts: "2026-04-20T00:00:00.000Z".to_string(), - model: "claude-sonnet-4-6".to_string(), - project: None, - project_key: None, - usage: Usage { - input: 10, - output: 5, - reasoning: 0, - cache_read: 100, - cache_create_5m: 50, - cache_create_1h: 0, - }, - tool_calls, - files_touched: None, - subagent: None, - stop_reason: None, - activity: None, - retries: None, - has_edits: None, - fidelity: None, - } +fn turn_with( + source: SourceKind, + session_id: &str, + message_id: &str, + turn_index: u64, + tool_calls: Vec, +) -> TurnRecord { + TurnRecord { + v: 1, + source, + session_id: session_id.to_string(), + session_path: None, + message_id: message_id.to_string(), + turn_index, + ts: "2026-04-20T00:00:00.000Z".to_string(), + model: "claude-sonnet-4-6".to_string(), + project: None, + project_key: None, + usage: Usage { + input: 10, + output: 5, + reasoning: 0, + cache_read: 100, + cache_create_5m: 50, + cache_create_1h: 0, + }, + tool_calls, + files_touched: None, + subagent: None, + stop_reason: None, + activity: None, + retries: None, + has_edits: None, + fidelity: None, } +} - fn tc(id: &str, name: &str) -> ToolCall { - ToolCall { - id: id.to_string(), - name: name.to_string(), - target: None, - args_hash: "hash".to_string(), - is_error: None, - edit_pre_hash: None, - edit_post_hash: None, - skill_name: None, - replaced_tools: None, - collapsed_calls: None, - } +fn tc(id: &str, name: &str) -> ToolCall { + ToolCall { + id: id.to_string(), + name: name.to_string(), + target: None, + args_hash: "hash".to_string(), + is_error: None, + edit_pre_hash: None, + edit_post_hash: None, + skill_name: None, + replaced_tools: None, + collapsed_calls: None, } +} - // ------------------------------------------------------------------- - // Signal A — static-config check - // ------------------------------------------------------------------- +// ------------------------------------------------------------------- +// Signal A — static-config check +// ------------------------------------------------------------------- - #[test] - fn signal_a_flags_oversized_bash_max_output_length() { - let settings = vec![loaded( - "/home/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), - )]; - let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }); - assert_eq!(out.len(), 1); - let f = &out[0]; - assert_eq!(f.kind, ToolOutputBloatKind::StaticConfig); - assert_eq!(f.source, SourceKind::ClaudeCode); - assert_eq!(f.tool_name, "Bash"); - assert_eq!(f.configured_limit, Some(80_000)); - assert_eq!(f.evidenced_max_output, 20_000); - assert_eq!(f.occurrence_count, 1); - assert_eq!(f.cost, 0.0); - assert_eq!( - f.evidence, - vec!["/home/u/.claude/settings.json".to_string()] - ); - } +#[test] +fn signal_a_flags_oversized_bash_max_output_length() { + let settings = vec![loaded( + "/home/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), + )]; + let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }); + assert_eq!(out.len(), 1); + let f = &out[0]; + assert_eq!(f.kind, ToolOutputBloatKind::StaticConfig); + assert_eq!(f.source, SourceKind::ClaudeCode); + assert_eq!(f.tool_name, "Bash"); + assert_eq!(f.configured_limit, Some(80_000)); + assert_eq!(f.evidenced_max_output, 20_000); + assert_eq!(f.occurrence_count, 1); + assert_eq!(f.cost, 0.0); + assert_eq!( + f.evidence, + vec!["/home/u/.claude/settings.json".to_string()] + ); +} + +#[test] +fn signal_a_does_not_flag_at_threshold() { + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "60000" }), + )]; + assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }) + .is_empty()); +} + +#[test] +fn signal_a_unit_conversion_under_threshold() { + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "50000" }), + )]; + assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }) + .is_empty()); +} - #[test] - fn signal_a_does_not_flag_at_threshold() { - let settings = vec![loaded( +#[test] +fn signal_a_no_env_block() { + let settings = vec![loaded_no_env("/u/.claude/settings.json")]; + assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }) + .is_empty()); +} + +#[test] +fn signal_a_project_overrides_user() { + let settings = vec![ + loaded( "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), + ), + loaded( + "/cwd/.claude/settings.json", json!({ BASH_MAX_OUTPUT_ENV_KEY: "60000" }), - )]; - assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }) - .is_empty()); - } + ), + ]; + assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }) + .is_empty()); +} - #[test] - fn signal_a_unit_conversion_under_threshold() { - let settings = vec![loaded( +#[test] +fn signal_a_project_path_reported_when_project_overrides_to_oversized() { + let settings = vec![ + loaded( "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "50000" }), - )]; - assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }) - .is_empty()); - } + json!({ BASH_MAX_OUTPUT_ENV_KEY: "15000" }), + ), + loaded( + "/cwd/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "99999" }), + ), + ]; + let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings, + }); + assert_eq!(out.len(), 1); + assert_eq!( + out[0].evidence, + vec!["/cwd/.claude/settings.json".to_string()] + ); + assert_eq!(out[0].configured_limit, Some(99_999)); +} - #[test] - fn signal_a_no_env_block() { - let settings = vec![loaded_no_env("/u/.claude/settings.json")]; - assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }) - .is_empty()); - } +#[test] +fn signal_a_honors_custom_threshold() { + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "5000" }), + )]; + let tight = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: Some(1_000), + settings: settings.clone(), + }); + assert_eq!(tight.len(), 1); + let loose = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: Some(10_000), + settings, + }); + assert!(loose.is_empty()); +} - #[test] - fn signal_a_project_overrides_user() { - let settings = vec![ - loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), - ), - loaded( - "/cwd/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "60000" }), - ), - ]; - assert!(detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }) - .is_empty()); - } +// ------------------------------------------------------------------- +// Filesystem loader +// ------------------------------------------------------------------- - #[test] - fn signal_a_project_path_reported_when_project_overrides_to_oversized() { - let settings = vec![ - loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "15000" }), - ), - loaded( - "/cwd/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "99999" }), - ), - ]; - let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings, - }); - assert_eq!(out.len(), 1); - assert_eq!( - out[0].evidence, - vec!["/cwd/.claude/settings.json".to_string()] - ); - assert_eq!(out[0].configured_limit, Some(99_999)); - } +#[test] +fn load_settings_returns_none_for_missing_file() { + let dir = tempdir().unwrap(); + assert!(load_claude_settings(dir.path().join("nope.json")).is_none()); +} - #[test] - fn signal_a_honors_custom_threshold() { - let settings = vec![loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "5000" }), - )]; - let tight = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: Some(1_000), - settings: settings.clone(), - }); - assert_eq!(tight.len(), 1); - let loose = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: Some(10_000), - settings, - }); - assert!(loose.is_empty()); - } +#[test] +fn load_settings_returns_none_for_malformed_json() { + let dir = tempdir().unwrap(); + let p = dir.path().join("bad.json"); + std::fs::write(&p, "{not json").unwrap(); + assert!(load_claude_settings(&p).is_none()); +} - // ------------------------------------------------------------------- - // Filesystem loader - // ------------------------------------------------------------------- +#[test] +fn load_settings_reads_env_block() { + let dir = tempdir().unwrap(); + let p = dir.path().join("settings.json"); + std::fs::write( + &p, + json!({ "env": { BASH_MAX_OUTPUT_ENV_KEY: "80000" } }).to_string(), + ) + .unwrap(); + let loaded = load_claude_settings(&p).expect("loads"); + assert_eq!(loaded.path, p); + let env = loaded.settings.env.as_ref().expect("env present"); + assert_eq!( + env.get(BASH_MAX_OUTPUT_ENV_KEY).and_then(|v| v.as_str()), + Some("80000"), + ); +} - #[test] - fn load_settings_returns_none_for_missing_file() { - let dir = tempdir().unwrap(); - assert!(load_claude_settings(dir.path().join("nope.json")).is_none()); - } - - #[test] - fn load_settings_returns_none_for_malformed_json() { - let dir = tempdir().unwrap(); - let p = dir.path().join("bad.json"); - std::fs::write(&p, "{not json").unwrap(); - assert!(load_claude_settings(&p).is_none()); - } +#[test] +fn load_and_detect_end_to_end() { + let dir = tempdir().unwrap(); + let claude_dir = dir.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + let p = claude_dir.join("settings.json"); + std::fs::write( + &p, + json!({ "env": { BASH_MAX_OUTPUT_ENV_KEY: "80000" } }).to_string(), + ) + .unwrap(); + let loaded = load_claude_settings(&p).expect("loads"); + let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings: vec![loaded], + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].configured_limit, Some(80_000)); +} - #[test] - fn load_settings_reads_env_block() { - let dir = tempdir().unwrap(); - let p = dir.path().join("settings.json"); - std::fs::write( - &p, - json!({ "env": { BASH_MAX_OUTPUT_ENV_KEY: "80000" } }).to_string(), - ) - .unwrap(); - let loaded = load_claude_settings(&p).expect("loads"); - assert_eq!(loaded.path, p); - let env = loaded.settings.env.as_ref().expect("env present"); - assert_eq!( - env.get(BASH_MAX_OUTPUT_ENV_KEY).and_then(|v| v.as_str()), - Some("80000"), - ); - } +// ------------------------------------------------------------------- +// Signal B — observed bloat across sessions +// ------------------------------------------------------------------- - #[test] - fn load_and_detect_end_to_end() { - let dir = tempdir().unwrap(); - let claude_dir = dir.path().join(".claude"); - std::fs::create_dir_all(&claude_dir).unwrap(); - let p = claude_dir.join("settings.json"); - std::fs::write( - &p, - json!({ "env": { BASH_MAX_OUTPUT_ENV_KEY: "80000" } }).to_string(), - ) - .unwrap(); - let loaded = load_claude_settings(&p).expect("loads"); - let out = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings: vec![loaded], - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].configured_limit, Some(80_000)); - } +#[test] +fn signal_b_flags_bash_above_threshold() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + let b = &out[0]; + assert_eq!(b.kind, ToolOutputBloatKind::ObservedBloat); + assert_eq!(b.source, SourceKind::ClaudeCode); + assert_eq!(b.tool_name, "Bash"); + assert_eq!(b.occurrence_count, 1); + assert_eq!(b.evidenced_max_output, 20_000); + assert_eq!(b.evidence, vec!["s1".to_string()]); + assert!(b.cost > 0.0, "cost should be priced via the model rate"); +} - // ------------------------------------------------------------------- - // Signal B — observed bloat across sessions - // ------------------------------------------------------------------- +#[test] +fn signal_b_does_not_flag_below_threshold() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 40_000, + 10_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert!(out.is_empty()); +} - #[test] - fn signal_b_flags_bash_above_threshold() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( +#[test] +fn signal_b_aggregates_into_single_bucket() { + let pricing = load_builtin_pricing(); + let events = vec![ + evt("s1", "tu_a", 0, Some("m1")), + evt("s2", "tu_b", 0, Some("m2")), + evt("s3", "tu_c", 0, Some("m3")), + ]; + let user_turns = vec![ + user_turn_with( SourceKind::ClaudeCode, "s1", "u1", @@ -370,421 +447,103 @@ "tu_a", 80_000, 20_000, - )]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - let b = &out[0]; - assert_eq!(b.kind, ToolOutputBloatKind::ObservedBloat); - assert_eq!(b.source, SourceKind::ClaudeCode); - assert_eq!(b.tool_name, "Bash"); - assert_eq!(b.occurrence_count, 1); - assert_eq!(b.evidenced_max_output, 20_000); - assert_eq!(b.evidence, vec!["s1".to_string()]); - assert!(b.cost > 0.0, "cost should be priced via the model rate"); - } - - #[test] - fn signal_b_does_not_flag_below_threshold() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( + ), + user_turn_with( SourceKind::ClaudeCode, - "s1", - "u1", - "m1", + "s2", + "u2", "m2", - "tu_a", - 40_000, - 10_000, - )]; - let turns = vec![turn_with( + "m3", + "tu_b", + 100_000, + 25_000, + ), + user_turn_with( SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert!(out.is_empty()); - } - - #[test] - fn signal_b_aggregates_into_single_bucket() { - let pricing = load_builtin_pricing(); - let events = vec![ - evt("s1", "tu_a", 0, Some("m1")), - evt("s2", "tu_b", 0, Some("m2")), - evt("s3", "tu_c", 0, Some("m3")), - ]; - let user_turns = vec![ - user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 80_000, - 20_000, - ), - user_turn_with( - SourceKind::ClaudeCode, - "s2", - "u2", - "m2", - "m3", - "tu_b", - 100_000, - 25_000, - ), - user_turn_with( - SourceKind::ClaudeCode, - "s3", - "u3", - "m3", - "m4", - "tu_c", - 120_000, - 30_000, - ), - ]; - let turns = vec![ - turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - ), - turn_with( - SourceKind::ClaudeCode, - "s2", - "m2", - 0, - vec![tc("tu_b", "Bash")], - ), - turn_with( - SourceKind::ClaudeCode, - "s3", - "m3", - 0, - vec![tc("tu_c", "Bash")], - ), - ]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - let b = &out[0]; - assert_eq!(b.occurrence_count, 3); - assert_eq!(b.evidenced_max_output, 30_000); - assert_eq!(b.evidence.len(), 3); - } - - #[test] - fn signal_b_emits_one_bucket_per_source_tool_pair() { - let pricing = load_builtin_pricing(); - let events = vec![ - evt_with( - SourceKind::ClaudeCode, - "s1", - "tu_a", - 0, - Some("m1"), - ToolResultEventSource::ToolResult, - None, - None, - ), - evt_with( - SourceKind::Codex, - "s2", - "call_b", - 0, - Some("m2"), - ToolResultEventSource::ToolResult, - None, - None, - ), - evt_with( - SourceKind::Opencode, - "s3", - "opc_c", - 0, - Some("m3"), - ToolResultEventSource::ToolResult, - None, - None, - ), - ]; - let user_turns = vec![ - user_turn_with( - SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "tu_a", - 80_000, - 20_000, - ), - user_turn_with( - SourceKind::Codex, - "s2", - "u2", - "m2", - "m3", - "call_b", - 90_000, - 22_500, - ), - user_turn_with( - SourceKind::Opencode, - "s3", - "u3", - "m3", - "m4", - "opc_c", - 85_000, - 21_250, - ), - ]; - let turns = vec![ - turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - ), - turn_with( - SourceKind::Codex, - "s2", - "m2", - 0, - vec![tc("call_b", "shell")], - ), - turn_with( - SourceKind::Opencode, - "s3", - "m3", - 0, - vec![tc("opc_c", "bash")], - ), - ]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 3); - let mut sources: Vec = out.iter().map(|b| b.source).collect(); - sources.sort_by_key(|s| match s { - SourceKind::ClaudeCode => 0, - SourceKind::Codex => 1, - SourceKind::Opencode => 2, - _ => 3, - }); - assert_eq!( - sources, - vec![ - SourceKind::ClaudeCode, - SourceKind::Codex, - SourceKind::Opencode - ] - ); - for b in &out { - assert_eq!(b.tool_name, "Bash"); - } - } - - #[test] - fn signal_b_skips_events_without_user_turn_blocks() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let turns = vec![turn_with( + "s3", + "u3", + "m3", + "m4", + "tu_c", + 120_000, + 30_000, + ), + ]; + let turns = vec![ + turn_with( SourceKind::ClaudeCode, "s1", "m1", 0, vec![tc("tu_a", "Bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &[], - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert!(out.is_empty()); - } - - #[test] - fn signal_b_honors_custom_threshold() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( + ), + turn_with( SourceKind::ClaudeCode, - "s1", - "u1", - "m1", + "s2", "m2", - "tu_a", - 4_000, - 1_000, - )]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", 0, - vec![tc("tu_a", "Bash")], - )]; - let def = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert!(def.is_empty()); - let tight = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: Some(500), - min_occurrences: None, - }); - assert_eq!(tight.len(), 1); - assert_eq!(tight[0].evidenced_max_output, 1_000); - } - - #[test] - fn signal_b_falls_back_to_unknown_tool_name() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "orphan", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( + vec![tc("tu_b", "Bash")], + ), + turn_with( SourceKind::ClaudeCode, - "s1", - "u1", - "m1", - "m2", - "orphan", - 80_000, - 20_000, - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &[], - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].tool_name, ""); - assert_eq!(out[0].cost, 0.0); - } + "s3", + "m3", + 0, + vec![tc("tu_c", "Bash")], + ), + ]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + let b = &out[0]; + assert_eq!(b.occurrence_count, 3); + assert_eq!(b.evidenced_max_output, 30_000); + assert_eq!(b.evidence.len(), 3); +} - #[test] - fn signal_b_does_not_double_count_carrier_plus_subagent_notification() { - let pricing = load_builtin_pricing(); - let events = vec![ - evt_with( - SourceKind::ClaudeCode, - "s1", - "tu_a", - 0, - Some("m1"), - ToolResultEventSource::ToolResult, - None, - Some(0), - ), - evt_with( - SourceKind::ClaudeCode, - "s1", - "tu_a", - 1, - Some("m1"), - ToolResultEventSource::SubagentNotification, - Some(200), - Some(1), - ), - ]; - let user_turns = vec![user_turn_with( +#[test] +fn signal_b_emits_one_bucket_per_source_tool_pair() { + let pricing = load_builtin_pricing(); + let events = vec![ + evt_with( SourceKind::ClaudeCode, "s1", - "u1", - "m1", - "m2", "tu_a", - 80_000, - 20_000, - )]; - let turns = vec![turn_with( - SourceKind::ClaudeCode, - "s1", - "m1", 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].occurrence_count, 1); - assert_eq!(out[0].evidenced_max_output, 20_000); - } - - // ------------------------------------------------------------------- - // Top-level orchestration - // ------------------------------------------------------------------- - - #[test] - fn orchestration_runs_both_signals() { - let pricing = load_builtin_pricing(); - let settings = vec![loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), - )]; - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( + Some("m1"), + ToolResultEventSource::ToolResult, + None, + None, + ), + evt_with( + SourceKind::Codex, + "s2", + "call_b", + 0, + Some("m2"), + ToolResultEventSource::ToolResult, + None, + None, + ), + evt_with( + SourceKind::Opencode, + "s3", + "opc_c", + 0, + Some("m3"), + ToolResultEventSource::ToolResult, + None, + None, + ), + ]; + let user_turns = vec![ + user_turn_with( SourceKind::ClaudeCode, "s1", "u1", @@ -793,281 +552,522 @@ "tu_a", 80_000, 20_000, - )]; - let turns = vec![turn_with( + ), + user_turn_with( + SourceKind::Codex, + "s2", + "u2", + "m2", + "m3", + "call_b", + 90_000, + 22_500, + ), + user_turn_with( + SourceKind::Opencode, + "s3", + "u3", + "m3", + "m4", + "opc_c", + 85_000, + 21_250, + ), + ]; + let turns = vec![ + turn_with( SourceKind::ClaudeCode, "s1", "m1", 0, vec![tc("tu_a", "Bash")], - )]; - let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { - settings: &settings, - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 2); - let mut kinds: Vec = out.iter().map(|b| b.kind).collect(); - kinds.sort_by_key(|k| match k { - ToolOutputBloatKind::ObservedBloat => 0, - ToolOutputBloatKind::StaticConfig => 1, - }); - assert_eq!( - kinds, - vec![ - ToolOutputBloatKind::ObservedBloat, - ToolOutputBloatKind::StaticConfig, - ] - ); + ), + turn_with( + SourceKind::Codex, + "s2", + "m2", + 0, + vec![tc("call_b", "shell")], + ), + turn_with( + SourceKind::Opencode, + "s3", + "m3", + 0, + vec![tc("opc_c", "bash")], + ), + ]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 3); + let mut sources: Vec = out.iter().map(|b| b.source).collect(); + sources.sort_by_key(|s| match s { + SourceKind::ClaudeCode => 0, + SourceKind::Codex => 1, + SourceKind::Opencode => 2, + _ => 3, + }); + assert_eq!( + sources, + vec![ + SourceKind::ClaudeCode, + SourceKind::Codex, + SourceKind::Opencode + ] + ); + for b in &out { + assert_eq!(b.tool_name, "Bash"); } +} - #[test] - fn orchestration_signal_a_only() { - let pricing = load_builtin_pricing(); - let settings = vec![loaded( - "/u/.claude/settings.json", - json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), - )]; - let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { - settings: &settings, - tool_result_events: &[], - user_turns: &[], - turns: &[], - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].kind, ToolOutputBloatKind::StaticConfig); - } +#[test] +fn signal_b_skips_events_without_user_turn_blocks() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &[], + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert!(out.is_empty()); +} - #[test] - fn orchestration_signal_b_only() { - let pricing = load_builtin_pricing(); - let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; - let user_turns = vec![user_turn_with( +#[test] +fn signal_b_honors_custom_threshold() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 4_000, + 1_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let def = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert!(def.is_empty()); + let tight = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: Some(500), + min_occurrences: None, + }); + assert_eq!(tight.len(), 1); + assert_eq!(tight[0].evidenced_max_output, 1_000); +} + +#[test] +fn signal_b_falls_back_to_unknown_tool_name() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "orphan", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "orphan", + 80_000, + 20_000, + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &[], + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].tool_name, ""); + assert_eq!(out[0].cost, 0.0); +} + +#[test] +fn signal_b_does_not_double_count_carrier_plus_subagent_notification() { + let pricing = load_builtin_pricing(); + let events = vec![ + evt_with( SourceKind::ClaudeCode, "s1", - "u1", - "m1", - "m2", "tu_a", - 80_000, - 20_000, - )]; - let turns = vec![turn_with( + 0, + Some("m1"), + ToolResultEventSource::ToolResult, + None, + Some(0), + ), + evt_with( SourceKind::ClaudeCode, "s1", - "m1", - 0, - vec![tc("tu_a", "Bash")], - )]; - let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { - settings: &[], - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].kind, ToolOutputBloatKind::ObservedBloat); - } + "tu_a", + 1, + Some("m1"), + ToolResultEventSource::SubagentNotification, + Some(200), + Some(1), + ), + ]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].occurrence_count, 1); + assert_eq!(out[0].evidenced_max_output, 20_000); +} + +// ------------------------------------------------------------------- +// Top-level orchestration +// ------------------------------------------------------------------- + +#[test] +fn orchestration_runs_both_signals() { + let pricing = load_builtin_pricing(); + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), + )]; + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { + settings: &settings, + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 2); + let mut kinds: Vec = out.iter().map(|b| b.kind).collect(); + kinds.sort_by_key(|k| match k { + ToolOutputBloatKind::ObservedBloat => 0, + ToolOutputBloatKind::StaticConfig => 1, + }); + assert_eq!( + kinds, + vec![ + ToolOutputBloatKind::ObservedBloat, + ToolOutputBloatKind::StaticConfig, + ] + ); +} + +#[test] +fn orchestration_signal_a_only() { + let pricing = load_builtin_pricing(); + let settings = vec![loaded( + "/u/.claude/settings.json", + json!({ BASH_MAX_OUTPUT_ENV_KEY: "80000" }), + )]; + let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { + settings: &settings, + tool_result_events: &[], + user_turns: &[], + turns: &[], + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].kind, ToolOutputBloatKind::StaticConfig); +} - // ------------------------------------------------------------------- - // WasteFinding adapter - // ------------------------------------------------------------------- +#[test] +fn orchestration_signal_b_only() { + let pricing = load_builtin_pricing(); + let events = vec![evt("s1", "tu_a", 0, Some("m1"))]; + let user_turns = vec![user_turn_with( + SourceKind::ClaudeCode, + "s1", + "u1", + "m1", + "m2", + "tu_a", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::ClaudeCode, + "s1", + "m1", + 0, + vec![tc("tu_a", "Bash")], + )]; + let out = detect_tool_output_bloat(&DetectToolOutputBloatOptions { + settings: &[], + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].kind, ToolOutputBloatKind::ObservedBloat); +} - #[test] - fn finding_adapter_signal_a_paste_targets_settings_json() { - let f = tool_output_bloat_to_finding(&ToolOutputBloat { - source: SourceKind::ClaudeCode, - kind: ToolOutputBloatKind::StaticConfig, - tool_name: "Bash".to_string(), - configured_limit: Some(80_000), - evidenced_max_output: 20_000, - evidenced_p95_output: None, - occurrence_count: 1, - cost: 0.0, - evidence: vec!["/u/.claude/settings.json".to_string()], - }); - assert_eq!(f.kind, "tool-output-bloat"); - assert_eq!(f.actions.len(), 1); - match &f.actions[0] { - WasteAction::Paste { label, text } => { - assert!(label.contains("settings.json"), "label: {label}"); - assert!(text.contains(BASH_MAX_OUTPUT_ENV_KEY), "text: {text}"); - assert!( - text.contains("\"60000\""), - "text should target 60000 chars: {text}" - ); - } - other => panic!("expected Paste action, got {other:?}"), +// ------------------------------------------------------------------- +// WasteFinding adapter +// ------------------------------------------------------------------- + +#[test] +fn finding_adapter_signal_a_paste_targets_settings_json() { + let f = tool_output_bloat_to_finding(&ToolOutputBloat { + source: SourceKind::ClaudeCode, + kind: ToolOutputBloatKind::StaticConfig, + tool_name: "Bash".to_string(), + configured_limit: Some(80_000), + evidenced_max_output: 20_000, + evidenced_p95_output: None, + occurrence_count: 1, + cost: 0.0, + evidence: vec!["/u/.claude/settings.json".to_string()], + }); + assert_eq!(f.kind, "tool-output-bloat"); + assert_eq!(f.actions.len(), 1); + match &f.actions[0] { + WasteAction::Paste { label, text } => { + assert!(label.contains("settings.json"), "label: {label}"); + assert!(text.contains(BASH_MAX_OUTPUT_ENV_KEY), "text: {text}"); + assert!( + text.contains("\"60000\""), + "text should target 60000 chars: {text}" + ); } - assert_eq!(f.estimated_savings.tokens_per_session, Some(20_000)); + other => panic!("expected Paste action, got {other:?}"), } + assert_eq!(f.estimated_savings.tokens_per_session, Some(20_000)); +} - #[test] - fn finding_adapter_signal_b_emits_instruction_paste() { - let f = tool_output_bloat_to_finding(&ToolOutputBloat { - source: SourceKind::Codex, - kind: ToolOutputBloatKind::ObservedBloat, - tool_name: "shell".to_string(), - configured_limit: None, - evidenced_max_output: 25_000, - evidenced_p95_output: Some(24_000), - occurrence_count: 4, - cost: 0.07, - evidence: vec!["s1".to_string(), "s2".to_string()], - }); - assert_eq!(f.kind, "tool-output-bloat"); - assert_eq!(f.severity, WasteSeverity::Warn); - assert!(f.title.contains("codex shell"), "title: {}", f.title); - assert!(f.title.contains("4×"), "title: {}", f.title); - assert!(f.detail.contains("head"), "detail: {}", f.detail); - assert!(f.detail.contains("tail"), "detail: {}", f.detail); - assert!(f.detail.contains("grep"), "detail: {}", f.detail); - assert!(matches!(f.actions[0], WasteAction::Paste { .. })); - } +#[test] +fn finding_adapter_signal_b_emits_instruction_paste() { + let f = tool_output_bloat_to_finding(&ToolOutputBloat { + source: SourceKind::Codex, + kind: ToolOutputBloatKind::ObservedBloat, + tool_name: "shell".to_string(), + configured_limit: None, + evidenced_max_output: 25_000, + evidenced_p95_output: Some(24_000), + occurrence_count: 4, + cost: 0.07, + evidence: vec!["s1".to_string(), "s2".to_string()], + }); + assert_eq!(f.kind, "tool-output-bloat"); + assert_eq!(f.severity, WasteSeverity::Warn); + assert!(f.title.contains("codex shell"), "title: {}", f.title); + assert!(f.title.contains("4×"), "title: {}", f.title); + assert!(f.detail.contains("head"), "detail: {}", f.detail); + assert!(f.detail.contains("tail"), "detail: {}", f.detail); + assert!(f.detail.contains("grep"), "detail: {}", f.detail); + assert!(matches!(f.actions[0], WasteAction::Paste { .. })); +} - // ------------------------------------------------------------------- - // Fixture-driven integration coverage - // ------------------------------------------------------------------- +// ------------------------------------------------------------------- +// Fixture-driven integration coverage +// ------------------------------------------------------------------- - fn workspace_fixture(rel: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("tests") - .join("fixtures") - .join(rel) - } +fn workspace_fixture(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("tests") + .join("fixtures") + .join(rel) +} - #[test] - fn fixture_settings_json_oversized_bash_output_length() { - let path = workspace_fixture("claude/settings/oversized-bash-output-length.json"); - let loaded = load_claude_settings(&path).expect("fixture loads"); - let result = detect_static_config_bloat(&DetectStaticConfigBloatOptions { - threshold: None, - settings: vec![loaded], - }); - assert_eq!(result.len(), 1); - assert_eq!(result[0].configured_limit, Some(80_000)); - assert_eq!( - result[0].evidence, - vec![path.to_string_lossy().into_owned()] - ); - } +#[test] +fn fixture_settings_json_oversized_bash_output_length() { + let path = workspace_fixture("claude/settings/oversized-bash-output-length.json"); + let loaded = load_claude_settings(&path).expect("fixture loads"); + let result = detect_static_config_bloat(&DetectStaticConfigBloatOptions { + threshold: None, + settings: vec![loaded], + }); + assert_eq!(result.len(), 1); + assert_eq!(result[0].configured_limit, Some(80_000)); + assert_eq!( + result[0].evidence, + vec![path.to_string_lossy().into_owned()] + ); +} - #[test] - fn fixture_claude_oversized_bash_output_enriched_path() { - use crate::reader::{parse_claude_session, ClaudeParseOptions}; - let pricing = load_builtin_pricing(); - let path = workspace_fixture("claude/oversized-bash-output.jsonl"); - let parsed = parse_claude_session(&path, &ClaudeParseOptions::default()).expect("parses"); - // cl100k tokenizes repeated single-char content far below the - // bytes/4 heuristic; we don't have cl100k wired here so the - // detector falls back to bytes/4 either way. Use a low threshold - // so the assertion still trips on the synthetic content. - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &parsed.tool_result_events, - user_turns: &parsed.user_turns, - turns: &parsed.turns, - pricing: &pricing, - threshold: Some(5_000), - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].source, SourceKind::ClaudeCode); - assert_eq!(out[0].tool_name, "Bash"); - assert!(out[0].evidenced_max_output > 5_000); - } +#[test] +fn fixture_claude_oversized_bash_output_enriched_path() { + use crate::reader::{parse_claude_session, ClaudeParseOptions}; + let pricing = load_builtin_pricing(); + let path = workspace_fixture("claude/oversized-bash-output.jsonl"); + let parsed = parse_claude_session(&path, &ClaudeParseOptions::default()).expect("parses"); + // cl100k tokenizes repeated single-char content far below the + // bytes/4 heuristic; we don't have cl100k wired here so the + // detector falls back to bytes/4 either way. Use a low threshold + // so the assertion still trips on the synthetic content. + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &parsed.tool_result_events, + user_turns: &parsed.user_turns, + turns: &parsed.turns, + pricing: &pricing, + threshold: Some(5_000), + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].source, SourceKind::ClaudeCode); + assert_eq!(out[0].tool_name, "Bash"); + assert!(out[0].evidenced_max_output > 5_000); +} - #[test] - fn fixture_claude_oversized_bash_output_content_length_fallback() { - use crate::reader::{parse_claude_session, ClaudeParseOptions}; - let pricing = load_builtin_pricing(); - let path = workspace_fixture("claude/oversized-bash-output.jsonl"); - let parsed = parse_claude_session(&path, &ClaudeParseOptions::default()).expect("parses"); - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &parsed.tool_result_events, - user_turns: &[], - turns: &parsed.turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].source, SourceKind::ClaudeCode); - assert_eq!(out[0].tool_name, "Bash"); - assert!(out[0].evidenced_max_output >= DEFAULT_BLOAT_TOKEN_THRESHOLD); - } +#[test] +fn fixture_claude_oversized_bash_output_content_length_fallback() { + use crate::reader::{parse_claude_session, ClaudeParseOptions}; + let pricing = load_builtin_pricing(); + let path = workspace_fixture("claude/oversized-bash-output.jsonl"); + let parsed = parse_claude_session(&path, &ClaudeParseOptions::default()).expect("parses"); + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &parsed.tool_result_events, + user_turns: &[], + turns: &parsed.turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].source, SourceKind::ClaudeCode); + assert_eq!(out[0].tool_name, "Bash"); + assert!(out[0].evidenced_max_output >= DEFAULT_BLOAT_TOKEN_THRESHOLD); +} - #[test] - fn fixture_codex_oversized_shell_output() { - use crate::reader::codex::{parse_codex_session, ParseCodexOptions}; - let pricing = load_builtin_pricing(); - let path = workspace_fixture("codex/oversized-shell-output.jsonl"); - let parsed = parse_codex_session(&path, &ParseCodexOptions::default()).expect("parses"); - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &parsed.tool_result_events, - user_turns: &parsed.user_turns, - turns: &parsed.turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].source, SourceKind::Codex); - // Codex `shell` normalizes to canonical `Bash`. - assert_eq!(out[0].tool_name, "Bash"); - assert!(out[0].evidenced_max_output >= DEFAULT_BLOAT_TOKEN_THRESHOLD); - } +#[test] +fn fixture_codex_oversized_shell_output() { + use crate::reader::codex::{parse_codex_session, ParseCodexOptions}; + let pricing = load_builtin_pricing(); + let path = workspace_fixture("codex/oversized-shell-output.jsonl"); + let parsed = parse_codex_session(&path, &ParseCodexOptions::default()).expect("parses"); + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &parsed.tool_result_events, + user_turns: &parsed.user_turns, + turns: &parsed.turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].source, SourceKind::Codex); + // Codex `shell` normalizes to canonical `Bash`. + assert_eq!(out[0].tool_name, "Bash"); + assert!(out[0].evidenced_max_output >= DEFAULT_BLOAT_TOKEN_THRESHOLD); +} - #[test] - fn fixture_opencode_synthesized_bash() { - let pricing = load_builtin_pricing(); - let events = vec![evt_with( - SourceKind::Opencode, - "ses_bloat", - "opc_bash_1", - 0, - Some("msg_bloat"), - ToolResultEventSource::ToolResult, - None, - None, - )]; - let user_turns = vec![user_turn_with( - SourceKind::Opencode, - "ses_bloat", - "u_bloat", - "msg_bloat", - "msg_bloat_next", - "opc_bash_1", - 80_000, - 20_000, - )]; - let turns = vec![turn_with( - SourceKind::Opencode, - "ses_bloat", - "msg_bloat", - 0, - vec![tc("opc_bash_1", "bash")], - )]; - let out = detect_observed_bloat(&DetectObservedBloatOptions { - tool_result_events: &events, - user_turns: &user_turns, - turns: &turns, - pricing: &pricing, - threshold: None, - min_occurrences: None, - }); - assert_eq!(out.len(), 1); - assert_eq!(out[0].source, SourceKind::Opencode); - assert_eq!(out[0].tool_name, "Bash"); - } +#[test] +fn fixture_opencode_synthesized_bash() { + let pricing = load_builtin_pricing(); + let events = vec![evt_with( + SourceKind::Opencode, + "ses_bloat", + "opc_bash_1", + 0, + Some("msg_bloat"), + ToolResultEventSource::ToolResult, + None, + None, + )]; + let user_turns = vec![user_turn_with( + SourceKind::Opencode, + "ses_bloat", + "u_bloat", + "msg_bloat", + "msg_bloat_next", + "opc_bash_1", + 80_000, + 20_000, + )]; + let turns = vec![turn_with( + SourceKind::Opencode, + "ses_bloat", + "msg_bloat", + 0, + vec![tc("opc_bash_1", "bash")], + )]; + let out = detect_observed_bloat(&DetectObservedBloatOptions { + tool_result_events: &events, + user_turns: &user_turns, + turns: &turns, + pricing: &pricing, + threshold: None, + min_occurrences: None, + }); + assert_eq!(out.len(), 1); + assert_eq!(out[0].source, SourceKind::Opencode); + assert_eq!(out[0].tool_name, "Bash"); +} diff --git a/crates/relayburn-sdk/src/analyze/util.rs b/crates/relayburn-sdk/src/analyze/util.rs index cf0d23e..e02ecbf 100644 --- a/crates/relayburn-sdk/src/analyze/util.rs +++ b/crates/relayburn-sdk/src/analyze/util.rs @@ -32,6 +32,25 @@ where by_session } +/// Like [`group_turns_by_session`], but stable-sorts each session's bucket by +/// `turn_index` before returning. Most detectors want chronological order +/// within a session; this folds the repeated post-grouping +/// `bucket.sort_by_key(|t| t.turn_index)` into one place. The sort is stable, +/// so it preserves input order among equal `turn_index` values — matching the +/// TS `Array.prototype.sort` contract the detectors mirror. +pub(crate) fn group_turns_by_session_sorted<'a, I>( + turns: I, +) -> IndexMap> +where + I: IntoIterator, +{ + let mut by_session = group_turns_by_session(turns); + for bucket in by_session.values_mut() { + bucket.sort_by_key(|t| t.turn_index); + } + by_session +} + /// Format a USD amount to 4 decimal places (`$0.1234`), matching the TS /// finding adapters' money formatting. pub(crate) fn fmt_usd(n: f64) -> String { From f3fbf72c5b88a3570cf9a8b23ba0a52fa4e03527 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:24:17 -0400 Subject: [PATCH 13/22] refactor(sdk/analyze): consolidate first-seen-unique into util helpers The "keep first occurrence per key, in input order" loop was reimplemented six times: dedup_strings / dedup_numbers (tool_call_patterns), dedup_turns (patterns), and three inline tools_involved builders in patterns/streaks.rs. Add util::first_seen_unique_by (key fn) + first_seen_unique (value-keyed) and route all six through them. dedup_numbers keeps its trailing sort_unstable at the call site; the streaks tools_involved order (fixture-gated) is preserved since first-seen order is identical. All 992 tests pass, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/patterns.rs | 16 ++++------ .../src/analyze/patterns/streaks.rs | 17 ++--------- .../src/analyze/tool_call_patterns.rs | 21 +++---------- crates/relayburn-sdk/src/analyze/util.rs | 30 +++++++++++++++++++ 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/patterns.rs b/crates/relayburn-sdk/src/analyze/patterns.rs index 644961a..296e3ee 100644 --- a/crates/relayburn-sdk/src/analyze/patterns.rs +++ b/crates/relayburn-sdk/src/analyze/patterns.rs @@ -28,7 +28,9 @@ use crate::analyze::findings::{ SkillRecallDup, SystemPromptTax, }; use crate::analyze::pricing::PricingTable; -use crate::analyze::util::{group_turns_by_session_sorted, stringify_tool_result, truncate_chars}; +use crate::analyze::util::{ + first_seen_unique_by, group_turns_by_session_sorted, stringify_tool_result, truncate_chars, +}; mod shell; @@ -617,16 +619,8 @@ fn detect_streaks( // Misc helpers // --------------------------------------------------------------------------- -fn dedup_turns<'a>(turns: Vec<&'a TurnRecord>) -> Vec<&'a TurnRecord> { - let mut seen: HashSet = HashSet::new(); - let mut out: Vec<&'a TurnRecord> = Vec::new(); - for t in turns { - let key = format!("{}|{}", t.session_id, t.message_id); - if seen.insert(key) { - out.push(t); - } - } - out +fn dedup_turns(turns: Vec<&TurnRecord>) -> Vec<&TurnRecord> { + first_seen_unique_by(turns, |t| format!("{}|{}", t.session_id, t.message_id)) } #[allow(clippy::too_many_arguments)] diff --git a/crates/relayburn-sdk/src/analyze/patterns/streaks.rs b/crates/relayburn-sdk/src/analyze/patterns/streaks.rs index 806d959..5ff089e 100644 --- a/crates/relayburn-sdk/src/analyze/patterns/streaks.rs +++ b/crates/relayburn-sdk/src/analyze/patterns/streaks.rs @@ -6,6 +6,7 @@ use crate::reader::{ToolResultEventRecord, ToolResultEventSource, ToolResultStat use crate::analyze::findings::{CancellationRun, FailureRun, FailureRunErrorSignature, RetryLoop}; use crate::analyze::pricing::PricingTable; +use crate::analyze::util::first_seen_unique; pub(super) struct GraphStatusPatterns { pub(super) retry_loops: Vec, @@ -128,13 +129,7 @@ fn detect_graph_failure_runs_for_session<'a>( let first = streak.first().unwrap(); let last = streak.last().unwrap(); // First-seen unique tool order. - let mut tools: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for r in streak.iter() { - if seen.insert(r.tool.clone()) { - tools.push(r.tool.clone()); - } - } + let tools = first_seen_unique(streak.iter().map(|r| r.tool.clone())); let contributing = dedup_defined_turns(streak); let mut run = FailureRun { session_id: session_id.to_string(), @@ -313,13 +308,7 @@ pub(crate) fn detect_failure_runs_for_session<'a>( } let first = streak.first().unwrap(); let last = streak.last().unwrap(); - let mut tools: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for r in streak.iter() { - if seen.insert(r.call.name.clone()) { - tools.push(r.call.name.clone()); - } - } + let tools = first_seen_unique(streak.iter().map(|r| r.call.name.clone())); let turns_in_streak: Vec<&TurnRecord> = streak.iter().map(|r| r.turn).collect(); let contributing = dedup_turns(turns_in_streak); let mut run = FailureRun { diff --git a/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs b/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs index ebaf1fa..a2815c8 100644 --- a/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs +++ b/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs @@ -7,7 +7,7 @@ //! overhead estimate. Reads only `TurnRecord.tool_calls` so it runs on any //! slice with `has_tool_calls` coverage. -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use crate::reader::{ normalize_tool_name, parse_bash_command, BashParse, SourceKind, ToolCall, TurnRecord, @@ -390,26 +390,13 @@ fn price_tokens(tokens: u64, rate_per_token: f64) -> f64 { } fn dedup_numbers(xs: &[u64]) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut out: Vec = Vec::new(); - for &x in xs { - if seen.insert(x) { - out.push(x); - } - } + let mut out = first_seen_unique(xs.iter().copied()); out.sort_unstable(); out } fn dedup_strings(xs: &[String]) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut out: Vec = Vec::new(); - for x in xs { - if seen.insert(x.clone()) { - out.push(x.clone()); - } - } - out + first_seen_unique(xs.iter().cloned()) } // --------------------------------------------------------------------------- @@ -447,7 +434,7 @@ only the PR fields the agent reads.", } } -use super::util::{fmt_usd, format_with_commas, group_turns_by_session_sorted}; +use super::util::{first_seen_unique, fmt_usd, format_with_commas, group_turns_by_session_sorted}; pub fn tool_call_pattern_to_finding(finding: &ToolCallPatternFinding) -> WasteFinding { let evidence_str = if finding.evidence.is_empty() { diff --git a/crates/relayburn-sdk/src/analyze/util.rs b/crates/relayburn-sdk/src/analyze/util.rs index e02ecbf..2e86d6a 100644 --- a/crates/relayburn-sdk/src/analyze/util.rs +++ b/crates/relayburn-sdk/src/analyze/util.rs @@ -2,11 +2,41 @@ //! approximate token<->byte heuristic, turn grouping, and tool-result //! stringification. +use std::collections::HashSet; +use std::hash::Hash; + use indexmap::IndexMap; use serde_json::Value; use crate::reader::TurnRecord; +/// Collect items in first-seen order, dropping later duplicates as judged by +/// `key`. The first occurrence of each distinct key is kept, in input order — +/// the "first-seen-unique" pattern several detectors use to build +/// `tools_involved`-style lists. +pub(crate) fn first_seen_unique_by(items: impl IntoIterator, key: F) -> Vec +where + K: Hash + Eq, + F: Fn(&T) -> K, +{ + let mut seen: HashSet = HashSet::new(); + let mut out: Vec = Vec::new(); + for item in items { + if seen.insert(key(&item)) { + out.push(item); + } + } + out +} + +/// [`first_seen_unique_by`] keyed on the item itself, for hashable values. +pub(crate) fn first_seen_unique(items: impl IntoIterator) -> Vec +where + T: Clone + Hash + Eq, +{ + first_seen_unique_by(items, |x| x.clone()) +} + /// Bucket turns by `session_id`, preserving first-seen (insertion) order so /// the result iterates in the same order as the TS `Map` it ports — analyze fixtures depend on that ordering. From cce05ea08c4b0efcf59926d00410f5888d6dbf6d Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:26:09 -0400 Subject: [PATCH 14/22] docs(sdk/analyze): explain why quality::parse_iso8601_ms is not shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A survey flagged quality.rs's ISO parser as a missed consolidation against util::time::parse_iso_ms. It is a deliberate superset (applies ±HH:MM offsets, rejects out-of-range components and trailing garbage) that outcome inference needs for correct is_recent classification, whereas the shared parser stays lean for the reader/ledger ingest path. Document the divergence so it is not naively merged later. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/analyze/quality.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/relayburn-sdk/src/analyze/quality.rs b/crates/relayburn-sdk/src/analyze/quality.rs index 6dcf7b1..80e5a0e 100644 --- a/crates/relayburn-sdk/src/analyze/quality.rs +++ b/crates/relayburn-sdk/src/analyze/quality.rs @@ -363,6 +363,15 @@ fn now_ms_system() -> i64 { /// readers and the test fixtures: `YYYY-MM-DDTHH:MM:SS(.fff)?(Z|±HH:MM)`. /// Returns milliseconds since the Unix epoch, or `None` when the input fails /// to match — mirroring `Number.isFinite(Date.parse(...))` in TS. +/// +/// Deliberately *not* folded into [`crate::util::time::parse_iso_ms`]: this +/// variant is a strict superset — it applies `±HH:MM` timezone offsets, +/// rejects out-of-range components (month/day/hour/minute/second) and trailing +/// garbage, because outcome inference compares these timestamps against `now` +/// and a lenient parse would misclassify `is_recent`. The shared parser is +/// consumed by the readers/ledger on the hot ingest path and intentionally +/// stays lean; unifying them would mean widening that shared parser's +/// behavior for every caller, which is out of scope here. fn parse_iso8601_ms(s: &str) -> Option { let bytes = s.as_bytes(); if bytes.len() < 19 { From 63b0b74b6b86c08276ca0e446917f95d8bbd0ffa Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:35:03 -0400 Subject: [PATCH 15/22] refactor(sdk/analyze): externalize ghost_surface tests ghost_surface.rs was 1235 lines with a ~700-line inline test block. Move the main `mod tests` block verbatim to ghost_surface_tests.rs via `#[cfg(test)] #[path] mod tests;` (the test-only `use adapters::{...}` import stays inline). Source file is now 533 lines of code; the 30 tests pass identically. Pure relocation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/analyze/ghost_surface.rs | 706 +----------------- .../src/analyze/ghost_surface_tests.rs | 705 +++++++++++++++++ 2 files changed, 707 insertions(+), 704 deletions(-) create mode 100644 crates/relayburn-sdk/src/analyze/ghost_surface_tests.rs diff --git a/crates/relayburn-sdk/src/analyze/ghost_surface.rs b/crates/relayburn-sdk/src/analyze/ghost_surface.rs index 7e73f13..3edf70a 100644 --- a/crates/relayburn-sdk/src/analyze/ghost_surface.rs +++ b/crates/relayburn-sdk/src/analyze/ghost_surface.rs @@ -529,707 +529,5 @@ pub fn ghost_surface_to_finding( // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - use crate::analyze::findings::WasteSeverity; - use std::path::PathBuf; - - fn fixtures_root() -> PathBuf { - // crates/relayburn-analyze/Cargo.toml -> repo root is two levels up. - let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - manifest - .parent() - .unwrap() - .parent() - .unwrap() - .join("tests") - .join("fixtures") - .join("ghost-surface") - } - - fn claude_home() -> PathBuf { - fixtures_root().join("claude") - } - fn codex_home() -> PathBuf { - fixtures_root().join("codex") - } - fn opencode_project() -> PathBuf { - fixtures_root().join("opencode-project") - } - - const RATE: f64 = 1e-6; - - fn make_inputs() -> GhostSurfaceInputs { - GhostSurfaceInputs { - observed_names_by_source: HashMap::new(), - session_count_by_source: HashMap::new(), - dollar_per_token: RATE, - claude_home: Some(claude_home()), - codex_home: Some(codex_home()), - opencode_projects: Some(vec![opencode_project()]), - user_turn_text_by_session: None, - } - } - - fn observed(source: SourceKind, names: &[&str]) -> HashMap> { - let mut m = HashMap::new(); - m.insert(source, names.iter().map(|s| s.to_string()).collect()); - m - } - - fn observed_multi(entries: &[(SourceKind, &[&str])]) -> HashMap> { - let mut m = HashMap::new(); - for (s, names) in entries { - m.insert(*s, names.iter().map(|s| s.to_string()).collect()); - } - m - } - - fn count_map(entries: &[(SourceKind, u64)]) -> HashMap { - entries.iter().copied().collect() - } - - type UserTextEntries = Vec<(SourceKind, Vec<(String, Vec)>)>; - - fn user_text(entries: UserTextEntries) -> HashMap>> { - let mut out = HashMap::new(); - for (src, sessions) in entries { - let mut inner = HashMap::new(); - for (sid, texts) in sessions { - inner.insert(sid, texts); - } - out.insert(src, inner); - } - out - } - - // ---- claudeGhostAdapter -------------------------------------------------- - - #[test] - fn claude_enumerates_agents_skills_commands() { - let candidates = ClaudeGhostAdapter.enumerate(&make_inputs()); - let kinds: HashSet = candidates.iter().map(|c| c.kind).collect(); - assert!(kinds.contains(&GhostFindingKind::GhostAgent), "has agents"); - assert!(kinds.contains(&GhostFindingKind::GhostSkill), "has skills"); - assert!( - kinds.contains(&GhostFindingKind::GhostCommand), - "has commands" - ); - let mut agents: Vec = candidates - .iter() - .filter(|c| c.kind == GhostFindingKind::GhostAgent) - .map(|c| c.basename.clone()) - .collect(); - agents.sort(); - assert_eq!(agents, vec!["code-reviewer.md", "forgotten-helper.md"]); - } - - #[test] - fn claude_returns_empty_when_home_missing() { - let mut inputs = make_inputs(); - inputs.claude_home = Some(fixtures_root().join("does-not-exist")); - let candidates = ClaudeGhostAdapter.enumerate(&inputs); - assert_eq!(candidates.len(), 0); - } - - #[test] - fn claude_detects_ghost_agent_when_basename_not_observed() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); - let ghosts = detect_ghost_surface(&inputs); - let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .collect(); - let mut basenames: Vec = - claude_ghosts.iter().map(|g| basename_of(&g.path)).collect(); - basenames.sort(); - assert_eq!( - basenames, - vec![ - "forgotten-helper.md", - "openspec-apply.md", - "openspec-archive.md", - ] - ); - let helper = claude_ghosts - .iter() - .find(|g| g.path.ends_with("forgotten-helper.md")) - .unwrap(); - assert_eq!(helper.kind, GhostFindingKind::GhostAgent); - assert_eq!(helper.session_count, 10); - assert!(helper.cost > 0.0); - assert!(helper.size_tokens > 0); - } - - #[test] - fn claude_de_ghosts_command_via_slash_form() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::ClaudeCode, - vec![( - "session-1".to_string(), - vec![ - "/openspec-apply\nApply the latest proposal." - .to_string(), - ], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let mut basenames: Vec = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .map(|g| basename_of(&g.path)) - .collect(); - basenames.sort(); - assert_eq!( - basenames, - vec!["forgotten-helper.md", "openspec-archive.md"] - ); - } - - #[test] - fn claude_recognises_bare_command_name_no_leading_slash() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::ClaudeCode, - vec![( - "session-1".to_string(), - vec!["openspec-apply\nbody".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); - assert!( - apply.is_none(), - "claude openspec-apply should be de-ghosted" - ); - } - - #[test] - fn claude_falls_back_to_v1_when_user_text_empty() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); - inputs.user_turn_text_by_session = Some(HashMap::new()); - let ghosts = detect_ghost_surface(&inputs); - let mut basenames: Vec = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .map(|g| basename_of(&g.path)) - .collect(); - basenames.sort(); - assert_eq!( - basenames, - vec![ - "forgotten-helper.md", - "openspec-apply.md", - "openspec-archive.md", - ] - ); - } - - // ---- codexGhostAdapter -------------------------------------------------- - - #[test] - fn codex_enumerates_prompts_skills_rules_memories() { - let candidates = CodexGhostAdapter.enumerate(&make_inputs()); - let mut by_kind: HashMap> = HashMap::new(); - for c in &candidates { - by_kind.entry(c.kind).or_default().push(c.basename.clone()); - } - for v in by_kind.values_mut() { - v.sort(); - } - assert_eq!( - by_kind - .get(&GhostFindingKind::GhostPrompt) - .cloned() - .unwrap_or_default(), - vec!["openspec-apply.md", "openspec-archive.md", "refactor.md"] - ); - assert_eq!( - by_kind - .get(&GhostFindingKind::GhostSkill) - .cloned() - .unwrap_or_default(), - vec!["code-search.md"] - ); - assert_eq!( - by_kind - .get(&GhostFindingKind::GhostRule) - .cloned() - .unwrap_or_default(), - vec!["no-print.md"] - ); - assert_eq!( - by_kind - .get(&GhostFindingKind::GhostMemory) - .cloned() - .unwrap_or_default(), - vec!["preferences.md"] - ); - } - - #[test] - fn codex_flags_openspec_archive_as_ghost() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &["refactor", "code-search"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 5)]); - let ghosts = detect_ghost_surface(&inputs); - let codex_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::Codex) - .collect(); - let openspec = codex_ghosts - .iter() - .find(|g| g.path.ends_with("openspec-archive.md")); - assert!(openspec.is_some()); - assert_eq!(openspec.unwrap().kind, GhostFindingKind::GhostPrompt); - assert_eq!(openspec.unwrap().session_count, 5); - assert!(openspec.unwrap().cost > 0.0); - let kinds: HashSet = codex_ghosts.iter().map(|g| g.kind).collect(); - assert!(kinds.contains(&GhostFindingKind::GhostRule)); - assert!(kinds.contains(&GhostFindingKind::GhostMemory)); - } - - #[test] - fn codex_de_ghosts_via_slash_in_user_text() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &["refactor", "code-search"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 5)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["/openspec-apply\nApply the latest proposal please.".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let codex_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::Codex) - .collect(); - let apply = codex_ghosts - .iter() - .find(|g| g.path.ends_with("openspec-apply.md")); - assert!(apply.is_none(), "codex openspec-apply should be de-ghosted"); - let archive = codex_ghosts - .iter() - .find(|g| g.path.ends_with("openspec-archive.md")); - assert!( - archive.is_some(), - "codex openspec-archive should remain a ghost" - ); - } - - #[test] - fn codex_recognises_slash_not_at_start() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["Please run the /openspec-apply prompt now.".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!(apply.is_none(), "mid-line /openspec-apply should de-ghost"); - } - - #[test] - fn codex_does_not_match_extended_slash_command() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["/openspec-apply-foo bar".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!( - apply.is_some(), - "a longer slash command should not de-ghost the shorter stem" - ); - } - - #[test] - fn codex_ignores_slash_after_word_char() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["See https://example.com/openspec-apply for docs.".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!( - apply.is_some(), - "URL-style /openspec-apply should not de-ghost" - ); - } - - #[test] - fn codex_matches_case_insensitively() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["/OPENSPEC-Apply now".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!( - apply.is_none(), - "mixed-case /OPENSPEC-Apply should de-ghost" - ); - } - - #[test] - fn codex_does_not_de_ghost_from_claude_command_marker() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed_multi(&[ - (SourceKind::ClaudeCode, &["code-reviewer"]), - (SourceKind::Codex, &["refactor"]), - ]); - inputs.session_count_by_source = - count_map(&[(SourceKind::ClaudeCode, 1), (SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::ClaudeCode, - vec![( - "claude-session-1".to_string(), - vec!["/openspec-apply\nbody".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let codex_apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!(codex_apply.is_some(), "Codex must remain a ghost"); - let claude_apply = ghosts - .iter() - .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); - assert!( - claude_apply.is_none(), - "Claude side is de-ghosted by its own marker" - ); - } - - #[test] - fn claude_does_not_de_ghost_from_codex_slash() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed_multi(&[ - (SourceKind::ClaudeCode, &["code-reviewer"]), - (SourceKind::Codex, &["refactor"]), - ]); - inputs.session_count_by_source = - count_map(&[(SourceKind::ClaudeCode, 1), (SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "codex-session-1".to_string(), - vec!["/openspec-apply\nApply the latest proposal.".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let claude_apply = ghosts - .iter() - .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); - assert!( - claude_apply.is_some(), - "Claude must remain a ghost — Codex slash mustn't leak" - ); - let codex_apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!(codex_apply.is_none()); - } - - // ---- opencodeGhostAdapter ---------------------------------------------- - - #[test] - fn opencode_enumerates_declared_skills_commands_and_project_skills() { - let candidates = OpenCodeGhostAdapter.enumerate(&make_inputs()); - let declared: Vec<&GhostCandidate> = candidates - .iter() - .filter(|c| c.counted_by_catalog_bloat == Some(true)) - .collect(); - let project: Vec<&GhostCandidate> = candidates - .iter() - .filter(|c| c.counted_by_catalog_bloat != Some(true)) - .collect(); - let mut declared_names: Vec = declared.iter().map(|c| c.basename.clone()).collect(); - declared_names.sort(); - assert_eq!( - declared_names, - vec!["abandoned-helper", "code-search"], - "declared catalog skills are flagged with countedByCatalogBloat", - ); - let project_skills: Vec = project - .iter() - .filter(|c| c.kind == GhostFindingKind::GhostSkill) - .map(|c| c.basename.clone()) - .collect(); - assert_eq!(project_skills, vec!["project-skill.md"]); - let mut commands: Vec = project - .iter() - .filter(|c| c.kind == GhostFindingKind::GhostCommand) - .map(|c| c.basename.clone()) - .collect(); - commands.sort(); - assert_eq!(commands, vec!["deploy", "ghost-command"]); - } - - #[test] - fn opencode_emits_zero_cost_for_declared_catalog_bloat() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::Opencode, &["code-search", "deploy"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 20)]); - let ghosts = detect_ghost_surface(&inputs); - let opencode_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::Opencode) - .collect(); - let abandoned = opencode_ghosts - .iter() - .find(|g| g.path.contains("abandoned-helper")); - assert!(abandoned.is_some(), "declared catalog skill is reported"); - assert_eq!(abandoned.unwrap().cost, 0.0); - assert_eq!(abandoned.unwrap().counted_by_catalog_bloat, Some(true)); - let ghost_cmd = opencode_ghosts - .iter() - .find(|g| g.path.ends_with("#/commands/ghost-command")); - assert!(ghost_cmd.is_some()); - assert!(ghost_cmd.unwrap().cost > 0.0); - assert_eq!(ghost_cmd.unwrap().counted_by_catalog_bloat, None); - let project_skill = opencode_ghosts - .iter() - .find(|g| g.path.ends_with("project-skill.md")); - assert!(project_skill.is_some()); - assert!(project_skill.unwrap().cost > 0.0); - assert_eq!(project_skill.unwrap().counted_by_catalog_bloat, None); - } - - // ---- detectGhostSurface — orchestrator --------------------------------- - - #[test] - fn orchestrator_runs_every_adapter_sorted_by_cost_desc() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed_multi(&[ - (SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]), - (SourceKind::Codex, &["refactor", "code-search"]), - (SourceKind::Opencode, &["code-search", "deploy"]), - ]); - inputs.session_count_by_source = count_map(&[ - (SourceKind::ClaudeCode, 10), - (SourceKind::Codex, 5), - (SourceKind::Opencode, 20), - ]); - let ghosts = detect_ghost_surface(&inputs); - for w in ghosts.windows(2) { - assert!(w[0].cost >= w[1].cost, "sorted by cost desc"); - } - let sources: HashSet = ghosts.iter().map(|g| g.source).collect(); - assert!(sources.contains(&SourceKind::ClaudeCode)); - assert!(sources.contains(&SourceKind::Codex)); - assert!(sources.contains(&SourceKind::Opencode)); - } - - #[test] - fn orchestrator_treats_observed_case_insensitively() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed( - SourceKind::ClaudeCode, - &[ - "Code-Reviewer", - "GIT-COMMIT", - "forgotten-HELPER", - "openspec-archive", - "openspec-apply", - ], - ); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 1)]); - let ghosts = detect_ghost_surface(&inputs); - let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .collect(); - assert_eq!(claude_ghosts.len(), 0); - } - - #[test] - fn orchestrator_includes_ghost_when_session_count_zero() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &[]); - let ghosts = detect_ghost_surface(&inputs); - let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .collect(); - assert!(!claude_ghosts.is_empty()); - for g in &claude_ghosts { - assert_eq!(g.cost, 0.0); - assert_eq!(g.session_count, 0); - } - } - - // ---- ghostSurfaceToFinding --------------------------------------------- - - #[test] - fn finding_produces_mv_command_action() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &["code-reviewer"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); - let ghosts = detect_ghost_surface(&inputs); - let helper = ghosts - .iter() - .find(|g| g.path.ends_with("forgotten-helper.md")) - .unwrap(); - let finding = ghost_surface_to_finding( - helper, - &GhostSurfaceFindingOptions { - archive_dir: Some(PathBuf::from("/tmp/ghost-archive")), - }, - ); - assert_eq!(finding.kind, "ghost-agent"); - assert_eq!(finding.actions.len(), 1); - match &finding.actions[0] { - WasteAction::Command { text, .. } => { - assert!(text.contains("mv ")); - assert!(text.contains("/tmp/ghost-archive")); - assert!(text.contains(&helper.path)); - } - other => panic!("expected Command, got {other:?}"), - } - assert!(finding.title.contains("forgotten-helper")); - assert!(finding.detail.contains("claude-code")); - } - - #[test] - fn finding_marks_catalog_bloat_with_zero_cost_and_dedup_note() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Opencode, &["deploy"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 100)]); - let ghosts = detect_ghost_surface(&inputs); - let abandoned = ghosts - .iter() - .find(|g| g.path.contains("abandoned-helper")) - .unwrap(); - let finding = ghost_surface_to_finding(abandoned, &GhostSurfaceFindingOptions::default()); - assert_eq!(finding.estimated_savings.usd_per_session, Some(0.0)); - assert!(finding.detail.contains("catalog-bloat")); - } - - #[test] - fn finding_uses_per_session_cost_for_severity() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &["code-reviewer"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 100_000)]); - let ghosts = detect_ghost_surface(&inputs); - let helper = ghosts - .iter() - .find(|g| g.path.ends_with("forgotten-helper.md")) - .unwrap(); - // Cumulative cost is well above $1 (severity High threshold = $0.5). - assert!(helper.cost > 1.0, "expected cumulative cost > $1"); - // Per-session cost should be far below $0.05 (severity Warn threshold). - assert!( - helper.cost_per_session < 0.05, - "per-session cost should be below warn threshold" - ); - let finding = ghost_surface_to_finding( - helper, - &GhostSurfaceFindingOptions { - archive_dir: Some(PathBuf::from("/tmp/ghost-archive")), - }, - ); - assert_eq!( - finding.estimated_savings.usd_per_session, - Some(helper.cost_per_session) - ); - assert_eq!(finding.severity, WasteSeverity::Info); - } - - #[test] - fn finding_shell_quotes_paths_with_spaces() { - let ghost = GhostSurfaceFinding { - source: SourceKind::ClaudeCode, - kind: GhostFindingKind::GhostAgent, - path: "/Users/me/.claude/agents/my helper.md".to_string(), - size_tokens: 100, - cost: 0.001, - cost_per_session: 0.0001, - session_count: 10, - counted_by_catalog_bloat: None, - }; - let finding = ghost_surface_to_finding( - &ghost, - &GhostSurfaceFindingOptions { - archive_dir: Some(PathBuf::from("/tmp/ghost archive")), - }, - ); - match &finding.actions[0] { - WasteAction::Command { text, .. } => { - assert!(text.contains("'/Users/me/.claude/agents/my helper.md'")); - assert!(text.contains("'/tmp/ghost archive")); - } - other => panic!("expected Command action, got {other:?}"), - } - } - - #[test] - fn finding_emits_paste_for_synthetic_opencode_paths() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Opencode, &["deploy"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 5)]); - let ghosts = detect_ghost_surface(&inputs); - let synthetic = ghosts - .iter() - .find(|g| g.path.contains("#/commands/ghost-command")) - .unwrap(); - let finding = ghost_surface_to_finding(synthetic, &GhostSurfaceFindingOptions::default()); - match &finding.actions[0] { - WasteAction::Paste { text, .. } => { - assert!(!text.contains("mv ")); - assert!(text.contains("opencode.json")); - assert!(text.contains("/commands/ghost-command")); - } - other => panic!("expected Paste action, got {other:?}"), - } - } -} +#[path = "ghost_surface_tests.rs"] +mod tests; diff --git a/crates/relayburn-sdk/src/analyze/ghost_surface_tests.rs b/crates/relayburn-sdk/src/analyze/ghost_surface_tests.rs new file mode 100644 index 0000000..52d3319 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/ghost_surface_tests.rs @@ -0,0 +1,705 @@ +//! Conformance tests for the ghost_surface module — extracted verbatim from the +//! former inline `#[cfg(test)] mod tests` block (included via `#[path]`). + + use super::*; + use crate::analyze::findings::WasteSeverity; + use std::path::PathBuf; + + fn fixtures_root() -> PathBuf { + // crates/relayburn-analyze/Cargo.toml -> repo root is two levels up. + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .parent() + .unwrap() + .parent() + .unwrap() + .join("tests") + .join("fixtures") + .join("ghost-surface") + } + + fn claude_home() -> PathBuf { + fixtures_root().join("claude") + } + fn codex_home() -> PathBuf { + fixtures_root().join("codex") + } + fn opencode_project() -> PathBuf { + fixtures_root().join("opencode-project") + } + + const RATE: f64 = 1e-6; + + fn make_inputs() -> GhostSurfaceInputs { + GhostSurfaceInputs { + observed_names_by_source: HashMap::new(), + session_count_by_source: HashMap::new(), + dollar_per_token: RATE, + claude_home: Some(claude_home()), + codex_home: Some(codex_home()), + opencode_projects: Some(vec![opencode_project()]), + user_turn_text_by_session: None, + } + } + + fn observed(source: SourceKind, names: &[&str]) -> HashMap> { + let mut m = HashMap::new(); + m.insert(source, names.iter().map(|s| s.to_string()).collect()); + m + } + + fn observed_multi(entries: &[(SourceKind, &[&str])]) -> HashMap> { + let mut m = HashMap::new(); + for (s, names) in entries { + m.insert(*s, names.iter().map(|s| s.to_string()).collect()); + } + m + } + + fn count_map(entries: &[(SourceKind, u64)]) -> HashMap { + entries.iter().copied().collect() + } + + type UserTextEntries = Vec<(SourceKind, Vec<(String, Vec)>)>; + + fn user_text(entries: UserTextEntries) -> HashMap>> { + let mut out = HashMap::new(); + for (src, sessions) in entries { + let mut inner = HashMap::new(); + for (sid, texts) in sessions { + inner.insert(sid, texts); + } + out.insert(src, inner); + } + out + } + + // ---- claudeGhostAdapter -------------------------------------------------- + + #[test] + fn claude_enumerates_agents_skills_commands() { + let candidates = ClaudeGhostAdapter.enumerate(&make_inputs()); + let kinds: HashSet = candidates.iter().map(|c| c.kind).collect(); + assert!(kinds.contains(&GhostFindingKind::GhostAgent), "has agents"); + assert!(kinds.contains(&GhostFindingKind::GhostSkill), "has skills"); + assert!( + kinds.contains(&GhostFindingKind::GhostCommand), + "has commands" + ); + let mut agents: Vec = candidates + .iter() + .filter(|c| c.kind == GhostFindingKind::GhostAgent) + .map(|c| c.basename.clone()) + .collect(); + agents.sort(); + assert_eq!(agents, vec!["code-reviewer.md", "forgotten-helper.md"]); + } + + #[test] + fn claude_returns_empty_when_home_missing() { + let mut inputs = make_inputs(); + inputs.claude_home = Some(fixtures_root().join("does-not-exist")); + let candidates = ClaudeGhostAdapter.enumerate(&inputs); + assert_eq!(candidates.len(), 0); + } + + #[test] + fn claude_detects_ghost_agent_when_basename_not_observed() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = + observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); + let ghosts = detect_ghost_surface(&inputs); + let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .collect(); + let mut basenames: Vec = + claude_ghosts.iter().map(|g| basename_of(&g.path)).collect(); + basenames.sort(); + assert_eq!( + basenames, + vec![ + "forgotten-helper.md", + "openspec-apply.md", + "openspec-archive.md", + ] + ); + let helper = claude_ghosts + .iter() + .find(|g| g.path.ends_with("forgotten-helper.md")) + .unwrap(); + assert_eq!(helper.kind, GhostFindingKind::GhostAgent); + assert_eq!(helper.session_count, 10); + assert!(helper.cost > 0.0); + assert!(helper.size_tokens > 0); + } + + #[test] + fn claude_de_ghosts_command_via_slash_form() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = + observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::ClaudeCode, + vec![( + "session-1".to_string(), + vec![ + "/openspec-apply\nApply the latest proposal." + .to_string(), + ], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let mut basenames: Vec = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .map(|g| basename_of(&g.path)) + .collect(); + basenames.sort(); + assert_eq!( + basenames, + vec!["forgotten-helper.md", "openspec-archive.md"] + ); + } + + #[test] + fn claude_recognises_bare_command_name_no_leading_slash() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = + observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::ClaudeCode, + vec![( + "session-1".to_string(), + vec!["openspec-apply\nbody".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); + assert!( + apply.is_none(), + "claude openspec-apply should be de-ghosted" + ); + } + + #[test] + fn claude_falls_back_to_v1_when_user_text_empty() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = + observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); + inputs.user_turn_text_by_session = Some(HashMap::new()); + let ghosts = detect_ghost_surface(&inputs); + let mut basenames: Vec = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .map(|g| basename_of(&g.path)) + .collect(); + basenames.sort(); + assert_eq!( + basenames, + vec![ + "forgotten-helper.md", + "openspec-apply.md", + "openspec-archive.md", + ] + ); + } + + // ---- codexGhostAdapter -------------------------------------------------- + + #[test] + fn codex_enumerates_prompts_skills_rules_memories() { + let candidates = CodexGhostAdapter.enumerate(&make_inputs()); + let mut by_kind: HashMap> = HashMap::new(); + for c in &candidates { + by_kind.entry(c.kind).or_default().push(c.basename.clone()); + } + for v in by_kind.values_mut() { + v.sort(); + } + assert_eq!( + by_kind + .get(&GhostFindingKind::GhostPrompt) + .cloned() + .unwrap_or_default(), + vec!["openspec-apply.md", "openspec-archive.md", "refactor.md"] + ); + assert_eq!( + by_kind + .get(&GhostFindingKind::GhostSkill) + .cloned() + .unwrap_or_default(), + vec!["code-search.md"] + ); + assert_eq!( + by_kind + .get(&GhostFindingKind::GhostRule) + .cloned() + .unwrap_or_default(), + vec!["no-print.md"] + ); + assert_eq!( + by_kind + .get(&GhostFindingKind::GhostMemory) + .cloned() + .unwrap_or_default(), + vec!["preferences.md"] + ); + } + + #[test] + fn codex_flags_openspec_archive_as_ghost() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &["refactor", "code-search"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 5)]); + let ghosts = detect_ghost_surface(&inputs); + let codex_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::Codex) + .collect(); + let openspec = codex_ghosts + .iter() + .find(|g| g.path.ends_with("openspec-archive.md")); + assert!(openspec.is_some()); + assert_eq!(openspec.unwrap().kind, GhostFindingKind::GhostPrompt); + assert_eq!(openspec.unwrap().session_count, 5); + assert!(openspec.unwrap().cost > 0.0); + let kinds: HashSet = codex_ghosts.iter().map(|g| g.kind).collect(); + assert!(kinds.contains(&GhostFindingKind::GhostRule)); + assert!(kinds.contains(&GhostFindingKind::GhostMemory)); + } + + #[test] + fn codex_de_ghosts_via_slash_in_user_text() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &["refactor", "code-search"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 5)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["/openspec-apply\nApply the latest proposal please.".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let codex_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::Codex) + .collect(); + let apply = codex_ghosts + .iter() + .find(|g| g.path.ends_with("openspec-apply.md")); + assert!(apply.is_none(), "codex openspec-apply should be de-ghosted"); + let archive = codex_ghosts + .iter() + .find(|g| g.path.ends_with("openspec-archive.md")); + assert!( + archive.is_some(), + "codex openspec-archive should remain a ghost" + ); + } + + #[test] + fn codex_recognises_slash_not_at_start() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["Please run the /openspec-apply prompt now.".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!(apply.is_none(), "mid-line /openspec-apply should de-ghost"); + } + + #[test] + fn codex_does_not_match_extended_slash_command() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["/openspec-apply-foo bar".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!( + apply.is_some(), + "a longer slash command should not de-ghost the shorter stem" + ); + } + + #[test] + fn codex_ignores_slash_after_word_char() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["See https://example.com/openspec-apply for docs.".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!( + apply.is_some(), + "URL-style /openspec-apply should not de-ghost" + ); + } + + #[test] + fn codex_matches_case_insensitively() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["/OPENSPEC-Apply now".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!( + apply.is_none(), + "mixed-case /OPENSPEC-Apply should de-ghost" + ); + } + + #[test] + fn codex_does_not_de_ghost_from_claude_command_marker() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed_multi(&[ + (SourceKind::ClaudeCode, &["code-reviewer"]), + (SourceKind::Codex, &["refactor"]), + ]); + inputs.session_count_by_source = + count_map(&[(SourceKind::ClaudeCode, 1), (SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::ClaudeCode, + vec![( + "claude-session-1".to_string(), + vec!["/openspec-apply\nbody".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let codex_apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!(codex_apply.is_some(), "Codex must remain a ghost"); + let claude_apply = ghosts + .iter() + .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); + assert!( + claude_apply.is_none(), + "Claude side is de-ghosted by its own marker" + ); + } + + #[test] + fn claude_does_not_de_ghost_from_codex_slash() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed_multi(&[ + (SourceKind::ClaudeCode, &["code-reviewer"]), + (SourceKind::Codex, &["refactor"]), + ]); + inputs.session_count_by_source = + count_map(&[(SourceKind::ClaudeCode, 1), (SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "codex-session-1".to_string(), + vec!["/openspec-apply\nApply the latest proposal.".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let claude_apply = ghosts + .iter() + .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); + assert!( + claude_apply.is_some(), + "Claude must remain a ghost — Codex slash mustn't leak" + ); + let codex_apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!(codex_apply.is_none()); + } + + // ---- opencodeGhostAdapter ---------------------------------------------- + + #[test] + fn opencode_enumerates_declared_skills_commands_and_project_skills() { + let candidates = OpenCodeGhostAdapter.enumerate(&make_inputs()); + let declared: Vec<&GhostCandidate> = candidates + .iter() + .filter(|c| c.counted_by_catalog_bloat == Some(true)) + .collect(); + let project: Vec<&GhostCandidate> = candidates + .iter() + .filter(|c| c.counted_by_catalog_bloat != Some(true)) + .collect(); + let mut declared_names: Vec = declared.iter().map(|c| c.basename.clone()).collect(); + declared_names.sort(); + assert_eq!( + declared_names, + vec!["abandoned-helper", "code-search"], + "declared catalog skills are flagged with countedByCatalogBloat", + ); + let project_skills: Vec = project + .iter() + .filter(|c| c.kind == GhostFindingKind::GhostSkill) + .map(|c| c.basename.clone()) + .collect(); + assert_eq!(project_skills, vec!["project-skill.md"]); + let mut commands: Vec = project + .iter() + .filter(|c| c.kind == GhostFindingKind::GhostCommand) + .map(|c| c.basename.clone()) + .collect(); + commands.sort(); + assert_eq!(commands, vec!["deploy", "ghost-command"]); + } + + #[test] + fn opencode_emits_zero_cost_for_declared_catalog_bloat() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = + observed(SourceKind::Opencode, &["code-search", "deploy"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 20)]); + let ghosts = detect_ghost_surface(&inputs); + let opencode_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::Opencode) + .collect(); + let abandoned = opencode_ghosts + .iter() + .find(|g| g.path.contains("abandoned-helper")); + assert!(abandoned.is_some(), "declared catalog skill is reported"); + assert_eq!(abandoned.unwrap().cost, 0.0); + assert_eq!(abandoned.unwrap().counted_by_catalog_bloat, Some(true)); + let ghost_cmd = opencode_ghosts + .iter() + .find(|g| g.path.ends_with("#/commands/ghost-command")); + assert!(ghost_cmd.is_some()); + assert!(ghost_cmd.unwrap().cost > 0.0); + assert_eq!(ghost_cmd.unwrap().counted_by_catalog_bloat, None); + let project_skill = opencode_ghosts + .iter() + .find(|g| g.path.ends_with("project-skill.md")); + assert!(project_skill.is_some()); + assert!(project_skill.unwrap().cost > 0.0); + assert_eq!(project_skill.unwrap().counted_by_catalog_bloat, None); + } + + // ---- detectGhostSurface — orchestrator --------------------------------- + + #[test] + fn orchestrator_runs_every_adapter_sorted_by_cost_desc() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed_multi(&[ + (SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]), + (SourceKind::Codex, &["refactor", "code-search"]), + (SourceKind::Opencode, &["code-search", "deploy"]), + ]); + inputs.session_count_by_source = count_map(&[ + (SourceKind::ClaudeCode, 10), + (SourceKind::Codex, 5), + (SourceKind::Opencode, 20), + ]); + let ghosts = detect_ghost_surface(&inputs); + for w in ghosts.windows(2) { + assert!(w[0].cost >= w[1].cost, "sorted by cost desc"); + } + let sources: HashSet = ghosts.iter().map(|g| g.source).collect(); + assert!(sources.contains(&SourceKind::ClaudeCode)); + assert!(sources.contains(&SourceKind::Codex)); + assert!(sources.contains(&SourceKind::Opencode)); + } + + #[test] + fn orchestrator_treats_observed_case_insensitively() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed( + SourceKind::ClaudeCode, + &[ + "Code-Reviewer", + "GIT-COMMIT", + "forgotten-HELPER", + "openspec-archive", + "openspec-apply", + ], + ); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 1)]); + let ghosts = detect_ghost_surface(&inputs); + let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .collect(); + assert_eq!(claude_ghosts.len(), 0); + } + + #[test] + fn orchestrator_includes_ghost_when_session_count_zero() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &[]); + let ghosts = detect_ghost_surface(&inputs); + let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .collect(); + assert!(!claude_ghosts.is_empty()); + for g in &claude_ghosts { + assert_eq!(g.cost, 0.0); + assert_eq!(g.session_count, 0); + } + } + + // ---- ghostSurfaceToFinding --------------------------------------------- + + #[test] + fn finding_produces_mv_command_action() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &["code-reviewer"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); + let ghosts = detect_ghost_surface(&inputs); + let helper = ghosts + .iter() + .find(|g| g.path.ends_with("forgotten-helper.md")) + .unwrap(); + let finding = ghost_surface_to_finding( + helper, + &GhostSurfaceFindingOptions { + archive_dir: Some(PathBuf::from("/tmp/ghost-archive")), + }, + ); + assert_eq!(finding.kind, "ghost-agent"); + assert_eq!(finding.actions.len(), 1); + match &finding.actions[0] { + WasteAction::Command { text, .. } => { + assert!(text.contains("mv ")); + assert!(text.contains("/tmp/ghost-archive")); + assert!(text.contains(&helper.path)); + } + other => panic!("expected Command, got {other:?}"), + } + assert!(finding.title.contains("forgotten-helper")); + assert!(finding.detail.contains("claude-code")); + } + + #[test] + fn finding_marks_catalog_bloat_with_zero_cost_and_dedup_note() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Opencode, &["deploy"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 100)]); + let ghosts = detect_ghost_surface(&inputs); + let abandoned = ghosts + .iter() + .find(|g| g.path.contains("abandoned-helper")) + .unwrap(); + let finding = ghost_surface_to_finding(abandoned, &GhostSurfaceFindingOptions::default()); + assert_eq!(finding.estimated_savings.usd_per_session, Some(0.0)); + assert!(finding.detail.contains("catalog-bloat")); + } + + #[test] + fn finding_uses_per_session_cost_for_severity() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &["code-reviewer"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 100_000)]); + let ghosts = detect_ghost_surface(&inputs); + let helper = ghosts + .iter() + .find(|g| g.path.ends_with("forgotten-helper.md")) + .unwrap(); + // Cumulative cost is well above $1 (severity High threshold = $0.5). + assert!(helper.cost > 1.0, "expected cumulative cost > $1"); + // Per-session cost should be far below $0.05 (severity Warn threshold). + assert!( + helper.cost_per_session < 0.05, + "per-session cost should be below warn threshold" + ); + let finding = ghost_surface_to_finding( + helper, + &GhostSurfaceFindingOptions { + archive_dir: Some(PathBuf::from("/tmp/ghost-archive")), + }, + ); + assert_eq!( + finding.estimated_savings.usd_per_session, + Some(helper.cost_per_session) + ); + assert_eq!(finding.severity, WasteSeverity::Info); + } + + #[test] + fn finding_shell_quotes_paths_with_spaces() { + let ghost = GhostSurfaceFinding { + source: SourceKind::ClaudeCode, + kind: GhostFindingKind::GhostAgent, + path: "/Users/me/.claude/agents/my helper.md".to_string(), + size_tokens: 100, + cost: 0.001, + cost_per_session: 0.0001, + session_count: 10, + counted_by_catalog_bloat: None, + }; + let finding = ghost_surface_to_finding( + &ghost, + &GhostSurfaceFindingOptions { + archive_dir: Some(PathBuf::from("/tmp/ghost archive")), + }, + ); + match &finding.actions[0] { + WasteAction::Command { text, .. } => { + assert!(text.contains("'/Users/me/.claude/agents/my helper.md'")); + assert!(text.contains("'/tmp/ghost archive")); + } + other => panic!("expected Command action, got {other:?}"), + } + } + + #[test] + fn finding_emits_paste_for_synthetic_opencode_paths() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Opencode, &["deploy"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 5)]); + let ghosts = detect_ghost_surface(&inputs); + let synthetic = ghosts + .iter() + .find(|g| g.path.contains("#/commands/ghost-command")) + .unwrap(); + let finding = ghost_surface_to_finding(synthetic, &GhostSurfaceFindingOptions::default()); + match &finding.actions[0] { + WasteAction::Paste { text, .. } => { + assert!(!text.contains("mv ")); + assert!(text.contains("opencode.json")); + assert!(text.contains("/commands/ghost-command")); + } + other => panic!("expected Paste action, got {other:?}"), + } + } From 225594eb916afc8a4086571d4693ab03b21095a2 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 22:57:35 -0400 Subject: [PATCH 16/22] refactor(sdk/analyze): remove the legacy subagent-tree builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_subagent_tree dispatched to a separate build_legacy_subagent_trees fallback whenever no relationship rows were supplied. That path was dead for any ledger ingested by current code (every session now carries an always- emitted Root relationship row), and build_relationship_trees already folds in the per-turn TurnRecord.subagent data via add_legacy_subagent_gaps + ensure_turn_session_roots — so it reconstructs the same tree from subagent fields alone when handed an empty relationship slice. Collapse the dispatch to the single relationship path (empty slice when no rows), and delete the now-dead build_legacy_subagent_trees, build_session_tree, assign_depth, and resolve_parent_or_root (~200 lines). The equivalence guard test now asserts the no-relationship tree matches the with-relationship tree — the invariant that lets the fallback stand in for the removed builder. Per owner decision: old event logs that predate Root emission are not supported without a re-ingest. All 992 tests pass (incl. the nested-subagent and unresolved-sidechain cases), clippy clean, live subagent-tree render verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/analyze/subagent_tree.rs | 219 +----------------- .../src/analyze/subagent_tree_tests.rs | 10 +- 2 files changed, 16 insertions(+), 213 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/subagent_tree.rs b/crates/relayburn-sdk/src/analyze/subagent_tree.rs index 9486365..85fcad4 100644 --- a/crates/relayburn-sdk/src/analyze/subagent_tree.rs +++ b/crates/relayburn-sdk/src/analyze/subagent_tree.rs @@ -1,11 +1,12 @@ //! Subagent tree / per-type rollups — Rust port of //! `packages/analyze/src/subagent-tree.ts`. //! -//! Walks the parent-uuid chains in `TurnRecord.subagent` (or -//! `SessionRelationshipRecord` rows when supplied) to build one tree per -//! session, with cost rolled up from leaves. The relationship-row path is -//! the primary substrate for newer ingests; the legacy path falls back to -//! `TurnRecord.subagent` only. +//! Builds one tree per session from `SessionRelationshipRecord` rows, with +//! cost rolled up from leaves. Per-turn `TurnRecord.subagent` fields are folded +//! in to attach turn cost and to fill gaps for sessions whose relationship rows +//! are sparse (so a ledger with only the always-emitted Root rows still +//! reconstructs its subagent sidechains). Callers that pass no relationship +//! rows get a tree built from `TurnRecord.subagent` alone. use crate::reader::{RelationshipType, SessionRelationshipRecord, TurnRecord}; use indexmap::{IndexMap, IndexSet}; @@ -13,7 +14,7 @@ use serde::{Deserialize, Serialize}; use crate::analyze::cost::total_cost_for_turn; use crate::analyze::pricing::PricingTable; -use crate::analyze::util::{group_turns_by_session, percentile}; +use crate::analyze::util::percentile; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -63,12 +64,8 @@ pub fn build_subagent_tree( turns: &[TurnRecord], opts: &BuildSubagentTreeOptions<'_>, ) -> IndexMap { - if let Some(rels) = opts.relationships { - if !rels.is_empty() { - return build_relationship_trees(turns, rels, opts.pricing); - } - } - build_legacy_subagent_trees(turns, opts.pricing) + let relationships = opts.relationships.unwrap_or(&[]); + build_relationship_trees(turns, relationships, opts.pricing) } #[derive(Debug)] @@ -112,158 +109,6 @@ struct GraphState { parent_by_node: IndexMap, } -fn build_legacy_subagent_trees( - turns: &[TurnRecord], - pricing: &PricingTable, -) -> IndexMap { - let by_session = group_turns_by_session(turns); - let mut out: IndexMap = IndexMap::new(); - for (session_id, session_turns) in by_session { - let root = build_session_tree(&session_id, &session_turns, pricing); - out.insert(session_id, root); - } - out -} - -fn build_session_tree( - session_id: &str, - turns: &[&TurnRecord], - pricing: &PricingTable, -) -> SubagentTreeNode { - let mut nodes: IndexMap = IndexMap::new(); - let mut models: IndexMap> = IndexMap::new(); - nodes.insert( - session_id.to_string(), - MutableNode { - depth: 0, - ..MutableNode::new( - session_id.to_string(), - "main".to_string(), - RelationshipType::Root, - ) - }, - ); - models.insert(session_id.to_string(), IndexSet::new()); - - let unresolved_id = format!("{session_id}:__unresolved"); - let mut unresolved_created = false; - - for t in turns { - let cost = total_cost_for_turn(t, pricing); - let Some(sub) = &t.subagent else { - let node = nodes.get_mut(session_id).unwrap(); - node.self_turns += 1; - node.self_cost += cost; - if !t.model.is_empty() { - models.get_mut(session_id).unwrap().insert(t.model.clone()); - } - continue; - }; - let Some(agent_id) = &sub.agent_id else { - if !unresolved_created { - let mut un = MutableNode::new( - unresolved_id.clone(), - "(unresolved)".to_string(), - RelationshipType::Subagent, - ); - un.depth = 1; - nodes.insert(unresolved_id.clone(), un); - models.insert(unresolved_id.clone(), IndexSet::new()); - nodes - .get_mut(session_id) - .unwrap() - .children - .push(unresolved_id.clone()); - unresolved_created = true; - } - let n = nodes.get_mut(&unresolved_id).unwrap(); - n.self_turns += 1; - n.self_cost += cost; - if !t.model.is_empty() { - models - .get_mut(&unresolved_id) - .unwrap() - .insert(t.model.clone()); - } - continue; - }; - if !nodes.contains_key(agent_id) { - let mut n = MutableNode::new( - agent_id.clone(), - sub.subagent_type - .clone() - .unwrap_or_else(|| "(unknown)".to_string()), - RelationshipType::Subagent, - ); - n.subagent_type = sub.subagent_type.clone(); - n.description = sub.description.clone(); - nodes.insert(agent_id.clone(), n); - models.insert(agent_id.clone(), IndexSet::new()); - } else { - let n = nodes.get_mut(agent_id).unwrap(); - if n.subagent_type.is_none() { - if let Some(st) = &sub.subagent_type { - n.subagent_type = Some(st.clone()); - if n.label == "(unknown)" { - n.label = st.clone(); - } - } - } - if n.description.is_none() { - if let Some(d) = &sub.description { - n.description = Some(d.clone()); - } - } - } - let n = nodes.get_mut(agent_id).unwrap(); - n.self_turns += 1; - n.self_cost += cost; - if !t.model.is_empty() { - models.get_mut(agent_id).unwrap().insert(t.model.clone()); - } - } - - // Build parent map (insertion order = first-encounter order in turns). - let mut parent_by_node: IndexMap = IndexMap::new(); - for t in turns { - let Some(sub) = &t.subagent else { continue }; - let Some(agent_id) = &sub.agent_id else { - continue; - }; - if parent_by_node.contains_key(agent_id) { - continue; - } - let pid = sub - .parent_agent_id - .clone() - .unwrap_or_else(|| session_id.to_string()); - parent_by_node.insert(agent_id.clone(), pid); - } - - // Attach children, redirecting cycles / self-parents to the session root. - for (id, parent_id) in parent_by_node.clone() { - if !nodes.contains_key(&id) { - continue; - } - let resolved = resolve_parent_or_root(&id, &parent_id, &parent_by_node, session_id); - let parent_target = if nodes.contains_key(&resolved) { - resolved - } else { - session_id.to_string() - }; - let parent_node = nodes.get_mut(&parent_target).unwrap(); - parent_node.children.push(id); - } - - // BFS depth assignment. - assign_depth(&mut nodes, session_id); - - fold_cumulative(&mut nodes, session_id); - sort_tree(&mut nodes, session_id); - - materialize_session_tree(&nodes, &models, session_id) -} - fn build_relationship_trees( turns: &[TurnRecord], relationships: &[SessionRelationshipRecord], @@ -583,27 +428,6 @@ fn finalize_tree(state: &mut GraphState, root_id: &str) { sort_tree(&mut state.node_by_id, root_id); } -fn assign_depth(nodes: &mut IndexMap, root_id: &str) { - let mut queue: std::collections::VecDeque<(String, i32)> = std::collections::VecDeque::new(); - queue.push_back((root_id.to_string(), 0)); - let mut seen: IndexSet = IndexSet::new(); - while let Some((id, depth)) = queue.pop_front() { - if seen.contains(&id) { - continue; - } - seen.insert(id.clone()); - let children = if let Some(n) = nodes.get_mut(&id) { - n.depth = depth; - n.children.clone() - } else { - continue; - }; - for c in children { - queue.push_back((c, depth + 1)); - } - } -} - fn fold_cumulative(nodes: &mut IndexMap, root_id: &str) { let order = topo_post_order(nodes, root_id); for id in order { @@ -662,31 +486,6 @@ fn sort_tree(nodes: &mut IndexMap, root_id: &str) { } } -fn resolve_parent_or_root( - id: &str, - parent_id: &str, - parent_by_node: &IndexMap, - session_id: &str, -) -> String { - if parent_id == id { - return session_id.to_string(); - } - let mut seen: IndexSet = IndexSet::new(); - seen.insert(id.to_string()); - let mut cursor = parent_id.to_string(); - while cursor != session_id { - if seen.contains(&cursor) { - return session_id.to_string(); - } - seen.insert(cursor.clone()); - match parent_by_node.get(&cursor) { - Some(next) => cursor = next.clone(), - None => return parent_id.to_string(), - } - } - parent_id.to_string() -} - fn resolve_graph_parent( id: &str, parent_id: &str, diff --git a/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs b/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs index c1855f6..e3880ea 100644 --- a/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs +++ b/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs @@ -285,8 +285,12 @@ fn builds_the_same_claude_tree_from_session_relationship_records() { ), ]; - let legacy_opts = BuildSubagentTreeOptions::new(&pricing); - let legacy = build_subagent_tree(&turns, &legacy_opts) + // The tree built from `TurnRecord.subagent` alone (no relationship rows) + // must match the tree built with explicit relationship rows — the + // invariant that lets the no-relationship fallback stand in for the + // removed legacy builder. + let subagent_only_opts = BuildSubagentTreeOptions::new(&pricing); + let subagent_only = build_subagent_tree(&turns, &subagent_only_opts) .get(session_id) .unwrap() .clone(); @@ -295,7 +299,7 @@ fn builds_the_same_claude_tree_from_session_relationship_records() { .get(session_id) .unwrap() .clone(); - assert_eq!(graph, legacy); + assert_eq!(graph, subagent_only); assert_eq!(graph.relationship_type, RelationshipType::Root); assert_eq!( graph.children[0].relationship_type, From 68245f6d3a16a72985599a3c3cd63e5f58f09c24 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:39:47 -0400 Subject: [PATCH 17/22] refactor(sdk): drop two duplicate ISO-8601 parsers, use util::time reader/inference.rs::parse_iso_ms was a byte-for-byte copy of util::time::parse_iso_ms; its "keep reader free of a query_verbs dependency" comment is stale (the canonical parser now lives in crate::util::time, which sibling reader submodules already import). Delete the copy, import the shared one, and drop its two unit tests (covered by util/time's own tests). query_verbs/flow.rs::parse_iso_ms_compat was a pure pass-through wrapper around the same shared helper; inline it at the two call sites and delete the shim. Behavior-preserving; build warning-free, clippy clean, tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/query_verbs/flow.rs | 10 +-- crates/relayburn-sdk/src/reader/inference.rs | 73 +------------------- 2 files changed, 3 insertions(+), 80 deletions(-) diff --git a/crates/relayburn-sdk/src/query_verbs/flow.rs b/crates/relayburn-sdk/src/query_verbs/flow.rs index 3c276c7..394714b 100644 --- a/crates/relayburn-sdk/src/query_verbs/flow.rs +++ b/crates/relayburn-sdk/src/query_verbs/flow.rs @@ -209,7 +209,7 @@ pub(crate) fn bucket_subagents_per_turn( // search. Cheap — one parse per turn. let turn_starts: Vec = turns .iter() - .map(|t| parse_iso_ms_compat(&t.ts).unwrap_or(0)) + .map(|t| crate::util::time::parse_iso_ms(&t.ts).unwrap_or(0)) .collect(); for (sa_idx, sa) in subagents.iter().enumerate() { @@ -256,7 +256,7 @@ fn first_record_ts_ms(records: &[serde_json::Value]) -> Option { .and_then(|v| v.as_str()) .or_else(|| rec.get("ts").and_then(|v| v.as_str())); if let Some(s) = ts_str { - if let Some(ms) = parse_iso_ms_compat(s) { + if let Some(ms) = crate::util::time::parse_iso_ms(s) { earliest = Some(match earliest { Some(e) => e.min(ms), None => ms, @@ -267,12 +267,6 @@ fn first_record_ts_ms(records: &[serde_json::Value]) -> Option { earliest } -/// ISO-8601 parser thin wrapper. Reuses the shared `crate::util::time` -/// helper so all four ex-copies stay in sync. -fn parse_iso_ms_compat(s: &str) -> Option { - crate::util::time::parse_iso_ms(s) -} - /// Resolve the Claude projects root and discover + pair subagent /// sidecars for `session_id`. Returns an empty `Vec` when: /// diff --git a/crates/relayburn-sdk/src/reader/inference.rs b/crates/relayburn-sdk/src/reader/inference.rs index 1dc74ef..4aa556a 100644 --- a/crates/relayburn-sdk/src/reader/inference.rs +++ b/crates/relayburn-sdk/src/reader/inference.rs @@ -30,6 +30,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::reader::types::{SourceKind, TurnRecord, Usage}; +use crate::util::time::parse_iso_ms; /// Coarse classification of an [`Inference`]'s content blocks. /// @@ -399,60 +400,6 @@ fn composite_storage_key(turn: &TurnRecord, key: &KeyPair) -> String { /// - The ledger normalizes every `ts` to `YYYY-MM-DDTHH:MM:SS.mmmZ` /// on write, so the parser only needs to handle that shape plus the /// handful of legacy strings (`...Z`, no fraction; date-only). -fn parse_iso_ms(s: &str) -> Option { - let bytes = s.as_bytes(); - if bytes.len() < 19 { - return None; - } - if !(bytes[4] == b'-' - && bytes[7] == b'-' - && (bytes[10] == b'T' || bytes[10] == b' ') - && bytes[13] == b':' - && bytes[16] == b':') - { - return None; - } - let year: i64 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?; - let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?; - let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?; - let hour: u32 = std::str::from_utf8(&bytes[11..13]).ok()?.parse().ok()?; - let minute: u32 = std::str::from_utf8(&bytes[14..16]).ok()?.parse().ok()?; - let second: u32 = std::str::from_utf8(&bytes[17..19]).ok()?.parse().ok()?; - let mut millis: i64 = 0; - let mut idx = 19; - if idx < bytes.len() && bytes[idx] == b'.' { - idx += 1; - let frac_start = idx; - while idx < bytes.len() && bytes[idx].is_ascii_digit() { - idx += 1; - } - let mut frac = std::str::from_utf8(&bytes[frac_start..idx]) - .ok()? - .to_string(); - if frac.len() > 3 { - frac.truncate(3); - } - while frac.len() < 3 { - frac.push('0'); - } - millis = frac.parse().ok()?; - } - // Howard Hinnant civil-from-fields. Same math as - // `query_verbs::ymd_to_days` — duplicated here to keep `reader` free - // of an upward dependency on `query_verbs`. - let m = month as i64; - let d = day as i64; - let y = if m <= 2 { year - 1 } else { year }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = (y - era * 400) as u64; - let mp = if m > 2 { m - 3 } else { m + 9 } as u64; - let doy = (153 * mp + 2) / 5 + (d as u64) - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days_from_epoch = era * 146_097 + (doe as i64) - 719_468; - let secs = - days_from_epoch * 86_400 + (hour as i64) * 3_600 + (minute as i64) * 60 + (second as i64); - Some(secs * 1_000 + millis) -} #[cfg(test)] mod tests { @@ -489,24 +436,6 @@ mod tests { } } - #[test] - fn parses_iso_with_millis() { - // 2026-04-20T00:00:01.500Z = Unix epoch ms 1_776_643_201_500. - // Cross-check: 20566 days from 1970-01-01 to 2026-04-20 × - // 86_400_000 = 1_776_902_400_000; reverse-confirm by computing - // the integer-day offset directly inside `parse_iso_ms` (Howard - // Hinnant). The exact value is what the function returns; the - // contract is "round-trippable monotonic millisecond clock". - let ms = parse_iso_ms("2026-04-20T00:00:01.500Z").unwrap(); - assert_eq!(ms, 1_776_643_201_500); - } - - #[test] - fn parses_iso_without_millis() { - let ms = parse_iso_ms("2026-04-20T00:00:01Z").unwrap(); - assert_eq!(ms, 1_776_643_201_000); - } - #[test] fn request_id_groups_collapse_one_turn_one_inference() { // Single turn, request_id lookup hits → exactly one Inference, From b372c2f2f8552179d72d09f3030557800672d03c Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:43:59 -0400 Subject: [PATCH 18/22] refactor(sdk): centralize Hinnant civil-date math in util::time The days-from-civil / civil-from-days primitives were copied across util::time (inlined in parse_iso_ms), query_verbs::mod, and reader::opencode. Promote ymd_to_days + days_to_ymd to pub(crate) in util::time and route the callers there: - parse_iso_ms now calls ymd_to_days instead of inlining it. - query_verbs::ymd_to_days keeps only its out-of-range guard, deferring the math to the shared primitive; its days_to_ymd copy is replaced by the import. - reader::opencode::ms_to_iso uses the shared days_to_ymd (the {:04} year format works on the i64 year identically to the old i32). Identical math, behavior-preserving; build warning-free, clippy clean, 990 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/query_verbs/mod.rs | 29 ++++--------------- crates/relayburn-sdk/src/reader/opencode.rs | 21 +------------- crates/relayburn-sdk/src/util/time.rs | 32 ++++++++++++++++++--- 3 files changed, 34 insertions(+), 48 deletions(-) diff --git a/crates/relayburn-sdk/src/query_verbs/mod.rs b/crates/relayburn-sdk/src/query_verbs/mod.rs index d3e3522..86880f2 100644 --- a/crates/relayburn-sdk/src/query_verbs/mod.rs +++ b/crates/relayburn-sdk/src/query_verbs/mod.rs @@ -259,36 +259,17 @@ fn format_iso_z_ms(secs: i64, millis: u32) -> String { format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") } -/// Civil-date → days-from-Unix-epoch (Howard Hinnant's algorithm, proleptic -/// Gregorian). Inverse of [`days_to_ymd`]. +/// Range-checking wrapper over [`crate::util::time::ymd_to_days`]: rejects +/// out-of-range month/day (since this parses untrusted `since` strings), +/// then defers to the shared Hinnant primitive. fn ymd_to_days(year: i64, month: u32, day: u32) -> Option { if !(1..=12).contains(&month) || !(1..=31).contains(&day) { return None; } - let m = month as i64; - let d = day as i64; - let y = if m <= 2 { year - 1 } else { year }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = (y - era * 400) as u64; - let mp = if m > 2 { m - 3 } else { m + 9 } as u64; - let doy = (153 * mp + 2) / 5 + (d as u64) - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - Some(era * 146_097 + (doe as i64) - 719_468) + Some(crate::util::time::ymd_to_days(year, month, day)) } -fn days_to_ymd(days_from_epoch: i64) -> (i64, u32, u32) { - let z = days_from_epoch + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = if m <= 2 { y + 1 } else { y }; - (year, m as u32, d as u32) -} +use crate::util::time::days_to_ymd; // --------------------------------------------------------------------------- // time-bucketing — shared by `--bucket` on summary / compare / hotspots / overhead diff --git a/crates/relayburn-sdk/src/reader/opencode.rs b/crates/relayburn-sdk/src/reader/opencode.rs index 4dc32b6..685cb91 100644 --- a/crates/relayburn-sdk/src/reader/opencode.rs +++ b/crates/relayburn-sdk/src/reader/opencode.rs @@ -1285,7 +1285,7 @@ fn ms_to_iso(ms: i64) -> String { const MS_PER_DAY: i64 = 86_400_000; let total_days_since_epoch = ms.div_euclid(MS_PER_DAY); let ms_in_day = ms.rem_euclid(MS_PER_DAY); - let (y, mo, d) = days_to_ymd(total_days_since_epoch); + let (y, mo, d) = crate::util::time::days_to_ymd(total_days_since_epoch); let h = ms_in_day / 3_600_000; let m = (ms_in_day / 60_000) % 60; let s = (ms_in_day / 1_000) % 60; @@ -1296,25 +1296,6 @@ fn ms_to_iso(ms: i64) -> String { ) } -fn days_to_ymd(days_since_epoch: i64) -> (i32, u32, u32) { - // Hinnant's days-from-civil inverse. - let z = days_since_epoch + 719_468; - let era = if z >= 0 { - z / 146_097 - } else { - (z - 146_096) / 146_097 - }; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - (y as i32, m, d) -} - fn resolve_token_counter( tokenizer: Option, ) -> std::io::Result { diff --git a/crates/relayburn-sdk/src/util/time.rs b/crates/relayburn-sdk/src/util/time.rs index 61b12cd..2f22f6b 100644 --- a/crates/relayburn-sdk/src/util/time.rs +++ b/crates/relayburn-sdk/src/util/time.rs @@ -50,6 +50,17 @@ pub(crate) fn parse_iso_ms(s: &str) -> Option { } millis = frac.parse().ok()?; } + let days_from_epoch = ymd_to_days(year, month, day); + let secs = + days_from_epoch * 86_400 + (hour as i64) * 3_600 + (minute as i64) * 60 + (second as i64); + Some(secs * 1_000 + millis) +} + +/// Civil date → days from the Unix epoch (Howard Hinnant's proleptic-Gregorian +/// `days_from_civil`). Inverse of [`days_to_ymd`]. Does **not** range-check its +/// inputs — callers that accept untrusted month/day values guard the range +/// themselves before calling. +pub(crate) fn ymd_to_days(year: i64, month: u32, day: u32) -> i64 { let m = month as i64; let d = day as i64; let y = if m <= 2 { year - 1 } else { year }; @@ -58,10 +69,23 @@ pub(crate) fn parse_iso_ms(s: &str) -> Option { let mp = if m > 2 { m - 3 } else { m + 9 } as u64; let doy = (153 * mp + 2) / 5 + (d as u64) - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days_from_epoch = era * 146_097 + (doe as i64) - 719_468; - let secs = - days_from_epoch * 86_400 + (hour as i64) * 3_600 + (minute as i64) * 60 + (second as i64); - Some(secs * 1_000 + millis) + era * 146_097 + (doe as i64) - 719_468 +} + +/// Days from the Unix epoch → `(year, month, day)` (Howard Hinnant's +/// `civil_from_days`). Inverse of [`ymd_to_days`]. +pub(crate) fn days_to_ymd(days_from_epoch: i64) -> (i64, u32, u32) { + let z = days_from_epoch + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if m <= 2 { y + 1 } else { y }; + (year, m as u32, d as u32) } #[cfg(test)] From 35f8f242e5ab8e1a916d0c1a0b7f11538dce1946 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:50:15 -0400 Subject: [PATCH 19/22] refactor(sdk): single util::time::format_iso_ms for the wire timestamp format The canonical `YYYY-MM-DDTHH:MM:SS.mmmZ` formatter was hand-rolled twice (query_verbs::format_iso_z_ms and reader::opencode::ms_to_iso), each pairing days_to_ymd with the same format string. Add util::time::format_iso_ms as the single source of truth: - opencode's six ms_to_iso call sites route to format_iso_ms; the local copy is deleted. - format_iso_z_ms keeps its (seconds, millis) convenience signature but is now a thin adapter onto format_iso_ms, so the template lives in one place. Byte-identical output (same days_to_ymd + same template); avoids the heavier `time`-crate migration. Build warning-free, clippy clean, 990 tests pass, live bucket/ingest timestamps verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/relayburn-sdk/src/query_verbs/mod.rs | 12 ++------ crates/relayburn-sdk/src/query_verbs/tests.rs | 2 +- crates/relayburn-sdk/src/reader/inference.rs | 11 -------- crates/relayburn-sdk/src/reader/opencode.rs | 28 ++++--------------- crates/relayburn-sdk/src/util/time.rs | 15 ++++++++++ 5 files changed, 25 insertions(+), 43 deletions(-) diff --git a/crates/relayburn-sdk/src/query_verbs/mod.rs b/crates/relayburn-sdk/src/query_verbs/mod.rs index 86880f2..4ef077e 100644 --- a/crates/relayburn-sdk/src/query_verbs/mod.rs +++ b/crates/relayburn-sdk/src/query_verbs/mod.rs @@ -249,14 +249,10 @@ fn normalize_iso_to_utc_z(s: &str) -> Option { Some(format_iso_z_ms(utc_secs, millis)) } +/// Adapt the `(whole seconds, millis)` shape used by the since/bucket paths to +/// the shared [`crate::util::time::format_iso_ms`] formatter. fn format_iso_z_ms(secs: i64, millis: u32) -> String { - let total_days = secs.div_euclid(86_400); - let secs_in_day = secs.rem_euclid(86_400) as u32; - let hour = secs_in_day / 3_600; - let minute = (secs_in_day / 60) % 60; - let second = secs_in_day % 60; - let (year, month, day) = days_to_ymd(total_days); - format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") + crate::util::time::format_iso_ms(secs * 1_000 + millis as i64) } /// Range-checking wrapper over [`crate::util::time::ymd_to_days`]: rejects @@ -269,8 +265,6 @@ fn ymd_to_days(year: i64, month: u32, day: u32) -> Option { Some(crate::util::time::ymd_to_days(year, month, day)) } -use crate::util::time::days_to_ymd; - // --------------------------------------------------------------------------- // time-bucketing — shared by `--bucket` on summary / compare / hotspots / overhead // --------------------------------------------------------------------------- diff --git a/crates/relayburn-sdk/src/query_verbs/tests.rs b/crates/relayburn-sdk/src/query_verbs/tests.rs index e7aaad6..1b6782b 100644 --- a/crates/relayburn-sdk/src/query_verbs/tests.rs +++ b/crates/relayburn-sdk/src/query_verbs/tests.rs @@ -216,7 +216,7 @@ fn normalize_since_cutoff_lex_compatible_with_ledger_rows() { fn ymd_days_round_trip() { for (y, m, d) in &[(1970, 1, 1), (2026, 5, 6), (2000, 2, 29), (1999, 12, 31)] { let days = ymd_to_days(*y, *m, *d).unwrap(); - let (ry, rm, rd) = days_to_ymd(days); + let (ry, rm, rd) = crate::util::time::days_to_ymd(days); assert_eq!((*y, *m, *d), (ry, rm, rd)); } } diff --git a/crates/relayburn-sdk/src/reader/inference.rs b/crates/relayburn-sdk/src/reader/inference.rs index 4aa556a..206185f 100644 --- a/crates/relayburn-sdk/src/reader/inference.rs +++ b/crates/relayburn-sdk/src/reader/inference.rs @@ -390,17 +390,6 @@ fn composite_storage_key(turn: &TurnRecord, key: &KeyPair) -> String { ) } -/// Parse an ISO-8601 / RFC-3339 timestamp `YYYY-MM-DDTHH:MM:SS[.sss]Z` -/// into Unix milliseconds. Returns `None` for inputs that don't match the -/// canonical ledger shape; callers fall back to `0`. We hand-roll this -/// rather than pull in a calendar crate because: -/// -/// - The function is used for ordering / span widths, not absolute -/// instants; sub-millisecond accuracy is irrelevant. -/// - The ledger normalizes every `ts` to `YYYY-MM-DDTHH:MM:SS.mmmZ` -/// on write, so the parser only needs to handle that shape plus the -/// handful of legacy strings (`...Z`, no fraction; date-only). - #[cfg(test)] mod tests { use super::*; diff --git a/crates/relayburn-sdk/src/reader/opencode.rs b/crates/relayburn-sdk/src/reader/opencode.rs index 685cb91..341d2f7 100644 --- a/crates/relayburn-sdk/src/reader/opencode.rs +++ b/crates/relayburn-sdk/src/reader/opencode.rs @@ -204,7 +204,7 @@ pub fn parse_opencode_session_incremental( merge_usage_coverage(&usage_coverage, &coverage_from_tokens(Some(&sf))); } - let ts = ms_to_iso(m.time_created); + let ts = crate::util::time::format_iso_ms(m.time_created); let mut record = TurnRecord { v: 1, source: SourceKind::Opencode, @@ -285,7 +285,7 @@ pub fn parse_opencode_session_incremental( if capture_content { let assistant_ts = ts.clone(); if let Some(um) = user_msg.as_ref() { - let user_ts = ms_to_iso(um.time_created); + let user_ts = crate::util::time::format_iso_ms(um.time_created); for t in read_user_text_parts(&storage_root, &um.id) { content_out.push(ContentRecord { v: 1, @@ -321,7 +321,7 @@ pub fn parse_opencode_session_incremental( v: 1, source: SourceKind::Opencode, session_id: session.id.clone(), - ts: ms_to_iso(u.time_created), + ts: crate::util::time::format_iso_ms(u.time_created), preceding_message_id: None, tokens_before_compact: None, }; @@ -859,7 +859,7 @@ fn build_opencode_relationships( assistants: &[AssistantMessage], ) -> Vec { let mut out = Vec::new(); - let first_ts = assistants.first().map(|a| ms_to_iso(a.time_created)); + let first_ts = assistants.first().map(|a| crate::util::time::format_iso_ms(a.time_created)); let mut root = SessionRelationshipRecord { v: 1, source: RelationshipSourceKind::Opencode, @@ -936,7 +936,7 @@ fn build_opencode_user_turn_record( } let mut ts = user_msg - .map(|u| ms_to_iso(u.time_created)) + .map(|u| crate::util::time::format_iso_ms(u.time_created)) .unwrap_or_default(); if let Some(um) = user_msg { let user_parts = read_parts(storage_root, &um.id); @@ -956,7 +956,7 @@ fn build_opencode_user_turn_record( return None; } if ts.is_empty() { - ts = ms_to_iso(next.time_created); + ts = crate::util::time::format_iso_ms(next.time_created); } let user_uuid = match user_msg { Some(um) => um.id.clone(), @@ -1280,22 +1280,6 @@ fn join_nonempty(parts: &[&str], sep: &str) -> String { out.join(sep) } -fn ms_to_iso(ms: i64) -> String { - // `new Date(ms).toISOString()` — UTC with millisecond precision. - const MS_PER_DAY: i64 = 86_400_000; - let total_days_since_epoch = ms.div_euclid(MS_PER_DAY); - let ms_in_day = ms.rem_euclid(MS_PER_DAY); - let (y, mo, d) = crate::util::time::days_to_ymd(total_days_since_epoch); - let h = ms_in_day / 3_600_000; - let m = (ms_in_day / 60_000) % 60; - let s = (ms_in_day / 1_000) % 60; - let frac = ms_in_day % 1_000; - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", - y, mo, d, h, m, s, frac - ) -} - fn resolve_token_counter( tokenizer: Option, ) -> std::io::Result { diff --git a/crates/relayburn-sdk/src/util/time.rs b/crates/relayburn-sdk/src/util/time.rs index 2f22f6b..8788dba 100644 --- a/crates/relayburn-sdk/src/util/time.rs +++ b/crates/relayburn-sdk/src/util/time.rs @@ -72,6 +72,21 @@ pub(crate) fn ymd_to_days(year: i64, month: u32, day: u32) -> i64 { era * 146_097 + (doe as i64) - 719_468 } +/// Format Unix milliseconds as a canonical UTC ISO-8601 string +/// (`YYYY-MM-DDTHH:MM:SS.mmmZ`), matching JS `new Date(ms).toISOString()`. +/// The single source of truth for the SDK's wire timestamp format. +pub(crate) fn format_iso_ms(ms: i64) -> String { + const MS_PER_DAY: i64 = 86_400_000; + let total_days = ms.div_euclid(MS_PER_DAY); + let ms_in_day = ms.rem_euclid(MS_PER_DAY); + let (year, month, day) = days_to_ymd(total_days); + let hour = ms_in_day / 3_600_000; + let minute = (ms_in_day / 60_000) % 60; + let second = (ms_in_day / 1_000) % 60; + let millis = ms_in_day % 1_000; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") +} + /// Days from the Unix epoch → `(year, month, day)` (Howard Hinnant's /// `civil_from_days`). Inverse of [`ymd_to_days`]. pub(crate) fn days_to_ymd(days_from_epoch: i64) -> (i64, u32, u32) { From 003fe5a78d76a2c87fbea46d670388dd1e6e8962 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 21:53:36 -0400 Subject: [PATCH 20/22] refactor(sdk): share bucket-partition loop between summary and compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit summary and compare each hand-rolled the same --bucket partition: anchor → ensure_bucket_span → Buckets::new → allocate per-bucket vecs → loop pushing by iso_z_to_epoch_secs/index_for. They differed only in element type (TurnRecord vs EnrichedTurn) and ts accessor (t.ts vs t.turn.ts). Extract a generic query_verbs::partition_into_buckets(items, since, bucket_secs, ts_of) that returns Ok(None) when there's no anchor, letting each caller keep its own empty-timeseries return shape. Behavior-preserving; 990 tests pass, clippy clean, both bucketed surfaces verified live. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../relayburn-sdk/src/query_verbs/compare.rs | 23 ++----------- crates/relayburn-sdk/src/query_verbs/mod.rs | 33 +++++++++++++++++++ .../relayburn-sdk/src/query_verbs/summary.rs | 23 ++----------- 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/crates/relayburn-sdk/src/query_verbs/compare.rs b/crates/relayburn-sdk/src/query_verbs/compare.rs index 0d71fb5..52ad3c5 100644 --- a/crates/relayburn-sdk/src/query_verbs/compare.rs +++ b/crates/relayburn-sdk/src/query_verbs/compare.rs @@ -185,31 +185,14 @@ impl LedgerHandle { } let pricing = load_pricing(None); - let Some(anchor) = super::bucket_anchor_secs( - q.since.as_deref(), - turns - .iter() - .filter_map(|t| super::iso_z_to_epoch_secs(&t.turn.ts)), - ) else { + let Some((buckets, per_bucket)) = + super::partition_into_buckets(turns, q.since.as_deref(), bucket_secs, |t| &t.turn.ts)? + else { return Ok(CompareTimeseries { bucket_secs, buckets: Vec::new(), }); }; - let now = super::system_now_secs() as i64; - super::ensure_bucket_span(anchor, now, bucket_secs)?; - let buckets = super::Buckets::new(anchor, now, bucket_secs); - let n = buckets.len(); - - let mut per_bucket: Vec> = (0..n).map(|_| Vec::new()).collect(); - for t in turns { - let Some(ep) = super::iso_z_to_epoch_secs(&t.turn.ts) else { - continue; - }; - if let Some(i) = buckets.index_for(ep) { - per_bucket[i].push(t); - } - } let out = per_bucket .into_iter() diff --git a/crates/relayburn-sdk/src/query_verbs/mod.rs b/crates/relayburn-sdk/src/query_verbs/mod.rs index 4ef077e..0fb75ab 100644 --- a/crates/relayburn-sdk/src/query_verbs/mod.rs +++ b/crates/relayburn-sdk/src/query_verbs/mod.rs @@ -398,6 +398,39 @@ pub(crate) fn ensure_bucket_span(anchor: i64, end: i64, bucket_secs: u64) -> Res Ok(()) } +/// Partition `items` into time buckets by their timestamp string. Returns the +/// `Buckets` window plus a per-bucket vector of items, or `Ok(None)` when there +/// is no anchor (no `--since` and no parseable timestamps) so each caller can +/// return its own empty-timeseries shape. `ts_of` extracts the ISO timestamp +/// from an item (`|t| &t.ts` for `TurnRecord`, `|t| &t.turn.ts` for +/// `EnrichedTurn`). Shared by the `summary` and `compare` `--bucket` paths. +pub(crate) fn partition_into_buckets( + items: Vec, + since: Option<&str>, + bucket_secs: u64, + ts_of: impl Fn(&T) -> &str, +) -> Result>)>> { + let Some(anchor) = bucket_anchor_secs( + since, + items.iter().filter_map(|t| iso_z_to_epoch_secs(ts_of(t))), + ) else { + return Ok(None); + }; + let now = system_now_secs() as i64; + ensure_bucket_span(anchor, now, bucket_secs)?; + let buckets = Buckets::new(anchor, now, bucket_secs); + let mut per_bucket: Vec> = (0..buckets.len()).map(|_| Vec::new()).collect(); + for t in items { + let Some(ep) = iso_z_to_epoch_secs(ts_of(&t)) else { + continue; + }; + if let Some(i) = buckets.index_for(ep) { + per_bucket[i].push(t); + } + } + Ok(Some((buckets, per_bucket))) +} + // --------------------------------------------------------------------------- // Shared helpers — query construction + hotspots coverage gate // --------------------------------------------------------------------------- diff --git a/crates/relayburn-sdk/src/query_verbs/summary.rs b/crates/relayburn-sdk/src/query_verbs/summary.rs index 34b8df6..5ac6c71 100644 --- a/crates/relayburn-sdk/src/query_verbs/summary.rs +++ b/crates/relayburn-sdk/src/query_verbs/summary.rs @@ -602,31 +602,14 @@ impl LedgerHandle { ); let turns = summary_turns_from_enriched(&enriched); - let Some(anchor) = super::bucket_anchor_secs( - q.since.as_deref(), - turns - .iter() - .filter_map(|t| super::iso_z_to_epoch_secs(&t.ts)), - ) else { + let Some((buckets, per_bucket)) = + super::partition_into_buckets(turns, q.since.as_deref(), bucket_secs, |t| &t.ts)? + else { return Ok(SummaryTimeseries { bucket_secs, buckets: Vec::new(), }); }; - let now = super::system_now_secs() as i64; - super::ensure_bucket_span(anchor, now, bucket_secs)?; - let buckets = super::Buckets::new(anchor, now, bucket_secs); - let n = buckets.len(); - - let mut per_bucket: Vec> = (0..n).map(|_| Vec::new()).collect(); - for t in turns { - let Some(ep) = super::iso_z_to_epoch_secs(&t.ts) else { - continue; - }; - if let Some(i) = buckets.index_for(ep) { - per_bucket[i].push(t); - } - } let group_by = if by_provider { SummaryGroupBy::Provider From b086886a4f3156f61a21b413676ea69bef239c21 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 23:17:19 -0400 Subject: [PATCH 21/22] style(sdk): cargo fmt (de-indent externalized ghost_surface tests) ghost_surface_tests.rs was extracted from the inline `mod tests` block with its original 4-space indentation; as a `#[path]`-included module file its items belong at column 0 (matching patterns_tests.rs). Run cargo fmt to fix it plus a small wrap in opencode.rs. Pure formatting; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/analyze/ghost_surface_tests.rs | 1388 ++++++++--------- crates/relayburn-sdk/src/reader/opencode.rs | 4 +- 2 files changed, 696 insertions(+), 696 deletions(-) diff --git a/crates/relayburn-sdk/src/analyze/ghost_surface_tests.rs b/crates/relayburn-sdk/src/analyze/ghost_surface_tests.rs index 52d3319..649b112 100644 --- a/crates/relayburn-sdk/src/analyze/ghost_surface_tests.rs +++ b/crates/relayburn-sdk/src/analyze/ghost_surface_tests.rs @@ -1,705 +1,703 @@ //! Conformance tests for the ghost_surface module — extracted verbatim from the //! former inline `#[cfg(test)] mod tests` block (included via `#[path]`). - use super::*; - use crate::analyze::findings::WasteSeverity; - use std::path::PathBuf; - - fn fixtures_root() -> PathBuf { - // crates/relayburn-analyze/Cargo.toml -> repo root is two levels up. - let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - manifest - .parent() - .unwrap() - .parent() - .unwrap() - .join("tests") - .join("fixtures") - .join("ghost-surface") - } - - fn claude_home() -> PathBuf { - fixtures_root().join("claude") - } - fn codex_home() -> PathBuf { - fixtures_root().join("codex") - } - fn opencode_project() -> PathBuf { - fixtures_root().join("opencode-project") - } - - const RATE: f64 = 1e-6; - - fn make_inputs() -> GhostSurfaceInputs { - GhostSurfaceInputs { - observed_names_by_source: HashMap::new(), - session_count_by_source: HashMap::new(), - dollar_per_token: RATE, - claude_home: Some(claude_home()), - codex_home: Some(codex_home()), - opencode_projects: Some(vec![opencode_project()]), - user_turn_text_by_session: None, +use super::*; +use crate::analyze::findings::WasteSeverity; +use std::path::PathBuf; + +fn fixtures_root() -> PathBuf { + // crates/relayburn-analyze/Cargo.toml -> repo root is two levels up. + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .parent() + .unwrap() + .parent() + .unwrap() + .join("tests") + .join("fixtures") + .join("ghost-surface") +} + +fn claude_home() -> PathBuf { + fixtures_root().join("claude") +} +fn codex_home() -> PathBuf { + fixtures_root().join("codex") +} +fn opencode_project() -> PathBuf { + fixtures_root().join("opencode-project") +} + +const RATE: f64 = 1e-6; + +fn make_inputs() -> GhostSurfaceInputs { + GhostSurfaceInputs { + observed_names_by_source: HashMap::new(), + session_count_by_source: HashMap::new(), + dollar_per_token: RATE, + claude_home: Some(claude_home()), + codex_home: Some(codex_home()), + opencode_projects: Some(vec![opencode_project()]), + user_turn_text_by_session: None, + } +} + +fn observed(source: SourceKind, names: &[&str]) -> HashMap> { + let mut m = HashMap::new(); + m.insert(source, names.iter().map(|s| s.to_string()).collect()); + m +} + +fn observed_multi(entries: &[(SourceKind, &[&str])]) -> HashMap> { + let mut m = HashMap::new(); + for (s, names) in entries { + m.insert(*s, names.iter().map(|s| s.to_string()).collect()); + } + m +} + +fn count_map(entries: &[(SourceKind, u64)]) -> HashMap { + entries.iter().copied().collect() +} + +type UserTextEntries = Vec<(SourceKind, Vec<(String, Vec)>)>; + +fn user_text(entries: UserTextEntries) -> HashMap>> { + let mut out = HashMap::new(); + for (src, sessions) in entries { + let mut inner = HashMap::new(); + for (sid, texts) in sessions { + inner.insert(sid, texts); } - } - - fn observed(source: SourceKind, names: &[&str]) -> HashMap> { - let mut m = HashMap::new(); - m.insert(source, names.iter().map(|s| s.to_string()).collect()); - m - } - - fn observed_multi(entries: &[(SourceKind, &[&str])]) -> HashMap> { - let mut m = HashMap::new(); - for (s, names) in entries { - m.insert(*s, names.iter().map(|s| s.to_string()).collect()); - } - m - } - - fn count_map(entries: &[(SourceKind, u64)]) -> HashMap { - entries.iter().copied().collect() - } - - type UserTextEntries = Vec<(SourceKind, Vec<(String, Vec)>)>; - - fn user_text(entries: UserTextEntries) -> HashMap>> { - let mut out = HashMap::new(); - for (src, sessions) in entries { - let mut inner = HashMap::new(); - for (sid, texts) in sessions { - inner.insert(sid, texts); - } - out.insert(src, inner); - } - out - } - - // ---- claudeGhostAdapter -------------------------------------------------- - - #[test] - fn claude_enumerates_agents_skills_commands() { - let candidates = ClaudeGhostAdapter.enumerate(&make_inputs()); - let kinds: HashSet = candidates.iter().map(|c| c.kind).collect(); - assert!(kinds.contains(&GhostFindingKind::GhostAgent), "has agents"); - assert!(kinds.contains(&GhostFindingKind::GhostSkill), "has skills"); - assert!( - kinds.contains(&GhostFindingKind::GhostCommand), - "has commands" - ); - let mut agents: Vec = candidates - .iter() - .filter(|c| c.kind == GhostFindingKind::GhostAgent) - .map(|c| c.basename.clone()) - .collect(); - agents.sort(); - assert_eq!(agents, vec!["code-reviewer.md", "forgotten-helper.md"]); - } - - #[test] - fn claude_returns_empty_when_home_missing() { - let mut inputs = make_inputs(); - inputs.claude_home = Some(fixtures_root().join("does-not-exist")); - let candidates = ClaudeGhostAdapter.enumerate(&inputs); - assert_eq!(candidates.len(), 0); - } - - #[test] - fn claude_detects_ghost_agent_when_basename_not_observed() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); - let ghosts = detect_ghost_surface(&inputs); - let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .collect(); - let mut basenames: Vec = - claude_ghosts.iter().map(|g| basename_of(&g.path)).collect(); - basenames.sort(); - assert_eq!( - basenames, - vec![ - "forgotten-helper.md", - "openspec-apply.md", - "openspec-archive.md", - ] - ); - let helper = claude_ghosts - .iter() - .find(|g| g.path.ends_with("forgotten-helper.md")) - .unwrap(); - assert_eq!(helper.kind, GhostFindingKind::GhostAgent); - assert_eq!(helper.session_count, 10); - assert!(helper.cost > 0.0); - assert!(helper.size_tokens > 0); - } - - #[test] - fn claude_de_ghosts_command_via_slash_form() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::ClaudeCode, - vec![( - "session-1".to_string(), - vec![ - "/openspec-apply\nApply the latest proposal." - .to_string(), - ], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let mut basenames: Vec = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .map(|g| basename_of(&g.path)) - .collect(); - basenames.sort(); - assert_eq!( - basenames, - vec!["forgotten-helper.md", "openspec-archive.md"] - ); - } - - #[test] - fn claude_recognises_bare_command_name_no_leading_slash() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::ClaudeCode, - vec![( - "session-1".to_string(), - vec!["openspec-apply\nbody".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); - assert!( - apply.is_none(), - "claude openspec-apply should be de-ghosted" - ); - } - - #[test] - fn claude_falls_back_to_v1_when_user_text_empty() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); - inputs.user_turn_text_by_session = Some(HashMap::new()); - let ghosts = detect_ghost_surface(&inputs); - let mut basenames: Vec = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .map(|g| basename_of(&g.path)) - .collect(); - basenames.sort(); - assert_eq!( - basenames, + out.insert(src, inner); + } + out +} + +// ---- claudeGhostAdapter -------------------------------------------------- + +#[test] +fn claude_enumerates_agents_skills_commands() { + let candidates = ClaudeGhostAdapter.enumerate(&make_inputs()); + let kinds: HashSet = candidates.iter().map(|c| c.kind).collect(); + assert!(kinds.contains(&GhostFindingKind::GhostAgent), "has agents"); + assert!(kinds.contains(&GhostFindingKind::GhostSkill), "has skills"); + assert!( + kinds.contains(&GhostFindingKind::GhostCommand), + "has commands" + ); + let mut agents: Vec = candidates + .iter() + .filter(|c| c.kind == GhostFindingKind::GhostAgent) + .map(|c| c.basename.clone()) + .collect(); + agents.sort(); + assert_eq!(agents, vec!["code-reviewer.md", "forgotten-helper.md"]); +} + +#[test] +fn claude_returns_empty_when_home_missing() { + let mut inputs = make_inputs(); + inputs.claude_home = Some(fixtures_root().join("does-not-exist")); + let candidates = ClaudeGhostAdapter.enumerate(&inputs); + assert_eq!(candidates.len(), 0); +} + +#[test] +fn claude_detects_ghost_agent_when_basename_not_observed() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = + observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); + let ghosts = detect_ghost_surface(&inputs); + let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .collect(); + let mut basenames: Vec = claude_ghosts.iter().map(|g| basename_of(&g.path)).collect(); + basenames.sort(); + assert_eq!( + basenames, + vec![ + "forgotten-helper.md", + "openspec-apply.md", + "openspec-archive.md", + ] + ); + let helper = claude_ghosts + .iter() + .find(|g| g.path.ends_with("forgotten-helper.md")) + .unwrap(); + assert_eq!(helper.kind, GhostFindingKind::GhostAgent); + assert_eq!(helper.session_count, 10); + assert!(helper.cost > 0.0); + assert!(helper.size_tokens > 0); +} + +#[test] +fn claude_de_ghosts_command_via_slash_form() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = + observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::ClaudeCode, + vec![( + "session-1".to_string(), vec![ - "forgotten-helper.md", - "openspec-apply.md", - "openspec-archive.md", - ] - ); - } - - // ---- codexGhostAdapter -------------------------------------------------- - - #[test] - fn codex_enumerates_prompts_skills_rules_memories() { - let candidates = CodexGhostAdapter.enumerate(&make_inputs()); - let mut by_kind: HashMap> = HashMap::new(); - for c in &candidates { - by_kind.entry(c.kind).or_default().push(c.basename.clone()); - } - for v in by_kind.values_mut() { - v.sort(); - } - assert_eq!( - by_kind - .get(&GhostFindingKind::GhostPrompt) - .cloned() - .unwrap_or_default(), - vec!["openspec-apply.md", "openspec-archive.md", "refactor.md"] - ); - assert_eq!( - by_kind - .get(&GhostFindingKind::GhostSkill) - .cloned() - .unwrap_or_default(), - vec!["code-search.md"] - ); - assert_eq!( - by_kind - .get(&GhostFindingKind::GhostRule) - .cloned() - .unwrap_or_default(), - vec!["no-print.md"] - ); - assert_eq!( - by_kind - .get(&GhostFindingKind::GhostMemory) - .cloned() - .unwrap_or_default(), - vec!["preferences.md"] - ); - } - - #[test] - fn codex_flags_openspec_archive_as_ghost() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &["refactor", "code-search"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 5)]); - let ghosts = detect_ghost_surface(&inputs); - let codex_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::Codex) - .collect(); - let openspec = codex_ghosts - .iter() - .find(|g| g.path.ends_with("openspec-archive.md")); - assert!(openspec.is_some()); - assert_eq!(openspec.unwrap().kind, GhostFindingKind::GhostPrompt); - assert_eq!(openspec.unwrap().session_count, 5); - assert!(openspec.unwrap().cost > 0.0); - let kinds: HashSet = codex_ghosts.iter().map(|g| g.kind).collect(); - assert!(kinds.contains(&GhostFindingKind::GhostRule)); - assert!(kinds.contains(&GhostFindingKind::GhostMemory)); - } - - #[test] - fn codex_de_ghosts_via_slash_in_user_text() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &["refactor", "code-search"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 5)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["/openspec-apply\nApply the latest proposal please.".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let codex_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::Codex) - .collect(); - let apply = codex_ghosts - .iter() - .find(|g| g.path.ends_with("openspec-apply.md")); - assert!(apply.is_none(), "codex openspec-apply should be de-ghosted"); - let archive = codex_ghosts - .iter() - .find(|g| g.path.ends_with("openspec-archive.md")); - assert!( - archive.is_some(), - "codex openspec-archive should remain a ghost" - ); - } - - #[test] - fn codex_recognises_slash_not_at_start() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["Please run the /openspec-apply prompt now.".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!(apply.is_none(), "mid-line /openspec-apply should de-ghost"); - } - - #[test] - fn codex_does_not_match_extended_slash_command() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["/openspec-apply-foo bar".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!( - apply.is_some(), - "a longer slash command should not de-ghost the shorter stem" - ); - } - - #[test] - fn codex_ignores_slash_after_word_char() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["See https://example.com/openspec-apply for docs.".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!( - apply.is_some(), - "URL-style /openspec-apply should not de-ghost" - ); - } - - #[test] - fn codex_matches_case_insensitively() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); - inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "session-1".to_string(), - vec!["/OPENSPEC-Apply now".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!( - apply.is_none(), - "mixed-case /OPENSPEC-Apply should de-ghost" - ); - } - - #[test] - fn codex_does_not_de_ghost_from_claude_command_marker() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed_multi(&[ - (SourceKind::ClaudeCode, &["code-reviewer"]), - (SourceKind::Codex, &["refactor"]), - ]); - inputs.session_count_by_source = - count_map(&[(SourceKind::ClaudeCode, 1), (SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::ClaudeCode, - vec![( - "claude-session-1".to_string(), - vec!["/openspec-apply\nbody".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let codex_apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!(codex_apply.is_some(), "Codex must remain a ghost"); - let claude_apply = ghosts - .iter() - .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); - assert!( - claude_apply.is_none(), - "Claude side is de-ghosted by its own marker" - ); - } - - #[test] - fn claude_does_not_de_ghost_from_codex_slash() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed_multi(&[ - (SourceKind::ClaudeCode, &["code-reviewer"]), - (SourceKind::Codex, &["refactor"]), - ]); - inputs.session_count_by_source = - count_map(&[(SourceKind::ClaudeCode, 1), (SourceKind::Codex, 1)]); - inputs.user_turn_text_by_session = Some(user_text(vec![( - SourceKind::Codex, - vec![( - "codex-session-1".to_string(), - vec!["/openspec-apply\nApply the latest proposal.".to_string()], - )], - )])); - let ghosts = detect_ghost_surface(&inputs); - let claude_apply = ghosts - .iter() - .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); - assert!( - claude_apply.is_some(), - "Claude must remain a ghost — Codex slash mustn't leak" - ); - let codex_apply = ghosts - .iter() - .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); - assert!(codex_apply.is_none()); - } - - // ---- opencodeGhostAdapter ---------------------------------------------- - - #[test] - fn opencode_enumerates_declared_skills_commands_and_project_skills() { - let candidates = OpenCodeGhostAdapter.enumerate(&make_inputs()); - let declared: Vec<&GhostCandidate> = candidates - .iter() - .filter(|c| c.counted_by_catalog_bloat == Some(true)) - .collect(); - let project: Vec<&GhostCandidate> = candidates - .iter() - .filter(|c| c.counted_by_catalog_bloat != Some(true)) - .collect(); - let mut declared_names: Vec = declared.iter().map(|c| c.basename.clone()).collect(); - declared_names.sort(); - assert_eq!( - declared_names, - vec!["abandoned-helper", "code-search"], - "declared catalog skills are flagged with countedByCatalogBloat", - ); - let project_skills: Vec = project - .iter() - .filter(|c| c.kind == GhostFindingKind::GhostSkill) - .map(|c| c.basename.clone()) - .collect(); - assert_eq!(project_skills, vec!["project-skill.md"]); - let mut commands: Vec = project - .iter() - .filter(|c| c.kind == GhostFindingKind::GhostCommand) - .map(|c| c.basename.clone()) - .collect(); - commands.sort(); - assert_eq!(commands, vec!["deploy", "ghost-command"]); - } - - #[test] - fn opencode_emits_zero_cost_for_declared_catalog_bloat() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = - observed(SourceKind::Opencode, &["code-search", "deploy"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 20)]); - let ghosts = detect_ghost_surface(&inputs); - let opencode_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::Opencode) - .collect(); - let abandoned = opencode_ghosts - .iter() - .find(|g| g.path.contains("abandoned-helper")); - assert!(abandoned.is_some(), "declared catalog skill is reported"); - assert_eq!(abandoned.unwrap().cost, 0.0); - assert_eq!(abandoned.unwrap().counted_by_catalog_bloat, Some(true)); - let ghost_cmd = opencode_ghosts - .iter() - .find(|g| g.path.ends_with("#/commands/ghost-command")); - assert!(ghost_cmd.is_some()); - assert!(ghost_cmd.unwrap().cost > 0.0); - assert_eq!(ghost_cmd.unwrap().counted_by_catalog_bloat, None); - let project_skill = opencode_ghosts - .iter() - .find(|g| g.path.ends_with("project-skill.md")); - assert!(project_skill.is_some()); - assert!(project_skill.unwrap().cost > 0.0); - assert_eq!(project_skill.unwrap().counted_by_catalog_bloat, None); - } - - // ---- detectGhostSurface — orchestrator --------------------------------- - - #[test] - fn orchestrator_runs_every_adapter_sorted_by_cost_desc() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed_multi(&[ - (SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]), - (SourceKind::Codex, &["refactor", "code-search"]), - (SourceKind::Opencode, &["code-search", "deploy"]), - ]); - inputs.session_count_by_source = count_map(&[ - (SourceKind::ClaudeCode, 10), - (SourceKind::Codex, 5), - (SourceKind::Opencode, 20), - ]); - let ghosts = detect_ghost_surface(&inputs); - for w in ghosts.windows(2) { - assert!(w[0].cost >= w[1].cost, "sorted by cost desc"); - } - let sources: HashSet = ghosts.iter().map(|g| g.source).collect(); - assert!(sources.contains(&SourceKind::ClaudeCode)); - assert!(sources.contains(&SourceKind::Codex)); - assert!(sources.contains(&SourceKind::Opencode)); - } - - #[test] - fn orchestrator_treats_observed_case_insensitively() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed( - SourceKind::ClaudeCode, - &[ - "Code-Reviewer", - "GIT-COMMIT", - "forgotten-HELPER", - "openspec-archive", - "openspec-apply", + "/openspec-apply\nApply the latest proposal." + .to_string(), ], - ); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 1)]); - let ghosts = detect_ghost_surface(&inputs); - let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .collect(); - assert_eq!(claude_ghosts.len(), 0); - } - - #[test] - fn orchestrator_includes_ghost_when_session_count_zero() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &[]); - let ghosts = detect_ghost_surface(&inputs); - let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts - .iter() - .filter(|g| g.source == SourceKind::ClaudeCode) - .collect(); - assert!(!claude_ghosts.is_empty()); - for g in &claude_ghosts { - assert_eq!(g.cost, 0.0); - assert_eq!(g.session_count, 0); - } - } - - // ---- ghostSurfaceToFinding --------------------------------------------- - - #[test] - fn finding_produces_mv_command_action() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &["code-reviewer"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); - let ghosts = detect_ghost_surface(&inputs); - let helper = ghosts - .iter() - .find(|g| g.path.ends_with("forgotten-helper.md")) - .unwrap(); - let finding = ghost_surface_to_finding( - helper, - &GhostSurfaceFindingOptions { - archive_dir: Some(PathBuf::from("/tmp/ghost-archive")), - }, - ); - assert_eq!(finding.kind, "ghost-agent"); - assert_eq!(finding.actions.len(), 1); - match &finding.actions[0] { - WasteAction::Command { text, .. } => { - assert!(text.contains("mv ")); - assert!(text.contains("/tmp/ghost-archive")); - assert!(text.contains(&helper.path)); - } - other => panic!("expected Command, got {other:?}"), + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let mut basenames: Vec = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .map(|g| basename_of(&g.path)) + .collect(); + basenames.sort(); + assert_eq!( + basenames, + vec!["forgotten-helper.md", "openspec-archive.md"] + ); +} + +#[test] +fn claude_recognises_bare_command_name_no_leading_slash() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = + observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::ClaudeCode, + vec![( + "session-1".to_string(), + vec!["openspec-apply\nbody".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); + assert!( + apply.is_none(), + "claude openspec-apply should be de-ghosted" + ); +} + +#[test] +fn claude_falls_back_to_v1_when_user_text_empty() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = + observed(SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); + inputs.user_turn_text_by_session = Some(HashMap::new()); + let ghosts = detect_ghost_surface(&inputs); + let mut basenames: Vec = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .map(|g| basename_of(&g.path)) + .collect(); + basenames.sort(); + assert_eq!( + basenames, + vec![ + "forgotten-helper.md", + "openspec-apply.md", + "openspec-archive.md", + ] + ); +} + +// ---- codexGhostAdapter -------------------------------------------------- + +#[test] +fn codex_enumerates_prompts_skills_rules_memories() { + let candidates = CodexGhostAdapter.enumerate(&make_inputs()); + let mut by_kind: HashMap> = HashMap::new(); + for c in &candidates { + by_kind.entry(c.kind).or_default().push(c.basename.clone()); + } + for v in by_kind.values_mut() { + v.sort(); + } + assert_eq!( + by_kind + .get(&GhostFindingKind::GhostPrompt) + .cloned() + .unwrap_or_default(), + vec!["openspec-apply.md", "openspec-archive.md", "refactor.md"] + ); + assert_eq!( + by_kind + .get(&GhostFindingKind::GhostSkill) + .cloned() + .unwrap_or_default(), + vec!["code-search.md"] + ); + assert_eq!( + by_kind + .get(&GhostFindingKind::GhostRule) + .cloned() + .unwrap_or_default(), + vec!["no-print.md"] + ); + assert_eq!( + by_kind + .get(&GhostFindingKind::GhostMemory) + .cloned() + .unwrap_or_default(), + vec!["preferences.md"] + ); +} + +#[test] +fn codex_flags_openspec_archive_as_ghost() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &["refactor", "code-search"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 5)]); + let ghosts = detect_ghost_surface(&inputs); + let codex_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::Codex) + .collect(); + let openspec = codex_ghosts + .iter() + .find(|g| g.path.ends_with("openspec-archive.md")); + assert!(openspec.is_some()); + assert_eq!(openspec.unwrap().kind, GhostFindingKind::GhostPrompt); + assert_eq!(openspec.unwrap().session_count, 5); + assert!(openspec.unwrap().cost > 0.0); + let kinds: HashSet = codex_ghosts.iter().map(|g| g.kind).collect(); + assert!(kinds.contains(&GhostFindingKind::GhostRule)); + assert!(kinds.contains(&GhostFindingKind::GhostMemory)); +} + +#[test] +fn codex_de_ghosts_via_slash_in_user_text() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &["refactor", "code-search"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 5)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["/openspec-apply\nApply the latest proposal please.".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let codex_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::Codex) + .collect(); + let apply = codex_ghosts + .iter() + .find(|g| g.path.ends_with("openspec-apply.md")); + assert!(apply.is_none(), "codex openspec-apply should be de-ghosted"); + let archive = codex_ghosts + .iter() + .find(|g| g.path.ends_with("openspec-archive.md")); + assert!( + archive.is_some(), + "codex openspec-archive should remain a ghost" + ); +} + +#[test] +fn codex_recognises_slash_not_at_start() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["Please run the /openspec-apply prompt now.".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!(apply.is_none(), "mid-line /openspec-apply should de-ghost"); +} + +#[test] +fn codex_does_not_match_extended_slash_command() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["/openspec-apply-foo bar".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!( + apply.is_some(), + "a longer slash command should not de-ghost the shorter stem" + ); +} + +#[test] +fn codex_ignores_slash_after_word_char() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["See https://example.com/openspec-apply for docs.".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!( + apply.is_some(), + "URL-style /openspec-apply should not de-ghost" + ); +} + +#[test] +fn codex_matches_case_insensitively() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Codex, &[]); + inputs.session_count_by_source = count_map(&[(SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "session-1".to_string(), + vec!["/OPENSPEC-Apply now".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!( + apply.is_none(), + "mixed-case /OPENSPEC-Apply should de-ghost" + ); +} + +#[test] +fn codex_does_not_de_ghost_from_claude_command_marker() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed_multi(&[ + (SourceKind::ClaudeCode, &["code-reviewer"]), + (SourceKind::Codex, &["refactor"]), + ]); + inputs.session_count_by_source = + count_map(&[(SourceKind::ClaudeCode, 1), (SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::ClaudeCode, + vec![( + "claude-session-1".to_string(), + vec!["/openspec-apply\nbody".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let codex_apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!(codex_apply.is_some(), "Codex must remain a ghost"); + let claude_apply = ghosts + .iter() + .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); + assert!( + claude_apply.is_none(), + "Claude side is de-ghosted by its own marker" + ); +} + +#[test] +fn claude_does_not_de_ghost_from_codex_slash() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed_multi(&[ + (SourceKind::ClaudeCode, &["code-reviewer"]), + (SourceKind::Codex, &["refactor"]), + ]); + inputs.session_count_by_source = + count_map(&[(SourceKind::ClaudeCode, 1), (SourceKind::Codex, 1)]); + inputs.user_turn_text_by_session = Some(user_text(vec![( + SourceKind::Codex, + vec![( + "codex-session-1".to_string(), + vec!["/openspec-apply\nApply the latest proposal.".to_string()], + )], + )])); + let ghosts = detect_ghost_surface(&inputs); + let claude_apply = ghosts + .iter() + .find(|g| g.source == SourceKind::ClaudeCode && g.path.ends_with("openspec-apply.md")); + assert!( + claude_apply.is_some(), + "Claude must remain a ghost — Codex slash mustn't leak" + ); + let codex_apply = ghosts + .iter() + .find(|g| g.source == SourceKind::Codex && g.path.ends_with("openspec-apply.md")); + assert!(codex_apply.is_none()); +} + +// ---- opencodeGhostAdapter ---------------------------------------------- + +#[test] +fn opencode_enumerates_declared_skills_commands_and_project_skills() { + let candidates = OpenCodeGhostAdapter.enumerate(&make_inputs()); + let declared: Vec<&GhostCandidate> = candidates + .iter() + .filter(|c| c.counted_by_catalog_bloat == Some(true)) + .collect(); + let project: Vec<&GhostCandidate> = candidates + .iter() + .filter(|c| c.counted_by_catalog_bloat != Some(true)) + .collect(); + let mut declared_names: Vec = declared.iter().map(|c| c.basename.clone()).collect(); + declared_names.sort(); + assert_eq!( + declared_names, + vec!["abandoned-helper", "code-search"], + "declared catalog skills are flagged with countedByCatalogBloat", + ); + let project_skills: Vec = project + .iter() + .filter(|c| c.kind == GhostFindingKind::GhostSkill) + .map(|c| c.basename.clone()) + .collect(); + assert_eq!(project_skills, vec!["project-skill.md"]); + let mut commands: Vec = project + .iter() + .filter(|c| c.kind == GhostFindingKind::GhostCommand) + .map(|c| c.basename.clone()) + .collect(); + commands.sort(); + assert_eq!(commands, vec!["deploy", "ghost-command"]); +} + +#[test] +fn opencode_emits_zero_cost_for_declared_catalog_bloat() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Opencode, &["code-search", "deploy"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 20)]); + let ghosts = detect_ghost_surface(&inputs); + let opencode_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::Opencode) + .collect(); + let abandoned = opencode_ghosts + .iter() + .find(|g| g.path.contains("abandoned-helper")); + assert!(abandoned.is_some(), "declared catalog skill is reported"); + assert_eq!(abandoned.unwrap().cost, 0.0); + assert_eq!(abandoned.unwrap().counted_by_catalog_bloat, Some(true)); + let ghost_cmd = opencode_ghosts + .iter() + .find(|g| g.path.ends_with("#/commands/ghost-command")); + assert!(ghost_cmd.is_some()); + assert!(ghost_cmd.unwrap().cost > 0.0); + assert_eq!(ghost_cmd.unwrap().counted_by_catalog_bloat, None); + let project_skill = opencode_ghosts + .iter() + .find(|g| g.path.ends_with("project-skill.md")); + assert!(project_skill.is_some()); + assert!(project_skill.unwrap().cost > 0.0); + assert_eq!(project_skill.unwrap().counted_by_catalog_bloat, None); +} + +// ---- detectGhostSurface — orchestrator --------------------------------- + +#[test] +fn orchestrator_runs_every_adapter_sorted_by_cost_desc() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed_multi(&[ + (SourceKind::ClaudeCode, &["code-reviewer", "git-commit"]), + (SourceKind::Codex, &["refactor", "code-search"]), + (SourceKind::Opencode, &["code-search", "deploy"]), + ]); + inputs.session_count_by_source = count_map(&[ + (SourceKind::ClaudeCode, 10), + (SourceKind::Codex, 5), + (SourceKind::Opencode, 20), + ]); + let ghosts = detect_ghost_surface(&inputs); + for w in ghosts.windows(2) { + assert!(w[0].cost >= w[1].cost, "sorted by cost desc"); + } + let sources: HashSet = ghosts.iter().map(|g| g.source).collect(); + assert!(sources.contains(&SourceKind::ClaudeCode)); + assert!(sources.contains(&SourceKind::Codex)); + assert!(sources.contains(&SourceKind::Opencode)); +} + +#[test] +fn orchestrator_treats_observed_case_insensitively() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed( + SourceKind::ClaudeCode, + &[ + "Code-Reviewer", + "GIT-COMMIT", + "forgotten-HELPER", + "openspec-archive", + "openspec-apply", + ], + ); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 1)]); + let ghosts = detect_ghost_surface(&inputs); + let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .collect(); + assert_eq!(claude_ghosts.len(), 0); +} + +#[test] +fn orchestrator_includes_ghost_when_session_count_zero() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &[]); + let ghosts = detect_ghost_surface(&inputs); + let claude_ghosts: Vec<&GhostSurfaceFinding> = ghosts + .iter() + .filter(|g| g.source == SourceKind::ClaudeCode) + .collect(); + assert!(!claude_ghosts.is_empty()); + for g in &claude_ghosts { + assert_eq!(g.cost, 0.0); + assert_eq!(g.session_count, 0); + } +} + +// ---- ghostSurfaceToFinding --------------------------------------------- + +#[test] +fn finding_produces_mv_command_action() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &["code-reviewer"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 10)]); + let ghosts = detect_ghost_surface(&inputs); + let helper = ghosts + .iter() + .find(|g| g.path.ends_with("forgotten-helper.md")) + .unwrap(); + let finding = ghost_surface_to_finding( + helper, + &GhostSurfaceFindingOptions { + archive_dir: Some(PathBuf::from("/tmp/ghost-archive")), + }, + ); + assert_eq!(finding.kind, "ghost-agent"); + assert_eq!(finding.actions.len(), 1); + match &finding.actions[0] { + WasteAction::Command { text, .. } => { + assert!(text.contains("mv ")); + assert!(text.contains("/tmp/ghost-archive")); + assert!(text.contains(&helper.path)); } - assert!(finding.title.contains("forgotten-helper")); - assert!(finding.detail.contains("claude-code")); - } - - #[test] - fn finding_marks_catalog_bloat_with_zero_cost_and_dedup_note() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Opencode, &["deploy"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 100)]); - let ghosts = detect_ghost_surface(&inputs); - let abandoned = ghosts - .iter() - .find(|g| g.path.contains("abandoned-helper")) - .unwrap(); - let finding = ghost_surface_to_finding(abandoned, &GhostSurfaceFindingOptions::default()); - assert_eq!(finding.estimated_savings.usd_per_session, Some(0.0)); - assert!(finding.detail.contains("catalog-bloat")); - } - - #[test] - fn finding_uses_per_session_cost_for_severity() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &["code-reviewer"]); - inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 100_000)]); - let ghosts = detect_ghost_surface(&inputs); - let helper = ghosts - .iter() - .find(|g| g.path.ends_with("forgotten-helper.md")) - .unwrap(); - // Cumulative cost is well above $1 (severity High threshold = $0.5). - assert!(helper.cost > 1.0, "expected cumulative cost > $1"); - // Per-session cost should be far below $0.05 (severity Warn threshold). - assert!( - helper.cost_per_session < 0.05, - "per-session cost should be below warn threshold" - ); - let finding = ghost_surface_to_finding( - helper, - &GhostSurfaceFindingOptions { - archive_dir: Some(PathBuf::from("/tmp/ghost-archive")), - }, - ); - assert_eq!( - finding.estimated_savings.usd_per_session, - Some(helper.cost_per_session) - ); - assert_eq!(finding.severity, WasteSeverity::Info); - } - - #[test] - fn finding_shell_quotes_paths_with_spaces() { - let ghost = GhostSurfaceFinding { - source: SourceKind::ClaudeCode, - kind: GhostFindingKind::GhostAgent, - path: "/Users/me/.claude/agents/my helper.md".to_string(), - size_tokens: 100, - cost: 0.001, - cost_per_session: 0.0001, - session_count: 10, - counted_by_catalog_bloat: None, - }; - let finding = ghost_surface_to_finding( - &ghost, - &GhostSurfaceFindingOptions { - archive_dir: Some(PathBuf::from("/tmp/ghost archive")), - }, - ); - match &finding.actions[0] { - WasteAction::Command { text, .. } => { - assert!(text.contains("'/Users/me/.claude/agents/my helper.md'")); - assert!(text.contains("'/tmp/ghost archive")); - } - other => panic!("expected Command action, got {other:?}"), + other => panic!("expected Command, got {other:?}"), + } + assert!(finding.title.contains("forgotten-helper")); + assert!(finding.detail.contains("claude-code")); +} + +#[test] +fn finding_marks_catalog_bloat_with_zero_cost_and_dedup_note() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Opencode, &["deploy"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 100)]); + let ghosts = detect_ghost_surface(&inputs); + let abandoned = ghosts + .iter() + .find(|g| g.path.contains("abandoned-helper")) + .unwrap(); + let finding = ghost_surface_to_finding(abandoned, &GhostSurfaceFindingOptions::default()); + assert_eq!(finding.estimated_savings.usd_per_session, Some(0.0)); + assert!(finding.detail.contains("catalog-bloat")); +} + +#[test] +fn finding_uses_per_session_cost_for_severity() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::ClaudeCode, &["code-reviewer"]); + inputs.session_count_by_source = count_map(&[(SourceKind::ClaudeCode, 100_000)]); + let ghosts = detect_ghost_surface(&inputs); + let helper = ghosts + .iter() + .find(|g| g.path.ends_with("forgotten-helper.md")) + .unwrap(); + // Cumulative cost is well above $1 (severity High threshold = $0.5). + assert!(helper.cost > 1.0, "expected cumulative cost > $1"); + // Per-session cost should be far below $0.05 (severity Warn threshold). + assert!( + helper.cost_per_session < 0.05, + "per-session cost should be below warn threshold" + ); + let finding = ghost_surface_to_finding( + helper, + &GhostSurfaceFindingOptions { + archive_dir: Some(PathBuf::from("/tmp/ghost-archive")), + }, + ); + assert_eq!( + finding.estimated_savings.usd_per_session, + Some(helper.cost_per_session) + ); + assert_eq!(finding.severity, WasteSeverity::Info); +} + +#[test] +fn finding_shell_quotes_paths_with_spaces() { + let ghost = GhostSurfaceFinding { + source: SourceKind::ClaudeCode, + kind: GhostFindingKind::GhostAgent, + path: "/Users/me/.claude/agents/my helper.md".to_string(), + size_tokens: 100, + cost: 0.001, + cost_per_session: 0.0001, + session_count: 10, + counted_by_catalog_bloat: None, + }; + let finding = ghost_surface_to_finding( + &ghost, + &GhostSurfaceFindingOptions { + archive_dir: Some(PathBuf::from("/tmp/ghost archive")), + }, + ); + match &finding.actions[0] { + WasteAction::Command { text, .. } => { + assert!(text.contains("'/Users/me/.claude/agents/my helper.md'")); + assert!(text.contains("'/tmp/ghost archive")); } - } - - #[test] - fn finding_emits_paste_for_synthetic_opencode_paths() { - let mut inputs = make_inputs(); - inputs.observed_names_by_source = observed(SourceKind::Opencode, &["deploy"]); - inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 5)]); - let ghosts = detect_ghost_surface(&inputs); - let synthetic = ghosts - .iter() - .find(|g| g.path.contains("#/commands/ghost-command")) - .unwrap(); - let finding = ghost_surface_to_finding(synthetic, &GhostSurfaceFindingOptions::default()); - match &finding.actions[0] { - WasteAction::Paste { text, .. } => { - assert!(!text.contains("mv ")); - assert!(text.contains("opencode.json")); - assert!(text.contains("/commands/ghost-command")); - } - other => panic!("expected Paste action, got {other:?}"), + other => panic!("expected Command action, got {other:?}"), + } +} + +#[test] +fn finding_emits_paste_for_synthetic_opencode_paths() { + let mut inputs = make_inputs(); + inputs.observed_names_by_source = observed(SourceKind::Opencode, &["deploy"]); + inputs.session_count_by_source = count_map(&[(SourceKind::Opencode, 5)]); + let ghosts = detect_ghost_surface(&inputs); + let synthetic = ghosts + .iter() + .find(|g| g.path.contains("#/commands/ghost-command")) + .unwrap(); + let finding = ghost_surface_to_finding(synthetic, &GhostSurfaceFindingOptions::default()); + match &finding.actions[0] { + WasteAction::Paste { text, .. } => { + assert!(!text.contains("mv ")); + assert!(text.contains("opencode.json")); + assert!(text.contains("/commands/ghost-command")); } + other => panic!("expected Paste action, got {other:?}"), } +} diff --git a/crates/relayburn-sdk/src/reader/opencode.rs b/crates/relayburn-sdk/src/reader/opencode.rs index 341d2f7..1c83792 100644 --- a/crates/relayburn-sdk/src/reader/opencode.rs +++ b/crates/relayburn-sdk/src/reader/opencode.rs @@ -859,7 +859,9 @@ fn build_opencode_relationships( assistants: &[AssistantMessage], ) -> Vec { let mut out = Vec::new(); - let first_ts = assistants.first().map(|a| crate::util::time::format_iso_ms(a.time_created)); + let first_ts = assistants + .first() + .map(|a| crate::util::time::format_iso_ms(a.time_created)); let mut root = SessionRelationshipRecord { v: 1, source: RelationshipSourceKind::Opencode, From 263e7fab09685af1661c561019db258e0ab3bada Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 21 Jun 2026 23:30:40 -0400 Subject: [PATCH 22/22] docs(changelog): note subagent-tree legacy path removal Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab237f4..149b70e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `burn` subagent-tree views now require a re-ingest to render pre-Root-emission event logs (legacy reconstruction path removed). + ## [3.4.0] - 2026-06-20 - `burn summary` and `burn compare` accept `--bucket ` to emit a per-bucket time-series across the `--since` window instead of a single total (`{ "bucketSeconds": N, "buckets": [...] }` in JSON). Bucket units: `30s` / `5m` / `1h` / `12h` / `1d` / `7d` — note `m` is minutes here, unlike `--since` where `m` is months. `summary --bucket` supports only the default grouped (`byModel` / `--by-provider`) modes; `hotspots` / `overhead` are unchanged.