1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TurnOutcome {
16 Completed,
18 ToolCalls,
20 Error,
22 MaxTurns,
24 Cancelled,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum SessionOutcome {
32 Completed,
34 Failed,
36 Cancelled,
38 MaxTurns,
40}
41
42#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct TurnMetrics {
47 pub turn_number: u32,
49 pub started_at: String, pub duration_ms: u64,
51
52 pub time_to_first_token_ms: u64,
54 pub llm_duration_ms: u64,
55 pub tool_duration_ms: u64,
56
57 pub llm_calls: u32,
59 pub input_tokens: u64,
60 pub output_tokens: u64,
61 #[serde(default)]
62 pub thinking_tokens: u64,
63 #[serde(default)]
66 pub thinking_bytes: u64,
67 pub model: String,
68
69 pub tool_call_count: u32,
71 pub tools_used: Vec<String>,
72 pub tool_success: u32,
73 pub tool_failed: u32,
74
75 pub outcome: TurnOutcome,
77 pub text_length: u64,
78 pub error_message: Option<String>,
79 pub has_thinking: bool,
80
81 #[serde(default)]
83 pub plan_updates: u32,
84 #[serde(default)]
85 pub approval_count: u32,
86
87 pub user_input: String,
89
90 #[serde(default)]
92 pub custom: Value,
93}
94
95impl TurnMetrics {
96 pub fn new(
98 turn_number: u32,
99 started_at: String,
100 duration_ms: u64,
101 model: String,
102 user_input: String,
103 outcome: TurnOutcome,
104 ) -> Self {
105 Self {
106 turn_number,
107 started_at,
108 duration_ms,
109 time_to_first_token_ms: 0,
110 llm_duration_ms: 0,
111 tool_duration_ms: 0,
112 llm_calls: 1,
113 input_tokens: 0,
114 output_tokens: 0,
115 thinking_tokens: 0,
116 thinking_bytes: 0,
117 model,
118 tool_call_count: 0,
119 tools_used: Vec::new(),
120 tool_success: 0,
121 tool_failed: 0,
122 outcome,
123 text_length: 0,
124 error_message: None,
125 has_thinking: false,
126 plan_updates: 0,
127 approval_count: 0,
128 user_input: truncate_str(&user_input, 80),
129 custom: Value::Object(serde_json::Map::new()),
130 }
131 }
132}
133
134#[derive(Clone, Debug, Serialize, Deserialize)]
139pub struct SessionMetrics {
140 pub session_id: String,
142 #[serde(default)]
143 pub node_id: String,
144 pub created_at: String,
145
146 pub model: String,
148 pub total_input_tokens: u64,
149 pub total_output_tokens: u64,
150 #[serde(default)]
151 pub total_thinking_tokens: u64,
152 #[serde(default)]
154 pub total_thinking_bytes: u64,
155 pub estimated_cost: f64,
156
157 #[serde(default)]
159 pub total_chars: u64,
160
161 pub total_tool_calls: u32,
163 pub tool_breakdown: HashMap<String, u32>,
164 pub tool_fail_rate: f64,
165 #[serde(default)]
167 pub total_failed: u32,
168
169 pub total_duration_ms: u64,
171 pub total_llm_ms: u64,
172 pub total_tool_ms: u64,
173 pub total_turns: u32,
174 pub avg_turn_ms: u64,
175 pub p50_turn_ms: u64,
176 pub p95_turn_ms: u64,
177 pub p99_turn_ms: u64,
178
179 pub outcome: SessionOutcome,
181 pub error_count: u32,
182
183 #[serde(default)]
185 pub total_plan_updates: u32,
186 #[serde(default)]
187 pub total_approvals: u32,
188
189 #[serde(default)]
191 pub custom: Value,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub parent_session_id: Option<String>,
196 #[serde(default = "default_session_type")]
197 pub session_type: String,
198
199 pub turns: Vec<TurnMetrics>,
201}
202
203fn default_session_type() -> String {
204 "default".to_string()
205}
206
207impl SessionMetrics {
208 pub fn new(session_id: String, node_id: String, model: String) -> Self {
210 Self {
211 session_id,
212 node_id,
213 created_at: chrono::Utc::now().to_rfc3339(),
214 model,
215 total_input_tokens: 0,
216 total_output_tokens: 0,
217 total_thinking_tokens: 0,
218 total_thinking_bytes: 0,
219 estimated_cost: 0.0,
220 total_chars: 0,
221 total_tool_calls: 0,
222 tool_breakdown: HashMap::new(),
223 tool_fail_rate: 0.0,
224 total_failed: 0,
225 total_duration_ms: 0,
226 total_llm_ms: 0,
227 total_tool_ms: 0,
228 total_turns: 0,
229 avg_turn_ms: 0,
230 p50_turn_ms: 0,
231 p95_turn_ms: 0,
232 p99_turn_ms: 0,
233 outcome: SessionOutcome::Completed,
234 error_count: 0,
235 total_plan_updates: 0,
236 total_approvals: 0,
237 custom: Value::Object(serde_json::Map::new()),
238 parent_session_id: None,
239 session_type: "default".to_string(),
240 turns: Vec::new(),
241 }
242 }
243
244 pub fn append_turn(&mut self, turn: TurnMetrics) {
246 self.total_turns += 1;
247 self.total_input_tokens += turn.input_tokens;
248 self.total_output_tokens += turn.output_tokens;
249 self.total_thinking_tokens += turn.thinking_tokens;
250 self.total_thinking_bytes += turn.thinking_bytes;
251 self.total_chars += turn.text_length;
252 self.total_duration_ms += turn.duration_ms;
253 self.total_llm_ms += turn.llm_duration_ms;
254 self.total_tool_ms += turn.tool_duration_ms;
255 self.total_tool_calls += turn.tool_call_count;
256
257 for tool_name in &turn.tools_used {
259 *self.tool_breakdown.entry(tool_name.clone()).or_insert(0) += 1;
260 }
261
262 self.total_failed += turn.tool_failed;
264 if self.total_tool_calls > 0 {
265 self.tool_fail_rate = self.total_failed as f64 / self.total_tool_calls as f64;
266 }
267
268 if matches!(turn.outcome, TurnOutcome::Error) {
270 self.error_count += 1;
271 }
272
273 self.total_plan_updates += turn.plan_updates;
275 self.total_approvals += turn.approval_count;
276
277 self.avg_turn_ms = self.total_duration_ms / self.total_turns as u64;
279
280 {
282 let mut model_counts: HashMap<String, u32> = HashMap::new();
283 for t in &self.turns {
284 if !t.model.is_empty() {
285 *model_counts.entry(t.model.clone()).or_insert(0) += 1;
286 }
287 }
288 if !turn.model.is_empty() {
289 *model_counts.entry(turn.model.clone()).or_insert(0) += 1;
290 }
291 if let Some(top_model) = model_counts
292 .into_iter()
293 .max_by_key(|(_, count)| *count)
294 .map(|(m, _)| m)
295 {
296 self.model = top_model;
297 }
298 }
299
300 self.turns.push(turn);
302 self.recompute_percentiles();
303 }
304
305 fn recompute_percentiles(&mut self) {
307 if self.turns.is_empty() {
308 self.p50_turn_ms = 0;
309 self.p95_turn_ms = 0;
310 self.p99_turn_ms = 0;
311 return;
312 }
313
314 let mut durations: Vec<u64> = self.turns.iter().map(|t| t.duration_ms).collect();
315 durations.sort_unstable();
316
317 self.p50_turn_ms = percentile_from_sorted(&durations, 50.0);
318 self.p95_turn_ms = percentile_from_sorted(&durations, 95.0);
319 self.p99_turn_ms = percentile_from_sorted(&durations, 99.0);
320 }
321
322 pub fn finalize(&mut self, outcome: SessionOutcome) {
324 self.outcome = outcome;
325 self.recompute_percentiles();
326 }
327}
328
329#[derive(Clone, Debug, Serialize, Deserialize)]
333pub struct SessionSummary {
334 pub session_id: String,
335 pub node_id: String,
336 pub created_at: String,
337 pub model: String,
338 pub total_turns: u32,
339 pub total_chars: u64,
340 pub outcome: SessionOutcome,
341 pub product: Option<String>,
343}
344
345pub(crate) fn percentile_from_sorted(sorted: &[u64], p: f64) -> u64 {
349 if sorted.is_empty() {
350 return 0;
351 }
352 let n = sorted.len() as f64;
353 let idx = ((p / 100.0) * (n - 1.0)).round() as usize;
354 sorted[idx.min(sorted.len() - 1)]
355}
356
357pub(crate) fn truncate_str(s: &str, max_chars: usize) -> String {
359 if s.chars().count() > max_chars {
360 let truncated: String = s.chars().take(max_chars).collect();
361 format!("{}...", truncated)
362 } else {
363 s.to_string()
364 }
365}
366
367pub fn run_outcome_to_turn_outcome(
372 outcome: &agent_base::RunOutcome,
373 tools_used: &[String],
374) -> TurnOutcome {
375 match outcome {
376 agent_base::RunOutcome::Completed => {
377 if tools_used.is_empty() {
378 TurnOutcome::Completed
379 } else {
380 TurnOutcome::ToolCalls
381 }
382 }
383 agent_base::RunOutcome::Continuing => TurnOutcome::Completed,
386 agent_base::RunOutcome::Failed { .. } => TurnOutcome::Error,
387 agent_base::RunOutcome::Cancelled => TurnOutcome::Cancelled,
388 agent_base::RunOutcome::MaxTurnsExceeded { .. } => TurnOutcome::MaxTurns,
389 }
390}
391
392pub fn run_outcome_to_session_outcome(outcome: &agent_base::RunOutcome) -> SessionOutcome {
394 match outcome {
395 agent_base::RunOutcome::Completed => SessionOutcome::Completed,
396 agent_base::RunOutcome::Continuing => SessionOutcome::Completed,
397 agent_base::RunOutcome::Failed { .. } => SessionOutcome::Failed,
398 agent_base::RunOutcome::Cancelled => SessionOutcome::Cancelled,
399 agent_base::RunOutcome::MaxTurnsExceeded { .. } => SessionOutcome::MaxTurns,
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_truncate_str() {
409 assert_eq!(truncate_str("hello", 10), "hello");
410 assert_eq!(
411 truncate_str("hello world this is long", 10),
412 "hello worl..."
413 );
414 assert_eq!(truncate_str("你好世界测试文本", 4), "你好世界...");
415 }
416
417 #[test]
418 fn test_percentile_from_sorted() {
419 let data = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
420 assert_eq!(percentile_from_sorted(&data, 50.0), 60);
421 assert_eq!(percentile_from_sorted(&data, 95.0), 100);
422 assert_eq!(percentile_from_sorted(&data, 0.0), 10);
423 assert_eq!(percentile_from_sorted(&data, 100.0), 100);
424 }
425
426 #[test]
427 fn test_percentile_empty() {
428 assert_eq!(percentile_from_sorted(&[], 50.0), 0);
429 }
430
431 #[test]
432 fn test_session_metrics_new() {
433 let m = SessionMetrics::new(
434 "20260729_test".to_string(),
435 "".to_string(),
436 "claude-sonnet".to_string(),
437 );
438 assert_eq!(m.session_id, "20260729_test");
439 assert_eq!(m.node_id, "");
440 assert_eq!(m.model, "claude-sonnet");
441 assert_eq!(m.total_turns, 0);
442 assert_eq!(m.parent_session_id, None);
443 assert_eq!(m.session_type, "default");
444 assert!(m.turns.is_empty());
445 }
446
447 #[test]
448 fn test_session_metrics_append_turn() {
449 let mut m = SessionMetrics::new(
450 "test".to_string(),
451 "".to_string(),
452 "claude-sonnet".to_string(),
453 );
454 let turn = TurnMetrics {
455 turn_number: 1,
456 started_at: "2026-07-29T00:00:00Z".to_string(),
457 duration_ms: 1000,
458 time_to_first_token_ms: 200,
459 llm_duration_ms: 800,
460 tool_duration_ms: 100,
461 llm_calls: 1,
462 input_tokens: 500,
463 output_tokens: 300,
464 thinking_tokens: 0,
465 thinking_bytes: 0,
466 model: "claude-sonnet".to_string(),
467 tool_call_count: 1,
468 tools_used: vec!["shell".to_string()],
469 tool_success: 1,
470 tool_failed: 0,
471 outcome: TurnOutcome::ToolCalls,
472 text_length: 200,
473 error_message: None,
474 has_thinking: true,
475 plan_updates: 0,
476 approval_count: 0,
477 user_input: "test input".to_string(),
478 custom: Value::Object(serde_json::Map::new()),
479 };
480 m.append_turn(turn);
481 assert_eq!(m.total_turns, 1);
482 assert_eq!(m.total_input_tokens, 500);
483 assert_eq!(m.total_output_tokens, 300);
484 assert_eq!(m.total_duration_ms, 1000);
485 assert_eq!(m.total_tool_calls, 1);
486 assert_eq!(m.tool_breakdown.get("shell"), Some(&1));
487 assert_eq!(m.tool_fail_rate, 0.0);
488 assert_eq!(m.avg_turn_ms, 1000);
489 assert_eq!(m.p50_turn_ms, 1000);
490 }
491
492 #[test]
493 fn test_session_metrics_append_multiple_turns() {
494 let mut m = SessionMetrics::new(
495 "test".to_string(),
496 "".to_string(),
497 "claude-sonnet".to_string(),
498 );
499 for i in 1..=5 {
500 let turn = TurnMetrics {
501 turn_number: i,
502 started_at: "2026-07-29T00:00:00Z".to_string(),
503 duration_ms: i as u64 * 1000,
504 time_to_first_token_ms: 200,
505 llm_duration_ms: 800,
506 tool_duration_ms: 100,
507 llm_calls: 1,
508 input_tokens: 500,
509 output_tokens: 300,
510 thinking_tokens: 0,
511 thinking_bytes: 0,
512 model: "claude-sonnet".to_string(),
513 tool_call_count: 1,
514 tools_used: vec!["shell".to_string()],
515 tool_success: 1,
516 tool_failed: 0,
517 outcome: TurnOutcome::ToolCalls,
518 text_length: 200,
519 error_message: None,
520 has_thinking: true,
521 plan_updates: 0,
522 approval_count: 0,
523 user_input: "test".to_string(),
524 custom: Value::Object(serde_json::Map::new()),
525 };
526 m.append_turn(turn);
527 }
528 assert_eq!(m.total_turns, 5);
529 assert_eq!(m.total_duration_ms, 15000);
530 assert_eq!(m.avg_turn_ms, 3000);
531 assert_eq!(m.p50_turn_ms, 3000);
532 }
533
534 #[test]
535 fn test_session_metrics_thinking_bytes_accumulation() {
536 let mut m = SessionMetrics::new(
537 "test".to_string(),
538 "".to_string(),
539 "mimo-v2.5-pro".to_string(),
540 );
541
542 let turn1 = TurnMetrics {
544 turn_number: 1,
545 started_at: "2026-07-29T00:00:00Z".to_string(),
546 duration_ms: 1000,
547 time_to_first_token_ms: 200,
548 llm_duration_ms: 800,
549 tool_duration_ms: 0,
550 llm_calls: 1,
551 input_tokens: 5000,
552 output_tokens: 80,
553 thinking_tokens: 0,
554 thinking_bytes: 43,
555 model: "mimo-v2.5-pro".to_string(),
556 tool_call_count: 0,
557 tools_used: vec![],
558 tool_success: 0,
559 tool_failed: 0,
560 outcome: TurnOutcome::Completed,
561 text_length: 100,
562 error_message: None,
563 has_thinking: true,
564 plan_updates: 0,
565 approval_count: 0,
566 user_input: "test".to_string(),
567 custom: Value::Object(serde_json::Map::new()),
568 };
569 m.append_turn(turn1);
570
571 let turn2 = TurnMetrics {
573 turn_number: 2,
574 started_at: "2026-07-29T00:00:01Z".to_string(),
575 duration_ms: 1200,
576 time_to_first_token_ms: 150,
577 llm_duration_ms: 900,
578 tool_duration_ms: 0,
579 llm_calls: 1,
580 input_tokens: 7000,
581 output_tokens: 131,
582 thinking_tokens: 0,
583 thinking_bytes: 86,
584 model: "mimo-v2.5-pro".to_string(),
585 tool_call_count: 0,
586 tools_used: vec![],
587 tool_success: 0,
588 tool_failed: 0,
589 outcome: TurnOutcome::Completed,
590 text_length: 200,
591 error_message: None,
592 has_thinking: true,
593 plan_updates: 0,
594 approval_count: 0,
595 user_input: "test".to_string(),
596 custom: Value::Object(serde_json::Map::new()),
597 };
598 m.append_turn(turn2);
599
600 assert_eq!(m.total_thinking_tokens, 0);
602 assert_eq!(m.total_thinking_bytes, 129); assert_eq!(m.total_turns, 2);
604 }
605
606 #[test]
607 fn test_turn_metrics_thinking_bytes_default() {
608 let turn = TurnMetrics::new(
609 1,
610 "2026-07-29T00:00:00Z".to_string(),
611 1000,
612 "mimo-v2.5-pro".to_string(),
613 "test".to_string(),
614 TurnOutcome::Completed,
615 );
616 assert_eq!(turn.thinking_bytes, 0);
617 assert_eq!(turn.thinking_tokens, 0);
618 }
619
620 #[test]
621 fn test_session_metrics_backward_compat_thinking_bytes() {
622 let old_json = r#"{
624 "session_id": "test",
625 "node_id": "",
626 "created_at": "2026-07-29T00:00:00Z",
627 "model": "mimo-v2.5-pro",
628 "total_input_tokens": 100,
629 "total_output_tokens": 50,
630 "total_thinking_tokens": 0,
631 "estimated_cost": 0.0,
632 "total_chars": 100,
633 "total_tool_calls": 0,
634 "tool_breakdown": {},
635 "tool_fail_rate": 0.0,
636 "total_duration_ms": 1000,
637 "total_llm_ms": 800,
638 "total_tool_ms": 0,
639 "total_turns": 1,
640 "avg_turn_ms": 1000,
641 "p50_turn_ms": 1000,
642 "p95_turn_ms": 1000,
643 "p99_turn_ms": 1000,
644 "outcome": "completed",
645 "error_count": 0,
646 "custom": {},
647 "turns": []
648 }"#;
649 let m: SessionMetrics = serde_json::from_str(old_json).unwrap();
650 assert_eq!(m.total_thinking_bytes, 0);
651 assert_eq!(m.total_thinking_tokens, 0);
652 }
653
654 #[test]
655 fn test_session_metrics_finalize() {
656 let mut m = SessionMetrics::new(
657 "test".to_string(),
658 "".to_string(),
659 "claude-sonnet".to_string(),
660 );
661 m.finalize(SessionOutcome::Completed);
662 assert_eq!(m.outcome, SessionOutcome::Completed);
663 }
664
665 #[test]
666 fn test_session_metrics_json_roundtrip() {
667 let m = SessionMetrics::new(
668 "20260729_test".to_string(),
669 "node-1".to_string(),
670 "claude-sonnet".to_string(),
671 );
672 let json = serde_json::to_string(&m).unwrap();
673 let m2: SessionMetrics = serde_json::from_str(&json).unwrap();
674 assert_eq!(m2.session_id, "20260729_test");
675 assert_eq!(m2.node_id, "node-1");
676 assert_eq!(m2.parent_session_id, None);
677 assert_eq!(m2.session_type, "default");
678 }
679
680 #[test]
681 fn test_session_metrics_backward_compat() {
682 let old_json = r#"{
683 "session_id": "test",
684 "node_id": "",
685 "created_at": "2026-07-29T00:00:00Z",
686 "model": "claude-sonnet",
687 "total_input_tokens": 0,
688 "total_output_tokens": 0,
689 "estimated_cost": 0.0,
690 "total_chars": 0,
691 "total_tool_calls": 0,
692 "tool_breakdown": {},
693 "tool_fail_rate": 0.0,
694 "total_duration_ms": 0,
695 "total_llm_ms": 0,
696 "total_tool_ms": 0,
697 "total_turns": 0,
698 "avg_turn_ms": 0,
699 "p50_turn_ms": 0,
700 "p95_turn_ms": 0,
701 "p99_turn_ms": 0,
702 "outcome": "completed",
703 "error_count": 0,
704 "custom": {},
705 "turns": []
706 }"#;
707 let m: SessionMetrics = serde_json::from_str(old_json).unwrap();
708 assert_eq!(m.parent_session_id, None);
709 assert_eq!(m.session_type, "default");
710 }
711
712 #[test]
713 fn test_turn_metrics_custom_default() {
714 let turn = TurnMetrics::new(
715 1,
716 "2026-07-29T00:00:00Z".to_string(),
717 1000,
718 "claude-sonnet".to_string(),
719 "hello world".to_string(),
720 TurnOutcome::Completed,
721 );
722 assert_eq!(turn.custom, Value::Object(serde_json::Map::new()));
723 assert_eq!(turn.user_input, "hello world");
724 let turn_long = TurnMetrics::new(
725 1,
726 "2026-07-29T00:00:00Z".to_string(),
727 1000,
728 "claude-sonnet".to_string(),
729 "this is a very long user input that should definitely be truncated at eighty characters because that's the max we allow for storage".to_string(),
730 TurnOutcome::Completed,
731 );
732 assert!(turn_long.user_input.chars().count() <= 83);
733 assert!(turn_long.user_input.ends_with("..."));
734 }
735
736 #[test]
737 fn test_run_outcome_conversion() {
738 use agent_base::RunOutcome;
739
740 assert_eq!(
742 run_outcome_to_turn_outcome(&RunOutcome::Completed, &[]),
743 TurnOutcome::Completed
744 );
745
746 assert_eq!(
748 run_outcome_to_turn_outcome(&RunOutcome::Completed, &["shell".to_string()]),
749 TurnOutcome::ToolCalls
750 );
751
752 assert_eq!(
754 run_outcome_to_turn_outcome(
755 &RunOutcome::Failed {
756 error: "oops".to_string()
757 },
758 &[]
759 ),
760 TurnOutcome::Error
761 );
762
763 assert_eq!(
765 run_outcome_to_turn_outcome(&RunOutcome::Cancelled, &[]),
766 TurnOutcome::Cancelled
767 );
768 }
769}