Skip to main content

phi_telemetry/
types.rs

1//! Structured metrics types for Agent observability.
2//!
3//! These types were moved from agent-base to phi-telemetry so that agent-base
4//! remains a pure runtime kernel with no knowledge of metrics.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10// ── Outcome enums ──
11
12/// Outcome of a single turn (one LLM interaction).
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TurnOutcome {
16    /// Turn completed successfully (text response, no tool calls).
17    Completed,
18    /// Turn ended with tool calls (agent will loop back for another turn).
19    ToolCalls,
20    /// Turn ended with an error.
21    Error,
22    /// Turn hit the max-turns safety limit.
23    MaxTurns,
24    /// User cancelled this turn.
25    Cancelled,
26}
27
28/// Outcome of a session (the entire conversation).
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum SessionOutcome {
32    /// Session completed normally.
33    Completed,
34    /// Session ended with an unrecoverable error.
35    Failed,
36    /// User cancelled the session.
37    Cancelled,
38    /// Max turns exceeded (safety limit).
39    MaxTurns,
40}
41
42// ── TurnMetrics ──
43
44/// Per-turn metrics — one per LLM interaction.
45#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct TurnMetrics {
47    // Timing
48    pub turn_number: u32,
49    pub started_at: String, // ISO8601
50    pub duration_ms: u64,
51
52    // Latency breakdown
53    pub time_to_first_token_ms: u64,
54    pub llm_duration_ms: u64,
55    pub tool_duration_ms: u64,
56
57    // LLM
58    pub llm_calls: u32,
59    pub input_tokens: u64,
60    pub output_tokens: u64,
61    #[serde(default)]
62    pub thinking_tokens: u64,
63    /// Byte length of reasoning/thinking content (always available, even when
64    /// the provider does not report reasoning token counts).
65    #[serde(default)]
66    pub thinking_bytes: u64,
67    pub model: String,
68
69    // Tool
70    pub tool_call_count: u32,
71    pub tools_used: Vec<String>,
72    pub tool_success: u32,
73    pub tool_failed: u32,
74
75    // Result
76    pub outcome: TurnOutcome,
77    pub text_length: u64,
78    pub error_message: Option<String>,
79    pub has_thinking: bool,
80
81    // Meta events
82    #[serde(default)]
83    pub plan_updates: u32,
84    #[serde(default)]
85    pub approval_count: u32,
86
87    // User input (truncated to 80 chars)
88    pub user_input: String,
89
90    // Business extension
91    #[serde(default)]
92    pub custom: Value,
93}
94
95impl TurnMetrics {
96    /// Create a new TurnMetrics with defaults (custom = {}).
97    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// ── SessionMetrics ──
135
136/// Accumulated session metrics. Written incrementally to `session_metrics.json`
137/// at the end of each turn.
138#[derive(Clone, Debug, Serialize, Deserialize)]
139pub struct SessionMetrics {
140    // Identity
141    pub session_id: String,
142    #[serde(default)]
143    pub node_id: String,
144    pub created_at: String,
145
146    // LLM summary
147    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    /// Total byte length of reasoning/thinking content across all turns.
153    #[serde(default)]
154    pub total_thinking_bytes: u64,
155    pub estimated_cost: f64,
156
157    // Characters (always available, even without API token support)
158    #[serde(default)]
159    pub total_chars: u64,
160
161    // Tool summary
162    pub total_tool_calls: u32,
163    pub tool_breakdown: HashMap<String, u32>,
164    pub tool_fail_rate: f64,
165    /// Running total of failed tool calls.
166    #[serde(default)]
167    pub total_failed: u32,
168
169    // Timing
170    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    // Outcome
180    pub outcome: SessionOutcome,
181    pub error_count: u32,
182
183    // Meta events
184    #[serde(default)]
185    pub total_plan_updates: u32,
186    #[serde(default)]
187    pub total_approvals: u32,
188
189    // Business extension
190    #[serde(default)]
191    pub custom: Value,
192
193    // Multi-agent reservation (Phase 1: always None / "default")
194    #[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    // Per-turn details
200    pub turns: Vec<TurnMetrics>,
201}
202
203fn default_session_type() -> String {
204    "default".to_string()
205}
206
207impl SessionMetrics {
208    /// Create a new session metrics accumulator (empty turns).
209    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    /// Append a turn and recompute session-level aggregates.
245    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        // Tool breakdown
258        for tool_name in &turn.tools_used {
259            *self.tool_breakdown.entry(tool_name.clone()).or_insert(0) += 1;
260        }
261
262        // Tool fail rate (incremental — O(1) per turn)
263        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        // Error count
269        if matches!(turn.outcome, TurnOutcome::Error) {
270            self.error_count += 1;
271        }
272
273        // Meta events
274        self.total_plan_updates += turn.plan_updates;
275        self.total_approvals += turn.approval_count;
276
277        // Average turn duration
278        self.avg_turn_ms = self.total_duration_ms / self.total_turns as u64;
279
280        // Update model to the most frequently used one
281        {
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        // Percentiles
301        self.turns.push(turn);
302        self.recompute_percentiles();
303    }
304
305    /// Recompute P50/P95/P99 from stored turn durations.
306    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    /// Finalize the session — recompute percentiles and set outcome.
323    pub fn finalize(&mut self, outcome: SessionOutcome) {
324        self.outcome = outcome;
325        self.recompute_percentiles();
326    }
327
328    /// Merge another accumulator's per-turn records into this one and rebuild
329    /// every session-level aggregate from the merged list.
330    ///
331    /// Turns are deduplicated on `(turn_number, started_at)`, so the union is
332    /// idempotent: re-saving the same turns from the same process changes
333    /// nothing, while turns recorded by another writer over the same session
334    /// (a TUI session switch, a new process resuming it) are folded in instead
335    /// of clobbering the history. Identity/custom fields and `outcome` keep
336    /// `self`'s values — the caller decides whose identity and final state win.
337    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    /// Recompute all incremental aggregates from scratch by replaying
358    /// `append_turn` over the stored turns (every total it maintains is a
359    /// function of the turn list).
360    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// ── Summary for CLI listing ──
388
389/// Lightweight summary returned by `list_all()`.
390#[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    /// Product name from custom field, if set (e.g. "phi-bard").
400    pub product: Option<String>,
401}
402
403// ── Helpers ──
404
405/// Compute the `p`-th percentile from an already-sorted slice of values.
406pub(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
415/// Truncate a string to `max_chars` characters, appending "..." if truncated.
416pub(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
425// ── Conversion from agent-base ──
426
427/// Convert an agent-base `RunOutcome` to a turn-level `TurnOutcome`.
428/// Checks `tools_used` to distinguish `ToolCalls` from `Completed`.
429pub 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        // Continuing means the guard nudged — treat as a completed turn
442        // (the run will loop, but this turn is done).
443        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
450/// Convert agent-base `RunOutcome` to `SessionOutcome`.
451pub 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    /// Helper: one turn with the given number/timestamp/duration/model.
490    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        // A second writer recorded two different turns (e.g. after a resume).
509        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        // Turns are ordered by time regardless of which writer recorded them.
518        assert_eq!(
519            base.turns.iter().map(|t| t.turn_number).collect::<Vec<_>>(),
520            vec![1, 2, 3]
521        );
522        // Aggregates rebuilt over the union.
523        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        // Model = most frequent across merged turns (2×m2 beats 1×m1).
528        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        // Re-merging the same turns (same process re-saving) must be a no-op.
538        base.merge_turns(&before);
539        assert_eq!(base.total_turns, 1);
540        assert_eq!(base.total_input_tokens, 100);
541
542        // Same turn_number but a different timestamp is a different turn —
543        // two processes can both record "turn 1" of the same session.
544        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        // Identity/custom/outcome keep `self`'s values — save_metrics grants
565        // the writer's outcome explicitly after merging.
566        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        // Turn 1: has thinking content (43 bytes), provider reports 0 tokens
684        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        // Turn 2: more thinking content (86 bytes)
713        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        // thinking_tokens stays 0 (provider never reported), but bytes accumulate
742        assert_eq!(m.total_thinking_tokens, 0);
743        assert_eq!(m.total_thinking_bytes, 129); // 43 + 86
744        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        // Old JSON without thinking_bytes should deserialize with default 0
764        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        // Completed with no tools → TurnOutcome::Completed
882        assert_eq!(
883            run_outcome_to_turn_outcome(&RunOutcome::Completed, &[]),
884            TurnOutcome::Completed
885        );
886
887        // Completed with tools → TurnOutcome::ToolCalls
888        assert_eq!(
889            run_outcome_to_turn_outcome(&RunOutcome::Completed, &["shell".to_string()]),
890            TurnOutcome::ToolCalls
891        );
892
893        // Failed → Error
894        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        // Cancelled → Cancelled
905        assert_eq!(
906            run_outcome_to_turn_outcome(&RunOutcome::Cancelled, &[]),
907            TurnOutcome::Cancelled
908        );
909    }
910}