1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4pub use cc_session_jsonl::scanner::SessionFile;
11
12#[derive(Debug, Clone, Default, Deserialize)]
16pub struct TokenUsage {
17 pub input_tokens: Option<u64>,
18 pub output_tokens: Option<u64>,
19 pub cache_creation_input_tokens: Option<u64>,
20 pub cache_read_input_tokens: Option<u64>,
21 pub cache_creation: Option<CacheCreationDetail>,
22 pub server_tool_use: Option<ServerToolUse>,
23 pub service_tier: Option<String>,
24 pub speed: Option<String>,
25 pub inference_geo: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Deserialize)]
30pub struct CacheCreationDetail {
31 pub ephemeral_5m_input_tokens: Option<u64>,
32 pub ephemeral_1h_input_tokens: Option<u64>,
33}
34
35#[derive(Debug, Clone, Deserialize)]
37pub struct ServerToolUse {
38 pub web_search_requests: Option<u64>,
39 pub web_fetch_requests: Option<u64>,
40}
41
42impl From<cc_session_jsonl::types::Usage> for TokenUsage {
45 fn from(u: cc_session_jsonl::types::Usage) -> Self {
46 Self {
47 input_tokens: u.input_tokens,
48 output_tokens: u.output_tokens,
49 cache_creation_input_tokens: u.cache_creation_input_tokens,
50 cache_read_input_tokens: u.cache_read_input_tokens,
51 cache_creation: u.cache_creation.map(|c| CacheCreationDetail {
52 ephemeral_5m_input_tokens: c.ephemeral_5m_input_tokens,
53 ephemeral_1h_input_tokens: c.ephemeral_1h_input_tokens,
54 }),
55 server_tool_use: u.server_tool_use.map(|s| ServerToolUse {
56 web_search_requests: s.web_search_requests,
57 web_fetch_requests: s.web_fetch_requests,
58 }),
59 service_tier: u.service_tier,
60 inference_geo: u.inference_geo,
61 speed: u.speed,
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
70pub struct ValidatedTurn {
71 pub uuid: String,
72 pub request_id: Option<String>,
73 pub timestamp: DateTime<Utc>,
74 pub model: String,
75 pub usage: TokenUsage,
76 pub stop_reason: Option<String>,
77 pub content_types: Vec<String>,
78 pub is_agent: bool,
79 pub agent_id: Option<String>,
80 pub user_text: Option<String>, pub assistant_text: Option<String>, pub tool_names: Vec<String>, pub service_tier: Option<String>,
84 pub speed: Option<String>,
85 pub inference_geo: Option<String>,
86 pub tool_error_count: usize, pub git_branch: Option<String>, pub attribution_plugin: Option<String>,
90 pub attribution_skill: Option<String>,
92}
93
94#[derive(Debug, Clone)]
101pub struct Subagent {
102 pub agent_id: String,
104 pub agent_type: Option<String>,
106 pub description: Option<String>,
108 pub turns: Vec<ValidatedTurn>,
110 pub first_timestamp: Option<DateTime<Utc>>,
111 pub last_timestamp: Option<DateTime<Utc>>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct PluginUsage {
121 pub plugin: String,
122 pub turns: u64,
123 pub cost: f64,
124 pub input_tokens: u64,
125 pub output_tokens: u64,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct SkillUsage {
135 pub skill: String,
136 pub turns: u64,
137 pub cost: f64,
138 pub input_tokens: u64,
139 pub output_tokens: u64,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct HookUsage {
149 pub command: String,
150 pub invocations: u64,
151 pub total_duration_ms: u64,
152 pub error_count: u64,
153 pub prevented_continuation_count: u64,
154}
155
156#[derive(Debug, Clone)]
158pub struct SessionData {
159 pub session_id: String,
160 pub project: Option<String>,
161 pub turns: Vec<ValidatedTurn>,
162 pub subagents: Vec<Subagent>,
165 pub plugins: Vec<PluginUsage>,
168 pub skills: Vec<SkillUsage>,
171 pub hooks: Vec<HookUsage>,
174 pub first_timestamp: Option<DateTime<Utc>>,
175 pub last_timestamp: Option<DateTime<Utc>>,
176 pub version: Option<String>,
177 pub quality: DataQuality,
178 pub metadata: SessionMetadata,
179 pub is_orphan: bool,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct SubagentTypeAggregate {
199 pub agent_type: String,
200 pub count: u64,
201 pub total_turns: u64,
202 pub total_cost: f64,
203 pub total_input_tokens: u64,
204 pub total_output_tokens: u64,
205 pub descriptions: Vec<String>,
208}
209
210#[derive(Debug, Clone, serde::Serialize)]
214pub struct PrLinkInfo {
215 pub number: u64,
216 pub url: String,
217 pub repository: String,
218}
219
220#[derive(Debug, Clone)]
222pub struct CollapseCommit {
223 pub collapse_id: String,
224 pub summary: String,
225}
226
227#[derive(Debug, Clone)]
229pub struct CollapseSnapshot {
230 pub staged_count: usize,
231 pub avg_risk: f64,
232 pub max_risk: f64,
233 pub armed: bool,
234 pub last_spawn_tokens: u64,
235}
236
237#[derive(Debug, Clone, serde::Serialize)]
239pub struct AttributionData {
240 pub surface: String,
241 pub file_count: usize,
242 pub total_claude_contribution: u64,
243 pub prompt_count: Option<u64>,
244 pub escape_count: Option<u64>,
245 pub permission_prompt_count: Option<u64>,
246}
247
248#[derive(Debug, Default, Clone)]
250pub struct SessionMetadata {
251 pub title: Option<String>, pub tags: Vec<String>,
253 pub mode: Option<String>, pub pr_links: Vec<PrLinkInfo>,
255 pub speculation_accepts: usize,
256 pub speculation_time_saved_ms: f64,
257 pub queue_enqueues: usize,
258 pub queue_dequeues: usize,
259 pub api_error_count: usize, pub user_prompt_count: usize, pub collapse_commits: Vec<CollapseCommit>,
262 pub collapse_snapshot: Option<CollapseSnapshot>,
263 pub attribution: Option<AttributionData>,
264}
265
266impl SessionData {
267 pub fn all_responses(&self) -> Vec<&ValidatedTurn> {
269 let mut all: Vec<&ValidatedTurn> = self
270 .turns
271 .iter()
272 .chain(self.subagents.iter().flat_map(|s| s.turns.iter()))
273 .collect();
274 all.sort_by_key(|r| r.timestamp);
275 all
276 }
277
278 pub fn total_turn_count(&self) -> usize {
280 self.turns.len() + self.subagents.iter().map(|s| s.turns.len()).sum::<usize>()
281 }
282
283 pub fn agent_turn_count(&self) -> usize {
285 self.subagents.iter().map(|s| s.turns.len()).sum::<usize>()
286 }
287
288 pub fn subagent_type_aggregates(
299 &self,
300 calc: &crate::pricing::calculator::PricingCalculator,
301 ) -> Vec<SubagentTypeAggregate> {
302 use std::collections::BTreeMap;
303
304 let mut sorted: Vec<&Subagent> = self.subagents.iter().collect();
306 sorted.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
307
308 let mut acc: BTreeMap<String, SubagentTypeAggregate> = BTreeMap::new();
309 for sa in sorted {
310 let key = sa
311 .agent_type
312 .clone()
313 .filter(|s| !s.is_empty())
314 .unwrap_or_else(|| "unknown".to_string());
315 let mut sa_input: u64 = 0;
316 let mut sa_output: u64 = 0;
317 let mut sa_cost: f64 = 0.0;
318 for t in &sa.turns {
319 sa_input += t.usage.input_tokens.unwrap_or(0);
320 sa_output += t.usage.output_tokens.unwrap_or(0);
321 sa_cost += calc.calculate_turn_cost(&t.model, &t.usage).total;
322 }
323 let entry = acc
324 .entry(key.clone())
325 .or_insert_with(|| SubagentTypeAggregate {
326 agent_type: key,
327 count: 0,
328 total_turns: 0,
329 total_cost: 0.0,
330 total_input_tokens: 0,
331 total_output_tokens: 0,
332 descriptions: Vec::new(),
333 });
334 entry.count += 1;
335 entry.total_turns += sa.turns.len() as u64;
336 entry.total_cost += sa_cost;
337 entry.total_input_tokens += sa_input;
338 entry.total_output_tokens += sa_output;
339 if let Some(desc) = sa.description.as_ref().filter(|d| !d.is_empty()) {
340 entry.descriptions.push(desc.clone());
341 }
342 }
343 acc.into_values().collect()
344 }
345}
346
347#[derive(Debug, Default, Clone)]
349pub struct DataQuality {
350 pub total_lines: usize,
351 pub valid_turns: usize,
352 pub skipped_synthetic: usize,
353 pub skipped_sidechain: usize,
354 pub skipped_invalid: usize,
355 pub skipped_parse_error: usize,
356 pub duplicate_turns: usize,
357}
358
359#[derive(Debug, Default, Clone, Serialize)]
361pub struct GlobalDataQuality {
362 pub total_session_files: usize,
363 pub total_agent_files: usize,
364 pub orphan_agents: usize,
365 pub total_valid_turns: usize,
366 pub total_skipped: usize,
367 pub time_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
368}
369
370#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_parse_assistant_message() {
378 let json = r#"{"parentUuid":"abc","isSidechain":false,"type":"assistant","uuid":"def","timestamp":"2026-03-16T13:51:35.912Z","message":{"model":"claude-opus-4-6","role":"assistant","stop_reason":"end_turn","usage":{"input_tokens":3,"cache_creation_input_tokens":1281,"cache_read_input_tokens":15204,"cache_creation":{"ephemeral_5m_input_tokens":1281,"ephemeral_1h_input_tokens":0},"output_tokens":108,"service_tier":"standard"},"content":[{"type":"text","text":"Hello"}]},"sessionId":"abc-123","version":"2.0.77","cwd":"/tmp","gitBranch":"main","userType":"external","requestId":"req_1"}"#;
379
380 let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
381
382 match entry {
383 cc_session_jsonl::types::Entry::Assistant(msg) => {
384 assert_eq!(msg.uuid.as_deref(), Some("def"));
385 assert_eq!(msg.session_id.as_deref(), Some("abc-123"));
386 assert_eq!(msg.request_id.as_deref(), Some("req_1"));
387 assert_eq!(msg.parent_uuid.as_deref(), Some("abc"));
388 assert_eq!(msg.is_sidechain, Some(false));
389
390 let api = msg.message.unwrap();
391 assert_eq!(api.model.as_deref(), Some("claude-opus-4-6"));
392 assert_eq!(api.stop_reason.as_deref(), Some("end_turn"));
393
394 let usage: TokenUsage = api.usage.unwrap().into();
395 assert_eq!(usage.input_tokens, Some(3));
396 assert_eq!(usage.output_tokens, Some(108));
397 assert_eq!(usage.cache_creation_input_tokens, Some(1281));
398 assert_eq!(usage.cache_read_input_tokens, Some(15204));
399 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
400
401 let cache = usage.cache_creation.unwrap();
402 assert_eq!(cache.ephemeral_5m_input_tokens, Some(1281));
403 assert_eq!(cache.ephemeral_1h_input_tokens, Some(0));
404
405 let content = api.content.unwrap();
406 assert_eq!(content.len(), 1);
407 match &content[0] {
408 cc_session_jsonl::types::ContentBlock::Text { text } => {
409 assert_eq!(text.as_deref(), Some("Hello"));
410 }
411 _ => panic!("expected Text content block"),
412 }
413 }
414 _ => panic!("expected Assistant variant"),
415 }
416 }
417
418 #[test]
419 fn test_parse_user_message() {
420 let json = r#"{"parentUuid":null,"isSidechain":false,"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello"}]},"uuid":"u1","timestamp":"2026-03-16T13:51:19.053Z","sessionId":"s1","version":"2.1.80","cwd":"/tmp","gitBranch":"main","userType":"external"}"#;
421
422 let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
423
424 match entry {
425 cc_session_jsonl::types::Entry::User(msg) => {
426 assert_eq!(msg.uuid.as_deref(), Some("u1"));
427 assert_eq!(msg.session_id.as_deref(), Some("s1"));
428 assert_eq!(msg.version.as_deref(), Some("2.1.80"));
429 assert_eq!(msg.cwd.as_deref(), Some("/tmp"));
430 assert_eq!(msg.git_branch.as_deref(), Some("main"));
431 assert!(msg.parent_uuid.is_none());
432 }
433 _ => panic!("expected User variant"),
434 }
435 }
436
437 #[test]
438 fn test_parse_queue_operation() {
439 let json = r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-03-16T13:51:19.041Z","sessionId":"abc"}"#;
440
441 let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
442
443 match entry {
444 cc_session_jsonl::types::Entry::QueueOperation(val) => {
445 assert_eq!(val.operation.as_deref(), Some("dequeue"));
446 assert_eq!(val.session_id.as_deref(), Some("abc"));
447 }
448 _ => panic!("expected QueueOperation variant"),
449 }
450 }
451
452 #[test]
453 fn test_parse_progress_entry() {
454 let json = r#"{"type":"progress","data":{"type":"hook_progress","hookEvent":"PostToolUse","hookName":"PostToolUse:Read","command":"callback"},"toolUseID":"toolu_01","parentToolUseID":"toolu_01","uuid":"u1","timestamp":"2026-03-16T13:51:19.053Z","sessionId":"s1"}"#;
455 let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
456 match entry {
457 cc_session_jsonl::types::Entry::Progress(p) => {
458 assert_eq!(p.tool_use_id.as_deref(), Some("toolu_01"));
459 match p.data.unwrap() {
460 cc_session_jsonl::types::ProgressData::HookProgress {
461 hook_event,
462 hook_name,
463 command,
464 } => {
465 assert_eq!(hook_event.as_deref(), Some("PostToolUse"));
466 assert_eq!(hook_name.as_deref(), Some("PostToolUse:Read"));
467 assert_eq!(command.as_deref(), Some("callback"));
468 }
469 other => panic!("expected HookProgress, got {other:?}"),
470 }
471 }
472 other => panic!("expected Progress, got {other:?}"),
473 }
474 }
475
476 #[test]
477 fn test_parse_system_entry() {
478 let json = r#"{"type":"system","subtype":"turn_duration","durationMs":1234,"uuid":"u1","timestamp":"2026-03-16T13:51:19.053Z","sessionId":"s1"}"#;
479 let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
480 assert!(matches!(entry, cc_session_jsonl::types::Entry::System(_)));
481 }
482
483 #[test]
484 fn test_parse_unknown_entry_type() {
485 let json = r#"{"type":"some-future-type","data":"whatever","uuid":"u1","timestamp":"2026-03-16T13:51:19.053Z"}"#;
486 let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
487 assert!(matches!(entry, cc_session_jsonl::types::Entry::Unknown));
488 }
489
490 #[test]
491 fn test_parse_thinking_content_block() {
492 let json = r#"{"type":"assistant","uuid":"u1","timestamp":"2026-03-16T10:00:00Z","message":{"model":"claude-opus-4-6","role":"assistant","stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":100,"cache_creation_input_tokens":500,"cache_read_input_tokens":10000},"content":[{"type":"thinking","thinking":"Let me analyze this...","signature":"abc123"},{"type":"text","text":"Here is my answer."}]},"sessionId":"s1","cwd":"/tmp","gitBranch":"","userType":"external","isSidechain":false,"parentUuid":null,"requestId":"r1"}"#;
493 let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
494 match entry {
495 cc_session_jsonl::types::Entry::Assistant(msg) => {
496 let content = msg.message.unwrap().content.unwrap();
497 assert_eq!(content.len(), 2);
498 assert!(
499 matches!(&content[0], cc_session_jsonl::types::ContentBlock::Thinking { thinking: Some(t), .. } if t.contains("analyze"))
500 );
501 assert!(matches!(
502 &content[1],
503 cc_session_jsonl::types::ContentBlock::Text { .. }
504 ));
505 }
506 _ => panic!("expected Assistant variant"),
507 }
508 }
509
510 #[test]
511 fn test_parse_synthetic_message() {
512 let json = r#"{"type":"assistant","uuid":"x","timestamp":"2026-03-16T00:00:00Z","message":{"model":"<synthetic>","role":"assistant","stop_reason":"stop_sequence","usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"content":[{"type":"text","text":"error"}]},"sessionId":"s1","cwd":"/tmp","gitBranch":"","userType":"external","isSidechain":false,"parentUuid":null}"#;
513
514 let entry: cc_session_jsonl::types::Entry = serde_json::from_str(json).unwrap();
515
516 match entry {
517 cc_session_jsonl::types::Entry::Assistant(msg) => {
518 let api = msg.message.unwrap();
519 assert_eq!(api.model.as_deref(), Some("<synthetic>"));
520 assert_eq!(api.stop_reason.as_deref(), Some("stop_sequence"));
521
522 let usage: TokenUsage = api.usage.unwrap().into();
523 assert_eq!(usage.input_tokens, Some(0));
524 assert_eq!(usage.output_tokens, Some(0));
525
526 assert!(usage.cache_creation.is_none());
528 }
529 _ => panic!("expected Assistant variant"),
530 }
531 }
532
533 #[test]
534 fn test_token_usage_from_conversion() {
535 let lib_usage = cc_session_jsonl::types::Usage {
536 input_tokens: Some(100),
537 output_tokens: Some(200),
538 cache_creation_input_tokens: Some(50),
539 cache_read_input_tokens: Some(300),
540 cache_creation: Some(cc_session_jsonl::types::CacheCreation {
541 ephemeral_5m_input_tokens: Some(30),
542 ephemeral_1h_input_tokens: Some(20),
543 }),
544 server_tool_use: Some(cc_session_jsonl::types::ServerToolUse {
545 web_search_requests: Some(2),
546 web_fetch_requests: Some(1),
547 }),
548 service_tier: Some("standard".into()),
549 inference_geo: Some("us".into()), iterations: None, speed: Some("fast".into()),
552 };
553
554 let local: TokenUsage = lib_usage.into();
555 assert_eq!(local.input_tokens, Some(100));
556 assert_eq!(local.output_tokens, Some(200));
557 assert_eq!(local.cache_creation_input_tokens, Some(50));
558 assert_eq!(local.cache_read_input_tokens, Some(300));
559 assert_eq!(local.service_tier.as_deref(), Some("standard"));
560 assert_eq!(local.speed.as_deref(), Some("fast"));
561
562 let cache = local.cache_creation.unwrap();
563 assert_eq!(cache.ephemeral_5m_input_tokens, Some(30));
564 assert_eq!(cache.ephemeral_1h_input_tokens, Some(20));
565
566 let stu = local.server_tool_use.unwrap();
567 assert_eq!(stu.web_search_requests, Some(2));
568 assert_eq!(stu.web_fetch_requests, Some(1));
569 }
570}