diff --git a/CHANGELOG.md b/CHANGELOG.md index ab237f49..149b70e8 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. diff --git a/crates/relayburn-sdk/src/analyze/claude_md.rs b/crates/relayburn-sdk/src/analyze/claude_md.rs index aa3169c5..d782b2cf 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_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(); @@ -417,18 +415,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/context_delta.rs b/crates/relayburn-sdk/src/analyze/context_delta.rs index 5e197ee6..ce9a9baf 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 00000000..3939eeec --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/context_delta_tests.rs @@ -0,0 +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 +} + +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/cost.rs b/crates/relayburn-sdk/src/analyze/cost.rs index 280a97a9..fff140be 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")] @@ -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. @@ -142,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/findings.rs b/crates/relayburn-sdk/src/analyze/findings.rs index 764711ef..4998b522 100644 --- a/crates/relayburn-sdk/src/analyze/findings.rs +++ b/crates/relayburn-sdk/src/analyze/findings.rs @@ -307,13 +307,61 @@ 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}"), } } +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,29 @@ 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 +503,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 +523,110 @@ 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/ghost_surface.rs b/crates/relayburn-sdk/src/analyze/ghost_surface.rs index 9acd557b..3edf70ad 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,723 +524,10 @@ 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 // --------------------------------------------------------------------------- #[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 00000000..649b1125 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/ghost_surface_tests.rs @@ -0,0 +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, + } +} + +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:?}"), + } +} diff --git a/crates/relayburn-sdk/src/analyze/hotspots.rs b/crates/relayburn-sdk/src/analyze/hotspots.rs index 2f49c78b..1cf6a95d 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)); @@ -1023,1429 +1022,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 00000000..1be98f26 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/hotspots_tests.rs @@ -0,0 +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; + +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)" + ); +} diff --git a/crates/relayburn-sdk/src/analyze/patterns.rs b/crates/relayburn-sdk/src/analyze/patterns.rs index 44e8d9c9..296e3ee6 100644 --- a/crates/relayburn-sdk/src/analyze/patterns.rs +++ b/crates/relayburn-sdk/src/analyze/patterns.rs @@ -13,27 +13,42 @@ //! 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_turn, cost_for_usage, 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, }; use crate::analyze::pricing::PricingTable; -use crate::analyze::util::{group_turns_by_session, stringify_tool_result}; +use crate::analyze::util::{ + first_seen_unique_by, group_turns_by_session_sorted, stringify_tool_result, truncate_chars, +}; 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 @@ -127,7 +142,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(); @@ -141,10 +156,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)) @@ -296,41 +308,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], @@ -541,23 +518,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 { @@ -588,853 +555,72 @@ 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 -} - -// --------------------------------------------------------------------------- -// Graph-backed detectors -// --------------------------------------------------------------------------- - -fn detect_graph_retry_loops_for_session<'a>( - session_id: &str, - refs: &[ToolResultEventRef<'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 -} - -fn detect_graph_failure_runs_for_session<'a>( - session_id: &str, - refs: &[ToolResultEventRef<'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()); - } - } - 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 -} - -fn detect_graph_cancellation_runs_for_session<'a>( - session_id: &str, - 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()); - } - } - 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 -} - -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 { - 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 -} - -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 { - 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()); - } - } - 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 -} - -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 + sum_turn_costs(turns.iter().copied(), pricing) } // --------------------------------------------------------------------------- -// Edit revert detector +// Shared streak-accumulation skeleton // --------------------------------------------------------------------------- -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, - )); +/// 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); } - 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()); + StreakOp::Break => { + if let Some(found) = commit(&streak) { + out.push(found); } - } else if is_read_tool(name) { - read_count += 1; + streak.clear(); } } } - 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), - }); + if let Some(found) = commit(&streak) { + out.push(found); } 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; - if let Some(c) = cost_for_turn(t, pricing) { - riding_cost += c.total; - } - } - } - if riding_turns == 0 { - continue; - } - let invoke_cost = cost_for_turn(r.turn, pricing) - .map(|c| c.total) - .unwrap_or(0.0); - 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; - if let Some(c) = cost_for_turn(t, pricing) { - total_cost += c.total; - } - } - } - 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 // --------------------------------------------------------------------------- -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/compaction.rs b/crates/relayburn-sdk/src/analyze/patterns/compaction.rs new file mode 100644 index 00000000..8275641f --- /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 00000000..fc3ecf27 --- /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 00000000..4e0de944 --- /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 00000000..5ff089e8 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/patterns/streaks.rs @@ -0,0 +1,357 @@ +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; +use crate::analyze::util::first_seen_unique; + +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 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(), + 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 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 { + 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 +} diff --git a/crates/relayburn-sdk/src/analyze/provider.rs b/crates/relayburn-sdk/src/analyze/provider.rs index 4a500f3b..ae22f498 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 5b13f572..554bfb9a 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/quality.rs b/crates/relayburn-sdk/src/analyze/quality.rs index f5446b2b..80e5a0e8 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, @@ -364,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 { diff --git a/crates/relayburn-sdk/src/analyze/subagent_tree.rs b/crates/relayburn-sdk/src/analyze/subagent_tree.rs index 6465605e..85fcad48 100644 --- a/crates/relayburn-sdk/src/analyze/subagent_tree.rs +++ b/crates/relayburn-sdk/src/analyze/subagent_tree.rs @@ -1,19 +1,20 @@ //! 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}; 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; +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 = cost_for_turn(t, pricing).map(|c| c.total).unwrap_or(0.0); - 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], @@ -506,7 +351,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() { @@ -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, @@ -784,9 +583,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(); @@ -823,468 +620,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::*; - 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 00000000..e3880ea1 --- /dev/null +++ b/crates/relayburn-sdk/src/analyze/subagent_tree_tests.rs @@ -0,0 +1,457 @@ +//! 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, + ), + ]; + + // 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(); + 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, subagent_only); + 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); +} diff --git a/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs b/crates/relayburn-sdk/src/analyze/tool_call_patterns.rs index 508fae6e..a2815c8f 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, @@ -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::WasteFinding; use crate::analyze::pricing::PricingTable; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -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. @@ -391,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()) } // --------------------------------------------------------------------------- @@ -448,14 +434,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::util::{fmt_usd, format_with_commas, group_turns_by_session}; +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() { @@ -479,39 +458,35 @@ 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)] 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/tool_output_bloat.rs b/crates/relayburn-sdk/src/analyze/tool_output_bloat.rs index e38ed1e9..f80c52ed 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 00000000..5addfb87 --- /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"); +} diff --git a/crates/relayburn-sdk/src/analyze/util.rs b/crates/relayburn-sdk/src/analyze/util.rs index 3cde7fe0..2e86d6ab 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. @@ -32,6 +62,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 { @@ -69,6 +118,40 @@ 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 +/// `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 { diff --git a/crates/relayburn-sdk/src/query_verbs/compare.rs b/crates/relayburn-sdk/src/query_verbs/compare.rs index 0d71fb50..52ad3c5b 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/flow.rs b/crates/relayburn-sdk/src/query_verbs/flow.rs index 6ba44ef4..394714b9 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() @@ -216,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() { @@ -263,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, @@ -274,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: /// @@ -359,7 +346,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 +355,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 +402,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 +426,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 +457,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 +467,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 +494,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 79d4bd60..0fb75abd 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::{ @@ -247,45 +249,20 @@ 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) } -/// 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) -} - -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) + Some(crate::util::time::ymd_to_days(year, month, day)) } // --------------------------------------------------------------------------- @@ -421,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 34b8df65..5ac6c712 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 diff --git a/crates/relayburn-sdk/src/query_verbs/tests.rs b/crates/relayburn-sdk/src/query_verbs/tests.rs index d9ca3b43..1b6782b6 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)); } } @@ -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 a6e0ffb2..e99238ed 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 05410618..63673eed 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, diff --git a/crates/relayburn-sdk/src/reader/inference.rs b/crates/relayburn-sdk/src/reader/inference.rs index 1dc74ef4..206185fa 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. /// @@ -389,71 +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). -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 { use super::*; @@ -489,24 +425,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, diff --git a/crates/relayburn-sdk/src/reader/opencode.rs b/crates/relayburn-sdk/src/reader/opencode.rs index 4dc32b68..1c83792c 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,9 @@ 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 +938,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 +958,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,41 +1282,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) = 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 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 61b12cd8..8788dba1 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,38 @@ 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 +} + +/// 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) { + 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)]