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 pub fn merge_turns(&mut self, other: &SessionMetrics) {
338 let known: std::collections::HashSet<(u32, String)> = self
339 .turns
340 .iter()
341 .map(|t| (t.turn_number, t.started_at.clone()))
342 .collect();
343 for t in &other.turns {
344 if known.contains(&(t.turn_number, t.started_at.clone())) {
345 continue;
346 }
347 self.turns.push(t.clone());
348 }
349 self.turns.sort_by(|a, b| {
350 a.started_at
351 .cmp(&b.started_at)
352 .then(a.turn_number.cmp(&b.turn_number))
353 });
354 self.rebuild_aggregates();
355 }
356
357 fn rebuild_aggregates(&mut self) {
361 self.total_input_tokens = 0;
362 self.total_output_tokens = 0;
363 self.total_thinking_tokens = 0;
364 self.total_thinking_bytes = 0;
365 self.total_chars = 0;
366 self.total_duration_ms = 0;
367 self.total_llm_ms = 0;
368 self.total_tool_ms = 0;
369 self.total_tool_calls = 0;
370 self.tool_breakdown.clear();
371 self.total_failed = 0;
372 self.tool_fail_rate = 0.0;
373 self.error_count = 0;
374 self.total_plan_updates = 0;
375 self.total_approvals = 0;
376 self.total_turns = 0;
377 self.avg_turn_ms = 0;
378 self.model = String::new();
379
380 let turns = std::mem::take(&mut self.turns);
381 for t in turns {
382 self.append_turn(t);
383 }
384 }
385}
386
387#[derive(Clone, Debug, Serialize, Deserialize)]
391pub struct SessionSummary {
392 pub session_id: String,
393 pub node_id: String,
394 pub created_at: String,
395 pub model: String,
396 pub total_turns: u32,
397 pub total_chars: u64,
398 pub outcome: SessionOutcome,
399 pub product: Option<String>,
401}
402
403pub(crate) fn percentile_from_sorted(sorted: &[u64], p: f64) -> u64 {
407 if sorted.is_empty() {
408 return 0;
409 }
410 let n = sorted.len() as f64;
411 let idx = ((p / 100.0) * (n - 1.0)).round() as usize;
412 sorted[idx.min(sorted.len() - 1)]
413}
414
415pub(crate) fn truncate_str(s: &str, max_chars: usize) -> String {
417 if s.chars().count() > max_chars {
418 let truncated: String = s.chars().take(max_chars).collect();
419 format!("{}...", truncated)
420 } else {
421 s.to_string()
422 }
423}
424
425pub fn run_outcome_to_turn_outcome(
430 outcome: &agent_base::RunOutcome,
431 tools_used: &[String],
432) -> TurnOutcome {
433 match outcome {
434 agent_base::RunOutcome::Completed => {
435 if tools_used.is_empty() {
436 TurnOutcome::Completed
437 } else {
438 TurnOutcome::ToolCalls
439 }
440 }
441 agent_base::RunOutcome::Continuing => TurnOutcome::Completed,
444 agent_base::RunOutcome::Failed { .. } => TurnOutcome::Error,
445 agent_base::RunOutcome::Cancelled => TurnOutcome::Cancelled,
446 agent_base::RunOutcome::MaxTurnsExceeded { .. } => TurnOutcome::MaxTurns,
447 }
448}
449
450pub fn run_outcome_to_session_outcome(outcome: &agent_base::RunOutcome) -> SessionOutcome {
452 match outcome {
453 agent_base::RunOutcome::Completed => SessionOutcome::Completed,
454 agent_base::RunOutcome::Continuing => SessionOutcome::Completed,
455 agent_base::RunOutcome::Failed { .. } => SessionOutcome::Failed,
456 agent_base::RunOutcome::Cancelled => SessionOutcome::Cancelled,
457 agent_base::RunOutcome::MaxTurnsExceeded { .. } => SessionOutcome::MaxTurns,
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 #[test]
466 fn test_truncate_str() {
467 assert_eq!(truncate_str("hello", 10), "hello");
468 assert_eq!(
469 truncate_str("hello world this is long", 10),
470 "hello worl..."
471 );
472 assert_eq!(truncate_str("你好世界测试文本", 4), "你好世界...");
473 }
474
475 #[test]
476 fn test_percentile_from_sorted() {
477 let data = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
478 assert_eq!(percentile_from_sorted(&data, 50.0), 60);
479 assert_eq!(percentile_from_sorted(&data, 95.0), 100);
480 assert_eq!(percentile_from_sorted(&data, 0.0), 10);
481 assert_eq!(percentile_from_sorted(&data, 100.0), 100);
482 }
483
484 #[test]
485 fn test_percentile_empty() {
486 assert_eq!(percentile_from_sorted(&[], 50.0), 0);
487 }
488
489 fn turn(n: u32, at: &str, dur: u64, model: &str) -> TurnMetrics {
491 let mut t = TurnMetrics::new(
492 n,
493 at.to_string(),
494 dur,
495 model.to_string(),
496 "input".to_string(),
497 TurnOutcome::Completed,
498 );
499 t.input_tokens = 100;
500 t.output_tokens = 10;
501 t
502 }
503
504 #[test]
505 fn merge_turns_unions_disjoint_writers_and_rebuilds_totals() {
506 let mut base = SessionMetrics::new("s".into(), "".into(), "m1".into());
507 base.append_turn(turn(1, "2026-08-01T12:00:00Z", 1000, "m1"));
508 let mut other = SessionMetrics::new("s".into(), "".into(), "m2".into());
510 other.append_turn(turn(2, "2026-08-01T12:01:00Z", 2000, "m2"));
511 other.append_turn(turn(3, "2026-08-01T12:02:00Z", 3000, "m2"));
512
513 base.merge_turns(&other);
514
515 assert_eq!(base.total_turns, 3);
516 assert_eq!(base.turns.len(), 3);
517 assert_eq!(
519 base.turns.iter().map(|t| t.turn_number).collect::<Vec<_>>(),
520 vec![1, 2, 3]
521 );
522 assert_eq!(base.total_input_tokens, 300);
524 assert_eq!(base.total_output_tokens, 30);
525 assert_eq!(base.total_duration_ms, 6000);
526 assert_eq!(base.avg_turn_ms, 2000);
527 assert_eq!(base.model, "m2");
529 }
530
531 #[test]
532 fn merge_turns_dedupes_on_turn_number_and_started_at() {
533 let mut base = SessionMetrics::new("s".into(), "".into(), "m1".into());
534 base.append_turn(turn(1, "2026-08-01T12:00:00Z", 1000, "m1"));
535 let before = base.clone();
536
537 base.merge_turns(&before);
539 assert_eq!(base.total_turns, 1);
540 assert_eq!(base.total_input_tokens, 100);
541
542 let mut fresh = SessionMetrics::new("s".into(), "".into(), "m1".into());
545 fresh.append_turn(turn(1, "2026-08-02T09:00:00Z", 500, "m1"));
546 base.merge_turns(&fresh);
547 assert_eq!(base.total_turns, 2);
548 assert_eq!(base.total_duration_ms, 1500);
549 }
550
551 #[test]
552 fn merge_turns_preserves_identity_and_outcome() {
553 let mut base = SessionMetrics::new("s-old".into(), "node-9".into(), "m1".into());
554 base.custom = serde_json::json!({"product": "phimint"});
555 base.outcome = SessionOutcome::Failed;
556 base.append_turn(turn(1, "2026-08-01T12:00:00Z", 1000, "m1"));
557
558 let mut other = SessionMetrics::new("s-new".into(), "node-x".into(), "m2".into());
559 other.outcome = SessionOutcome::Completed;
560 other.append_turn(turn(2, "2026-08-01T12:01:00Z", 2000, "m2"));
561
562 base.merge_turns(&other);
563
564 assert_eq!(base.session_id, "s-old");
567 assert_eq!(base.node_id, "node-9");
568 assert_eq!(base.custom, serde_json::json!({"product": "phimint"}));
569 assert_eq!(base.outcome, SessionOutcome::Failed);
570 }
571
572 #[test]
573 fn test_session_metrics_new() {
574 let m = SessionMetrics::new(
575 "20260729_test".to_string(),
576 "".to_string(),
577 "claude-sonnet".to_string(),
578 );
579 assert_eq!(m.session_id, "20260729_test");
580 assert_eq!(m.node_id, "");
581 assert_eq!(m.model, "claude-sonnet");
582 assert_eq!(m.total_turns, 0);
583 assert_eq!(m.parent_session_id, None);
584 assert_eq!(m.session_type, "default");
585 assert!(m.turns.is_empty());
586 }
587
588 #[test]
589 fn test_session_metrics_append_turn() {
590 let mut m = SessionMetrics::new(
591 "test".to_string(),
592 "".to_string(),
593 "claude-sonnet".to_string(),
594 );
595 let turn = TurnMetrics {
596 turn_number: 1,
597 started_at: "2026-07-29T00:00:00Z".to_string(),
598 duration_ms: 1000,
599 time_to_first_token_ms: 200,
600 llm_duration_ms: 800,
601 tool_duration_ms: 100,
602 llm_calls: 1,
603 input_tokens: 500,
604 output_tokens: 300,
605 thinking_tokens: 0,
606 thinking_bytes: 0,
607 model: "claude-sonnet".to_string(),
608 tool_call_count: 1,
609 tools_used: vec!["shell".to_string()],
610 tool_success: 1,
611 tool_failed: 0,
612 outcome: TurnOutcome::ToolCalls,
613 text_length: 200,
614 error_message: None,
615 has_thinking: true,
616 plan_updates: 0,
617 approval_count: 0,
618 user_input: "test input".to_string(),
619 custom: Value::Object(serde_json::Map::new()),
620 };
621 m.append_turn(turn);
622 assert_eq!(m.total_turns, 1);
623 assert_eq!(m.total_input_tokens, 500);
624 assert_eq!(m.total_output_tokens, 300);
625 assert_eq!(m.total_duration_ms, 1000);
626 assert_eq!(m.total_tool_calls, 1);
627 assert_eq!(m.tool_breakdown.get("shell"), Some(&1));
628 assert_eq!(m.tool_fail_rate, 0.0);
629 assert_eq!(m.avg_turn_ms, 1000);
630 assert_eq!(m.p50_turn_ms, 1000);
631 }
632
633 #[test]
634 fn test_session_metrics_append_multiple_turns() {
635 let mut m = SessionMetrics::new(
636 "test".to_string(),
637 "".to_string(),
638 "claude-sonnet".to_string(),
639 );
640 for i in 1..=5 {
641 let turn = TurnMetrics {
642 turn_number: i,
643 started_at: "2026-07-29T00:00:00Z".to_string(),
644 duration_ms: i as u64 * 1000,
645 time_to_first_token_ms: 200,
646 llm_duration_ms: 800,
647 tool_duration_ms: 100,
648 llm_calls: 1,
649 input_tokens: 500,
650 output_tokens: 300,
651 thinking_tokens: 0,
652 thinking_bytes: 0,
653 model: "claude-sonnet".to_string(),
654 tool_call_count: 1,
655 tools_used: vec!["shell".to_string()],
656 tool_success: 1,
657 tool_failed: 0,
658 outcome: TurnOutcome::ToolCalls,
659 text_length: 200,
660 error_message: None,
661 has_thinking: true,
662 plan_updates: 0,
663 approval_count: 0,
664 user_input: "test".to_string(),
665 custom: Value::Object(serde_json::Map::new()),
666 };
667 m.append_turn(turn);
668 }
669 assert_eq!(m.total_turns, 5);
670 assert_eq!(m.total_duration_ms, 15000);
671 assert_eq!(m.avg_turn_ms, 3000);
672 assert_eq!(m.p50_turn_ms, 3000);
673 }
674
675 #[test]
676 fn test_session_metrics_thinking_bytes_accumulation() {
677 let mut m = SessionMetrics::new(
678 "test".to_string(),
679 "".to_string(),
680 "mimo-v2.5-pro".to_string(),
681 );
682
683 let turn1 = TurnMetrics {
685 turn_number: 1,
686 started_at: "2026-07-29T00:00:00Z".to_string(),
687 duration_ms: 1000,
688 time_to_first_token_ms: 200,
689 llm_duration_ms: 800,
690 tool_duration_ms: 0,
691 llm_calls: 1,
692 input_tokens: 5000,
693 output_tokens: 80,
694 thinking_tokens: 0,
695 thinking_bytes: 43,
696 model: "mimo-v2.5-pro".to_string(),
697 tool_call_count: 0,
698 tools_used: vec![],
699 tool_success: 0,
700 tool_failed: 0,
701 outcome: TurnOutcome::Completed,
702 text_length: 100,
703 error_message: None,
704 has_thinking: true,
705 plan_updates: 0,
706 approval_count: 0,
707 user_input: "test".to_string(),
708 custom: Value::Object(serde_json::Map::new()),
709 };
710 m.append_turn(turn1);
711
712 let turn2 = TurnMetrics {
714 turn_number: 2,
715 started_at: "2026-07-29T00:00:01Z".to_string(),
716 duration_ms: 1200,
717 time_to_first_token_ms: 150,
718 llm_duration_ms: 900,
719 tool_duration_ms: 0,
720 llm_calls: 1,
721 input_tokens: 7000,
722 output_tokens: 131,
723 thinking_tokens: 0,
724 thinking_bytes: 86,
725 model: "mimo-v2.5-pro".to_string(),
726 tool_call_count: 0,
727 tools_used: vec![],
728 tool_success: 0,
729 tool_failed: 0,
730 outcome: TurnOutcome::Completed,
731 text_length: 200,
732 error_message: None,
733 has_thinking: true,
734 plan_updates: 0,
735 approval_count: 0,
736 user_input: "test".to_string(),
737 custom: Value::Object(serde_json::Map::new()),
738 };
739 m.append_turn(turn2);
740
741 assert_eq!(m.total_thinking_tokens, 0);
743 assert_eq!(m.total_thinking_bytes, 129); assert_eq!(m.total_turns, 2);
745 }
746
747 #[test]
748 fn test_turn_metrics_thinking_bytes_default() {
749 let turn = TurnMetrics::new(
750 1,
751 "2026-07-29T00:00:00Z".to_string(),
752 1000,
753 "mimo-v2.5-pro".to_string(),
754 "test".to_string(),
755 TurnOutcome::Completed,
756 );
757 assert_eq!(turn.thinking_bytes, 0);
758 assert_eq!(turn.thinking_tokens, 0);
759 }
760
761 #[test]
762 fn test_session_metrics_backward_compat_thinking_bytes() {
763 let old_json = r#"{
765 "session_id": "test",
766 "node_id": "",
767 "created_at": "2026-07-29T00:00:00Z",
768 "model": "mimo-v2.5-pro",
769 "total_input_tokens": 100,
770 "total_output_tokens": 50,
771 "total_thinking_tokens": 0,
772 "estimated_cost": 0.0,
773 "total_chars": 100,
774 "total_tool_calls": 0,
775 "tool_breakdown": {},
776 "tool_fail_rate": 0.0,
777 "total_duration_ms": 1000,
778 "total_llm_ms": 800,
779 "total_tool_ms": 0,
780 "total_turns": 1,
781 "avg_turn_ms": 1000,
782 "p50_turn_ms": 1000,
783 "p95_turn_ms": 1000,
784 "p99_turn_ms": 1000,
785 "outcome": "completed",
786 "error_count": 0,
787 "custom": {},
788 "turns": []
789 }"#;
790 let m: SessionMetrics = serde_json::from_str(old_json).unwrap();
791 assert_eq!(m.total_thinking_bytes, 0);
792 assert_eq!(m.total_thinking_tokens, 0);
793 }
794
795 #[test]
796 fn test_session_metrics_finalize() {
797 let mut m = SessionMetrics::new(
798 "test".to_string(),
799 "".to_string(),
800 "claude-sonnet".to_string(),
801 );
802 m.finalize(SessionOutcome::Completed);
803 assert_eq!(m.outcome, SessionOutcome::Completed);
804 }
805
806 #[test]
807 fn test_session_metrics_json_roundtrip() {
808 let m = SessionMetrics::new(
809 "20260729_test".to_string(),
810 "node-1".to_string(),
811 "claude-sonnet".to_string(),
812 );
813 let json = serde_json::to_string(&m).unwrap();
814 let m2: SessionMetrics = serde_json::from_str(&json).unwrap();
815 assert_eq!(m2.session_id, "20260729_test");
816 assert_eq!(m2.node_id, "node-1");
817 assert_eq!(m2.parent_session_id, None);
818 assert_eq!(m2.session_type, "default");
819 }
820
821 #[test]
822 fn test_session_metrics_backward_compat() {
823 let old_json = r#"{
824 "session_id": "test",
825 "node_id": "",
826 "created_at": "2026-07-29T00:00:00Z",
827 "model": "claude-sonnet",
828 "total_input_tokens": 0,
829 "total_output_tokens": 0,
830 "estimated_cost": 0.0,
831 "total_chars": 0,
832 "total_tool_calls": 0,
833 "tool_breakdown": {},
834 "tool_fail_rate": 0.0,
835 "total_duration_ms": 0,
836 "total_llm_ms": 0,
837 "total_tool_ms": 0,
838 "total_turns": 0,
839 "avg_turn_ms": 0,
840 "p50_turn_ms": 0,
841 "p95_turn_ms": 0,
842 "p99_turn_ms": 0,
843 "outcome": "completed",
844 "error_count": 0,
845 "custom": {},
846 "turns": []
847 }"#;
848 let m: SessionMetrics = serde_json::from_str(old_json).unwrap();
849 assert_eq!(m.parent_session_id, None);
850 assert_eq!(m.session_type, "default");
851 }
852
853 #[test]
854 fn test_turn_metrics_custom_default() {
855 let turn = TurnMetrics::new(
856 1,
857 "2026-07-29T00:00:00Z".to_string(),
858 1000,
859 "claude-sonnet".to_string(),
860 "hello world".to_string(),
861 TurnOutcome::Completed,
862 );
863 assert_eq!(turn.custom, Value::Object(serde_json::Map::new()));
864 assert_eq!(turn.user_input, "hello world");
865 let turn_long = TurnMetrics::new(
866 1,
867 "2026-07-29T00:00:00Z".to_string(),
868 1000,
869 "claude-sonnet".to_string(),
870 "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(),
871 TurnOutcome::Completed,
872 );
873 assert!(turn_long.user_input.chars().count() <= 83);
874 assert!(turn_long.user_input.ends_with("..."));
875 }
876
877 #[test]
878 fn test_run_outcome_conversion() {
879 use agent_base::RunOutcome;
880
881 assert_eq!(
883 run_outcome_to_turn_outcome(&RunOutcome::Completed, &[]),
884 TurnOutcome::Completed
885 );
886
887 assert_eq!(
889 run_outcome_to_turn_outcome(&RunOutcome::Completed, &["shell".to_string()]),
890 TurnOutcome::ToolCalls
891 );
892
893 assert_eq!(
895 run_outcome_to_turn_outcome(
896 &RunOutcome::Failed {
897 error: "oops".to_string()
898 },
899 &[]
900 ),
901 TurnOutcome::Error
902 );
903
904 assert_eq!(
906 run_outcome_to_turn_outcome(&RunOutcome::Cancelled, &[]),
907 TurnOutcome::Cancelled
908 );
909 }
910}