1use std::collections::BTreeMap;
20
21use jiff::civil::Date;
22use serde::Serialize;
23
24use crate::analysis::aggregate::{Filter, Rollup};
25use crate::analysis::dedup::{dedup_session, UsageRecord};
26use crate::model::{Event, EventKind, Session};
27use crate::pricing::PricingTable;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
35pub enum TaskType {
36 Debugging,
37 FeatureWork,
38 Refactoring,
39 Testing,
40 Documentation,
41 Exploration,
42 ConfigOps,
43 Unknown,
44}
45
46impl TaskType {
47 const SCORABLE: [TaskType; 7] = [
49 TaskType::Debugging,
50 TaskType::FeatureWork,
51 TaskType::Refactoring,
52 TaskType::Testing,
53 TaskType::Documentation,
54 TaskType::Exploration,
55 TaskType::ConfigOps,
56 ];
57
58 fn rank(self) -> u8 {
60 match self {
61 TaskType::Debugging => 0,
62 TaskType::FeatureWork => 1,
63 TaskType::Refactoring => 2,
64 TaskType::Testing => 3,
65 TaskType::Documentation => 4,
66 TaskType::Exploration => 5,
67 TaskType::ConfigOps => 6,
68 TaskType::Unknown => 7,
69 }
70 }
71
72 fn keywords(self) -> &'static [&'static str] {
75 match self {
76 TaskType::Debugging => &[
77 "fix",
78 "bug",
79 "error",
80 "broken",
81 "crash",
82 "fails",
83 "debug",
84 "stack trace",
85 "exception",
86 "regression",
87 ],
88 TaskType::FeatureWork => &[
89 "add",
90 "implement",
91 "create",
92 "build",
93 "feature",
94 "support",
95 "new",
96 "endpoint",
97 ],
98 TaskType::Refactoring => &[
99 "refactor",
100 "rename",
101 "clean up",
102 "extract",
103 "simplify",
104 "reorganize",
105 "move",
106 "dedupe",
107 ],
108 TaskType::Testing => &[
109 "test",
110 "tests",
111 "coverage",
112 "assert",
113 "unit test",
114 "integration test",
115 ],
116 TaskType::Documentation => &[
117 "document",
118 "docs",
119 "readme",
120 "comment",
121 "changelog",
122 "explain in writing",
123 ],
124 TaskType::Exploration => &[
125 "review",
126 "explain",
127 "understand",
128 "how does",
129 "what does",
130 "look at",
131 "analyze",
132 "investigate",
133 "find",
134 "trace",
135 ],
136 TaskType::ConfigOps => &[
137 "ci",
138 "cd",
139 "deploy",
140 "pipeline",
141 "config",
142 "dependency",
143 "dependencies",
144 "upgrade",
145 "bump",
146 "cargo",
147 "npm",
148 "docker",
149 "workflow",
150 "version",
151 ],
152 TaskType::Unknown => &[],
154 }
155 }
156}
157
158#[derive(Debug, Default, Clone, Copy)]
161struct ToolMix {
162 edit: u64,
163 write: u64,
164 bash: u64,
165 read: u64,
166 grep: u64,
167 glob: u64,
168}
169
170impl ToolMix {
171 fn from_session(session: &Session, since: Option<Date>) -> ToolMix {
173 let mut mix = ToolMix::default();
174 for event in &session.events {
175 if !in_window(since, event) {
176 continue;
177 }
178 for call in &event.tool_calls {
179 match call.name.as_str() {
180 "Edit" | "MultiEdit" => mix.edit += 1,
181 "Write" => mix.write += 1,
182 "Bash" => mix.bash += 1,
183 "Read" => mix.read += 1,
184 "Grep" => mix.grep += 1,
185 "Glob" => mix.glob += 1,
186 _ => {}
187 }
188 }
189 }
190 mix
191 }
192
193 fn is_read_dominant(&self) -> bool {
195 let browse = self.read + self.grep + self.glob;
196 browse > 0 && self.edit == 0 && self.write == 0
197 }
198}
199
200#[derive(Debug, Clone, Serialize)]
202pub struct TaskTypeRollup {
203 pub task_type: TaskType,
204 pub sessions: u64,
206 pub rollup: Rollup,
207 pub token_share: f64,
209 pub cost_share: Option<f64>,
212}
213
214#[derive(Debug, Clone, Serialize)]
216pub struct SessionClass {
217 pub id: String,
218 pub project: Option<String>,
219 pub model: Option<String>,
220 pub task_type: TaskType,
221 pub confidence: f64,
224 pub signals: Vec<String>,
227 pub rollup: Rollup,
228}
229
230#[derive(Debug, Serialize)]
232pub struct ClassifyReport {
233 pub agent: String,
234 pub since: Option<Date>,
236 pub sessions_classified: u64,
238 pub total_tokens: u64,
240 pub total_cost_usd: f64,
242 pub has_unpriced: bool,
245 pub by_task_type: Vec<TaskTypeRollup>,
247 pub sessions: Vec<SessionClass>,
249}
250
251struct GroupAcc {
253 project: Option<String>,
254 model: Option<String>,
255 rollup: Rollup,
256 representative: Option<Session>,
259 has_parent: bool,
262}
263
264impl GroupAcc {
265 fn new() -> GroupAcc {
266 GroupAcc {
267 project: None,
268 model: None,
269 rollup: Rollup::default(),
270 representative: None,
271 has_parent: false,
272 }
273 }
274}
275
276pub fn classify(
283 sessions: &[Session],
284 filter: &Filter,
285 agent: &str,
286 pricing: &PricingTable,
287) -> ClassifyReport {
288 let mut groups: BTreeMap<String, GroupAcc> = BTreeMap::new();
289
290 for session in sessions {
291 let row_id = session
292 .parent_session
293 .clone()
294 .unwrap_or_else(|| session.id.clone());
295 let group = groups.entry(row_id).or_insert_with(GroupAcc::new);
296
297 let is_parent = session.parent_session.is_none();
300 if is_parent {
301 group.project.clone_from(&session.project);
302 group.model.clone_from(&session.model);
303 } else if group.project.is_none() {
304 group.project.clone_from(&session.project);
305 }
306 if (is_parent && !group.has_parent) || group.representative.is_none() {
307 group.representative = Some(session.clone());
308 group.has_parent |= is_parent;
309 }
310
311 let (records, _) = dedup_session(session, filter.since);
312 for rec in &records {
313 add_record(&mut group.rollup, rec, pricing);
314 }
315 }
316
317 let groups: Vec<(String, GroupAcc)> = groups
319 .into_iter()
320 .filter(|(_, acc)| acc.rollup.requests > 0)
321 .collect();
322
323 let mut total_tokens: u64 = 0;
324 let mut total_cost_usd: f64 = 0.0;
325 let mut has_unpriced = false;
326 for (_, acc) in &groups {
327 total_tokens += acc.rollup.total_tokens();
328 total_cost_usd += acc.rollup.cost_usd;
329 has_unpriced |= acc.rollup.unpriced_requests > 0;
330 }
331
332 let mut sessions_out: Vec<SessionClass> = Vec::with_capacity(groups.len());
333 let mut by_type: BTreeMap<u8, (TaskType, u64, Rollup)> = BTreeMap::new();
334
335 for (id, acc) in groups {
336 let (task_type, confidence, signals) = match &acc.representative {
337 Some(rep) => classify_session(rep, filter.since),
338 None => (TaskType::Unknown, 0.0, Vec::new()),
341 };
342
343 let entry = by_type
344 .entry(task_type.rank())
345 .or_insert_with(|| (task_type, 0, Rollup::default()));
346 entry.1 += 1;
347 merge_rollup(&mut entry.2, &acc.rollup);
348
349 sessions_out.push(SessionClass {
350 id,
351 project: acc.project,
352 model: acc.model,
353 task_type,
354 confidence,
355 signals,
356 rollup: acc.rollup,
357 });
358 }
359
360 let mut by_task_type: Vec<TaskTypeRollup> = by_type
361 .into_values()
362 .map(|(task_type, sessions, rollup)| {
363 let token_share = if total_tokens == 0 {
364 0.0
365 } else {
366 rollup.total_tokens() as f64 / total_tokens as f64
367 };
368 let cost_share = (total_cost_usd > 0.0).then(|| rollup.cost_usd / total_cost_usd);
369 TaskTypeRollup {
370 task_type,
371 sessions,
372 rollup,
373 token_share,
374 cost_share,
375 }
376 })
377 .collect();
378
379 by_task_type.sort_by(|a, b| {
380 b.rollup
381 .cost_usd
382 .total_cmp(&a.rollup.cost_usd)
383 .then_with(|| b.rollup.total_tokens().cmp(&a.rollup.total_tokens()))
384 .then_with(|| a.task_type.rank().cmp(&b.task_type.rank()))
385 });
386
387 sessions_out.sort_by(|a, b| {
388 b.rollup
389 .cost_usd
390 .total_cmp(&a.rollup.cost_usd)
391 .then_with(|| b.rollup.total_tokens().cmp(&a.rollup.total_tokens()))
392 .then_with(|| a.id.cmp(&b.id))
393 });
394
395 ClassifyReport {
396 agent: agent.to_string(),
397 since: filter.since,
398 sessions_classified: sessions_out.len() as u64,
399 total_tokens,
400 total_cost_usd,
401 has_unpriced,
402 by_task_type,
403 sessions: sessions_out,
404 }
405}
406
407fn add_record(into: &mut Rollup, rec: &UsageRecord, pricing: &PricingTable) {
414 let u = &rec.usage;
415 into.requests += 1;
416 into.input += u.input.unwrap_or(0);
417 into.output += u.output.unwrap_or(0);
418 into.cache_creation += u.cache_creation.unwrap_or(0);
419 into.cache_read += u.cache_read.unwrap_or(0);
420 if u.input.is_none()
421 || u.output.is_none()
422 || u.cache_creation.is_none()
423 || u.cache_read.is_none()
424 {
425 into.incomplete_requests += 1;
426 }
427 match pricing.cost_usd(rec.model.as_deref(), u) {
428 Some(cost) => into.cost_usd += cost,
429 None => into.unpriced_requests += 1,
430 }
431}
432
433fn merge_rollup(into: &mut Rollup, from: &Rollup) {
436 into.requests += from.requests;
437 into.input += from.input;
438 into.output += from.output;
439 into.cache_creation += from.cache_creation;
440 into.cache_read += from.cache_read;
441 into.incomplete_requests += from.incomplete_requests;
442 into.cost_usd += from.cost_usd;
443 into.unpriced_requests += from.unpriced_requests;
444}
445
446fn classify_session(session: &Session, since: Option<Date>) -> (TaskType, f64, Vec<String>) {
455 let prompts = lowercased_prompts(session, since);
456 let mix = ToolMix::from_session(session, since);
457
458 let mut scores: Vec<(TaskType, f64, Vec<&'static str>)> = Vec::with_capacity(7);
460 for ty in TaskType::SCORABLE {
461 let mut hits: Vec<&'static str> = Vec::new();
462 for kw in ty.keywords() {
463 if prompts.iter().any(|p| p.contains(kw)) {
464 hits.push(kw);
465 }
466 }
467 let keyword_score = hits.len() as f64 * 2.0;
470 let tool_score = tool_mix_bonus(ty, &mix);
471 let total = keyword_score + tool_score;
472 scores.push((ty, total, hits));
473 }
474
475 let sum: f64 = scores.iter().map(|(_, s, _)| *s).sum();
476
477 let mut best_idx: Option<usize> = None;
480 for (i, (_, score, _)) in scores.iter().enumerate() {
481 if *score <= 0.0 {
482 continue;
483 }
484 match best_idx {
485 Some(b) if scores[b].1 >= *score => {}
486 _ => best_idx = Some(i),
487 }
488 }
489
490 let Some(best_idx) = best_idx else {
491 return (TaskType::Unknown, 0.0, Vec::new());
493 };
494
495 let (task_type, best_score, hits) = &scores[best_idx];
496 let confidence = if sum <= 0.0 {
497 0.0
498 } else {
499 (best_score / sum).clamp(0.0, 1.0)
500 };
501
502 let signals = build_signals(hits, *task_type, &mix);
503 (*task_type, confidence, signals)
504}
505
506fn tool_mix_bonus(ty: TaskType, mix: &ToolMix) -> f64 {
509 match ty {
510 TaskType::Debugging => {
512 if mix.bash >= 3 && mix.edit >= 1 {
513 3.0
514 } else if mix.bash >= 1 && mix.edit >= 1 {
515 1.0
516 } else {
517 0.0
518 }
519 }
520 TaskType::FeatureWork => {
522 if mix.write >= 1 && mix.edit >= 1 {
523 3.0
524 } else if mix.write >= 1 {
525 1.5
526 } else {
527 0.0
528 }
529 }
530 TaskType::Refactoring => {
532 if mix.edit >= 3 && mix.write == 0 {
533 3.0
534 } else {
535 0.0
536 }
537 }
538 TaskType::Exploration => {
540 if mix.is_read_dominant() {
541 3.0
542 } else {
543 0.0
544 }
545 }
546 TaskType::Testing | TaskType::Documentation | TaskType::ConfigOps => 0.0,
548 TaskType::Unknown => 0.0,
549 }
550}
551
552fn build_signals(hits: &[&'static str], ty: TaskType, mix: &ToolMix) -> Vec<String> {
555 let mut signals: Vec<String> = hits
556 .iter()
557 .map(|kw| format!("prompt keyword: \"{kw}\""))
558 .collect();
559 if tool_mix_bonus(ty, mix) > 0.0 {
560 if let Some(note) = tool_mix_note(mix) {
561 signals.push(note);
562 }
563 }
564 signals
565}
566
567fn tool_mix_note(mix: &ToolMix) -> Option<String> {
570 let parts: [(u64, &str); 6] = [
571 (mix.edit, "Edit"),
572 (mix.write, "Write"),
573 (mix.bash, "Bash"),
574 (mix.read, "Read"),
575 (mix.grep, "Grep"),
576 (mix.glob, "Glob"),
577 ];
578 let listed: Vec<String> = parts
579 .iter()
580 .filter(|(n, _)| *n > 0)
581 .map(|(n, name)| format!("{n} {name}"))
582 .collect();
583 if listed.is_empty() {
584 None
585 } else {
586 Some(format!("tool mix: {}", listed.join(", ")))
587 }
588}
589
590fn lowercased_prompts(session: &Session, since: Option<Date>) -> Vec<String> {
593 session
594 .events
595 .iter()
596 .filter(|e| e.kind == EventKind::User && in_window(since, e))
597 .filter_map(|e| e.content_summary.as_deref())
598 .map(|s| s.to_ascii_lowercase())
599 .collect()
600}
601
602fn in_window(since: Option<Date>, event: &Event) -> bool {
608 match (since, event.ts) {
609 (None, _) => true,
610 (Some(since), Some(ts)) => super::utc_date(ts) >= since,
611 (Some(_), None) => false,
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::model::{Event, ToolCall, Usage};
619
620 fn priced_usage(input: u64) -> Usage {
621 Usage {
622 input: Some(input),
623 output: Some(10),
624 cache_creation: Some(0),
625 cache_read: Some(0),
626 ..Usage::default()
627 }
628 }
629
630 fn turn(request_id: &str, ts: &str, input: u64, tools: &[&str]) -> Event {
632 Event {
633 kind: EventKind::Assistant,
634 ts: Some(ts.parse().unwrap()),
635 request_id: Some(request_id.into()),
636 model: Some("claude-sonnet-4-5".into()),
637 usage: Some(priced_usage(input)),
638 tool_calls: tools.iter().map(|name| tool_call(name)).collect(),
639 sidechain: false,
640 content_summary: None,
641 content_chars: 0,
642 thinking_chars: 0,
643 has_thinking: false,
644 tool_use_id: None,
645 attachment_kind: None,
646 item_count: 0,
647 }
648 }
649
650 fn unpriced_turn(request_id: &str, ts: &str, input: u64) -> Event {
652 let mut e = turn(request_id, ts, input, &[]);
653 e.model = Some("claude-opus-5-0".into()); e
655 }
656
657 fn user(ts: &str, prompt: &str) -> Event {
659 Event {
660 kind: EventKind::User,
661 ts: Some(ts.parse().unwrap()),
662 request_id: None,
663 model: None,
664 usage: None,
665 tool_calls: Vec::new(),
666 sidechain: false,
667 content_summary: Some(prompt.into()),
668 content_chars: prompt.len() as u64,
669 thinking_chars: 0,
670 has_thinking: false,
671 tool_use_id: None,
672 attachment_kind: None,
673 item_count: 0,
674 }
675 }
676
677 fn tool_call(name: &str) -> ToolCall {
678 ToolCall {
679 id: format!("tc_{name}"),
680 name: name.into(),
681 input_bytes: 0,
682 server: ToolCall::server_from_name(name),
683 }
684 }
685
686 fn session(id: &str, parent: Option<&str>, events: Vec<Event>) -> Session {
687 Session {
688 id: id.into(),
689 agent: "claude-code".into(),
690 project: Some("proj".into()),
691 model: Some("claude-sonnet-4-5".into()),
692 parent_session: parent.map(str::to_string),
693 started_at: None,
694 ended_at: None,
695 events,
696 sub_agents: Vec::new(),
697 skipped_lines: 0,
698 }
699 }
700
701 fn tools_turn(request_id: &str, tools: &[&str]) -> Event {
703 turn(request_id, "2026-06-01T10:00:00Z", 100, tools)
704 }
705
706 fn no_filter() -> Filter {
707 Filter::default()
708 }
709
710 fn class_of<'a>(report: &'a ClassifyReport, id: &str) -> &'a SessionClass {
711 report
712 .sessions
713 .iter()
714 .find(|s| s.id == id)
715 .expect("session present in report")
716 }
717
718 #[test]
721 fn debugging_fires_on_prompt_and_fix_loop() {
722 let s = session(
723 "dbg",
724 None,
725 vec![
726 user("2026-06-01T10:00:00Z", "Please fix the crash in the parser"),
727 tools_turn("r1", &["Edit", "Bash", "Bash", "Bash", "Edit"]),
728 ],
729 );
730 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
731 let c = class_of(&report, "dbg");
732 assert_eq!(c.task_type, TaskType::Debugging);
733 assert!(c.signals.iter().any(|s| s.contains("\"fix\"")));
734 assert!(c.signals.iter().any(|s| s.contains("tool mix")));
735 }
736
737 #[test]
738 fn feature_work_fires_on_implement_with_write() {
739 let s = session(
740 "feat",
741 None,
742 vec![
743 user("2026-06-01T10:00:00Z", "Implement a new export endpoint"),
744 tools_turn("r1", &["Write", "Edit"]),
745 ],
746 );
747 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
748 assert_eq!(class_of(&report, "feat").task_type, TaskType::FeatureWork);
749 }
750
751 #[test]
752 fn refactoring_fires_on_many_edits_no_write() {
753 let s = session(
754 "ref",
755 None,
756 vec![
757 user("2026-06-01T10:00:00Z", "Refactor and simplify this module"),
758 tools_turn("r1", &["Edit", "MultiEdit", "Edit", "Edit"]),
759 ],
760 );
761 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
762 let c = class_of(&report, "ref");
763 assert_eq!(c.task_type, TaskType::Refactoring);
764 assert!(c.signals.iter().any(|s| s.contains("4 Edit")));
766 }
767
768 #[test]
769 fn testing_fires_on_prompt() {
770 let s = session(
771 "tst",
772 None,
773 vec![user(
774 "2026-06-01T10:00:00Z",
775 "Add unit tests to raise coverage",
776 )],
777 );
778 let s = with_spend(s);
780 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
781 assert_eq!(class_of(&report, "tst").task_type, TaskType::Testing);
782 }
783
784 #[test]
785 fn documentation_fires_on_prompt() {
786 let s = with_spend(session(
787 "doc",
788 None,
789 vec![user(
790 "2026-06-01T10:00:00Z",
791 "Update the README and changelog",
792 )],
793 ));
794 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
795 assert_eq!(class_of(&report, "doc").task_type, TaskType::Documentation);
796 }
797
798 #[test]
799 fn exploration_fires_on_read_dominant_mix() {
800 let s = session(
801 "exp",
802 None,
803 vec![
804 user(
805 "2026-06-01T10:00:00Z",
806 "Help me understand how the dedup works",
807 ),
808 tools_turn("r1", &["Read", "Grep", "Glob", "Read"]),
809 ],
810 );
811 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
812 let c = class_of(&report, "exp");
813 assert_eq!(c.task_type, TaskType::Exploration);
814 assert!(c.signals.iter().any(|s| s.contains("tool mix")));
815 }
816
817 #[test]
818 fn config_ops_fires_on_prompt() {
819 let s = with_spend(session(
820 "cfg",
821 None,
822 vec![user(
823 "2026-06-01T10:00:00Z",
824 "Bump the cargo dependencies in the CI workflow",
825 )],
826 ));
827 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
828 assert_eq!(class_of(&report, "cfg").task_type, TaskType::ConfigOps);
829 }
830
831 fn with_spend(mut s: Session) -> Session {
833 s.events
834 .push(turn("spend", "2026-06-01T10:00:00Z", 100, &[]));
835 s
836 }
837
838 #[test]
841 fn unknown_when_no_signal_fires() {
842 let s = session(
843 "blank",
844 None,
845 vec![
846 user("2026-06-01T10:00:00Z", "hmm okay then proceed please"),
847 turn("r1", "2026-06-01T10:00:00Z", 100, &[]),
848 ],
849 );
850 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
851 let c = class_of(&report, "blank");
852 assert_eq!(c.task_type, TaskType::Unknown);
853 assert_eq!(c.confidence, 0.0);
854 assert!(c.signals.is_empty());
855 }
856
857 #[test]
858 fn confidence_is_one_when_only_one_type_scores() {
859 let s = with_spend(session(
860 "solo",
861 None,
862 vec![user("2026-06-01T10:00:00Z", "Update the readme docs")],
863 ));
864 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
865 let c = class_of(&report, "solo");
866 assert_eq!(c.task_type, TaskType::Documentation);
867 assert!((c.confidence - 1.0).abs() < 1e-9, "got {}", c.confidence);
868 }
869
870 #[test]
871 fn confidence_in_range_and_below_one_when_types_compete() {
872 let s = with_spend(session(
874 "mix",
875 None,
876 vec![user(
877 "2026-06-01T10:00:00Z",
878 "fix the failing tests and add coverage",
879 )],
880 ));
881 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
882 let c = class_of(&report, "mix");
883 assert!(
884 c.confidence > 0.0 && c.confidence < 1.0,
885 "got {}",
886 c.confidence
887 );
888 assert!((0.0..=1.0).contains(&c.confidence));
889 }
890
891 #[test]
896 fn sub_agent_spend_folds_into_parent() {
897 let parent = session(
898 "parent",
899 None,
900 vec![
901 user("2026-06-01T10:00:00Z", "Refactor and simplify the module"),
902 tools_turn("p1", &["Edit", "Edit", "Edit"]),
903 ],
904 );
905 let child = session(
906 "agent-x",
907 Some("parent"),
908 vec![turn("c1", "2026-06-01T10:01:00Z", 500, &[])],
909 );
910 let report = classify(
911 &[parent, child],
912 &no_filter(),
913 "claude-code",
914 &PricingTable::embedded(),
915 );
916
917 assert_eq!(report.sessions.len(), 1);
919 let row = &report.sessions[0];
920 assert_eq!(row.id, "parent");
921 assert_eq!(row.task_type, TaskType::Refactoring);
922 assert_eq!(row.rollup.requests, 2);
925 assert_eq!(row.rollup.input, 600);
926
927 let bucket = report
929 .by_task_type
930 .iter()
931 .find(|b| b.task_type == TaskType::Refactoring)
932 .unwrap();
933 assert_eq!(bucket.sessions, 1);
934 assert_eq!(bucket.rollup.input, 600);
935 }
936
937 #[test]
939 fn orphan_sub_agent_is_its_own_group() {
940 let child = session(
941 "agent-y",
942 Some("missing-parent"),
943 vec![
944 user("2026-06-01T10:00:00Z", "Investigate and trace the bug"),
945 tools_turn("c1", &["Read", "Grep"]),
946 ],
947 );
948 let report = classify(
949 &[child],
950 &no_filter(),
951 "claude-code",
952 &PricingTable::embedded(),
953 );
954 assert_eq!(report.sessions.len(), 1);
955 let row = &report.sessions[0];
956 assert_eq!(row.id, "missing-parent");
957 assert_eq!(row.task_type, TaskType::Exploration);
959 }
960
961 #[test]
964 fn since_drops_out_of_window_sessions() {
965 let old = session(
966 "old",
967 None,
968 vec![
969 user("2026-06-01T10:00:00Z", "fix the bug"),
970 turn("o1", "2026-06-01T10:00:00Z", 100, &[]),
971 ],
972 );
973 let new = session(
974 "new",
975 None,
976 vec![
977 user("2026-06-03T10:00:00Z", "implement the feature"),
978 turn("n1", "2026-06-03T10:00:00Z", 200, &["Write", "Edit"]),
979 ],
980 );
981 let filter = Filter {
982 since: Some("2026-06-03".parse().unwrap()),
983 };
984 let report = classify(
985 &[old, new],
986 &filter,
987 "claude-code",
988 &PricingTable::embedded(),
989 );
990 assert_eq!(report.sessions.len(), 1, "old dropped (no in-window spend)");
991 assert_eq!(report.sessions[0].id, "new");
992 assert_eq!(report.total_tokens, 200 + 10);
993 }
994
995 #[test]
999 fn since_scopes_classification_signals_not_just_spend() {
1000 let s = session(
1001 "spanning",
1002 None,
1003 vec![
1004 user("2026-06-01T10:00:00Z", "fix the crash"),
1006 tools_turn("dbg", &["Edit", "Bash", "Bash", "Bash"]),
1007 user("2026-06-03T10:00:00Z", "review and understand the module"),
1010 turn(
1011 "exp",
1012 "2026-06-03T10:00:00Z",
1013 200,
1014 &["Read", "Grep", "Glob"],
1015 ),
1016 ],
1017 );
1018 let filter = Filter {
1019 since: Some("2026-06-03".parse().unwrap()),
1020 };
1021 let report = classify(&[s], &filter, "claude-code", &PricingTable::embedded());
1022 let c = class_of(&report, "spanning");
1023 assert_eq!(c.task_type, TaskType::Exploration);
1026 assert!(
1027 !c.signals.iter().any(|sig| sig.contains("\"fix\"")),
1028 "out-of-window debugging prompt must not be a signal"
1029 );
1030 }
1031
1032 #[test]
1035 fn unpriced_model_sets_flag_and_nulls_cost_share() {
1036 let s = session(
1037 "u",
1038 None,
1039 vec![
1040 user("2026-06-01T10:00:00Z", "fix the crash"),
1041 unpriced_turn("u1", "2026-06-01T10:00:00Z", 1000),
1042 ],
1043 );
1044 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
1045 assert!(report.has_unpriced);
1046 assert_eq!(report.total_cost_usd, 0.0);
1047 assert_eq!(report.by_task_type.len(), 1);
1048 assert!(report.by_task_type[0].cost_share.is_none());
1050 assert_eq!(report.total_tokens, 1010);
1052 }
1053
1054 #[test]
1055 fn token_shares_sum_to_one_across_buckets() {
1056 let dbg = session(
1057 "d",
1058 None,
1059 vec![
1060 user("2026-06-01T10:00:00Z", "fix the bug"),
1061 turn("d1", "2026-06-01T10:00:00Z", 1000, &[]),
1062 ],
1063 );
1064 let feat = session(
1065 "f",
1066 None,
1067 vec![
1068 user("2026-06-01T10:00:00Z", "implement the feature"),
1069 turn("f1", "2026-06-01T10:00:00Z", 3000, &["Write", "Edit"]),
1070 ],
1071 );
1072 let report = classify(
1073 &[dbg, feat],
1074 &no_filter(),
1075 "claude-code",
1076 &PricingTable::embedded(),
1077 );
1078 assert_eq!(report.by_task_type.len(), 2);
1079 let share_sum: f64 = report.by_task_type.iter().map(|b| b.token_share).sum();
1080 assert!((share_sum - 1.0).abs() < 1e-9, "shares sum to {share_sum}");
1081 let cost_sum: f64 = report
1082 .by_task_type
1083 .iter()
1084 .map(|b| b.cost_share.unwrap_or(0.0))
1085 .sum();
1086 assert!(
1087 (cost_sum - 1.0).abs() < 1e-9,
1088 "cost shares sum to {cost_sum}"
1089 );
1090 }
1091
1092 #[test]
1095 fn buckets_and_sessions_sort_by_cost_desc() {
1096 let small = session(
1097 "small",
1098 None,
1099 vec![
1100 user("2026-06-01T10:00:00Z", "fix the bug"),
1101 turn("s1", "2026-06-01T10:00:00Z", 100, &[]),
1102 ],
1103 );
1104 let big = session(
1105 "big",
1106 None,
1107 vec![
1108 user("2026-06-01T10:00:00Z", "implement the feature"),
1109 turn("b1", "2026-06-01T10:00:00Z", 9000, &["Write", "Edit"]),
1110 ],
1111 );
1112 let report = classify(
1113 &[small, big],
1114 &no_filter(),
1115 "claude-code",
1116 &PricingTable::embedded(),
1117 );
1118 assert_eq!(report.sessions[0].id, "big");
1120 assert_eq!(report.sessions[1].id, "small");
1121 assert_eq!(report.by_task_type[0].task_type, TaskType::FeatureWork);
1123 assert_eq!(report.by_task_type[1].task_type, TaskType::Debugging);
1124 }
1125
1126 #[test]
1127 fn empty_input_yields_empty_report() {
1128 let report = classify(&[], &no_filter(), "claude-code", &PricingTable::embedded());
1129 assert_eq!(report.total_tokens, 0);
1130 assert_eq!(report.total_cost_usd, 0.0);
1131 assert!(!report.has_unpriced);
1132 assert!(report.by_task_type.is_empty());
1133 assert!(report.sessions.is_empty());
1134 }
1135
1136 #[test]
1137 fn sessions_without_spend_are_dropped() {
1138 let s = session(
1140 "noissue",
1141 None,
1142 vec![user("2026-06-01T10:00:00Z", "fix the bug")],
1143 );
1144 let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
1145 assert!(report.sessions.is_empty());
1146 assert!(report.by_task_type.is_empty());
1147 }
1148}