Skip to main content

llm_agent_runtime/
runtime.rs

1//! # Module: AgentRuntime
2//!
3//! ## Responsibility
4//! Wire memory, graph, orchestrator, and agent loop into a single coordinator
5//! using a builder pattern. Provides `run_agent` which executes a ReAct loop,
6//! optionally enriching context from memory and graph lookups.
7//!
8//! ## Guarantees
9//! - Builder uses a typestate parameter to enforce `agent_config` at compile time:
10//!   `build()` is only callable once `with_agent_config` has been called.
11//! - `run_agent` is async and returns a typed `AgentSession` with step count,
12//!   durations, and hits.
13//! - Non-panicking: all paths return `Result`
14//!
15//! ## NOT Responsible For
16//! - Actual LLM inference (callers supply a mock/stub inference fn)
17//! - Persistence across process restarts (unless `persistence` feature is enabled)
18
19use crate::agent::{AgentConfig, ReActLoop, ReActStep, ToolSpec};
20use crate::error::AgentRuntimeError;
21use crate::metrics::RuntimeMetrics;
22use crate::types::AgentId;
23
24#[cfg(feature = "memory")]
25use crate::memory::{EpisodicStore, WorkingMemory};
26use serde::{Deserialize, Serialize};
27use std::fmt::Write as FmtWrite;
28use std::marker::PhantomData;
29use std::sync::atomic::Ordering;
30use std::sync::Arc;
31use std::time::Instant;
32
33#[cfg(feature = "graph")]
34use crate::graph::GraphStore;
35
36#[cfg(feature = "orchestrator")]
37use crate::orchestrator::BackpressureGuard;
38
39// ── Typestate markers ─────────────────────────────────────────────────────────
40
41/// Builder state: agent config has not been provided yet.
42pub struct NeedsConfig;
43/// Builder state: agent config has been provided; `build()` is available.
44pub struct HasConfig;
45
46// ── AgentSession ──────────────────────────────────────────────────────────────
47
48/// The result of a single agent run.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct AgentSession {
51    /// Stable unique identifier for this session (UUID v4 string).
52    pub session_id: String,
53    /// The agent ID used for this session.
54    pub agent_id: AgentId,
55    /// All ReAct steps executed during the session.
56    pub steps: Vec<ReActStep>,
57    /// Number of episodic memory retrievals made during the session.
58    pub memory_hits: usize,
59    /// Number of graph lookups made during the session.
60    pub graph_lookups: usize,
61    /// Wall-clock duration of the session in milliseconds.
62    pub duration_ms: u64,
63    /// Non-fatal errors encountered while saving per-step checkpoints.
64    ///
65    /// Populated only when a persistence backend is configured.  A non-empty
66    /// list means some step snapshots may be missing from storage, but the
67    /// session itself completed successfully.
68    #[serde(default)]
69    pub checkpoint_errors: Vec<String>,
70}
71
72impl AgentSession {
73    /// Return the number of steps in the session.
74    ///
75    /// Each [`ReActStep`] in `steps` carries a `step_duration_ms` field measuring
76    /// wall-clock time from inference call to observation for that individual step.
77    /// Use this to identify slow steps:
78    /// ```rust,ignore
79    /// for (i, step) in session.steps.iter().enumerate() {
80    ///     println!("step {i}: {}ms", step.step_duration_ms);
81    /// }
82    /// ```
83    pub fn step_count(&self) -> usize {
84        self.steps.len()
85    }
86
87    /// Return `true` if the session has no recorded steps.
88    pub fn is_empty(&self) -> bool {
89        self.steps.is_empty()
90    }
91
92    /// Return the final answer text from the last step, if available.
93    ///
94    /// Extracts the content after `FINAL_ANSWER` in the last step's `action` field.
95    /// Returns `None` if there are no steps or the last action is not a FINAL_ANSWER.
96    pub fn final_answer(&self) -> Option<String> {
97        let last = self.steps.last()?;
98        let upper = last.action.trim().to_ascii_uppercase();
99        if upper.starts_with("FINAL_ANSWER") {
100            let answer = last.action.trim()["FINAL_ANSWER".len()..].trim().to_owned();
101            Some(answer)
102        } else {
103            None
104        }
105    }
106
107    /// Return `true` if the session ended with a `FINAL_ANSWER` action.
108    ///
109    /// This is the normal successful exit from a ReAct loop.  `false` means the
110    /// loop was cut short by a timeout, max-iterations limit, or an error.
111    pub fn is_successful(&self) -> bool {
112        self.final_answer().is_some()
113    }
114
115    /// Return the session wall-clock duration as a [`std::time::Duration`].
116    pub fn elapsed(&self) -> std::time::Duration {
117        std::time::Duration::from_millis(self.duration_ms)
118    }
119
120    /// Return the number of tool-call actions dispatched during the session.
121    ///
122    /// Each [`ReActStep`] whose `action` parses as a `ToolCall` (not a `FinalAnswer`)
123    /// is counted.
124    pub fn tool_calls_made(&self) -> usize {
125        self.steps
126            .iter()
127            .filter(|s| {
128                // A ToolCall action contains a JSON object.  A FinalAnswer starts
129                // with the literal "FINAL_ANSWER" prefix.
130                !s.action.trim().to_ascii_uppercase().starts_with("FINAL_ANSWER")
131                    && !s.action.trim().is_empty()
132            })
133            .count()
134    }
135
136    /// Return the sum of all individual step durations in milliseconds.
137    ///
138    /// This is the cumulative inference + tool execution time across all steps,
139    /// which may differ from `duration_ms` due to overhead between steps.
140    pub fn total_step_duration_ms(&self) -> u64 {
141        self.steps.iter().map(|s| s.step_duration_ms).sum()
142    }
143
144    /// Return the average step duration in milliseconds.
145    ///
146    /// Returns `0` when there are no steps.
147    pub fn average_step_duration_ms(&self) -> u64 {
148        if self.steps.is_empty() {
149            return 0;
150        }
151        self.total_step_duration_ms() / self.steps.len() as u64
152    }
153
154    /// Return a reference to the slowest step (highest `step_duration_ms`).
155    ///
156    /// Returns `None` when there are no steps.
157    pub fn slowest_step(&self) -> Option<&ReActStep> {
158        self.steps.iter().max_by_key(|s| s.step_duration_ms)
159    }
160
161    /// Return a reference to the fastest step (lowest `step_duration_ms`).
162    ///
163    /// Returns `None` when there are no steps.
164    pub fn fastest_step(&self) -> Option<&ReActStep> {
165        self.steps.iter().min_by_key(|s| s.step_duration_ms)
166    }
167
168    /// Return references to all steps that are tool calls (not `FINAL_ANSWER`).
169    pub fn filter_tool_call_steps(&self) -> Vec<&ReActStep> {
170        self.steps.iter().filter(|s| s.is_tool_call()).collect()
171    }
172
173    /// Return the zero-based index of the slowest step, or `None` if there are no steps.
174    pub fn slowest_step_index(&self) -> Option<usize> {
175        self.steps
176            .iter()
177            .enumerate()
178            .max_by_key(|(_, s)| s.step_duration_ms)
179            .map(|(i, _)| i)
180    }
181
182    /// Return the zero-based index of the fastest step, or `None` if there are no steps.
183    pub fn fastest_step_index(&self) -> Option<usize> {
184        self.steps
185            .iter()
186            .enumerate()
187            .min_by_key(|(_, s)| s.step_duration_ms)
188            .map(|(i, _)| i)
189    }
190
191    /// Return a reference to the last step, or `None` if there are no steps.
192    pub fn last_step(&self) -> Option<&ReActStep> {
193        self.steps.last()
194    }
195
196    /// Return a reference to the first step taken, or `None` if there are no steps.
197    pub fn first_step(&self) -> Option<&ReActStep> {
198        self.steps.first()
199    }
200
201    /// Return a reference to the step at zero-based index `idx`, or `None` if out of bounds.
202    pub fn step_at(&self, idx: usize) -> Option<&ReActStep> {
203        self.steps.get(idx)
204    }
205
206    /// Return the observation string at step `idx`, or `None` if out of bounds.
207    pub fn observation_at(&self, idx: usize) -> Option<&str> {
208        self.steps.get(idx).map(|s| s.observation.as_str())
209    }
210
211    /// Return the action string at step `idx`, or `None` if out of bounds.
212    pub fn action_at(&self, idx: usize) -> Option<&str> {
213        self.steps.get(idx).map(|s| s.action.as_str())
214    }
215
216    /// Return steps whose observation contains `pattern` (case-insensitive).
217    pub fn observations_matching(&self, pattern: &str) -> Vec<&ReActStep> {
218        let lower = pattern.to_ascii_lowercase();
219        self.steps
220            .iter()
221            .filter(|s| s.observation.to_ascii_lowercase().contains(&lower))
222            .collect()
223    }
224
225    /// Return steps whose thought contains `pattern` (case-insensitive).
226    pub fn thoughts_containing(&self, pattern: &str) -> Vec<&ReActStep> {
227        let lower = pattern.to_ascii_lowercase();
228        self.steps
229            .iter()
230            .filter(|s| s.thought.to_ascii_lowercase().contains(&lower))
231            .collect()
232    }
233
234    /// Return `true` if any step in this session used `action_name`.
235    pub fn has_action(&self, action_name: &str) -> bool {
236        self.steps.iter().any(|s| s.action == action_name)
237    }
238
239    /// Return the thought string at step `idx`, or `None` if out of bounds.
240    pub fn thought_at(&self, idx: usize) -> Option<&str> {
241        self.steps.get(idx).map(|s| s.thought.as_str())
242    }
243
244    /// Count how many steps used `action_name` as their action.
245    ///
246    /// Returns `0` if the action was never invoked.  Complements
247    /// [`has_action`], which only tests for presence.
248    ///
249    /// [`has_action`]: AgentSession::has_action
250    pub fn step_count_for_action(&self, action_name: &str) -> usize {
251        self.steps.iter().filter(|s| s.action == action_name).count()
252    }
253
254    /// Return all observation strings in step order.
255    ///
256    /// Each string is a borrow of the corresponding `ReActStep::observation`
257    /// field.  Useful for bulk post-processing of tool results.
258    pub fn observations(&self) -> Vec<&str> {
259        self.steps.iter().map(|s| s.observation.as_str()).collect()
260    }
261
262    /// Return the number of steps that have a non-empty observation string.
263    pub fn observation_count(&self) -> usize {
264        self.steps.iter().filter(|s| !s.observation.is_empty()).count()
265    }
266
267    /// Return up to the last `n` non-empty observation strings, ordered oldest
268    /// to newest.
269    ///
270    /// Empty observations are skipped.  If the session has fewer than `n`
271    /// non-empty observations, all of them are returned.
272    pub fn last_n_observations(&self, n: usize) -> Vec<&str> {
273        let all: Vec<&str> = self
274            .steps
275            .iter()
276            .filter(|s| !s.observation.is_empty())
277            .map(|s| s.observation.as_str())
278            .collect();
279        let skip = all.len().saturating_sub(n);
280        all[skip..].to_vec()
281    }
282
283    /// Return the action names from the last `n` steps, ordered oldest to newest.
284    ///
285    /// If the session has fewer than `n` steps, all action names are returned.
286    pub fn actions_in_window(&self, n: usize) -> Vec<&str> {
287        let skip = self.steps.len().saturating_sub(n);
288        self.steps[skip..]
289            .iter()
290            .map(|s| s.action.as_str())
291            .collect()
292    }
293
294    /// Return the number of steps whose observation string is empty.
295    pub fn steps_without_observation(&self) -> usize {
296        self.steps.iter().filter(|s| s.observation.is_empty()).count()
297    }
298
299    /// Return the thought string from the first step, or `None` if the session
300    /// has no steps.
301    pub fn first_thought(&self) -> Option<&str> {
302        self.steps.first().map(|s| s.thought.as_str())
303    }
304
305    /// Return the thought string from the last step, or `None` if the session
306    /// has no steps.
307    pub fn last_thought(&self) -> Option<&str> {
308        self.steps.last().map(|s| s.thought.as_str())
309    }
310
311    /// Return the action name from the first step, or `None` if the session
312    /// has no steps.
313    pub fn first_action(&self) -> Option<&str> {
314        self.steps.first().map(|s| s.action.as_str())
315    }
316
317    /// Return the action name from the last step, or `None` if the session
318    /// has no steps.
319    pub fn last_action(&self) -> Option<&str> {
320        self.steps.last().map(|s| s.action.as_str())
321    }
322
323    /// Return a slice of the last `n` steps.
324    ///
325    /// If `n` is greater than or equal to the total step count, all steps are
326    /// returned.  An empty slice is returned for sessions with no steps.
327    pub fn last_n_steps(&self, n: usize) -> &[crate::agent::ReActStep] {
328        let len = self.steps.len();
329        let start = len.saturating_sub(n);
330        &self.steps[start..]
331    }
332
333    /// Return a slice containing at most the first `n` steps.
334    ///
335    /// If the session has fewer than `n` steps all steps are returned.
336    /// Returns an empty slice for `n == 0` or an empty session.
337    pub fn first_n_steps(&self, n: usize) -> &[crate::agent::ReActStep] {
338        let end = n.min(self.steps.len());
339        &self.steps[..end]
340    }
341
342    /// Return references to steps whose action string contains `tool_name`.
343    ///
344    /// Useful for auditing which steps invoked a specific tool.  The comparison
345    /// is case-sensitive.
346    pub fn steps_with_tool<'a>(&'a self, tool_name: &str) -> Vec<&'a crate::agent::ReActStep> {
347        self.steps
348            .iter()
349            .filter(|s| s.action.contains(tool_name) && !s.is_final_answer())
350            .collect()
351    }
352
353    /// Return the total character count across all thought, action, and observation
354    /// strings in the session.
355    ///
356    /// Useful for estimating context consumption.  Returns `0` for empty sessions.
357    pub fn total_chars(&self) -> usize {
358        self.steps
359            .iter()
360            .map(|s| s.thought.len() + s.action.len() + s.observation.len())
361            .sum()
362    }
363
364    /// Return all per-step durations in milliseconds, in order.
365    ///
366    /// Useful for computing custom percentiles or detecting slow outlier steps.
367    pub fn step_durations_ms(&self) -> Vec<u64> {
368        self.steps.iter().map(|s| s.step_duration_ms).collect()
369    }
370
371    /// Return the sum of all step durations in milliseconds.
372    ///
373    /// Equivalent to `step_durations_ms().iter().sum()` but avoids allocating
374    /// a temporary Vec.
375    pub fn total_latency_ms(&self) -> u64 {
376        self.steps.iter().map(|s| s.step_duration_ms).sum()
377    }
378
379    /// Return the arithmetic mean step duration in milliseconds.
380    ///
381    /// Returns `0.0` for sessions with no steps.
382    pub fn avg_step_duration_ms(&self) -> f64 {
383        if self.steps.is_empty() {
384            return 0.0;
385        }
386        self.total_latency_ms() as f64 / self.steps.len() as f64
387    }
388
389    /// Return a reference to the step with the largest `step_duration_ms`.
390    ///
391    /// Returns `None` if the session has no steps.  When multiple steps share
392    /// the maximum duration the first one (lowest index) is returned.
393    pub fn longest_step(&self) -> Option<&crate::agent::ReActStep> {
394        self.steps.iter().max_by_key(|s| s.step_duration_ms)
395    }
396
397    /// Return a reference to the step with the smallest `step_duration_ms`.
398    ///
399    /// Returns `None` if the session has no steps.  When multiple steps share
400    /// the minimum duration the first one (lowest index) is returned.
401    pub fn shortest_step(&self) -> Option<&crate::agent::ReActStep> {
402        self.steps.iter().min_by_key(|s| s.step_duration_ms)
403    }
404
405    /// Return the sequence of action names taken, in step order.
406    ///
407    /// Unlike `all_actions()` this returns owned `String`s so the result can
408    /// outlive the session borrow.
409    pub fn action_sequence(&self) -> Vec<String> {
410        self.steps.iter().map(|s| s.action.clone()).collect()
411    }
412
413    /// Return the sorted, deduplicated set of tool names invoked during the session.
414    ///
415    /// Tool-call steps are identified by the same heuristic as `tool_calls_made`:
416    /// a non-empty action that does not start with `FINAL_ANSWER`.
417    pub fn unique_tools_used(&self) -> Vec<String> {
418        let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
419        for step in &self.steps {
420            let action = step.action.trim();
421            if action.is_empty() || action.to_ascii_uppercase().starts_with("FINAL_ANSWER") {
422                continue;
423            }
424            // Tool name is the JSON "tool" field, or the whole action string if not JSON.
425            if let Ok(v) = serde_json::from_str::<serde_json::Value>(action) {
426                if let Some(name) = v.get("tool").and_then(|n| n.as_str()) {
427                    names.insert(name.to_owned());
428                    continue;
429                }
430            }
431            names.insert(action.to_owned());
432        }
433        let mut sorted: Vec<String> = names.into_iter().collect();
434        sorted.sort_unstable();
435        sorted
436    }
437
438    /// Collect all thought strings from every step, in order.
439    pub fn all_thoughts(&self) -> Vec<&str> {
440        self.steps.iter().map(|s| s.thought.as_str()).collect()
441    }
442
443    /// Collect all action strings from every step, in order.
444    pub fn all_actions(&self) -> Vec<&str> {
445        self.steps.iter().map(|s| s.action.as_str()).collect()
446    }
447
448    /// Collect all observation strings from every step, in order.
449    pub fn all_observations(&self) -> Vec<&str> {
450        self.steps.iter().map(|s| s.observation.as_str()).collect()
451    }
452
453    /// Return references to steps where the observation indicates a tool error.
454    ///
455    /// A step is classified as failed when its observation starts with
456    /// `{"error"` (the structured error JSON produced by required-field
457    /// validation) or contains the substring `"error"` (case-insensitive).
458    pub fn failed_steps(&self) -> Vec<&crate::agent::ReActStep> {
459        self.steps
460            .iter()
461            .filter(|s| {
462                let obs = s.observation.trim();
463                obs.starts_with("{\"error\"")
464                    || obs.to_ascii_lowercase().contains("\"error\"")
465            })
466            .collect()
467    }
468
469    /// Return the number of tool-call steps whose observation indicates an error.
470    ///
471    /// Equivalent to `failed_steps().len()` but avoids collecting a `Vec`.
472    pub fn failed_tool_call_count(&self) -> usize {
473        self.steps
474            .iter()
475            .filter(|s| {
476                let obs = s.observation.trim();
477                obs.starts_with("{\"error\"")
478                    || obs.to_ascii_lowercase().contains("\"error\"")
479            })
480            .count()
481    }
482
483    /// Return a count of how many times each action was taken in this session.
484    ///
485    /// The map key is the action name (e.g. `"search"`, `"FINAL_ANSWER"`).
486    pub fn action_counts(&self) -> std::collections::HashMap<String, usize> {
487        let mut counts = std::collections::HashMap::new();
488        for step in &self.steps {
489            *counts.entry(step.action.clone()).or_insert(0) += 1;
490        }
491        counts
492    }
493
494    /// Return a sorted list of unique action names used in this session.
495    pub fn unique_actions(&self) -> Vec<String> {
496        let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
497        for step in &self.steps {
498            seen.insert(step.action.clone());
499        }
500        seen.into_iter().collect()
501    }
502
503    /// Return `true` when at least one action string appears more than once.
504    ///
505    /// Useful for detecting repetitive or looping agent behaviour without
506    /// iterating over the full step list manually.
507    pub fn has_duplicate_actions(&self) -> bool {
508        let mut seen = std::collections::HashSet::new();
509        self.steps.iter().any(|s| !seen.insert(s.action.as_str()))
510    }
511
512    /// Return the 0-based indices of steps whose action contains `tool_name`.
513    ///
514    /// Returns an empty `Vec` when no step matches.  Useful when you need
515    /// positions rather than step references for further slicing.
516    pub fn step_indices_with_tool(&self, tool_name: &str) -> Vec<usize> {
517        self.steps
518            .iter()
519            .enumerate()
520            .filter(|(_, s)| !s.is_final_answer() && s.action.contains(tool_name))
521            .map(|(i, _)| i)
522            .collect()
523    }
524
525    /// Return the action name used most often during the session.
526    ///
527    /// Returns `None` for sessions with no steps.  When multiple actions tie
528    /// for the maximum count, any one of them may be returned.
529    pub fn most_used_action(&self) -> Option<String> {
530        let counts = self.action_counts();
531        counts
532            .into_iter()
533            .max_by_key(|(_, count)| *count)
534            .map(|(name, _)| name)
535    }
536
537    /// Return the observation string from the most recent step that has one.
538    ///
539    /// Steps with an empty observation are skipped.  Returns `None` when no
540    /// step has produced an observation yet.
541    pub fn last_observation(&self) -> Option<&str> {
542        self.steps
543            .iter()
544            .rev()
545            .find(|s| !s.observation.is_empty())
546            .map(|s| s.observation.as_str())
547    }
548
549    /// Return the number of steps that have a non-empty thought string.
550    pub fn thought_count(&self) -> usize {
551        self.steps.iter().filter(|s| !s.thought.is_empty()).count()
552    }
553
554    /// Return the fraction of steps that contain a non-empty observation.
555    ///
556    /// Returns `0.0` for sessions with no steps.
557    pub fn observation_rate(&self) -> f64 {
558        let n = self.steps.len();
559        if n == 0 {
560            return 0.0;
561        }
562        let with_obs = self
563            .steps
564            .iter()
565            .filter(|s| !s.observation.is_empty())
566            .count();
567        with_obs as f64 / n as f64
568    }
569
570    /// Return `true` if at least one knowledge-graph lookup was performed during
571    /// this session.
572    pub fn has_graph_lookups(&self) -> bool {
573        self.graph_lookups > 0
574    }
575
576    /// Return how many times the last action in the session repeats consecutively
577    /// at the end of the step list.
578    ///
579    /// Returns `0` for empty sessions or single-step sessions where no repeat
580    /// is possible.  Useful for detecting a stuck agent that keeps retrying the
581    /// same action.
582    pub fn consecutive_same_action_at_end(&self) -> usize {
583        let n = self.steps.len();
584        if n == 0 {
585            return 0;
586        }
587        let last_action = &self.steps[n - 1].action;
588        self.steps
589            .iter()
590            .rev()
591            .take_while(|s| &s.action == last_action)
592            .count()
593            .saturating_sub(1) // don't count the step itself; only the *repeats*
594    }
595
596    /// Return the fraction of steps (from the second onward) that repeat the
597    /// immediately preceding action.
598    ///
599    /// Returns `0.0` for sessions with fewer than two steps.  A high value
600    /// may indicate the agent is stuck in a loop.
601    pub fn action_repetition_rate(&self) -> f64 {
602        let n = self.steps.len();
603        if n < 2 {
604            return 0.0;
605        }
606        let repeats = self
607            .steps
608            .windows(2)
609            .filter(|w| w[0].action == w[1].action)
610            .count();
611        repeats as f64 / (n - 1) as f64
612    }
613
614    /// Return the length of the longest consecutive run of failed steps.
615    ///
616    /// A step is considered failed when its observation starts with `{"error"`
617    /// or contains the substring `"error"` (case-insensitive).
618    /// Returns `0` for sessions with no steps or no failures.
619    pub fn max_consecutive_failures(&self) -> usize {
620        let mut max_run = 0usize;
621        let mut current = 0usize;
622        for step in &self.steps {
623            let obs = step.observation.trim();
624            if obs.starts_with("{\"error\"") || obs.to_ascii_lowercase().contains("\"error\"") {
625                current += 1;
626                if current > max_run {
627                    max_run = current;
628                }
629            } else {
630                current = 0;
631            }
632        }
633        max_run
634    }
635
636    /// Return the mean character length of non-empty thought strings.
637    ///
638    /// Only steps with a non-empty `thought` field are included.
639    /// Returns `0.0` when no step has a thought.
640    pub fn avg_thought_length(&self) -> f64 {
641        let thoughts: Vec<_> = self
642            .steps
643            .iter()
644            .filter(|s| !s.thought.is_empty())
645            .collect();
646        if thoughts.is_empty() {
647            return 0.0;
648        }
649        let total: usize = thoughts.iter().map(|s| s.thought.len()).sum();
650        total as f64 / thoughts.len() as f64
651    }
652
653    /// Return the rate of knowledge-graph lookups per step.
654    ///
655    /// Computed as `graph_lookups / step_count`.  Returns `0.0` when there
656    /// are no steps, to avoid division by zero.
657    pub fn graph_lookup_rate(&self) -> f64 {
658        let steps = self.steps.len();
659        if steps == 0 {
660            return 0.0;
661        }
662        self.graph_lookups as f64 / steps as f64
663    }
664
665    /// Return `true` if any checkpoint errors were recorded during the session.
666    ///
667    /// A non-empty `checkpoint_errors` list means some step snapshots may be
668    /// missing from storage, but the session itself completed successfully.
669    pub fn has_checkpoint_errors(&self) -> bool {
670        !self.checkpoint_errors.is_empty()
671    }
672
673    /// Return the number of checkpoint errors recorded during this session.
674    pub fn checkpoint_error_count(&self) -> usize {
675        self.checkpoint_errors.len()
676    }
677
678    /// Return the number of knowledge-graph lookups performed during this session.
679    pub fn graph_lookup_count(&self) -> usize {
680        self.graph_lookups
681    }
682
683    /// Return the episodic memory hit rate for this session.
684    ///
685    /// Computed as `memory_hits / step_count`. Returns `0.0` when there are
686    /// no steps, to avoid division by zero.
687    pub fn memory_hit_rate(&self) -> f64 {
688        let steps = self.steps.len();
689        if steps == 0 {
690            return 0.0;
691        }
692        self.memory_hits as f64 / steps as f64
693    }
694
695    /// Return the raw count of episodic memory hits for this session.
696    pub fn total_memory_hits(&self) -> usize {
697        self.memory_hits
698    }
699
700    /// Return the session throughput in steps per second.
701    ///
702    /// Computed as `step_count / (duration_ms / 1000.0)`.  Returns `0.0`
703    /// if `duration_ms` is zero.
704    pub fn throughput_steps_per_sec(&self) -> f64 {
705        if self.duration_ms == 0 {
706            return 0.0;
707        }
708        self.steps.len() as f64 / (self.duration_ms as f64 / 1000.0)
709    }
710
711    /// Return the session duration in full seconds (rounded down).
712    pub fn duration_secs(&self) -> u64 {
713        self.duration_ms / 1000
714    }
715
716    /// Return the count of steps whose thought string is longer than `threshold` bytes.
717    pub fn steps_above_thought_length(&self, threshold: usize) -> usize {
718        self.steps.iter().filter(|s| s.thought.len() > threshold).count()
719    }
720
721    /// Return `true` if any step's action begins with `"FINAL_ANSWER"` (case-insensitive).
722    pub fn has_final_answer(&self) -> bool {
723        self.steps
724            .iter()
725            .any(|s| s.action.to_ascii_uppercase().starts_with("FINAL_ANSWER"))
726    }
727
728    /// Return the mean byte length of all step action strings.
729    ///
730    /// Returns `0.0` for empty sessions.
731    pub fn avg_action_length(&self) -> f64 {
732        if self.steps.is_empty() {
733            return 0.0;
734        }
735        let total: usize = self.steps.iter().map(|s| s.action.len()).sum();
736        total as f64 / self.steps.len() as f64
737    }
738
739    /// Return `true` if any tool-call steps had error observations.
740    pub fn has_tool_failures(&self) -> bool {
741        self.failed_tool_call_count() > 0
742    }
743
744    /// Return the fraction of steps that were tool calls.
745    ///
746    /// Computed as `tool_calls_made / step_count`.  Returns `0.0` for empty
747    /// sessions to avoid division by zero.
748    pub fn tool_call_rate(&self) -> f64 {
749        let total = self.steps.len();
750        if total == 0 {
751            return 0.0;
752        }
753        self.tool_calls_made() as f64 / total as f64
754    }
755
756    /// Return the fraction of tool-call steps that succeeded.
757    ///
758    /// Computed as `1.0 - (failed_tool_call_count / step_count)`.  Returns
759    /// `1.0` for empty sessions (no failures possible).
760    pub fn step_success_rate(&self) -> f64 {
761        let total = self.steps.len();
762        if total == 0 {
763            return 1.0;
764        }
765        1.0 - (self.failed_tool_call_count() as f64 / total as f64)
766    }
767
768    /// Return the ratio of unique actions to total steps.
769    ///
770    /// Returns `0.0` for sessions with no steps.  A value of `1.0` means every
771    /// step used a different action; lower values indicate repeated actions.
772    pub fn action_diversity(&self) -> f64 {
773        let total = self.steps.len();
774        if total == 0 {
775            return 0.0;
776        }
777        let unique: std::collections::HashSet<&str> =
778            self.steps.iter().map(|s| s.action.as_str()).collect();
779        unique.len() as f64 / total as f64
780    }
781
782    /// Return the total byte length of all thought strings across all steps.
783    pub fn total_thought_length(&self) -> usize {
784        self.steps.iter().map(|s| s.thought.len()).sum()
785    }
786
787    /// Return the number of steps whose observation string is empty.
788    pub fn steps_with_empty_observations(&self) -> usize {
789        self.steps.iter().filter(|s| s.observation.is_empty()).count()
790    }
791
792    /// Return the byte length of each observation, in step order.
793    pub fn observation_lengths(&self) -> Vec<usize> {
794        self.steps.iter().map(|s| s.observation.len()).collect()
795    }
796
797    /// Return the mean observation byte length across all steps.
798    ///
799    /// Returns `0.0` for empty sessions.
800    pub fn avg_observation_length(&self) -> f64 {
801        let n = self.steps.len();
802        if n == 0 {
803            return 0.0;
804        }
805        let total: usize = self.steps.iter().map(|s| s.observation.len()).sum();
806        total as f64 / n as f64
807    }
808
809    /// Return the byte length of the shortest non-empty thought, or `0` if
810    /// no non-empty thoughts exist.
811    pub fn min_thought_length(&self) -> usize {
812        self.steps
813            .iter()
814            .filter(|s| !s.thought.is_empty())
815            .map(|s| s.thought.len())
816            .min()
817            .unwrap_or(0)
818    }
819
820    /// Return the longest observation string in the session, or `None` if
821    /// the session is empty.
822    pub fn longest_observation(&self) -> Option<&str> {
823        self.steps
824            .iter()
825            .max_by_key(|s| s.observation.len())
826            .map(|s| s.observation.as_str())
827    }
828
829    /// Return the byte length of each step's thought string, in step order.
830    pub fn thought_lengths(&self) -> Vec<usize> {
831        self.steps.iter().map(|s| s.thought.len()).collect()
832    }
833
834    /// Return the action string that appears most often across all steps.
835    ///
836    /// Returns `None` if the session has no steps.
837    pub fn most_common_action(&self) -> Option<&str> {
838        if self.steps.is_empty() {
839            return None;
840        }
841        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
842        for s in &self.steps {
843            *counts.entry(s.action.as_str()).or_insert(0) += 1;
844        }
845        counts.into_iter().max_by_key(|(_, c)| *c).map(|(a, _)| a)
846    }
847
848    /// Return the byte length of each step's action string, in step order.
849    pub fn action_lengths(&self) -> Vec<usize> {
850        self.steps.iter().map(|s| s.action.len()).collect()
851    }
852
853    /// Return the count of steps that did not have a tool failure.
854    pub fn step_success_count(&self) -> usize {
855        self.steps.len() - self.failed_tool_call_count()
856    }
857
858    /// Return the thought string of the step with the most bytes.
859    ///
860    /// Returns `None` if the session has no steps.
861    pub fn longest_thought(&self) -> Option<&str> {
862        self.steps
863            .iter()
864            .max_by_key(|s| s.thought.len())
865            .map(|s| s.thought.as_str())
866    }
867
868    /// Return the action string of the step with the fewest bytes.
869    ///
870    /// Returns `None` if the session has no steps.
871    pub fn shortest_action(&self) -> Option<&str> {
872        self.steps
873            .iter()
874            .min_by_key(|s| s.action.len())
875            .map(|s| s.action.as_str())
876    }
877
878    /// Return the sum of byte lengths of all thought strings in the session.
879    pub fn total_thought_bytes(&self) -> usize {
880        self.steps.iter().map(|s| s.thought.len()).sum()
881    }
882
883    /// Return the sum of byte lengths of all observation strings in the session.
884    pub fn total_observation_bytes(&self) -> usize {
885        self.steps.iter().map(|s| s.observation.len()).sum()
886    }
887
888    /// Return the action string of the first step in the session.
889    ///
890    /// Returns `None` if the session has no steps.
891    pub fn first_step_action(&self) -> Option<&str> {
892        self.steps.first().map(|s| s.action.as_str())
893    }
894
895    /// Return the action string of the last step in the session.
896    ///
897    /// Returns `None` if the session has no steps.
898    pub fn last_step_action(&self) -> Option<&str> {
899        self.steps.last().map(|s| s.action.as_str())
900    }
901
902    /// Return the count of steps that have a non-empty thought string.
903    pub fn count_nonempty_thoughts(&self) -> usize {
904        self.steps.iter().filter(|s| !s.thought.is_empty()).count()
905    }
906
907    /// Return the count of steps whose observation contains `substring`.
908    pub fn observation_contains_count(&self, substring: &str) -> usize {
909        self.steps.iter().filter(|s| s.observation.contains(substring)).count()
910    }
911
912    /// Return the number of steps whose action string matches `action` exactly.
913    pub fn count_steps_with_action(&self, action: &str) -> usize {
914        self.steps.iter().filter(|s| s.action == action).count()
915    }
916
917    /// Return the number of steps whose thought contains `substring`.
918    pub fn thought_contains_count(&self, substring: &str) -> usize {
919        self.steps.iter().filter(|s| s.thought.contains(substring)).count()
920    }
921
922    /// Return the fraction of steps that had a tool failure observation.
923    ///
924    /// Computed as `failed_tool_call_count / step_count`.  Returns `0.0` for
925    /// empty sessions.
926    pub fn failure_rate(&self) -> f64 {
927        let total = self.steps.len();
928        if total == 0 {
929            return 0.0;
930        }
931        self.failed_tool_call_count() as f64 / total as f64
932    }
933
934    /// Return the number of distinct action names used across all steps.
935    pub fn unique_action_count(&self) -> usize {
936        let unique: std::collections::HashSet<&str> =
937            self.steps.iter().map(|s| s.action.as_str()).collect();
938        unique.len()
939    }
940
941    /// Return references to steps whose indices fall in `[start, end)`.
942    ///
943    /// Clamps `end` to `step_count()` so out-of-bounds ranges are safe.
944    /// Returns an empty `Vec` when `start >= step_count()` or `start >= end`.
945    pub fn steps_in_range(&self, start: usize, end: usize) -> Vec<&ReActStep> {
946        let clamped_end = end.min(self.steps.len());
947        if start >= clamped_end {
948            return Vec::new();
949        }
950        self.steps[start..clamped_end].iter().collect()
951    }
952
953    /// Return the median step duration in milliseconds.
954    ///
955    /// Sorts step durations and picks the middle value (lower median for even
956    /// counts).  Returns `0` when the session has no steps.
957    pub fn median_step_duration_ms(&self) -> u64 {
958        if self.steps.is_empty() {
959            return 0;
960        }
961        let mut durations: Vec<u64> = self.steps.iter().map(|s| s.step_duration_ms).collect();
962        durations.sort_unstable();
963        durations[durations.len() / 2]
964    }
965
966    /// Return the 95th-percentile step duration in milliseconds.
967    ///
968    /// Uses the nearest-rank method: the value at index `⌈0.95 × n⌉ − 1` in
969    /// the sorted list of step durations.  Returns `0` when the session has no
970    /// steps.
971    pub fn p95_step_duration_ms(&self) -> u64 {
972        if self.steps.is_empty() {
973            return 0;
974        }
975        let mut durations: Vec<u64> = self.steps.iter().map(|s| s.step_duration_ms).collect();
976        durations.sort_unstable();
977        let idx = ((durations.len() as f64 * 0.95).ceil() as usize)
978            .saturating_sub(1)
979            .min(durations.len() - 1);
980        durations[idx]
981    }
982
983    /// Return the 99th-percentile step duration in milliseconds.
984    ///
985    /// Uses the nearest-rank method: the value at index `⌈0.99 × n⌉ − 1` in
986    /// the sorted list of step durations.  Returns `0` when the session has no
987    /// steps.
988    pub fn p99_step_duration_ms(&self) -> u64 {
989        if self.steps.is_empty() {
990            return 0;
991        }
992        let mut durations: Vec<u64> = self.steps.iter().map(|s| s.step_duration_ms).collect();
993        durations.sort_unstable();
994        let idx = ((durations.len() as f64 * 0.99).ceil() as usize)
995            .saturating_sub(1)
996            .min(durations.len() - 1);
997        durations[idx]
998    }
999
1000    /// Return the count of steps whose `step_duration_ms` is strictly greater
1001    /// than `threshold_ms`.
1002    ///
1003    /// Useful for identifying sessions that contain outlier-slow steps.
1004    /// Returns `0` for empty sessions.
1005    pub fn step_count_above_duration_ms(&self, threshold_ms: u64) -> usize {
1006        self.steps
1007            .iter()
1008            .filter(|s| s.step_duration_ms > threshold_ms)
1009            .count()
1010    }
1011
1012    /// Return the minimum `step_duration_ms` across all steps in the session.
1013    ///
1014    /// Returns `0` for empty sessions.
1015    pub fn min_step_duration_ms(&self) -> u64 {
1016        self.steps.iter().map(|s| s.step_duration_ms).min().unwrap_or(0)
1017    }
1018
1019    /// Return the maximum `step_duration_ms` across all steps in the session.
1020    ///
1021    /// Returns `0` for empty sessions.
1022    pub fn max_step_duration_ms(&self) -> u64 {
1023        self.steps.iter().map(|s| s.step_duration_ms).max().unwrap_or(0)
1024    }
1025
1026    /// Return the sum of byte lengths of all action strings in the session.
1027    ///
1028    /// Useful alongside [`total_thought_bytes`] and [`total_observation_bytes`]
1029    /// to estimate the full token budget consumed by a session.
1030    ///
1031    /// [`total_thought_bytes`]: AgentSession::total_thought_bytes
1032    /// [`total_observation_bytes`]: AgentSession::total_observation_bytes
1033    pub fn total_action_bytes(&self) -> usize {
1034        self.steps.iter().map(|s| s.action.len()).sum()
1035    }
1036
1037    /// Return the population variance of step durations in milliseconds squared.
1038    ///
1039    /// Returns `0.0` for sessions with fewer than two steps.
1040    pub fn step_duration_variance_ms(&self) -> f64 {
1041        let n = self.steps.len();
1042        if n < 2 {
1043            return 0.0;
1044        }
1045        let mean = self.average_step_duration_ms() as f64;
1046        let sum_sq: f64 = self
1047            .steps
1048            .iter()
1049            .map(|s| {
1050                let diff = s.step_duration_ms as f64 - mean;
1051                diff * diff
1052            })
1053            .sum();
1054        sum_sq / n as f64
1055    }
1056
1057    /// Return references to steps whose observation contains `"error"` (case-insensitive).
1058    ///
1059    /// A quick filter for identifying tool-call failures without inspecting the
1060    /// full observation string.  For more control use [`observations_matching`].
1061    ///
1062    /// [`observations_matching`]: AgentSession::observations_matching
1063    pub fn steps_with_errors(&self) -> Vec<&ReActStep> {
1064        self.steps
1065            .iter()
1066            .filter(|s| s.observation.to_ascii_lowercase().contains("error"))
1067            .collect()
1068    }
1069
1070    /// Return references to steps whose observation byte length exceeds `threshold_bytes`.
1071    ///
1072    /// Useful for identifying steps that produced unusually verbose tool output.
1073    pub fn steps_with_long_observations(&self, threshold_bytes: usize) -> Vec<&ReActStep> {
1074        self.steps
1075            .iter()
1076            .filter(|s| s.observation.len() > threshold_bytes)
1077            .collect()
1078    }
1079
1080    /// Return steps whose observation is strictly longer than `min_bytes`.
1081    ///
1082    /// Equivalent to [`steps_with_long_observations`] but uses the name
1083    /// "above" for consistency with other filtering predicates.
1084    ///
1085    /// [`steps_with_long_observations`]: AgentSession::steps_with_long_observations
1086    pub fn observations_above_bytes(&self, min_bytes: usize) -> Vec<&ReActStep> {
1087        self.steps
1088            .iter()
1089            .filter(|s| s.observation.len() > min_bytes)
1090            .collect()
1091    }
1092
1093    /// Return the total character count across all steps.
1094    ///
1095    /// Sums `thought.chars().count() + action.chars().count() + observation.chars().count()`
1096    /// for every step.  Useful as a proxy for token budget estimation.
1097    pub fn total_step_chars(&self) -> usize {
1098        self.steps
1099            .iter()
1100            .map(|s| {
1101                s.thought.chars().count()
1102                    + s.action.chars().count()
1103                    + s.observation.chars().count()
1104            })
1105            .sum()
1106    }
1107
1108    /// Return the number of distinct observation strings across all steps.
1109    ///
1110    /// Two steps with the same observation text are counted as one.
1111    /// Returns `0` for empty sessions.
1112    pub fn unique_observations_count(&self) -> usize {
1113        let unique: std::collections::HashSet<&str> =
1114            self.steps.iter().map(|s| s.observation.as_str()).collect();
1115        unique.len()
1116    }
1117
1118    /// Return the maximum byte length of any thought string in the session.
1119    ///
1120    /// Returns `0` for empty sessions or sessions where every thought is empty.
1121    pub fn thought_max_bytes(&self) -> usize {
1122        self.steps.iter().map(|s| s.thought.len()).max().unwrap_or(0)
1123    }
1124
1125    /// Return the maximum byte length of any observation string in the session.
1126    ///
1127    /// Returns `0` for empty sessions or sessions where every observation is empty.
1128    pub fn observation_max_bytes(&self) -> usize {
1129        self.steps.iter().map(|s| s.observation.len()).max().unwrap_or(0)
1130    }
1131
1132    /// Return the count of steps whose `step_duration_ms` is strictly less
1133    /// than `threshold_ms`.
1134    ///
1135    /// Complements [`step_count_above_duration_ms`].  Returns `0` for empty
1136    /// sessions.
1137    ///
1138    /// [`step_count_above_duration_ms`]: AgentSession::step_count_above_duration_ms
1139    pub fn step_count_below_duration_ms(&self, threshold_ms: u64) -> usize {
1140        self.steps
1141            .iter()
1142            .filter(|s| s.step_duration_ms < threshold_ms)
1143            .count()
1144    }
1145
1146    /// Return the maximum byte length of any action string across all steps.
1147    ///
1148    /// Returns `0` for sessions with no steps or where every action is empty.
1149    pub fn max_action_bytes(&self) -> usize {
1150        self.steps.iter().map(|s| s.action.len()).max().unwrap_or(0)
1151    }
1152
1153    /// Return the minimum byte length of any action string across all steps.
1154    ///
1155    /// Returns `0` for sessions with no steps or where every action is empty.
1156    pub fn min_action_bytes(&self) -> usize {
1157        self.steps.iter().map(|s| s.action.len()).min().unwrap_or(0)
1158    }
1159
1160    /// Return the fraction of steps that are tool calls (not `FINAL_ANSWER`).
1161    ///
1162    /// Returns `0.0` for sessions with no steps.
1163    pub fn proportion_tool_calls(&self) -> f64 {
1164        if self.steps.is_empty() {
1165            return 0.0;
1166        }
1167        let tool_calls = self.steps.iter().filter(|s| s.is_tool_call()).count();
1168        tool_calls as f64 / self.steps.len() as f64
1169    }
1170
1171    /// Return the ratio of total thought bytes to the total bytes across all
1172    /// step fields (thoughts + actions + observations).
1173    ///
1174    /// Returns `0.0` for sessions with no steps or where no bytes exist.
1175    pub fn thought_density(&self) -> f64 {
1176        let thought_bytes: usize = self.steps.iter().map(|s| s.thought.len()).sum();
1177        let total_bytes: usize = self
1178            .steps
1179            .iter()
1180            .map(|s| s.thought.len() + s.action.len() + s.observation.len())
1181            .sum();
1182        if total_bytes == 0 {
1183            return 0.0;
1184        }
1185        thought_bytes as f64 / total_bytes as f64
1186    }
1187
1188    /// Return the average number of ReAct steps completed per second.
1189    ///
1190    /// Computed as `step_count / (duration_ms / 1000.0)`.  Returns `0.0` for
1191    /// sessions with no steps or zero duration.
1192    pub fn step_throughput_per_sec(&self) -> f64 {
1193        if self.duration_ms == 0 || self.steps.is_empty() {
1194            return 0.0;
1195        }
1196        self.steps.len() as f64 / (self.duration_ms as f64 / 1000.0)
1197    }
1198
1199    /// Return the mean byte length of action strings across all steps.
1200    ///
1201    /// Returns `0.0` for sessions with no steps.
1202    pub fn avg_action_bytes(&self) -> f64 {
1203        if self.steps.is_empty() {
1204            return 0.0;
1205        }
1206        let total: usize = self.steps.iter().map(|s| s.action.len()).sum();
1207        total as f64 / self.steps.len() as f64
1208    }
1209
1210    /// Return the mean byte length of observation strings across all steps.
1211    ///
1212    /// Returns `0.0` for sessions with no steps.
1213    pub fn avg_observation_bytes(&self) -> f64 {
1214        if self.steps.is_empty() {
1215            return 0.0;
1216        }
1217        let total: usize = self.steps.iter().map(|s| s.observation.len()).sum();
1218        total as f64 / self.steps.len() as f64
1219    }
1220
1221    /// Return the number of steps that have a non-empty observation string.
1222    ///
1223    /// Steps where the tool produced no output (empty string) are excluded.
1224    pub fn total_observation_count(&self) -> usize {
1225        self.steps.iter().filter(|s| !s.observation.is_empty()).count()
1226    }
1227
1228    /// Return references to steps whose action string contains `substring`.
1229    ///
1230    /// The comparison is case-sensitive.  Returns an empty slice when no step
1231    /// matches or the session has no steps.
1232    pub fn actions_containing<'a>(&'a self, substring: &str) -> Vec<&'a ReActStep> {
1233        self.steps
1234            .iter()
1235            .filter(|s| s.action.contains(substring))
1236            .collect()
1237    }
1238
1239    /// Return the mean byte length of thought strings across all steps.
1240    ///
1241    /// Returns `0.0` for sessions with no steps.
1242    pub fn avg_thought_bytes(&self) -> f64 {
1243        if self.steps.is_empty() {
1244            return 0.0;
1245        }
1246        let total: usize = self.steps.iter().map(|s| s.thought.len()).sum();
1247        total as f64 / self.steps.len() as f64
1248    }
1249
1250    /// Return references to steps whose action byte length exceeds `min_bytes`.
1251    ///
1252    /// Returns an empty `Vec` for sessions with no steps or when no step
1253    /// action exceeds `min_bytes`.
1254    pub fn steps_above_action_bytes(&self, min_bytes: usize) -> Vec<&ReActStep> {
1255        self.steps
1256            .iter()
1257            .filter(|s| s.action.len() > min_bytes)
1258            .collect()
1259    }
1260
1261    /// Return the steps in the half-open index range `[start, end)`.
1262    ///
1263    /// Both bounds are clamped to `[0, step_count]`.  Returns an empty `Vec`
1264    /// when `start >= end` or the session has no steps.
1265    pub fn steps_between(&self, start: usize, end: usize) -> Vec<&ReActStep> {
1266        let clamped_end = end.min(self.steps.len());
1267        if start >= clamped_end {
1268            return Vec::new();
1269        }
1270        self.steps[start..clamped_end].iter().collect()
1271    }
1272
1273    /// Return the fraction of steps that have a non-empty observation string.
1274    ///
1275    /// Returns `0.0` for sessions with no steps.
1276    pub fn step_observation_rate(&self) -> f64 {
1277        if self.steps.is_empty() {
1278            return 0.0;
1279        }
1280        let count = self.steps.iter().filter(|s| !s.observation.is_empty()).count();
1281        count as f64 / self.steps.len() as f64
1282    }
1283
1284    /// Return references to steps whose thought byte length is strictly less
1285    /// than `max_bytes`.
1286    ///
1287    /// Returns an empty `Vec` when no steps qualify or the session is empty.
1288    pub fn steps_below_thought_bytes(&self, max_bytes: usize) -> Vec<&ReActStep> {
1289        self.steps
1290            .iter()
1291            .filter(|s| s.thought.len() < max_bytes)
1292            .collect()
1293    }
1294
1295    /// Return references to steps whose thought string duplicates an earlier
1296    /// step's thought.
1297    ///
1298    /// The first occurrence of each thought is not included; only the
1299    /// subsequent duplicates are returned.  Useful for detecting repetitive
1300    /// reasoning loops.
1301    pub fn steps_with_duplicate_thoughts(&self) -> Vec<&ReActStep> {
1302        let mut seen = std::collections::HashSet::new();
1303        self.steps
1304            .iter()
1305            .filter(|s| !seen.insert(s.thought.as_str()))
1306            .collect()
1307    }
1308
1309    /// Return the byte length of the longest thought in this session.
1310    ///
1311    /// Returns `0` for an empty session or when all thoughts are empty strings.
1312    pub fn max_thought_bytes(&self) -> usize {
1313        self.steps.iter().map(|s| s.thought.len()).max().unwrap_or(0)
1314    }
1315
1316    /// Return references to steps whose action starts with `prefix`.
1317    ///
1318    /// Useful for filtering tool-call steps by tool name prefix (e.g. all
1319    /// `"search_"` actions).  Returns an empty `Vec` when no step qualifies.
1320    pub fn steps_by_action_prefix<'a>(&'a self, prefix: &str) -> Vec<&'a ReActStep> {
1321        self.steps
1322            .iter()
1323            .filter(|s| s.action.starts_with(prefix))
1324            .collect()
1325    }
1326
1327    /// Return the number of tool-call steps in this session.
1328    ///
1329    /// Counts steps whose action is non-empty and is not a `FINAL_ANSWER`.
1330    /// Returns `0` for an empty session.
1331    pub fn action_count(&self) -> usize {
1332        self.steps.iter().filter(|s| s.is_tool_call()).count()
1333    }
1334
1335    /// Return references to steps whose observation byte length exceeds `min_bytes`.
1336    ///
1337    /// Useful for finding steps that produced unexpectedly large observations.
1338    /// Returns an empty `Vec` for an empty session or when no step qualifies.
1339    pub fn steps_above_observation_bytes(&self, min_bytes: usize) -> Vec<&ReActStep> {
1340        self.steps
1341            .iter()
1342            .filter(|s| s.observation.len() > min_bytes)
1343            .collect()
1344    }
1345
1346    /// Return references to steps whose observation contains `substr`.
1347    ///
1348    /// Case-sensitive substring match.  Returns an empty `Vec` when no step
1349    /// matches or the session is empty.
1350    pub fn steps_matching_observation<'a>(&'a self, substr: &str) -> Vec<&'a ReActStep> {
1351        self.steps
1352            .iter()
1353            .filter(|s| s.observation.contains(substr))
1354            .collect()
1355    }
1356
1357    /// Return the byte lengths of each step's action field, in order.
1358    ///
1359    /// Returns an empty `Vec` for an empty session.
1360    pub fn step_action_lengths(&self) -> Vec<usize> {
1361        self.steps.iter().map(|s| s.action.len()).collect()
1362    }
1363
1364    /// Return `true` if any step's thought starts with `prefix`.
1365    ///
1366    /// Returns `false` for an empty session.
1367    pub fn has_thought_starting_with(&self, prefix: &str) -> bool {
1368        self.steps.iter().any(|s| s.thought.starts_with(prefix))
1369    }
1370
1371    /// Return the number of steps whose action byte length exceeds `min_bytes`.
1372    ///
1373    /// Returns `0` for an empty session.
1374    pub fn step_count_above_action_bytes(&self, min_bytes: usize) -> usize {
1375        self.steps.iter().filter(|s| s.action.len() > min_bytes).count()
1376    }
1377
1378    /// Return all steps whose `action` field is empty.
1379    ///
1380    /// Returns an empty `Vec` when no steps have an empty action.
1381    pub fn steps_with_empty_action(&self) -> Vec<&ReActStep> {
1382        self.steps.iter().filter(|s| s.action.is_empty()).collect()
1383    }
1384
1385    /// Return `true` if any step's action contains `substr` as a substring.
1386    ///
1387    /// Returns `false` for an empty session.
1388    pub fn has_action_containing(&self, substr: &str) -> bool {
1389        self.steps.iter().any(|s| s.action.contains(substr))
1390    }
1391
1392    /// Return the maximum UTF-8 character count among all observation strings.
1393    ///
1394    /// Returns `0` for an empty session.
1395    pub fn max_observation_chars(&self) -> usize {
1396        self.steps
1397            .iter()
1398            .map(|s| s.observation.chars().count())
1399            .max()
1400            .unwrap_or(0)
1401    }
1402
1403    /// Return the 0-based index of the step with the longest `thought` by
1404    /// character count, or `None` for an empty session.
1405    ///
1406    /// When multiple steps tie for the longest thought the smallest index wins.
1407    pub fn step_index_of_longest_thought(&self) -> Option<usize> {
1408        self.steps
1409            .iter()
1410            .enumerate()
1411            .max_by_key(|(_, s)| s.thought.chars().count())
1412            .map(|(i, _)| i)
1413    }
1414
1415    /// Return the number of whitespace-delimited words in each observation,
1416    /// in step order.
1417    ///
1418    /// Returns an empty `Vec` for an empty session.
1419    pub fn observation_word_counts(&self) -> Vec<usize> {
1420        self.steps
1421            .iter()
1422            .map(|s| s.observation.split_whitespace().count())
1423            .collect()
1424    }
1425
1426    /// Return `true` if any observation starts with one of the given `prefixes`.
1427    ///
1428    /// Returns `false` for an empty session or when no prefix matches.
1429    pub fn observation_starts_with_any(&self, prefixes: &[&str]) -> bool {
1430        self.steps
1431            .iter()
1432            .any(|s| prefixes.iter().any(|p| s.observation.starts_with(p)))
1433    }
1434
1435    /// Return `true` if any action string appears more than once in the session.
1436    ///
1437    /// Non-empty duplicate actions often signal a stuck or looping agent.
1438    /// Returns `false` for a session with fewer than two steps.
1439    pub fn has_repeated_actions(&self) -> bool {
1440        let mut seen = std::collections::HashSet::new();
1441        self.steps
1442            .iter()
1443            .filter(|s| !s.action.is_empty())
1444            .any(|s| !seen.insert(s.action.as_str()))
1445    }
1446
1447    /// Return `true` if any step thought starts with one of the given `prefixes`.
1448    ///
1449    /// Returns `false` for an empty session or when no prefix matches.
1450    pub fn thought_starts_with_any(&self, prefixes: &[&str]) -> bool {
1451        self.steps
1452            .iter()
1453            .any(|s| prefixes.iter().any(|p| s.thought.starts_with(p)))
1454    }
1455
1456    /// Return the total number of whitespace-delimited words across all action
1457    /// strings in this session.
1458    ///
1459    /// Returns `0` for an empty session.
1460    pub fn action_word_count(&self) -> usize {
1461        self.steps
1462            .iter()
1463            .map(|s| s.action.split_whitespace().count())
1464            .sum()
1465    }
1466
1467    /// Return the number of steps whose thought character count exceeds `min`.
1468    ///
1469    /// Returns `0` for an empty session.
1470    pub fn steps_above_thought_chars(&self, min: usize) -> usize {
1471        self.steps
1472            .iter()
1473            .filter(|s| s.thought.chars().count() > min)
1474            .count()
1475    }
1476
1477    /// Return all steps that have a non-empty observation string.
1478    ///
1479    /// Returns an empty `Vec` for an empty session.
1480    pub fn steps_with_non_empty_observation(&self) -> Vec<&ReActStep> {
1481        self.steps
1482            .iter()
1483            .filter(|s| !s.observation.is_empty())
1484            .collect()
1485    }
1486
1487    /// Return all steps whose observation contains `substr`.
1488    ///
1489    /// Returns an empty `Vec` for an empty session or no matches.
1490    pub fn observations_containing(&self, substr: &str) -> Vec<&ReActStep> {
1491        self.steps
1492            .iter()
1493            .filter(|s| s.observation.contains(substr))
1494            .collect()
1495    }
1496
1497    /// Return the ratio of total thought characters to total observation
1498    /// characters.
1499    ///
1500    /// Returns `0.0` when there are no observation characters to avoid
1501    /// division by zero.
1502    pub fn thought_observation_ratio(&self) -> f64 {
1503        let obs: usize = self.steps.iter().map(|s| s.observation.chars().count()).sum();
1504        if obs == 0 {
1505            return 0.0;
1506        }
1507        let thoughts: usize = self.steps.iter().map(|s| s.thought.chars().count()).sum();
1508        thoughts as f64 / obs as f64
1509    }
1510
1511    /// Return all steps whose thought contains `substr` as a substring.
1512    ///
1513    /// Returns an empty `Vec` for an empty session or no matches.
1514    pub fn steps_matching_thought(&self, substr: &str) -> Vec<&ReActStep> {
1515        self.steps
1516            .iter()
1517            .filter(|s| s.thought.contains(substr))
1518            .collect()
1519    }
1520
1521    /// Return the median observation character count across all steps.
1522    ///
1523    /// Returns `0` for an empty session.  Uses the lower median when the step
1524    /// count is even.
1525    pub fn median_observation_chars(&self) -> usize {
1526        if self.steps.is_empty() {
1527            return 0;
1528        }
1529        let mut lens: Vec<usize> = self
1530            .steps
1531            .iter()
1532            .map(|s| s.observation.chars().count())
1533            .collect();
1534        lens.sort_unstable();
1535        lens[lens.len() / 2]
1536    }
1537
1538    /// Return the cumulative sum of thought character counts, step by step.
1539    ///
1540    /// The *i*-th element is the total thought characters from step 0 through
1541    /// step *i* inclusive.  Returns an empty `Vec` for an empty session.
1542    pub fn cumulative_thought_chars(&self) -> Vec<usize> {
1543        let mut total = 0usize;
1544        self.steps
1545            .iter()
1546            .map(|s| {
1547                total += s.thought.chars().count();
1548                total
1549            })
1550            .collect()
1551    }
1552
1553    /// Return the number of steps whose thought contains `substr`.
1554    ///
1555    /// Returns `0` for an empty session.
1556    pub fn count_steps_with_thought_containing(&self, substr: &str) -> usize {
1557        self.steps
1558            .iter()
1559            .filter(|s| s.thought.contains(substr))
1560            .count()
1561    }
1562
1563    /// Return the smallest byte length of non-empty observation strings in this
1564    /// session.
1565    ///
1566    /// Steps with an empty observation are excluded. Returns `0` if no non-empty
1567    /// observations exist.
1568    pub fn min_observation_bytes(&self) -> usize {
1569        self.steps
1570            .iter()
1571            .map(|s| s.observation.len())
1572            .filter(|&n| n > 0)
1573            .min()
1574            .unwrap_or(0)
1575    }
1576
1577    /// Return the smallest byte length of non-empty thought strings in this
1578    /// session.
1579    ///
1580    /// Steps with an empty thought are excluded. Returns `0` if no non-empty
1581    /// thoughts exist.
1582    pub fn min_thought_bytes(&self) -> usize {
1583        self.steps
1584            .iter()
1585            .map(|s| s.thought.len())
1586            .filter(|&n| n > 0)
1587            .min()
1588            .unwrap_or(0)
1589    }
1590
1591    /// Return the proportion of steps whose `thought` field is empty.
1592    ///
1593    /// Returns `0.0` for an empty session.
1594    pub fn proportion_empty_thoughts(&self) -> f64 {
1595        if self.steps.is_empty() {
1596            return 0.0;
1597        }
1598        let empty = self.steps.iter().filter(|s| s.thought.is_empty()).count();
1599        empty as f64 / self.steps.len() as f64
1600    }
1601
1602    /// Return `true` if any step in this session is marked as failed.
1603    ///
1604    /// A step is considered failed when its `observation` starts with
1605    /// `"[error]"` (the convention used by the built-in tool dispatcher).
1606    pub fn has_failed_steps(&self) -> bool {
1607        self.steps
1608            .iter()
1609            .any(|s| s.observation.starts_with("[error]"))
1610    }
1611
1612    /// Return the total number of UTF-8 characters across all step `thought` fields.
1613    ///
1614    /// Returns `0` for an empty session.
1615    pub fn total_thought_chars(&self) -> usize {
1616        self.steps.iter().map(|s| s.thought.chars().count()).sum()
1617    }
1618
1619    /// Return the total number of UTF-8 characters across all step `action` fields.
1620    ///
1621    /// Returns `0` for an empty session.
1622    pub fn total_action_chars(&self) -> usize {
1623        self.steps.iter().map(|s| s.action.chars().count()).sum()
1624    }
1625
1626    /// Return the total number of UTF-8 characters across all step `observation`
1627    /// fields.
1628    ///
1629    /// Returns `0` for an empty session.
1630    pub fn total_observation_chars(&self) -> usize {
1631        self.steps.iter().map(|s| s.observation.chars().count()).sum()
1632    }
1633
1634    /// Return the statistical variance of action byte lengths across all steps.
1635    ///
1636    /// Useful for detecting inconsistent action sizes. Returns `0.0` for a
1637    /// session with fewer than two steps or all equal lengths.
1638    pub fn action_byte_variance(&self) -> f64 {
1639        if self.steps.len() < 2 {
1640            return 0.0;
1641        }
1642        let lengths: Vec<f64> = self.steps.iter().map(|s| s.action.len() as f64).collect();
1643        let mean = lengths.iter().sum::<f64>() / lengths.len() as f64;
1644        lengths.iter().map(|&l| (l - mean).powi(2)).sum::<f64>() / lengths.len() as f64
1645    }
1646
1647    /// Return the count of steps that have a non-empty `action` string.
1648    ///
1649    /// Returns `0` for an empty session.
1650    pub fn non_empty_action_count(&self) -> usize {
1651        self.steps.iter().filter(|s| !s.action.is_empty()).count()
1652    }
1653
1654    /// Return the total byte count across all fields (`thought` + `action` +
1655    /// `observation`) for every step in the session.
1656    ///
1657    /// Returns `0` for an empty session.
1658    pub fn total_step_bytes(&self) -> usize {
1659        self.steps
1660            .iter()
1661            .map(|s| s.thought.len() + s.action.len() + s.observation.len())
1662            .sum()
1663    }
1664
1665    /// Return the byte length of the last step's `thought` field, or `0` if
1666    /// the session has no steps.
1667    pub fn last_thought_bytes(&self) -> usize {
1668        self.steps.last().map_or(0, |s| s.thought.len())
1669    }
1670
1671    /// Return the byte length of the first step's `observation` field, or `0`
1672    /// if the session has no steps.
1673    pub fn first_observation_bytes(&self) -> usize {
1674        self.steps.first().map_or(0, |s| s.observation.len())
1675    }
1676
1677    /// Return `true` if any step has an empty `observation` field.
1678    ///
1679    /// Useful for detecting incomplete ReAct traces where a tool produced no
1680    /// output.
1681    pub fn has_step_with_empty_observation(&self) -> bool {
1682        self.steps.iter().any(|s| s.observation.is_empty())
1683    }
1684
1685    /// Return the ratio of total thought bytes to total action bytes.
1686    ///
1687    /// Returns `0.0` when there are no action bytes (avoids division by zero).
1688    pub fn thought_to_action_byte_ratio(&self) -> f64 {
1689        let thought_bytes: usize = self.steps.iter().map(|s| s.thought.len()).sum();
1690        let action_bytes: usize = self.steps.iter().map(|s| s.action.len()).sum();
1691        if action_bytes == 0 {
1692            return 0.0;
1693        }
1694        thought_bytes as f64 / action_bytes as f64
1695    }
1696
1697    /// Return the number of steps whose `observation` byte length exceeds
1698    /// `min_bytes`.
1699    ///
1700    /// Returns `0` when the session is empty or no step qualifies.
1701    pub fn observation_above_bytes_count(&self, min_bytes: usize) -> usize {
1702        self.steps
1703            .iter()
1704            .filter(|s| s.observation.len() > min_bytes)
1705            .count()
1706    }
1707
1708    /// Return the number of steps where both the `thought` and `action`
1709    /// fields are non-empty.
1710    ///
1711    /// Returns `0` for an empty session.
1712    pub fn steps_with_both_thought_and_action(&self) -> usize {
1713        self.steps
1714            .iter()
1715            .filter(|s| !s.thought.is_empty() && !s.action.is_empty())
1716            .count()
1717    }
1718
1719    /// Return the number of steps whose `observation` field starts with
1720    /// `prefix`.
1721    ///
1722    /// Returns `0` for an empty session or when no step qualifies.
1723    pub fn steps_with_observation_prefix(&self, prefix: &str) -> usize {
1724        self.steps
1725            .iter()
1726            .filter(|s| s.observation.starts_with(prefix))
1727            .count()
1728    }
1729
1730    /// Return the total byte count of all `observation` fields across all
1731    /// steps.
1732    ///
1733    /// Returns `0` for an empty session.
1734    pub fn observation_bytes_total(&self) -> usize {
1735        self.steps.iter().map(|s| s.observation.len()).sum()
1736    }
1737
1738    /// Return the character count of the first step's `thought` field.
1739    ///
1740    /// Returns `0` for an empty session.
1741    pub fn first_thought_chars(&self) -> usize {
1742        self.steps.first().map_or(0, |s| s.thought.chars().count())
1743    }
1744
1745    /// Return the character count of the last step's `observation` field.
1746    ///
1747    /// Returns `0` for an empty session.
1748    pub fn last_observation_chars(&self) -> usize {
1749        self.steps.last().map_or(0, |s| s.observation.chars().count())
1750    }
1751
1752    /// Return the total word count across all `observation` fields.
1753    ///
1754    /// Words are split on whitespace.  Returns `0` for an empty session.
1755    pub fn observation_word_count_total(&self) -> usize {
1756        self.steps
1757            .iter()
1758            .map(|s| s.observation.split_whitespace().count())
1759            .sum()
1760    }
1761
1762    /// Return the count of steps whose `action` field ends with `suffix`.
1763    ///
1764    /// Returns `0` for an empty session or when no step qualifies.
1765    pub fn action_ends_with_count(&self, suffix: &str) -> usize {
1766        self.steps
1767            .iter()
1768            .filter(|s| s.action.ends_with(suffix))
1769            .count()
1770    }
1771
1772    /// Return the average word count per observation field across all steps.
1773    ///
1774    /// Returns `0.0` for an empty session.
1775    pub fn avg_observation_words(&self) -> f64 {
1776        if self.steps.is_empty() {
1777            return 0.0;
1778        }
1779        let total: usize = self
1780            .steps
1781            .iter()
1782            .map(|s| s.observation.split_whitespace().count())
1783            .sum();
1784        total as f64 / self.steps.len() as f64
1785    }
1786
1787    /// Return the statistical variance of thought byte lengths across all steps.
1788    ///
1789    /// Returns `0.0` for a session with fewer than two steps.
1790    pub fn thought_byte_variance(&self) -> f64 {
1791        if self.steps.len() < 2 {
1792            return 0.0;
1793        }
1794        let lengths: Vec<f64> = self.steps.iter().map(|s| s.thought.len() as f64).collect();
1795        let mean = lengths.iter().sum::<f64>() / lengths.len() as f64;
1796        lengths.iter().map(|&l| (l - mean).powi(2)).sum::<f64>() / lengths.len() as f64
1797    }
1798
1799    /// Return all steps whose `thought` byte length exceeds `min_bytes`.
1800    ///
1801    /// Returns an empty `Vec` if no step qualifies.
1802    pub fn steps_above_thought_bytes(&self, min_bytes: usize) -> Vec<&ReActStep> {
1803        self.steps
1804            .iter()
1805            .filter(|s| s.thought.len() > min_bytes)
1806            .collect()
1807    }
1808
1809    /// Return the number of steps where all three fields (`thought`, `action`,
1810    /// and `observation`) are empty strings.
1811    ///
1812    /// Fully empty steps typically indicate a malformed or aborted iteration.
1813    pub fn total_empty_steps(&self) -> usize {
1814        self.steps
1815            .iter()
1816            .filter(|s| s.thought.is_empty() && s.action.is_empty() && s.observation.is_empty())
1817            .count()
1818    }
1819
1820    /// Return the number of steps whose `action` begins with `prefix`.
1821    ///
1822    /// Returns `0` for an empty session or when no action matches.
1823    pub fn action_starts_with_count(&self, prefix: &str) -> usize {
1824        self.steps
1825            .iter()
1826            .filter(|s| s.action.starts_with(prefix))
1827            .count()
1828    }
1829
1830    /// Return the longest `action` string in the session, or `None` if the
1831    /// session is empty.
1832    ///
1833    /// When multiple steps share the maximum byte length, the first one is
1834    /// returned.
1835    pub fn longest_action(&self) -> Option<&str> {
1836        self.steps
1837            .iter()
1838            .max_by_key(|s| s.action.len())
1839            .map(|s| s.action.as_str())
1840    }
1841
1842    /// Return the proportion of steps that have a non-empty thought string.
1843    ///
1844    /// Returns `0.0` for an empty session.
1845    pub fn thought_completeness(&self) -> f64 {
1846        if self.steps.is_empty() {
1847            return 0.0;
1848        }
1849        let non_empty = self.steps.iter().filter(|s| !s.thought.is_empty()).count();
1850        non_empty as f64 / self.steps.len() as f64
1851    }
1852
1853    /// Return the 0-based index of the first `FINAL_ANSWER` step, or `None` if
1854    /// no such step exists in the session.
1855    ///
1856    /// Useful when you need the position of the answer step rather than just
1857    /// testing whether one exists (see [`has_final_answer`]).
1858    ///
1859    /// [`has_final_answer`]: AgentSession::has_final_answer
1860    pub fn final_answer_step_index(&self) -> Option<usize> {
1861        self.steps.iter().position(|s| s.is_final_answer())
1862    }
1863
1864    /// Return the `(min, max)` step duration range in milliseconds.
1865    ///
1866    /// Returns `(0, 0)` for sessions with no steps.
1867    pub fn step_duration_range_ms(&self) -> (u64, u64) {
1868        if self.steps.is_empty() {
1869            return (0, 0);
1870        }
1871        let min = self.steps.iter().map(|s| s.step_duration_ms).min().unwrap_or(0);
1872        let max = self.steps.iter().map(|s| s.step_duration_ms).max().unwrap_or(0);
1873        (min, max)
1874    }
1875
1876    /// Return the number of distinct thought strings across all steps.
1877    ///
1878    /// Two steps with identical thought text are counted once.  Returns `0`
1879    /// for empty sessions.
1880    pub fn count_unique_thoughts(&self) -> usize {
1881        let unique: std::collections::HashSet<&str> =
1882            self.steps.iter().map(|s| s.thought.as_str()).collect();
1883        unique.len()
1884    }
1885
1886    /// Return references to steps whose thought string is empty.
1887    ///
1888    /// Useful for detecting steps where the model skipped the reasoning phase.
1889    pub fn steps_with_empty_thoughts(&self) -> Vec<&ReActStep> {
1890        self.steps.iter().filter(|s| s.thought.is_empty()).collect()
1891    }
1892
1893    /// Return references to steps whose thought byte length exceeds `threshold_bytes`.
1894    ///
1895    /// Useful for identifying steps with unusually verbose reasoning traces.
1896    pub fn steps_with_long_thoughts(&self, threshold_bytes: usize) -> Vec<&ReActStep> {
1897        self.steps
1898            .iter()
1899            .filter(|s| s.thought.len() > threshold_bytes)
1900            .collect()
1901    }
1902
1903    /// Return the number of steps whose action string contains `substring`.
1904    ///
1905    /// The comparison is case-sensitive.  Returns `0` for empty sessions or
1906    /// when no step matches.
1907    pub fn action_count_containing(&self, substring: &str) -> usize {
1908        self.steps.iter().filter(|s| s.action.contains(substring)).count()
1909    }
1910
1911    /// Return the number of steps that have a non-empty thought string.
1912    ///
1913    /// Complement of [`steps_with_empty_thoughts`].  Returns `0` for empty
1914    /// sessions.
1915    ///
1916    /// [`steps_with_empty_thoughts`]: AgentSession::steps_with_empty_thoughts
1917    pub fn total_thought_count(&self) -> usize {
1918        self.steps.iter().filter(|s| !s.thought.is_empty()).count()
1919    }
1920
1921    /// Return `true` if any step's `thought` field contains `substring`
1922    /// (case-sensitive).
1923    ///
1924    /// Returns `false` for empty sessions or when no step matches.
1925    pub fn has_thought_containing(&self, substring: &str) -> bool {
1926        self.steps.iter().any(|s| s.thought.contains(substring))
1927    }
1928
1929    /// Return references to steps whose `action` field is longer than
1930    /// `min_bytes` bytes.
1931    ///
1932    /// Returns an empty `Vec` when no step qualifies.
1933    pub fn steps_with_action_length_above(&self, min_bytes: usize) -> Vec<&ReActStep> {
1934        self.steps
1935            .iter()
1936            .filter(|s| s.action.len() > min_bytes)
1937            .collect()
1938    }
1939
1940    /// Persist this session as a checkpoint under `"session:<session_id>"`.
1941    #[cfg(feature = "persistence")]
1942    pub async fn save_checkpoint(
1943        &self,
1944        backend: &dyn crate::persistence::PersistenceBackend,
1945    ) -> Result<(), AgentRuntimeError> {
1946        let key = format!("session:{}", self.session_id);
1947        let bytes = serde_json::to_vec(self)
1948            .map_err(|e| AgentRuntimeError::Persistence(format!("serialize: {e}")))?;
1949        backend.save(&key, &bytes).await
1950    }
1951
1952    /// Load a previously saved checkpoint by `session_id`.
1953    ///
1954    /// Returns `None` if no checkpoint exists for the given ID.
1955    #[cfg(feature = "persistence")]
1956    pub async fn load_checkpoint(
1957        backend: &dyn crate::persistence::PersistenceBackend,
1958        session_id: &str,
1959    ) -> Result<Option<AgentSession>, AgentRuntimeError> {
1960        let key = format!("session:{session_id}");
1961        match backend.load(&key).await? {
1962            None => Ok(None),
1963            Some(bytes) => {
1964                let session = serde_json::from_slice(&bytes)
1965                    .map_err(|e| AgentRuntimeError::Persistence(format!("deserialize: {e}")))?;
1966                Ok(Some(session))
1967            }
1968        }
1969    }
1970
1971    /// Load the session snapshot saved after step `step` completed.
1972    ///
1973    /// Alias for [`load_checkpoint_at_step`] — provided for ergonomic
1974    /// compatibility with call sites that prefer this naming convention.
1975    ///
1976    /// [`load_checkpoint_at_step`]: AgentSession::load_checkpoint_at_step
1977    #[cfg(feature = "persistence")]
1978    #[deprecated(since = "1.1.0", note = "Use load_checkpoint_at_step instead")]
1979    pub async fn load_step_checkpoint(
1980        backend: &dyn crate::persistence::PersistenceBackend,
1981        session_id: &str,
1982        step: usize,
1983    ) -> Result<Option<AgentSession>, AgentRuntimeError> {
1984        Self::load_checkpoint_at_step(backend, session_id, step).await
1985    }
1986
1987    /// Load the session snapshot saved after step `step` completed.
1988    ///
1989    /// Returns `None` if no checkpoint exists for the given session/step pair.
1990    /// The step number is 1-based (step 1 = after the first ReAct iteration).
1991    #[cfg(feature = "persistence")]
1992    pub async fn load_checkpoint_at_step(
1993        backend: &dyn crate::persistence::PersistenceBackend,
1994        session_id: &str,
1995        step: usize,
1996    ) -> Result<Option<AgentSession>, AgentRuntimeError> {
1997        let key = format!("session:{session_id}:step:{step}");
1998        match backend.load(&key).await? {
1999            None => Ok(None),
2000            Some(bytes) => {
2001                let session = serde_json::from_slice(&bytes)
2002                    .map_err(|e| AgentRuntimeError::Persistence(format!("deserialize: {e}")))?;
2003                Ok(Some(session))
2004            }
2005        }
2006    }
2007
2008    /// Consume this session and return its steps as an owned `Vec`.
2009    ///
2010    /// Useful when the caller needs owned `ReActStep` values — for example to
2011    /// move them into another data structure — without cloning the entire list.
2012    ///
2013    /// ```rust,ignore
2014    /// let steps = session.into_steps();
2015    /// for step in steps { /* owned */ }
2016    /// ```
2017    pub fn into_steps(self) -> Vec<crate::agent::ReActStep> {
2018        self.steps
2019    }
2020
2021    /// Return an iterator over the steps in this session.
2022    ///
2023    /// Equivalent to `session.steps.iter()` but avoids exposing the raw
2024    /// field for callers who prefer the method-call style.
2025    pub fn iter_steps(&self) -> std::slice::Iter<'_, crate::agent::ReActStep> {
2026        self.steps.iter()
2027    }
2028
2029    /// Return `true` if the session has at least `n` steps.
2030    ///
2031    /// More efficient than `step_count() >= n` because it uses
2032    /// `steps.len()` directly and is easy to read at the call site.
2033    pub fn has_at_least_steps(&self, n: usize) -> bool {
2034        self.steps.len() >= n
2035    }
2036
2037    /// Return `true` if every step in this session has a non-empty observation.
2038    ///
2039    /// Returns `true` for an empty session (vacuously true).
2040    pub fn all_observations_non_empty(&self) -> bool {
2041        self.steps.iter().all(|s| !s.observation.is_empty())
2042    }
2043
2044    /// Return the average combined byte length (thought + action + observation)
2045    /// per step.
2046    ///
2047    /// Returns `0.0` for an empty session.
2048    pub fn avg_combined_step_bytes(&self) -> f64 {
2049        if self.steps.is_empty() {
2050            return 0.0;
2051        }
2052        let total: usize = self.steps.iter().map(|s| s.combined_byte_length()).sum();
2053        total as f64 / self.steps.len() as f64
2054    }
2055
2056    /// Return a reference to the step with the shortest `observation` field.
2057    ///
2058    /// When multiple steps share the minimum observation length the first is
2059    /// returned.  Returns `None` for an empty session.
2060    pub fn shortest_observation_step(&self) -> Option<&ReActStep> {
2061        self.steps.iter().min_by_key(|s| s.observation.len())
2062    }
2063
2064    /// Return the number of distinct observation strings across all steps.
2065    ///
2066    /// Returns `0` for an empty session.
2067    pub fn unique_observation_count(&self) -> usize {
2068        self.steps
2069            .iter()
2070            .map(|s| s.observation.as_str())
2071            .collect::<std::collections::HashSet<_>>()
2072            .len()
2073    }
2074
2075    /// Return the average number of whitespace-delimited words per thought.
2076    ///
2077    /// Returns `0.0` for an empty session.
2078    pub fn avg_thought_word_count(&self) -> f64 {
2079        if self.steps.is_empty() {
2080            return 0.0;
2081        }
2082        let total: usize = self
2083            .steps
2084            .iter()
2085            .map(|s| s.thought.split_whitespace().count())
2086            .sum();
2087        total as f64 / self.steps.len() as f64
2088    }
2089
2090    /// Return `true` if any step's observation contains at least one of the
2091    /// provided `terms` (case-sensitive substring match).
2092    ///
2093    /// Returns `false` for an empty session or an empty `terms` slice.
2094    pub fn observation_contains_any(&self, terms: &[&str]) -> bool {
2095        if terms.is_empty() {
2096            return false;
2097        }
2098        self.steps
2099            .iter()
2100            .any(|s| terms.iter().any(|t| s.observation.contains(t)))
2101    }
2102
2103    /// Return the step at `index`, or `None` if the index is out of bounds.
2104    pub fn step_at_index(&self, index: usize) -> Option<&ReActStep> {
2105        self.steps.get(index)
2106    }
2107
2108    /// Return `true` if any step's thought contains **all** of the provided
2109    /// `terms` as substrings (case-sensitive).
2110    ///
2111    /// Returns `false` for an empty `terms` slice or an empty session.
2112    pub fn thought_contains_all(&self, terms: &[&str]) -> bool {
2113        if terms.is_empty() {
2114            return false;
2115        }
2116        self.steps
2117            .iter()
2118            .any(|s| terms.iter().all(|t| s.thought.contains(t)))
2119    }
2120
2121    /// Return `true` if any step's action contains at least one of the
2122    /// provided `terms` (case-sensitive substring match).
2123    ///
2124    /// Returns `false` for an empty `terms` slice or an empty session.
2125    pub fn action_contains_any(&self, terms: &[&str]) -> bool {
2126        if terms.is_empty() {
2127            return false;
2128        }
2129        self.steps
2130            .iter()
2131            .any(|s| terms.iter().any(|t| s.action.contains(t)))
2132    }
2133
2134    /// Return the maximum thought length in characters across all steps.
2135    ///
2136    /// Returns `0` for an empty session.
2137    pub fn max_thought_chars(&self) -> usize {
2138        self.steps
2139            .iter()
2140            .map(|s| s.thought.chars().count())
2141            .max()
2142            .unwrap_or(0)
2143    }
2144
2145    /// Return the minimum thought length in characters, considering only
2146    /// steps that have a non-empty thought.
2147    ///
2148    /// Returns `0` if there are no non-empty thoughts.
2149    pub fn min_thought_chars(&self) -> usize {
2150        self.steps
2151            .iter()
2152            .map(|s| s.thought.chars().count())
2153            .filter(|&n| n > 0)
2154            .min()
2155            .unwrap_or(0)
2156    }
2157
2158    /// Return the average number of Unicode chars in the `action` field across
2159    /// all steps.
2160    ///
2161    /// Returns `0.0` for an empty session.
2162    pub fn avg_action_chars(&self) -> f64 {
2163        if self.steps.is_empty() {
2164            return 0.0;
2165        }
2166        let total: usize = self.steps.iter().map(|s| s.action.chars().count()).sum();
2167        total as f64 / self.steps.len() as f64
2168    }
2169
2170    /// Return the average number of Unicode chars in the `observation` field
2171    /// across all steps.
2172    ///
2173    /// Returns `0.0` for an empty session.
2174    pub fn avg_observation_chars(&self) -> f64 {
2175        if self.steps.is_empty() {
2176            return 0.0;
2177        }
2178        let total: usize = self.steps.iter().map(|s| s.observation.chars().count()).sum();
2179        total as f64 / self.steps.len() as f64
2180    }
2181
2182    /// Return a reference to the step whose `action` string is the longest
2183    /// (by Unicode char count), or `None` for an empty session.
2184    ///
2185    /// When multiple steps tie, the first is returned.
2186    pub fn step_with_longest_action(&self) -> Option<&ReActStep> {
2187        self.steps.iter().max_by_key(|s| s.action.chars().count())
2188    }
2189
2190    /// Return `true` if any step's `action` ends with the given `suffix`.
2191    pub fn action_ends_with(&self, suffix: &str) -> bool {
2192        self.steps.iter().any(|s| s.action.ends_with(suffix))
2193    }
2194
2195    /// Return `true` if any step's `thought` ends with the given `suffix`.
2196    pub fn thought_ends_with(&self, suffix: &str) -> bool {
2197        self.steps.iter().any(|s| s.thought.ends_with(suffix))
2198    }
2199
2200    /// Return `true` if any step's `thought` contains `thought_term` AND that
2201    /// same step's `action` contains `action_term`.
2202    pub fn has_step_with_both(&self, thought_term: &str, action_term: &str) -> bool {
2203        self.steps
2204            .iter()
2205            .any(|s| s.thought.contains(thought_term) && s.action.contains(action_term))
2206    }
2207
2208    /// Return the number of steps whose `observation` byte length strictly
2209    /// exceeds `min_bytes`.
2210    ///
2211    /// Returns `0` for an empty session.
2212    pub fn step_count_with_observation_longer_than(&self, min_bytes: usize) -> usize {
2213        self.steps
2214            .iter()
2215            .filter(|s| s.observation.len() > min_bytes)
2216            .count()
2217    }
2218
2219    /// Return a `Vec` of word counts for each step's `thought`, in order.
2220    pub fn thought_word_counts(&self) -> Vec<usize> {
2221        self.steps
2222            .iter()
2223            .map(|s| s.thought.split_whitespace().count())
2224            .collect()
2225    }
2226
2227    /// Return steps sorted by `thought` byte length in ascending order.
2228    pub fn steps_sorted_by_thought_len(&self) -> Vec<&ReActStep> {
2229        let mut sorted: Vec<&ReActStep> = self.steps.iter().collect();
2230        sorted.sort_by_key(|s| s.thought.len());
2231        sorted
2232    }
2233
2234    /// Return all steps whose `thought` byte length strictly exceeds
2235    /// `min_bytes`.
2236    pub fn steps_with_thought_longer_than(&self, min_bytes: usize) -> Vec<&ReActStep> {
2237        self.steps
2238            .iter()
2239            .filter(|s| s.thought.len() > min_bytes)
2240            .collect()
2241    }
2242
2243    /// Return all steps whose `action` contains `substr` as a substring.
2244    pub fn steps_with_action_containing(&self, substr: &str) -> Vec<&ReActStep> {
2245        self.steps
2246            .iter()
2247            .filter(|s| s.action.contains(substr))
2248            .collect()
2249    }
2250
2251    /// Return the maximum `observation` length in Unicode chars across all
2252    /// steps.  Returns `0` for an empty session.
2253    pub fn observation_max_chars(&self) -> usize {
2254        self.steps
2255            .iter()
2256            .map(|s| s.observation.chars().count())
2257            .max()
2258            .unwrap_or(0)
2259    }
2260
2261    /// Return the minimum `observation` length in Unicode chars, considering
2262    /// only steps with a non-empty observation.  Returns `0` if no non-empty
2263    /// observations exist.
2264    pub fn observation_min_chars(&self) -> usize {
2265        self.steps
2266            .iter()
2267            .map(|s| s.observation.chars().count())
2268            .filter(|&n| n > 0)
2269            .min()
2270            .unwrap_or(0)
2271    }
2272
2273    /// Return a `Vec` of word counts for each step's `action`, in order.
2274    pub fn action_word_counts(&self) -> Vec<usize> {
2275        self.steps
2276            .iter()
2277            .map(|s| s.action.split_whitespace().count())
2278            .collect()
2279    }
2280
2281    /// Return the average number of Unicode chars in the `thought` field
2282    /// across all steps.  Returns `0.0` for an empty session.
2283    pub fn thought_avg_chars(&self) -> f64 {
2284        if self.steps.is_empty() {
2285            return 0.0;
2286        }
2287        let total: usize = self.steps.iter().map(|s| s.thought.chars().count()).sum();
2288        total as f64 / self.steps.len() as f64
2289    }
2290
2291    /// Return `(min_bytes, max_bytes)` of the `thought` field across all steps.
2292    ///
2293    /// Returns `(0, 0)` for an empty session.
2294    pub fn thought_byte_range(&self) -> (usize, usize) {
2295        if self.steps.is_empty() {
2296            return (0, 0);
2297        }
2298        let min = self.steps.iter().map(|s| s.thought.len()).min().unwrap_or(0);
2299        let max = self.steps.iter().map(|s| s.thought.len()).max().unwrap_or(0);
2300        (min, max)
2301    }
2302}
2303
2304// ── AgentRuntimeBuilder ───────────────────────────────────────────────────────
2305
2306/// Builder for `AgentRuntime`.
2307///
2308/// Uses a typestate parameter `S` to enforce that `with_agent_config` is called
2309/// before `build()`.  Calling `build()` on a `AgentRuntimeBuilder<NeedsConfig>`
2310/// is a **compile-time error**.
2311///
2312/// Typical usage:
2313/// ```ignore
2314/// let runtime = AgentRuntime::builder()      // AgentRuntimeBuilder<NeedsConfig>
2315///     .with_memory(store)
2316///     .with_agent_config(cfg)                // → AgentRuntimeBuilder<HasConfig>
2317///     .build();                              // → AgentRuntime (infallible)
2318/// ```
2319/// Builder for [`AgentRuntime`].
2320pub struct AgentRuntimeBuilder<S = NeedsConfig> {
2321    #[cfg(feature = "memory")]
2322    memory: Option<EpisodicStore>,
2323    #[cfg(feature = "memory")]
2324    working: Option<WorkingMemory>,
2325    #[cfg(feature = "graph")]
2326    graph: Option<GraphStore>,
2327    #[cfg(feature = "orchestrator")]
2328    backpressure: Option<BackpressureGuard>,
2329    agent_config: Option<AgentConfig>,
2330    tools: Vec<Arc<ToolSpec>>,
2331    metrics: Arc<RuntimeMetrics>,
2332    #[cfg(feature = "persistence")]
2333    checkpoint_backend: Option<Arc<dyn crate::persistence::PersistenceBackend>>,
2334    token_estimator: Option<Arc<dyn TokenEstimator>>,
2335    _state: PhantomData<S>,
2336}
2337
2338// ── DebugBuilderState sealed trait ────────────────────────────────────────────
2339
2340/// Private trait used to drive the single generic `Debug` impl for
2341/// `AgentRuntimeBuilder<S>`.  Only `NeedsConfig` and `HasConfig` implement it.
2342trait DebugBuilderState {
2343    /// Name shown in the debug output.
2344    const NAME: &'static str;
2345    /// Whether to emit the `agent_config` field (only `HasConfig` has one).
2346    const HAS_CONFIG: bool;
2347}
2348
2349impl DebugBuilderState for NeedsConfig {
2350    const NAME: &'static str = "AgentRuntimeBuilder<NeedsConfig>";
2351    const HAS_CONFIG: bool = false;
2352}
2353
2354impl DebugBuilderState for HasConfig {
2355    const NAME: &'static str = "AgentRuntimeBuilder<HasConfig>";
2356    const HAS_CONFIG: bool = true;
2357}
2358
2359impl<S: DebugBuilderState> std::fmt::Debug for AgentRuntimeBuilder<S> {
2360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2361        let mut s = f.debug_struct(S::NAME);
2362        #[cfg(feature = "memory")]
2363        {
2364            s.field("memory", &self.memory.is_some())
2365                .field("working", &self.working.is_some());
2366        }
2367        #[cfg(feature = "graph")]
2368        s.field("graph", &self.graph.is_some());
2369        #[cfg(feature = "orchestrator")]
2370        s.field("backpressure", &self.backpressure.is_some());
2371        if S::HAS_CONFIG {
2372            s.field("agent_config", &self.agent_config.is_some());
2373        }
2374        s.field("tools", &self.tools.len()).finish()
2375    }
2376}
2377
2378impl Default for AgentRuntimeBuilder<NeedsConfig> {
2379    fn default() -> Self {
2380        Self {
2381            #[cfg(feature = "memory")]
2382            memory: None,
2383            #[cfg(feature = "memory")]
2384            working: None,
2385            #[cfg(feature = "graph")]
2386            graph: None,
2387            #[cfg(feature = "orchestrator")]
2388            backpressure: None,
2389            agent_config: None,
2390            tools: Vec::new(),
2391            metrics: RuntimeMetrics::new(),
2392            #[cfg(feature = "persistence")]
2393            checkpoint_backend: None,
2394            token_estimator: None,
2395            _state: PhantomData,
2396        }
2397    }
2398}
2399
2400// Methods available on ALL builder states.
2401impl<S> AgentRuntimeBuilder<S> {
2402    /// Attach an episodic memory store.
2403    #[cfg(feature = "memory")]
2404    pub fn with_memory(mut self, store: EpisodicStore) -> Self {
2405        self.memory = Some(store);
2406        self
2407    }
2408
2409    /// Attach a working memory store.
2410    #[cfg(feature = "memory")]
2411    pub fn with_working_memory(mut self, wm: WorkingMemory) -> Self {
2412        self.working = Some(wm);
2413        self
2414    }
2415
2416    /// Attach a graph store.
2417    #[cfg(feature = "graph")]
2418    pub fn with_graph(mut self, graph: GraphStore) -> Self {
2419        self.graph = Some(graph);
2420        self
2421    }
2422
2423    /// Attach a backpressure guard.
2424    #[cfg(feature = "orchestrator")]
2425    pub fn with_backpressure(mut self, guard: BackpressureGuard) -> Self {
2426        self.backpressure = Some(guard);
2427        self
2428    }
2429
2430    /// Register a tool available to the agent loop.
2431    pub fn register_tool(mut self, spec: ToolSpec) -> Self {
2432        self.tools.push(Arc::new(spec));
2433        self
2434    }
2435
2436    /// Register multiple tools at once.
2437    ///
2438    /// Equivalent to calling [`register_tool`] for each spec.
2439    ///
2440    /// [`register_tool`]: AgentRuntimeBuilder::register_tool
2441    pub fn register_tools(mut self, specs: impl IntoIterator<Item = ToolSpec>) -> Self {
2442        for spec in specs {
2443            self.tools.push(Arc::new(spec));
2444        }
2445        self
2446    }
2447
2448    /// Attach a shared `RuntimeMetrics` instance.
2449    pub fn with_metrics(mut self, metrics: Arc<RuntimeMetrics>) -> Self {
2450        self.metrics = metrics;
2451        self
2452    }
2453
2454    /// Attach a persistence backend for session checkpointing.
2455    #[cfg(feature = "persistence")]
2456    pub fn with_checkpoint_backend(
2457        mut self,
2458        backend: Arc<dyn crate::persistence::PersistenceBackend>,
2459    ) -> Self {
2460        self.checkpoint_backend = Some(backend);
2461        self
2462    }
2463
2464    /// Provide a custom [`TokenEstimator`] for memory budget calculations.
2465    ///
2466    /// Replaces the default `len / 4` byte-counting heuristic.  Use this to
2467    /// plug in a model-specific tokenizer (e.g. tiktoken, sentencepiece) so
2468    /// that `AgentConfig::max_memory_tokens` is respected accurately.
2469    pub fn with_token_estimator(mut self, estimator: Arc<dyn TokenEstimator>) -> Self {
2470        self.token_estimator = Some(estimator);
2471        self
2472    }
2473}
2474
2475// `with_agent_config` transitions NeedsConfig → HasConfig.
2476impl AgentRuntimeBuilder<NeedsConfig> {
2477    /// Create a new builder (equivalent to `Default::default()`).
2478    pub fn new() -> Self {
2479        Self::default()
2480    }
2481
2482    /// Set the agent loop configuration.
2483    ///
2484    /// After this call the builder transitions to `AgentRuntimeBuilder<HasConfig>`,
2485    /// making `build()` available.
2486    pub fn with_agent_config(self, config: AgentConfig) -> AgentRuntimeBuilder<HasConfig> {
2487        AgentRuntimeBuilder {
2488            memory: self.memory,
2489            working: self.working,
2490            #[cfg(feature = "graph")]
2491            graph: self.graph,
2492            #[cfg(feature = "orchestrator")]
2493            backpressure: self.backpressure,
2494            agent_config: Some(config),
2495            tools: self.tools,
2496            metrics: self.metrics,
2497            #[cfg(feature = "persistence")]
2498            checkpoint_backend: self.checkpoint_backend,
2499            token_estimator: self.token_estimator,
2500            _state: PhantomData,
2501        }
2502    }
2503}
2504
2505// `build()` is only available once we have a config.
2506impl AgentRuntimeBuilder<HasConfig> {
2507    /// Build the `AgentRuntime`.
2508    ///
2509    /// This is infallible: the typestate guarantees `agent_config` is present.
2510    pub fn build(self) -> AgentRuntime {
2511        // SAFETY: `agent_config` is always `Some` in `HasConfig` state because
2512        // `with_agent_config` is the only way to reach this state.
2513        #[allow(clippy::unwrap_used)]
2514        let agent_config = self.agent_config.unwrap();
2515
2516        AgentRuntime {
2517            #[cfg(feature = "memory")]
2518            memory: self.memory,
2519            #[cfg(feature = "memory")]
2520            working: self.working,
2521            #[cfg(feature = "graph")]
2522            graph: self.graph,
2523            #[cfg(feature = "orchestrator")]
2524            backpressure: self.backpressure,
2525            agent_config,
2526            tools: self.tools,
2527            metrics: self.metrics,
2528            token_estimator: self
2529                .token_estimator
2530                .unwrap_or_else(|| Arc::new(CharDivTokenEstimator)),
2531            #[cfg(feature = "persistence")]
2532            checkpoint_backend: self.checkpoint_backend,
2533        }
2534    }
2535}
2536
2537// ── TokenEstimator ────────────────────────────────────────────────────────────
2538
2539/// Estimates the number of tokens in a string.
2540///
2541/// Implement this trait to replace the default `len / 4` heuristic with a
2542/// model-specific tokenizer (e.g. tiktoken, sentencepiece).
2543///
2544/// # Example
2545/// ```rust,ignore
2546/// struct TiktokenEstimator { enc: tiktoken::Encoding }
2547/// impl TokenEstimator for TiktokenEstimator {
2548///     fn count_tokens(&self, text: &str) -> usize {
2549///         self.enc.encode_ordinary(text).len()
2550///     }
2551/// }
2552/// ```
2553pub trait TokenEstimator: Send + Sync {
2554    /// Return an approximate token count for `text`.
2555    fn count_tokens(&self, text: &str) -> usize;
2556}
2557
2558/// Default heuristic: 1 token ≈ 4 bytes.
2559pub struct CharDivTokenEstimator;
2560
2561impl TokenEstimator for CharDivTokenEstimator {
2562    fn count_tokens(&self, text: &str) -> usize {
2563        (text.len() / 4).max(1)
2564    }
2565}
2566
2567// ── AgentRuntime ──────────────────────────────────────────────────────────────
2568
2569/// Unified runtime that coordinates memory, graph, orchestration, and agent loop.
2570pub struct AgentRuntime {
2571    #[cfg(feature = "memory")]
2572    memory: Option<EpisodicStore>,
2573    #[cfg(feature = "memory")]
2574    working: Option<WorkingMemory>,
2575    #[cfg(feature = "graph")]
2576    graph: Option<GraphStore>,
2577    #[cfg(feature = "orchestrator")]
2578    backpressure: Option<BackpressureGuard>,
2579    agent_config: AgentConfig,
2580    tools: Vec<Arc<ToolSpec>>,
2581    metrics: Arc<RuntimeMetrics>,
2582    #[cfg(feature = "persistence")]
2583    checkpoint_backend: Option<Arc<dyn crate::persistence::PersistenceBackend>>,
2584    token_estimator: Arc<dyn TokenEstimator>,
2585}
2586
2587impl std::fmt::Debug for AgentRuntime {
2588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2589        let mut s = f.debug_struct("AgentRuntime");
2590        s.field("memory", &self.memory.is_some())
2591            .field("working", &self.working.is_some());
2592        #[cfg(feature = "graph")]
2593        s.field("graph", &self.graph.is_some());
2594        #[cfg(feature = "orchestrator")]
2595        s.field("backpressure", &self.backpressure.is_some());
2596        s.field("tools", &self.tools.len());
2597        #[cfg(feature = "persistence")]
2598        s.field("checkpoint_backend", &self.checkpoint_backend.is_some());
2599        s.finish()
2600    }
2601}
2602
2603impl AgentRuntime {
2604    /// Return a new builder in the `NeedsConfig` state.
2605    pub fn builder() -> AgentRuntimeBuilder<NeedsConfig> {
2606        AgentRuntimeBuilder::new()
2607    }
2608
2609    /// Construct a minimal `AgentRuntime` in one call with sensible defaults.
2610    pub fn quick(max_iterations: usize, model: impl Into<String>) -> Self {
2611        AgentRuntime::builder()
2612            .with_agent_config(AgentConfig::new(max_iterations, model))
2613            .build()
2614    }
2615
2616    /// Return a shared reference to the runtime metrics.
2617    pub fn metrics(&self) -> Arc<RuntimeMetrics> {
2618        Arc::clone(&self.metrics)
2619    }
2620
2621    /// Run the agent loop for the given prompt.
2622    ///
2623    /// Optionally recalls episodic memories and injects them into the context.
2624    /// Optionally enforces backpressure before starting.
2625    ///
2626    /// # Arguments
2627    /// * `agent_id` — identifies the agent for memory retrieval
2628    /// * `prompt` — the user's input prompt
2629    /// * `infer` — async inference function: `(context: String) -> impl Future<Output = String>`
2630    ///
2631    /// # Returns
2632    /// An `AgentSession` with step count, hits, duration, and a stable session ID.
2633    #[tracing::instrument(skip(self, infer), fields(agent_id = %agent_id))]
2634    pub async fn run_agent<F, Fut>(
2635        &self,
2636        agent_id: AgentId,
2637        prompt: &str,
2638        infer: F,
2639    ) -> Result<AgentSession, AgentRuntimeError>
2640    where
2641        F: FnMut(String) -> Fut,
2642        Fut: std::future::Future<Output = String>,
2643    {
2644        // Acquire backpressure slot before counting the session — shed requests
2645        // must not inflate total_sessions or active_sessions.
2646        #[cfg(feature = "orchestrator")]
2647        {
2648            let backpressure_result = if let Some(ref guard) = self.backpressure {
2649                guard.try_acquire()
2650            } else {
2651                Ok(())
2652            };
2653            if let Err(e) = backpressure_result {
2654                tracing::warn!(agent_id = %agent_id, error = %e, "backpressure shed: rejecting session");
2655                self.metrics
2656                    .backpressure_shed_count
2657                    .fetch_add(1, Ordering::Relaxed);
2658                return Err(e);
2659            }
2660        }
2661
2662        self.metrics.total_sessions.fetch_add(1, Ordering::Relaxed);
2663        self.metrics.active_sessions.fetch_add(1, Ordering::Relaxed);
2664
2665        tracing::info!(agent_id = %agent_id, "agent session starting");
2666        let outcome = self.run_agent_inner(agent_id.clone(), prompt, infer).await;
2667
2668        // Always release backpressure — success or error.
2669        #[cfg(feature = "orchestrator")]
2670        if let Some(ref guard) = self.backpressure {
2671            let _ = guard.release();
2672        }
2673
2674        // Saturating decrement — guards against underflow to usize::MAX if
2675        // active_sessions is somehow already 0 (e.g. double-decrement bug).
2676        let _ = self.metrics.active_sessions.fetch_update(
2677            Ordering::Relaxed,
2678            Ordering::Relaxed,
2679            |v| Some(v.saturating_sub(1)),
2680        );
2681
2682        match &outcome {
2683            Ok(session) => {
2684                tracing::info!(
2685                    agent_id = %agent_id,
2686                    session_id = %session.session_id,
2687                    steps = session.step_count(),
2688                    duration_ms = session.duration_ms,
2689                    "agent session completed"
2690                );
2691                self.metrics
2692                    .total_steps
2693                    .fetch_add(session.step_count() as u64, Ordering::Relaxed);
2694            }
2695            Err(e) => {
2696                tracing::error!(agent_id = %agent_id, error = %e, "agent session failed");
2697            }
2698        }
2699
2700        outcome
2701    }
2702
2703    /// Inner implementation of `run_agent`, called after backpressure is acquired.
2704    #[tracing::instrument(skip(self, infer), fields(agent_id = %agent_id, session_id = tracing::field::Empty))]
2705    async fn run_agent_inner<F, Fut>(
2706        &self,
2707        agent_id: AgentId,
2708        prompt: &str,
2709        infer: F,
2710    ) -> Result<AgentSession, AgentRuntimeError>
2711    where
2712        F: FnMut(String) -> Fut,
2713        Fut: std::future::Future<Output = String>,
2714    {
2715        let start = Instant::now();
2716        let session_id = uuid::Uuid::new_v4().to_string();
2717
2718        let mut memory_hits = 0usize;
2719        let mut graph_lookups = 0usize;
2720
2721        // Build enriched prompt from episodic memory.
2722        #[cfg(feature = "memory")]
2723        let enriched_prompt = if let Some(ref store) = self.memory {
2724            let memories = store.recall(&agent_id, self.agent_config.max_memory_recalls)?;
2725
2726            // Apply token budget if configured.
2727            let memories = if let Some(token_budget) = self.agent_config.max_memory_tokens {
2728                let mut used = 0usize;
2729                memories
2730                    .into_iter()
2731                    .filter(|m| {
2732                        let tokens = self.token_estimator.count_tokens(&m.content);
2733                        if used + tokens <= token_budget {
2734                            used += tokens;
2735                            true
2736                        } else {
2737                            false
2738                        }
2739                    })
2740                    .collect::<Vec<_>>()
2741            } else {
2742                memories
2743            };
2744
2745            memory_hits = memories.len();
2746            self.metrics
2747                .memory_recall_count
2748                .fetch_add(1, Ordering::Relaxed);
2749
2750            if let Some(budget) = self.agent_config.max_memory_tokens {
2751                tracing::debug!(
2752                    "memory token budget: {budget}, injecting {} items",
2753                    memory_hits
2754                );
2755            } else {
2756                tracing::debug!("enriched prompt with {} memory items", memory_hits);
2757            }
2758
2759            if memories.is_empty() {
2760                prompt.to_owned()
2761            } else {
2762                // Build the enriched prompt directly into a String without an
2763                // intermediate Vec<String> allocation.
2764                let mut enriched =
2765                    String::with_capacity(prompt.len() + memories.len() * 64 + 32);
2766                enriched.push_str("Relevant memories:\n");
2767                for m in &memories {
2768                    let _ = writeln!(enriched, "- {}", m.content);
2769                }
2770                let _ = write!(enriched, "\nCurrent prompt: {prompt}");
2771                enriched
2772            }
2773        } else {
2774            prompt.to_owned()
2775        };
2776        #[cfg(not(feature = "memory"))]
2777        let enriched_prompt = prompt.to_owned();
2778
2779        // Inject working memory into prompt.
2780        #[cfg(feature = "memory")]
2781        let enriched_prompt = if let Some(ref wm) = self.working {
2782            let entries = wm.entries()?;
2783            if entries.is_empty() {
2784                enriched_prompt
2785            } else {
2786                // Build working-memory section without an intermediate Vec<String>.
2787                let mut out = String::with_capacity(
2788                    enriched_prompt.len() + entries.len() * 32 + 32,
2789                );
2790                out.push_str(&enriched_prompt);
2791                out.push_str("\n\nCurrent working state:\n");
2792                for (k, v) in &entries {
2793                    let _ = writeln!(out, "  {k}: {v}");
2794                }
2795                // Remove trailing newline added by writeln for the last entry.
2796                if out.ends_with('\n') {
2797                    out.pop();
2798                }
2799                out
2800            }
2801        } else {
2802            enriched_prompt
2803        };
2804
2805        // Count graph entities as "lookups" for session metadata.
2806        #[cfg(feature = "graph")]
2807        if let Some(ref graph) = self.graph {
2808            graph_lookups = graph.entity_count()?;
2809            tracing::debug!("graph has {} entities", graph_lookups);
2810        }
2811
2812        // Build the ReAct loop and register tools.
2813        // Each ToolSpec is stored as an Arc so we can clone the Arc into the
2814        // handler closure without moving ownership out of self.tools.
2815        // Required fields and the per-tool circuit breaker are preserved so
2816        // that validation and fast-fail behaviour work correctly at run time.
2817        let mut react_loop = ReActLoop::new(self.agent_config.clone())
2818            .with_metrics(Arc::clone(&self.metrics));
2819
2820        // Item 11 — wire per-step loop checkpointing.
2821        #[cfg(feature = "persistence")]
2822        if let Some(ref backend) = self.checkpoint_backend {
2823            react_loop = react_loop
2824                .with_step_checkpoint(Arc::clone(backend), session_id.clone());
2825        }
2826
2827        for tool in &self.tools {
2828            let tool_arc = Arc::clone(tool);
2829            let required_fields = tool_arc.required_fields.clone();
2830            #[cfg(feature = "orchestrator")]
2831            let circuit_breaker = tool_arc.circuit_breaker.clone();
2832
2833            let mut spec = ToolSpec::new_async(
2834                tool_arc.name.clone(),
2835                tool_arc.description.clone(),
2836                move |args| {
2837                    let t = Arc::clone(&tool_arc);
2838                    Box::pin(async move { t.call(args).await })
2839                },
2840            )
2841            .with_required_fields(required_fields);
2842
2843            #[cfg(feature = "orchestrator")]
2844            if let Some(cb) = circuit_breaker {
2845                spec = spec.with_circuit_breaker(cb);
2846            }
2847
2848            react_loop.register_tool(spec);
2849        }
2850
2851        // Record the session_id into the current tracing span so that all
2852        // child spans (ReActLoop iterations, tool calls) carry this field.
2853        tracing::Span::current().record("session_id", &session_id.as_str());
2854
2855        let steps = react_loop.run(&enriched_prompt, infer).await?;
2856        let duration_ms = start.elapsed().as_millis() as u64;
2857
2858        // Item 6 — collect per-step checkpoint errors; surfaced in AgentSession.
2859        #[cfg(feature = "persistence")]
2860        let mut ckpt_errors: Vec<String> = Vec::new();
2861
2862        // Save final checkpoint if a backend is configured.
2863        #[cfg(feature = "persistence")]
2864        if let Some(ref backend) = self.checkpoint_backend {
2865            tracing::info!(session_id = %session_id, "saving session checkpoint");
2866
2867            // Build a temporary session without errors to save as the base checkpoint.
2868            let tmp = AgentSession {
2869                session_id: session_id.clone(),
2870                agent_id: agent_id.clone(),
2871                steps: steps.clone(),
2872                memory_hits,
2873                graph_lookups,
2874                duration_ms,
2875                checkpoint_errors: vec![],
2876            };
2877            tmp.save_checkpoint(backend.as_ref()).await?;
2878
2879            // Save incremental per-step consolidated snapshots.
2880            for i in 1..=steps.len() {
2881                let partial = AgentSession {
2882                    session_id: session_id.clone(),
2883                    agent_id: agent_id.clone(),
2884                    steps: steps[..i].to_vec(),
2885                    memory_hits,
2886                    graph_lookups,
2887                    duration_ms,
2888                    checkpoint_errors: vec![],
2889                };
2890                let key = format!("session:{session_id}:step:{i}");
2891                match serde_json::to_vec(&partial) {
2892                    Ok(bytes) => {
2893                        if let Err(e) = backend.save(&key, &bytes).await {
2894                            let msg = format!("session:{session_id} step:{i} save: {e}");
2895                            tracing::warn!("{}", msg);
2896                            ckpt_errors.push(msg);
2897                        }
2898                    }
2899                    Err(e) => {
2900                        let msg =
2901                            format!("session:{session_id} step:{i} serialise: {e}");
2902                        tracing::warn!("{}", msg);
2903                        ckpt_errors.push(msg);
2904                    }
2905                }
2906            }
2907        }
2908
2909        let session = AgentSession {
2910            session_id,
2911            agent_id,
2912            steps,
2913            memory_hits,
2914            graph_lookups,
2915            duration_ms,
2916            #[cfg(feature = "persistence")]
2917            checkpoint_errors: ckpt_errors,
2918            #[cfg(not(feature = "persistence"))]
2919            checkpoint_errors: vec![],
2920        };
2921
2922        Ok(session)
2923    }
2924
2925    /// Return a reference to the episodic memory store, if configured.
2926    #[cfg(feature = "memory")]
2927    pub fn memory(&self) -> Option<&EpisodicStore> {
2928        self.memory.as_ref()
2929    }
2930
2931    /// Return a reference to the graph store, if configured.
2932    #[cfg(feature = "graph")]
2933    pub fn graph(&self) -> Option<&GraphStore> {
2934        self.graph.as_ref()
2935    }
2936
2937    /// Return a reference to the working memory, if configured.
2938    #[cfg(feature = "memory")]
2939    pub fn working_memory(&self) -> Option<&WorkingMemory> {
2940        self.working.as_ref()
2941    }
2942
2943    /// Return `true` if episodic memory was configured for this runtime.
2944    #[cfg(feature = "memory")]
2945    pub fn has_memory(&self) -> bool {
2946        self.memory.is_some()
2947    }
2948
2949    /// Return `true` if a graph store was configured for this runtime.
2950    #[cfg(feature = "graph")]
2951    pub fn has_graph(&self) -> bool {
2952        self.graph.is_some()
2953    }
2954
2955    /// Return `true` if working memory was configured for this runtime.
2956    #[cfg(feature = "memory")]
2957    pub fn has_working_memory(&self) -> bool {
2958        self.working.is_some()
2959    }
2960
2961    /// Return `true` if there is at least one session currently in progress.
2962    ///
2963    /// Reads the `active_sessions` counter from the shared metrics.
2964    pub fn has_active_sessions(&self) -> bool {
2965        self.metrics
2966            .active_sessions
2967            .load(std::sync::atomic::Ordering::Relaxed)
2968            > 0
2969    }
2970
2971    /// Return the number of tools registered in this runtime.
2972    ///
2973    /// Each entry in the internal `tools` list corresponds to one registered
2974    /// `ToolSpec`.
2975    pub fn tool_count(&self) -> usize {
2976        self.tools.len()
2977    }
2978
2979    /// Return the names of all registered tools, sorted alphabetically.
2980    ///
2981    /// Provides a stable, deterministic list independent of registration order.
2982    /// Returns an empty `Vec` when no tools have been registered.
2983    pub fn tool_names(&self) -> Vec<&str> {
2984        let mut names: Vec<&str> = self.tools.iter().map(|t| t.name.as_str()).collect();
2985        names.sort_unstable();
2986        names
2987    }
2988
2989    /// Return an owned, sorted list of registered tool names.
2990    ///
2991    /// Unlike [`AgentRuntime::tool_names`] which borrows from `self`, this
2992    /// method returns `Vec<String>` so the result can outlive the runtime
2993    /// reference — useful for logging, serialisation, or passing across async
2994    /// boundaries.
2995    pub fn registered_tool_names(&self) -> Vec<String> {
2996        let mut names: Vec<String> =
2997            self.tools.iter().map(|t| t.name.clone()).collect();
2998        names.sort_unstable();
2999        names
3000    }
3001
3002    /// Return a reference to the runtime's agent configuration.
3003    pub fn config(&self) -> &AgentConfig {
3004        &self.agent_config
3005    }
3006
3007    /// Return the model identifier configured for this runtime.
3008    pub fn model_name(&self) -> &str {
3009        &self.agent_config.model
3010    }
3011
3012    /// Return the maximum number of ReAct iterations this runtime is configured
3013    /// to allow per session.
3014    pub fn session_max_iterations(&self) -> usize {
3015        self.agent_config.max_iterations
3016    }
3017
3018    /// Return `true` if a tool with the given `name` is registered with this
3019    /// runtime's tool registry.
3020    pub fn is_registered_tool(&self, name: &str) -> bool {
3021        self.tools.iter().any(|t| t.name == name)
3022    }
3023
3024    /// Gracefully shut down the runtime.
3025    ///
3026    /// Logs a structured shutdown event with the final metrics snapshot.
3027    /// If the `persistence` feature is enabled and a checkpoint backend is
3028    /// configured, writes a sentinel key so operators can confirm clean shutdown.
3029    ///
3030    /// After calling `shutdown`, the runtime should not be used again.
3031    pub async fn shutdown(&self) {
3032        tracing::info!("AgentRuntime shutting down");
3033        tracing::info!(
3034            active_sessions = self.metrics.active_sessions(),
3035            total_sessions = self.metrics.total_sessions(),
3036            total_steps = self.metrics.total_steps(),
3037            total_tool_calls = self.metrics.total_tool_calls(),
3038            failed_tool_calls = self.metrics.failed_tool_calls(),
3039            "final metrics snapshot on shutdown"
3040        );
3041
3042        #[cfg(feature = "persistence")]
3043        if let Some(ref backend) = self.checkpoint_backend {
3044            let ts = chrono::Utc::now().to_rfc3339();
3045            match backend.save("runtime:shutdown", ts.as_bytes()).await {
3046                Ok(()) => tracing::debug!("shutdown sentinel saved"),
3047                Err(e) => tracing::warn!(error = %e, "failed to save shutdown sentinel"),
3048            }
3049        }
3050
3051        tracing::info!("AgentRuntime shutdown complete");
3052    }
3053
3054    /// Run an agent session using a shared [`LlmProvider`].
3055    ///
3056    /// Convenience wrapper around [`run_agent`] that wires the provider's
3057    /// `complete` method as the inference closure.  Inference errors are
3058    /// converted to a `FINAL ANSWER` string so the loop terminates gracefully
3059    /// rather than panicking.
3060    ///
3061    /// [`run_agent`]: AgentRuntime::run_agent
3062    /// [`LlmProvider`]: crate::providers::LlmProvider
3063    #[cfg(feature = "providers")]
3064    pub async fn run_agent_with_provider(
3065        &self,
3066        agent_id: AgentId,
3067        prompt: &str,
3068        provider: std::sync::Arc<dyn crate::providers::LlmProvider>,
3069    ) -> Result<AgentSession, AgentRuntimeError> {
3070        let model = self.agent_config.model.clone();
3071        self.run_agent(agent_id, prompt, |ctx| {
3072            let provider = provider.clone();
3073            let model = model.clone();
3074            async move {
3075                provider
3076                    .complete(&ctx, &model)
3077                    .await
3078                    .unwrap_or_else(|e| format!("FINAL ANSWER: inference error: {e}"))
3079            }
3080        })
3081        .await
3082    }
3083}
3084
3085// ── Tests ─────────────────────────────────────────────────────────────────────
3086
3087#[cfg(test)]
3088mod tests {
3089    use super::*;
3090    use crate::graph::{Entity, GraphStore, Relationship};
3091    use crate::memory::{EpisodicStore, WorkingMemory};
3092
3093    fn simple_config() -> AgentConfig {
3094        AgentConfig::new(5, "test")
3095    }
3096
3097    async fn final_answer_infer(_ctx: String) -> String {
3098        "Thought: done\nAction: FINAL_ANSWER 42".into()
3099    }
3100
3101    // ── Builder ───────────────────────────────────────────────────────────────
3102
3103    // NOTE: test_builder_fails_without_agent_config has been removed.
3104    // The typestate pattern makes calling .build() without .with_agent_config()
3105    // a *compile-time error* — AgentRuntimeBuilder<NeedsConfig> has no build()
3106    // method.  There is nothing to test at runtime.
3107
3108    /// Verifies that the builder compiles and produces a runtime when config is
3109    /// provided.  This is the runtime-observable counterpart to the former
3110    /// "fails without config" test.
3111    #[tokio::test]
3112    async fn test_builder_with_config_compiles() {
3113        let _runtime = AgentRuntime::builder()
3114            .with_agent_config(simple_config())
3115            .build();
3116        // If this compiles and runs, the typestate transition worked correctly.
3117    }
3118
3119    #[tokio::test]
3120    async fn test_builder_succeeds_with_minimal_config() {
3121        let _runtime = AgentRuntime::builder()
3122            .with_agent_config(simple_config())
3123            .build();
3124    }
3125
3126    #[tokio::test]
3127    async fn test_builder_with_all_subsystems() {
3128        let _runtime = AgentRuntime::builder()
3129            .with_agent_config(simple_config())
3130            .with_memory(EpisodicStore::new())
3131            .with_graph(GraphStore::new())
3132            .with_working_memory(WorkingMemory::new(10).unwrap())
3133            .with_backpressure(BackpressureGuard::new(5).unwrap())
3134            .build();
3135    }
3136
3137    #[tokio::test]
3138    async fn test_builder_produces_runtime_with_config() {
3139        // Confirm the built runtime accepts a run_agent call — the most direct
3140        // evidence that the builder wired everything correctly.
3141        let runtime = AgentRuntime::builder()
3142            .with_agent_config(simple_config())
3143            .build();
3144        let session = runtime
3145            .run_agent(AgentId::new("agent-x"), "hello", final_answer_infer)
3146            .await
3147            .unwrap();
3148        assert!(session.step_count() >= 1);
3149        assert!(!session.session_id.is_empty());
3150    }
3151
3152    // ── run_agent ─────────────────────────────────────────────────────────────
3153
3154    #[tokio::test]
3155    async fn test_run_agent_returns_session_with_steps() {
3156        let runtime = AgentRuntime::builder()
3157            .with_agent_config(simple_config())
3158            .build();
3159
3160        let session = runtime
3161            .run_agent(AgentId::new("agent-1"), "hello", final_answer_infer)
3162            .await
3163            .unwrap();
3164
3165        assert_eq!(session.step_count(), 1);
3166    }
3167
3168    #[tokio::test]
3169    async fn test_run_agent_session_has_agent_id() {
3170        let runtime = AgentRuntime::builder()
3171            .with_agent_config(simple_config())
3172            .build();
3173
3174        let session = runtime
3175            .run_agent(AgentId::new("agent-42"), "hello", final_answer_infer)
3176            .await
3177            .unwrap();
3178
3179        assert_eq!(session.agent_id.0, "agent-42");
3180    }
3181
3182    #[tokio::test]
3183    async fn test_run_agent_session_duration_is_set() {
3184        let runtime = AgentRuntime::builder()
3185            .with_agent_config(simple_config())
3186            .build();
3187
3188        let session = runtime
3189            .run_agent(AgentId::new("a"), "hello", final_answer_infer)
3190            .await
3191            .unwrap();
3192
3193        // Duration should be non-negative (0 ms is valid for a fast mock)
3194        let _ = session.duration_ms; // just verify it compiles and is set
3195    }
3196
3197    #[tokio::test]
3198    async fn test_run_agent_session_has_session_id() {
3199        let runtime = AgentRuntime::builder()
3200            .with_agent_config(simple_config())
3201            .build();
3202
3203        let session = runtime
3204            .run_agent(AgentId::new("a"), "hello", final_answer_infer)
3205            .await
3206            .unwrap();
3207
3208        // session_id must be a non-empty UUID string
3209        assert!(!session.session_id.is_empty());
3210        assert_eq!(session.session_id.len(), 36); // UUID v4 canonical form
3211    }
3212
3213    #[tokio::test]
3214    async fn test_run_agent_memory_hits_zero_without_memory() {
3215        let runtime = AgentRuntime::builder()
3216            .with_agent_config(simple_config())
3217            .build();
3218
3219        let session = runtime
3220            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
3221            .await
3222            .unwrap();
3223
3224        assert_eq!(session.memory_hits, 0);
3225    }
3226
3227    #[tokio::test]
3228    async fn test_run_agent_memory_hits_counts_recalled_items() {
3229        let store = EpisodicStore::new();
3230        let agent = AgentId::new("mem-agent");
3231        store
3232            .add_episode(agent.clone(), "remembered fact", 0.8)
3233            .unwrap();
3234
3235        let runtime = AgentRuntime::builder()
3236            .with_agent_config(simple_config())
3237            .with_memory(store)
3238            .build();
3239
3240        let session = runtime
3241            .run_agent(agent, "prompt", final_answer_infer)
3242            .await
3243            .unwrap();
3244
3245        assert_eq!(session.memory_hits, 1);
3246    }
3247
3248    #[tokio::test]
3249    async fn test_run_agent_graph_lookups_counts_entities() {
3250        let graph = GraphStore::new();
3251        graph.add_entity(Entity::new("e1", "Node")).unwrap();
3252        graph.add_entity(Entity::new("e2", "Node")).unwrap();
3253
3254        let runtime = AgentRuntime::builder()
3255            .with_agent_config(simple_config())
3256            .with_graph(graph)
3257            .build();
3258
3259        let session = runtime
3260            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
3261            .await
3262            .unwrap();
3263
3264        assert_eq!(session.graph_lookups, 2);
3265    }
3266
3267    #[tokio::test]
3268    async fn test_run_agent_backpressure_released_after_run() {
3269        let guard = BackpressureGuard::new(3).unwrap();
3270
3271        let runtime = AgentRuntime::builder()
3272            .with_agent_config(simple_config())
3273            .with_backpressure(guard.clone())
3274            .build();
3275
3276        runtime
3277            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
3278            .await
3279            .unwrap();
3280
3281        assert_eq!(guard.depth().unwrap(), 0);
3282    }
3283
3284    #[tokio::test]
3285    async fn test_run_agent_backpressure_sheds_when_full() {
3286        let guard = BackpressureGuard::new(1).unwrap();
3287        guard.try_acquire().unwrap(); // pre-fill
3288
3289        let runtime = AgentRuntime::builder()
3290            .with_agent_config(simple_config())
3291            .with_backpressure(guard)
3292            .build();
3293
3294        let result = runtime
3295            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
3296            .await;
3297        assert!(matches!(
3298            result,
3299            Err(AgentRuntimeError::BackpressureShed { .. })
3300        ));
3301    }
3302
3303    #[tokio::test]
3304    async fn test_run_agent_max_iterations_error_propagated() {
3305        let cfg = AgentConfig::new(2, "model");
3306        let runtime = AgentRuntime::builder().with_agent_config(cfg).build();
3307
3308        // Simulate an infer fn that always produces FINAL_ANSWER immediately
3309        let result = runtime
3310            .run_agent(AgentId::new("a"), "prompt", |_ctx: String| async {
3311                "Thought: looping\nAction: FINAL_ANSWER done".to_string()
3312            })
3313            .await;
3314        assert!(result.is_ok()); // final answer on first call, ok
3315    }
3316
3317    #[tokio::test]
3318    async fn test_agent_session_step_count_matches_steps() {
3319        let session = AgentSession {
3320            session_id: "test-session-id".into(),
3321            agent_id: AgentId::new("a"),
3322            steps: vec![
3323                ReActStep {
3324                    thought: "t".into(),
3325                    action: "a".into(),
3326                    observation: "o".into(),
3327                    step_duration_ms: 0,
3328                },
3329                ReActStep {
3330                    thought: "t2".into(),
3331                    action: "FINAL_ANSWER".into(),
3332                    observation: "done".into(),
3333                    step_duration_ms: 0,
3334                },
3335            ],
3336            memory_hits: 0,
3337            graph_lookups: 0,
3338            duration_ms: 10,
3339            checkpoint_errors: vec![],
3340        };
3341        assert_eq!(session.step_count(), 2);
3342    }
3343
3344    // ── Accessor methods ──────────────────────────────────────────────────────
3345
3346    #[tokio::test]
3347    async fn test_runtime_memory_accessor_returns_none_when_not_configured() {
3348        let runtime = AgentRuntime::builder()
3349            .with_agent_config(simple_config())
3350            .build();
3351        assert!(runtime.memory().is_none());
3352    }
3353
3354    #[tokio::test]
3355    async fn test_runtime_memory_accessor_returns_some_when_configured() {
3356        let runtime = AgentRuntime::builder()
3357            .with_agent_config(simple_config())
3358            .with_memory(EpisodicStore::new())
3359            .build();
3360        assert!(runtime.memory().is_some());
3361    }
3362
3363    #[tokio::test]
3364    async fn test_runtime_graph_accessor_returns_none_when_not_configured() {
3365        let runtime = AgentRuntime::builder()
3366            .with_agent_config(simple_config())
3367            .build();
3368        assert!(runtime.graph().is_none());
3369    }
3370
3371    #[tokio::test]
3372    async fn test_runtime_graph_accessor_returns_some_when_configured() {
3373        let runtime = AgentRuntime::builder()
3374            .with_agent_config(simple_config())
3375            .with_graph(GraphStore::new())
3376            .build();
3377        assert!(runtime.graph().is_some());
3378    }
3379
3380    #[tokio::test]
3381    async fn test_runtime_working_memory_accessor() {
3382        let runtime = AgentRuntime::builder()
3383            .with_agent_config(simple_config())
3384            .with_working_memory(WorkingMemory::new(5).unwrap())
3385            .build();
3386        assert!(runtime.working_memory().is_some());
3387    }
3388
3389    #[tokio::test]
3390    async fn test_runtime_with_tool_registered() {
3391        let runtime = AgentRuntime::builder()
3392            .with_agent_config(simple_config())
3393            .register_tool(ToolSpec::new("calc", "math", |_| serde_json::json!(99)))
3394            .build();
3395
3396        let mut call_count = 0;
3397        let session = runtime
3398            .run_agent(AgentId::new("a"), "compute", move |_ctx: String| {
3399                call_count += 1;
3400                let count = call_count;
3401                async move {
3402                    if count == 1 {
3403                        "Thought: use calc\nAction: calc {}".into()
3404                    } else {
3405                        "Thought: done\nAction: FINAL_ANSWER result".into()
3406                    }
3407                }
3408            })
3409            .await
3410            .unwrap();
3411
3412        assert!(session.step_count() >= 1);
3413    }
3414
3415    #[tokio::test]
3416    async fn test_run_agent_with_graph_relationship_lookup() {
3417        let graph = GraphStore::new();
3418        graph.add_entity(Entity::new("a", "X")).unwrap();
3419        graph.add_entity(Entity::new("b", "Y")).unwrap();
3420        graph
3421            .add_relationship(Relationship::new("a", "b", "LINKS", 1.0))
3422            .unwrap();
3423
3424        let runtime = AgentRuntime::builder()
3425            .with_agent_config(simple_config())
3426            .with_graph(graph)
3427            .build();
3428
3429        let session = runtime
3430            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
3431            .await
3432            .unwrap();
3433
3434        assert_eq!(session.graph_lookups, 2); // 2 entities
3435    }
3436
3437    // ── Metrics ───────────────────────────────────────────────────────────────
3438
3439    #[tokio::test]
3440    async fn test_metrics_active_sessions_decrements_after_run() {
3441        let runtime = AgentRuntime::builder()
3442            .with_agent_config(simple_config())
3443            .build();
3444
3445        runtime
3446            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
3447            .await
3448            .unwrap();
3449
3450        assert_eq!(runtime.metrics().active_sessions(), 0);
3451    }
3452
3453    #[tokio::test]
3454    async fn test_metrics_total_sessions_increments() {
3455        let runtime = AgentRuntime::builder()
3456            .with_agent_config(simple_config())
3457            .build();
3458
3459        runtime
3460            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
3461            .await
3462            .unwrap();
3463        runtime
3464            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
3465            .await
3466            .unwrap();
3467
3468        assert_eq!(runtime.metrics().total_sessions(), 2);
3469    }
3470
3471    #[tokio::test]
3472    async fn test_metrics_backpressure_shed_increments_on_shed() {
3473        let guard = BackpressureGuard::new(1).unwrap();
3474        guard.try_acquire().unwrap(); // pre-fill
3475
3476        let runtime = AgentRuntime::builder()
3477            .with_agent_config(simple_config())
3478            .with_backpressure(guard)
3479            .build();
3480
3481        let _ = runtime
3482            .run_agent(AgentId::new("a"), "prompt", final_answer_infer)
3483            .await;
3484
3485        assert_eq!(runtime.metrics().backpressure_shed_count(), 1);
3486    }
3487
3488    #[tokio::test]
3489    async fn test_metrics_memory_recall_count_increments() {
3490        let store = EpisodicStore::new();
3491        let agent = AgentId::new("a");
3492        store.add_episode(agent.clone(), "fact", 0.9).unwrap();
3493
3494        let runtime = AgentRuntime::builder()
3495            .with_agent_config(simple_config())
3496            .with_memory(store)
3497            .build();
3498
3499        runtime
3500            .run_agent(agent, "prompt", final_answer_infer)
3501            .await
3502            .unwrap();
3503
3504        assert_eq!(runtime.metrics().memory_recall_count(), 1);
3505    }
3506
3507    // ── Memory token budgeting ────────────────────────────────────────────────
3508
3509    #[tokio::test]
3510    async fn test_agent_config_max_memory_tokens_limits_injection() {
3511        let store = EpisodicStore::new();
3512        let agent = AgentId::new("budget-agent");
3513        // Each memory has ~100 chars → ~25 tokens each
3514        for i in 0..5 {
3515            let content = format!("{:0>100}", i); // 100-char string
3516            store.add_episode(agent.clone(), content, 0.9).unwrap();
3517        }
3518
3519        // Token budget of 10 allows at most ~1 memory (each is ~25 tokens).
3520        let cfg = AgentConfig::new(5, "test").with_max_memory_tokens(10);
3521        let runtime = AgentRuntime::builder()
3522            .with_agent_config(cfg)
3523            .with_memory(store)
3524            .build();
3525
3526        let session = runtime
3527            .run_agent(agent, "prompt", final_answer_infer)
3528            .await
3529            .unwrap();
3530
3531        assert!(
3532            session.memory_hits <= 1,
3533            "expected at most 1 memory hit with tight token budget, got {}",
3534            session.memory_hits
3535        );
3536    }
3537
3538    // ── Working memory injection ──────────────────────────────────────────────
3539
3540    #[tokio::test]
3541    async fn test_working_memory_injected_into_prompt() {
3542        let wm = WorkingMemory::new(10).unwrap();
3543        wm.set("task", "write tests").unwrap();
3544        wm.set("status", "in progress").unwrap();
3545
3546        let runtime = AgentRuntime::builder()
3547            .with_agent_config(simple_config())
3548            .with_working_memory(wm)
3549            .build();
3550
3551        let mut captured_ctx: Option<String> = None;
3552        let captured_ref = &mut captured_ctx;
3553
3554        runtime
3555            .run_agent(AgentId::new("a"), "do stuff", |ctx: String| {
3556                *captured_ref = Some(ctx.clone());
3557                async move { "Thought: done\nAction: FINAL_ANSWER ok".to_string() }
3558            })
3559            .await
3560            .unwrap();
3561
3562        let ctx = captured_ctx.expect("infer should have been called");
3563        assert!(
3564            ctx.contains("Current working state:"),
3565            "expected working memory injection in context, got: {ctx}"
3566        );
3567        assert!(ctx.contains("task: write tests"));
3568        assert!(ctx.contains("status: in progress"));
3569    }
3570
3571    // ── Task 15: Token budget edge case tests ─────────────────────────────────
3572
3573    #[tokio::test]
3574    async fn test_token_budget_zero_returns_no_memories() {
3575        // A budget of 0 should result in no memories being injected.
3576        let store = EpisodicStore::new();
3577        let agent = AgentId::new("budget-agent");
3578        store.add_episode(agent.clone(), "short", 0.9).unwrap();
3579
3580        let mut config = AgentConfig::new(5, "test-model");
3581        config.max_memory_tokens = Some(0);
3582        config.max_memory_recalls = 10;
3583
3584        let runtime = AgentRuntime::builder()
3585            .with_memory(store)
3586            .with_agent_config(config)
3587            .build();
3588
3589        let steps = runtime
3590            .run_agent(
3591                agent,
3592                "test",
3593                |_ctx| async { "Thought: ok\nAction: FINAL_ANSWER done".to_string() },
3594            )
3595            .await
3596            .unwrap();
3597
3598        // The run should succeed; we just verify it doesn't panic or error.
3599        assert_eq!(steps.steps.len(), 1);
3600    }
3601
3602    #[tokio::test]
3603    async fn test_token_budget_smaller_than_smallest_item_returns_no_memories() {
3604        let store = EpisodicStore::new();
3605        let agent = AgentId::new("budget-agent2");
3606        // Content is 40 chars → ~10 tokens (40/4). Budget = 1 → none fit.
3607        store
3608            .add_episode(agent.clone(), "a".repeat(40), 0.9)
3609            .unwrap();
3610
3611        let mut config = AgentConfig::new(5, "test-model");
3612        config.max_memory_tokens = Some(1);
3613        config.max_memory_recalls = 10;
3614
3615        let runtime = AgentRuntime::builder()
3616            .with_memory(store)
3617            .with_agent_config(config)
3618            .build();
3619
3620        let session = runtime
3621            .run_agent(
3622                agent,
3623                "test",
3624                |_ctx| async { "Thought: ok\nAction: FINAL_ANSWER done".to_string() },
3625            )
3626            .await
3627            .unwrap();
3628
3629        assert_eq!(session.memory_hits, 0);
3630    }
3631
3632    // ── Improvement 8: AgentRuntime::quick() ──────────────────────────────────
3633
3634    #[tokio::test]
3635    async fn test_agent_runtime_quick_runs_agent() {
3636        let runtime = AgentRuntime::quick(5, "test-model");
3637        let agent = AgentId::new("quick-agent");
3638        let session = runtime
3639            .run_agent(agent, "hello", |_ctx| async {
3640                "Thought: done\nAction: FINAL_ANSWER ok".to_string()
3641            })
3642            .await
3643            .unwrap();
3644        assert_eq!(session.step_count(), 1);
3645    }
3646
3647    // ── #1 final_answer() ─────────────────────────────────────────────────────
3648
3649    #[test]
3650    fn test_final_answer_extracts_text() {
3651        let session = AgentSession {
3652            session_id: "s".into(),
3653            agent_id: AgentId::new("a"),
3654            steps: vec![ReActStep {
3655                thought: "done".into(),
3656                action: "FINAL_ANSWER Paris".into(),
3657                observation: "".into(),
3658                step_duration_ms: 0,
3659            }],
3660            memory_hits: 0,
3661            graph_lookups: 0,
3662            duration_ms: 0,
3663            checkpoint_errors: vec![],
3664        };
3665        assert_eq!(session.final_answer(), Some("Paris".to_string()));
3666    }
3667
3668    #[test]
3669    fn test_final_answer_returns_none_without_final_step() {
3670        let session = AgentSession {
3671            session_id: "s".into(),
3672            agent_id: AgentId::new("a"),
3673            steps: vec![ReActStep {
3674                thought: "thinking".into(),
3675                action: "search {}".into(),
3676                observation: "result".into(),
3677                step_duration_ms: 0,
3678            }],
3679            memory_hits: 0,
3680            graph_lookups: 0,
3681            duration_ms: 0,
3682            checkpoint_errors: vec![],
3683        };
3684        assert_eq!(session.final_answer(), None);
3685
3686        let empty_session = AgentSession {
3687            session_id: "s2".into(),
3688            agent_id: AgentId::new("a"),
3689            steps: vec![],
3690            memory_hits: 0,
3691            graph_lookups: 0,
3692            duration_ms: 0,
3693            checkpoint_errors: vec![],
3694        };
3695        assert_eq!(empty_session.final_answer(), None);
3696    }
3697
3698    #[test]
3699    fn test_all_actions_returns_actions_in_order() {
3700        let session = AgentSession {
3701            session_id: "s".into(),
3702            agent_id: AgentId::new("a"),
3703            steps: vec![
3704                ReActStep::new("think1", "search {}", "result"),
3705                ReActStep::new("think2", "FINAL_ANSWER done", ""),
3706            ],
3707            memory_hits: 0,
3708            graph_lookups: 0,
3709            duration_ms: 10,
3710            checkpoint_errors: vec![],
3711        };
3712        assert_eq!(session.all_actions(), vec!["search {}", "FINAL_ANSWER done"]);
3713    }
3714
3715    #[test]
3716    fn test_has_checkpoint_errors_false_when_empty() {
3717        let session = AgentSession {
3718            session_id: "s".into(),
3719            agent_id: AgentId::new("a"),
3720            steps: vec![],
3721            memory_hits: 0,
3722            graph_lookups: 0,
3723            duration_ms: 0,
3724            checkpoint_errors: vec![],
3725        };
3726        assert!(!session.has_checkpoint_errors());
3727    }
3728
3729    #[test]
3730    fn test_has_checkpoint_errors_true_when_non_empty() {
3731        let session = AgentSession {
3732            session_id: "s".into(),
3733            agent_id: AgentId::new("a"),
3734            steps: vec![],
3735            memory_hits: 0,
3736            graph_lookups: 0,
3737            duration_ms: 0,
3738            checkpoint_errors: vec!["err".into()],
3739        };
3740        assert!(session.has_checkpoint_errors());
3741    }
3742
3743    #[test]
3744    fn test_memory_hit_rate_zero_with_no_steps() {
3745        let session = AgentSession {
3746            session_id: "s".into(),
3747            agent_id: AgentId::new("a"),
3748            steps: vec![],
3749            memory_hits: 5,
3750            graph_lookups: 0,
3751            duration_ms: 0,
3752            checkpoint_errors: vec![],
3753        };
3754        assert_eq!(session.memory_hit_rate(), 0.0);
3755    }
3756
3757    #[test]
3758    fn test_memory_hit_rate_correct_proportion() {
3759        let session = AgentSession {
3760            session_id: "s".into(),
3761            agent_id: AgentId::new("a"),
3762            steps: vec![
3763                ReActStep::new("t", "a", "o"),
3764                ReActStep::new("t", "a", "o"),
3765                ReActStep::new("t", "a", "o"),
3766                ReActStep::new("t", "a", "o"),
3767            ],
3768            memory_hits: 2,
3769            graph_lookups: 0,
3770            duration_ms: 0,
3771            checkpoint_errors: vec![],
3772        };
3773        assert!((session.memory_hit_rate() - 0.5).abs() < 1e-9);
3774    }
3775
3776    #[test]
3777    fn test_filter_tool_call_steps_excludes_final_answer() {
3778        let session = AgentSession {
3779            session_id: "s".into(),
3780            agent_id: AgentId::new("a"),
3781            steps: vec![
3782                ReActStep::new("t1", "search {}", "res"),
3783                ReActStep::new("t2", "FINAL_ANSWER done", ""),
3784            ],
3785            memory_hits: 0,
3786            graph_lookups: 0,
3787            duration_ms: 0,
3788            checkpoint_errors: vec![],
3789        };
3790        let tool_steps = session.filter_tool_call_steps();
3791        assert_eq!(tool_steps.len(), 1);
3792        assert_eq!(tool_steps[0].action, "search {}");
3793    }
3794
3795    #[test]
3796    fn test_slowest_step_index() {
3797        let mut s0 = ReActStep::new("t", "a", "o");
3798        s0.step_duration_ms = 5;
3799        let mut s1 = ReActStep::new("t", "a", "o");
3800        s1.step_duration_ms = 100;
3801        let mut s2 = ReActStep::new("t", "a", "o");
3802        s2.step_duration_ms = 10;
3803        let session = AgentSession {
3804            session_id: "s".into(),
3805            agent_id: AgentId::new("a"),
3806            steps: vec![s0, s1, s2],
3807            memory_hits: 0,
3808            graph_lookups: 0,
3809            duration_ms: 0,
3810            checkpoint_errors: vec![],
3811        };
3812        assert_eq!(session.slowest_step_index(), Some(1));
3813        assert_eq!(session.fastest_step_index(), Some(0));
3814    }
3815
3816    #[test]
3817    fn test_slowest_step_index_none_when_empty() {
3818        let session = AgentSession {
3819            session_id: "s".into(),
3820            agent_id: AgentId::new("a"),
3821            steps: vec![],
3822            memory_hits: 0,
3823            graph_lookups: 0,
3824            duration_ms: 0,
3825            checkpoint_errors: vec![],
3826        };
3827        assert_eq!(session.slowest_step_index(), None);
3828        assert_eq!(session.fastest_step_index(), None);
3829    }
3830
3831    #[test]
3832    fn test_last_step_returns_last() {
3833        let session = AgentSession {
3834            session_id: "s".into(),
3835            agent_id: AgentId::new("a"),
3836            steps: vec![
3837                ReActStep::new("t1", "a1", "o1"),
3838                ReActStep::new("t2", "FINAL_ANSWER done", ""),
3839            ],
3840            memory_hits: 0,
3841            graph_lookups: 0,
3842            duration_ms: 0,
3843            checkpoint_errors: vec![],
3844        };
3845        assert_eq!(session.last_step().map(|s| s.action.as_str()), Some("FINAL_ANSWER done"));
3846    }
3847
3848    #[test]
3849    fn test_last_step_none_when_empty() {
3850        let session = AgentSession {
3851            session_id: "s".into(),
3852            agent_id: AgentId::new("a"),
3853            steps: vec![],
3854            memory_hits: 0,
3855            graph_lookups: 0,
3856            duration_ms: 0,
3857            checkpoint_errors: vec![],
3858        };
3859        assert!(session.last_step().is_none());
3860    }
3861
3862    #[test]
3863    fn test_step_at_returns_correct_step() {
3864        let session = AgentSession {
3865            session_id: "s".into(),
3866            agent_id: AgentId::new("a"),
3867            steps: vec![
3868                ReActStep::new("t0", "a0", "o0"),
3869                ReActStep::new("t1", "a1", "o1"),
3870            ],
3871            memory_hits: 0,
3872            graph_lookups: 0,
3873            duration_ms: 0,
3874            checkpoint_errors: vec![],
3875        };
3876        assert_eq!(session.step_at(1).map(|s| s.thought.as_str()), Some("t1"));
3877        assert!(session.step_at(99).is_none());
3878    }
3879
3880    // ── Round 3: failed_steps ─────────────────────────────────────────────────
3881
3882    #[test]
3883    fn test_failed_steps_returns_steps_with_error_observation() {
3884        use crate::agent::ReActStep;
3885        let session = AgentSession {
3886            session_id: "s".into(),
3887            agent_id: AgentId::new("a"),
3888            steps: vec![
3889                ReActStep::new("t", "tool_a {}", r#"{"error":"bad input","ok":false}"#),
3890                ReActStep::new("t", "tool_b {}", r#"{"result":"ok","ok":true}"#),
3891            ],
3892            memory_hits: 0,
3893            graph_lookups: 0,
3894            duration_ms: 0,
3895            checkpoint_errors: vec![],
3896        };
3897        let failed = session.failed_steps();
3898        assert_eq!(failed.len(), 1);
3899        assert!(failed[0].observation.contains("bad input"));
3900    }
3901
3902    #[test]
3903    fn test_failed_steps_empty_when_no_errors() {
3904        use crate::agent::ReActStep;
3905        let session = AgentSession {
3906            session_id: "s".into(),
3907            agent_id: AgentId::new("a"),
3908            steps: vec![ReActStep::new("t", "FINAL_ANSWER done", "")],
3909            memory_hits: 0,
3910            graph_lookups: 0,
3911            duration_ms: 0,
3912            checkpoint_errors: vec![],
3913        };
3914        assert!(session.failed_steps().is_empty());
3915    }
3916
3917    // ── Round 17: untested AgentSession methods ───────────────────────────────
3918
3919    fn make_step(thought: &str, action: &str, observation: &str) -> ReActStep {
3920        ReActStep::new(thought, action, observation)
3921    }
3922
3923    fn make_session(steps: Vec<ReActStep>, duration_ms: u64) -> AgentSession {
3924        AgentSession {
3925            session_id: "s".into(),
3926            agent_id: AgentId::new("a"),
3927            steps,
3928            memory_hits: 0,
3929            graph_lookups: 0,
3930            duration_ms,
3931            checkpoint_errors: vec![],
3932        }
3933    }
3934
3935    #[test]
3936    fn test_step_count_returns_number_of_steps() {
3937        let s = make_session(vec![ReActStep::new("t", "a", "o"), ReActStep::new("t", "a", "o")], 0);
3938        assert_eq!(s.step_count(), 2);
3939    }
3940
3941    #[test]
3942    fn test_is_empty_true_for_no_steps() {
3943        let s = make_session(vec![], 0);
3944        assert!(s.is_empty());
3945    }
3946
3947    #[test]
3948    fn test_is_empty_false_with_steps() {
3949        let s = make_session(vec![ReActStep::new("t", "a", "o")], 0);
3950        assert!(!s.is_empty());
3951    }
3952
3953    #[test]
3954    fn test_is_successful_true_with_final_answer() {
3955        let s = make_session(vec![ReActStep::new("t", "FINAL_ANSWER yes", "")], 0);
3956        assert!(s.is_successful());
3957    }
3958
3959    #[test]
3960    fn test_is_successful_false_without_final_answer() {
3961        let s = make_session(vec![ReActStep::new("t", "search {}", "result")], 0);
3962        assert!(!s.is_successful());
3963    }
3964
3965    #[test]
3966    fn test_elapsed_returns_duration_from_duration_ms() {
3967        let s = make_session(vec![], 500);
3968        assert_eq!(s.elapsed(), std::time::Duration::from_millis(500));
3969    }
3970
3971    #[test]
3972    fn test_tool_calls_made_excludes_final_answer() {
3973        let s = make_session(vec![
3974            ReActStep::new("t", "search {}", "res"),
3975            ReActStep::new("t", "lookup {}", "res"),
3976            ReActStep::new("t", "FINAL_ANSWER done", ""),
3977        ], 0);
3978        assert_eq!(s.tool_calls_made(), 2);
3979    }
3980
3981    #[test]
3982    fn test_total_step_duration_ms_sums_all_steps() {
3983        let mut s1 = ReActStep::new("t", "a", "o"); s1.step_duration_ms = 10;
3984        let mut s2 = ReActStep::new("t", "a", "o"); s2.step_duration_ms = 30;
3985        let s = make_session(vec![s1, s2], 0);
3986        assert_eq!(s.total_step_duration_ms(), 40);
3987    }
3988
3989    #[test]
3990    fn test_average_step_duration_ms() {
3991        let mut s1 = ReActStep::new("t", "a", "o"); s1.step_duration_ms = 20;
3992        let mut s2 = ReActStep::new("t", "a", "o"); s2.step_duration_ms = 40;
3993        let s = make_session(vec![s1, s2], 0);
3994        assert_eq!(s.average_step_duration_ms(), 30);
3995    }
3996
3997    #[test]
3998    fn test_all_thoughts_returns_thoughts_in_order() {
3999        let s = make_session(vec![
4000            ReActStep::new("first thought", "a1", "o1"),
4001            ReActStep::new("second thought", "a2", "o2"),
4002        ], 0);
4003        assert_eq!(s.all_thoughts(), vec!["first thought", "second thought"]);
4004    }
4005
4006    #[test]
4007    fn test_all_observations_returns_observations_in_order() {
4008        let s = make_session(vec![
4009            ReActStep::new("t1", "a1", "obs one"),
4010            ReActStep::new("t2", "a2", "obs two"),
4011        ], 0);
4012        assert_eq!(s.all_observations(), vec!["obs one", "obs two"]);
4013    }
4014
4015    #[test]
4016    fn test_observations_matching_finds_matching_steps() {
4017        let s = make_session(vec![
4018            ReActStep::new("t1", "a1", "found the answer"),
4019            ReActStep::new("t2", "a2", "nothing relevant"),
4020        ], 0);
4021        let matching = s.observations_matching("answer");
4022        assert_eq!(matching.len(), 1);
4023        assert!(matching[0].observation.contains("answer"));
4024    }
4025
4026    #[test]
4027    fn test_first_step_returns_first() {
4028        let s = make_session(vec![
4029            ReActStep::new("first", "a1", "o1"),
4030            ReActStep::new("second", "a2", "o2"),
4031        ], 0);
4032        assert_eq!(s.first_step().map(|s| s.thought.as_str()), Some("first"));
4033    }
4034
4035    #[test]
4036    fn test_first_step_none_when_empty() {
4037        let s = make_session(vec![], 0);
4038        assert!(s.first_step().is_none());
4039    }
4040
4041    // ── Round 18: graph_lookup_count ─────────────────────────────────────────
4042
4043    #[test]
4044    fn test_graph_lookup_count_returns_field() {
4045        let session = AgentSession {
4046            session_id: "s".into(),
4047            agent_id: AgentId::new("a"),
4048            steps: vec![],
4049            memory_hits: 0,
4050            graph_lookups: 7,
4051            duration_ms: 0,
4052            checkpoint_errors: vec![],
4053        };
4054        assert_eq!(session.graph_lookup_count(), 7usize);
4055    }
4056
4057    // ── Round 7: action_counts / unique_actions ───────────────────────────────
4058
4059    #[test]
4060    fn test_action_counts_counts_each_action() {
4061        let session = make_session(
4062            vec![
4063                ReActStep::new("t1", "search", "r1"),
4064                ReActStep::new("t2", "search", "r2"),
4065                ReActStep::new("t3", "FINAL_ANSWER", "done"),
4066            ],
4067            0,
4068        );
4069        let counts = session.action_counts();
4070        assert_eq!(counts.get("search").copied().unwrap_or(0), 2);
4071        assert_eq!(counts.get("FINAL_ANSWER").copied().unwrap_or(0), 1);
4072    }
4073
4074    #[test]
4075    fn test_unique_actions_returns_sorted_deduped() {
4076        let session = make_session(
4077            vec![
4078                ReActStep::new("t", "b_action", "r"),
4079                ReActStep::new("t", "a_action", "r"),
4080                ReActStep::new("t", "b_action", "r"),
4081            ],
4082            0,
4083        );
4084        assert_eq!(session.unique_actions(), vec!["a_action", "b_action"]);
4085    }
4086
4087    #[test]
4088    fn test_unique_actions_empty_when_no_steps() {
4089        let session = make_session(vec![], 0);
4090        assert!(session.unique_actions().is_empty());
4091    }
4092
4093    // ── Round 8: total_latency_ms / action_sequence ───────────────────────────
4094
4095    #[test]
4096    fn test_total_latency_ms_sums_step_durations() {
4097        let mut steps = vec![
4098            ReActStep::new("t1", "a1", "o1"),
4099            ReActStep::new("t2", "a2", "o2"),
4100        ];
4101        steps[0].step_duration_ms = 100;
4102        steps[1].step_duration_ms = 250;
4103        let session = make_session(steps, 350);
4104        assert_eq!(session.total_latency_ms(), 350);
4105    }
4106
4107    #[test]
4108    fn test_total_latency_ms_zero_for_empty_session() {
4109        let session = make_session(vec![], 0);
4110        assert_eq!(session.total_latency_ms(), 0);
4111    }
4112
4113    #[test]
4114    fn test_action_sequence_returns_actions_in_order() {
4115        let session = make_session(
4116            vec![
4117                ReActStep::new("t", "search", "r"),
4118                ReActStep::new("t", "FINAL_ANSWER", "done"),
4119            ],
4120            0,
4121        );
4122        assert_eq!(session.action_sequence(), vec!["search", "FINAL_ANSWER"]);
4123    }
4124
4125    // ── Round 9: has_action / thought_at ─────────────────────────────────────
4126
4127    #[test]
4128    fn test_has_action_returns_true_for_present_action() {
4129        let session = make_session(
4130            vec![ReActStep::new("t", "search", "r")],
4131            0,
4132        );
4133        assert!(session.has_action("search"));
4134    }
4135
4136    #[test]
4137    fn test_has_action_returns_false_for_absent_action() {
4138        let session = make_session(
4139            vec![ReActStep::new("t", "search", "r")],
4140            0,
4141        );
4142        assert!(!session.has_action("compute"));
4143    }
4144
4145    #[test]
4146    fn test_thought_at_returns_thought_for_valid_index() {
4147        let session = make_session(
4148            vec![
4149                ReActStep::new("first thought", "a1", "r1"),
4150                ReActStep::new("second thought", "a2", "r2"),
4151            ],
4152            0,
4153        );
4154        assert_eq!(session.thought_at(0), Some("first thought"));
4155        assert_eq!(session.thought_at(1), Some("second thought"));
4156    }
4157
4158    #[test]
4159    fn test_thought_at_returns_none_for_out_of_bounds_index() {
4160        let session = make_session(vec![ReActStep::new("t", "a", "r")], 0);
4161        assert!(session.thought_at(99).is_none());
4162    }
4163
4164    // ── Round 10: step_count_for_action / observations ────────────────────────
4165
4166    #[test]
4167    fn test_step_count_for_action_counts_correctly() {
4168        let session = make_session(
4169            vec![
4170                ReActStep::new("t", "search", "r1"),
4171                ReActStep::new("t", "search", "r2"),
4172                ReActStep::new("t", "FINAL_ANSWER", "done"),
4173            ],
4174            0,
4175        );
4176        assert_eq!(session.step_count_for_action("search"), 2);
4177        assert_eq!(session.step_count_for_action("FINAL_ANSWER"), 1);
4178        assert_eq!(session.step_count_for_action("unknown"), 0);
4179    }
4180
4181    #[test]
4182    fn test_observations_returns_all_observation_strings() {
4183        let session = make_session(
4184            vec![
4185                ReActStep::new("t1", "a", "obs_one"),
4186                ReActStep::new("t2", "b", "obs_two"),
4187            ],
4188            0,
4189        );
4190        let obs = session.observations();
4191        assert_eq!(obs, vec!["obs_one", "obs_two"]);
4192    }
4193
4194    #[test]
4195    fn test_observations_empty_for_no_steps() {
4196        let session = make_session(vec![], 0);
4197        assert!(session.observations().is_empty());
4198    }
4199
4200    // ── Round 10: unique_tools_used ──────────────────────────────────────────
4201
4202    #[test]
4203    fn test_unique_tools_used_deduplicates_actions() {
4204        let session = make_session(
4205            vec![
4206                ReActStep::new("t", "search", "r1"),
4207                ReActStep::new("t", "lookup", "r2"),
4208                ReActStep::new("t", "search", "r3"),
4209            ],
4210            0,
4211        );
4212        let tools = session.unique_tools_used();
4213        assert_eq!(tools.len(), 2);
4214        assert!(tools.contains(&"search".to_string()));
4215        assert!(tools.contains(&"lookup".to_string()));
4216    }
4217
4218    #[test]
4219    fn test_unique_tools_used_excludes_final_answer() {
4220        let session = make_session(
4221            vec![
4222                ReActStep::new("t", "search", "r1"),
4223                ReActStep::new("t", "FINAL_ANSWER: done", "r2"),
4224            ],
4225            0,
4226        );
4227        let tools = session.unique_tools_used();
4228        assert_eq!(tools.len(), 1);
4229        assert!(tools.contains(&"search".to_string()));
4230    }
4231
4232    #[test]
4233    fn test_unique_tools_used_empty_for_no_steps() {
4234        let session = make_session(vec![], 0);
4235        assert!(session.unique_tools_used().is_empty());
4236    }
4237
4238    // ── Round 11: avg_step_duration_ms / longest_step / shortest_step ─────────
4239
4240    #[test]
4241    fn test_avg_step_duration_zero_for_empty_session() {
4242        let session = make_session(vec![], 0);
4243        assert!((session.avg_step_duration_ms() - 0.0).abs() < 1e-9);
4244    }
4245
4246    #[test]
4247    fn test_avg_step_duration_single_step() {
4248        let mut step = ReActStep::new("t", "a", "r");
4249        step.step_duration_ms = 100;
4250        let session = make_session(vec![step], 0);
4251        assert!((session.avg_step_duration_ms() - 100.0).abs() < 1e-9);
4252    }
4253
4254    #[test]
4255    fn test_avg_step_duration_multiple_steps() {
4256        let mut s1 = ReActStep::new("t1", "a", "r");
4257        s1.step_duration_ms = 100;
4258        let mut s2 = ReActStep::new("t2", "b", "r");
4259        s2.step_duration_ms = 200;
4260        let session = make_session(vec![s1, s2], 0);
4261        assert!((session.avg_step_duration_ms() - 150.0).abs() < 1e-9);
4262    }
4263
4264    #[test]
4265    fn test_longest_step_returns_step_with_max_duration() {
4266        let mut s1 = ReActStep::new("t1", "a", "r");
4267        s1.step_duration_ms = 50;
4268        let mut s2 = ReActStep::new("t2", "b", "r");
4269        s2.step_duration_ms = 200;
4270        let session = make_session(vec![s1, s2], 0);
4271        assert_eq!(session.longest_step().map(|s| s.step_duration_ms), Some(200));
4272    }
4273
4274    #[test]
4275    fn test_longest_step_returns_none_for_empty_session() {
4276        let session = make_session(vec![], 0);
4277        assert!(session.longest_step().is_none());
4278    }
4279
4280    #[test]
4281    fn test_shortest_step_returns_step_with_min_duration() {
4282        let mut s1 = ReActStep::new("t1", "a", "r");
4283        s1.step_duration_ms = 50;
4284        let mut s2 = ReActStep::new("t2", "b", "r");
4285        s2.step_duration_ms = 200;
4286        let session = make_session(vec![s1, s2], 0);
4287        assert_eq!(session.shortest_step().map(|s| s.step_duration_ms), Some(50));
4288    }
4289
4290    // ── Round 12: first_thought / last_thought ────────────────────────────────
4291
4292    #[test]
4293    fn test_first_thought_returns_thought_from_first_step() {
4294        let session = make_session(
4295            vec![
4296                ReActStep::new("alpha", "a1", "r1"),
4297                ReActStep::new("beta", "a2", "r2"),
4298            ],
4299            0,
4300        );
4301        assert_eq!(session.first_thought(), Some("alpha"));
4302    }
4303
4304    #[test]
4305    fn test_last_thought_returns_thought_from_last_step() {
4306        let session = make_session(
4307            vec![
4308                ReActStep::new("alpha", "a1", "r1"),
4309                ReActStep::new("beta", "a2", "r2"),
4310            ],
4311            0,
4312        );
4313        assert_eq!(session.last_thought(), Some("beta"));
4314    }
4315
4316    #[test]
4317    fn test_first_thought_none_for_empty_session() {
4318        let session = make_session(vec![], 0);
4319        assert!(session.first_thought().is_none());
4320    }
4321
4322    #[test]
4323    fn test_last_thought_none_for_empty_session() {
4324        let session = make_session(vec![], 0);
4325        assert!(session.last_thought().is_none());
4326    }
4327
4328    // ── Round 13: first_action / last_action ──────────────────────────────────
4329
4330    #[test]
4331    fn test_first_action_returns_action_from_first_step() {
4332        let session = make_session(
4333            vec![
4334                ReActStep::new("t1", "search", "r1"),
4335                ReActStep::new("t2", "FINAL_ANSWER", "r2"),
4336            ],
4337            0,
4338        );
4339        assert_eq!(session.first_action(), Some("search"));
4340    }
4341
4342    #[test]
4343    fn test_last_action_returns_action_from_last_step() {
4344        let session = make_session(
4345            vec![
4346                ReActStep::new("t1", "search", "r1"),
4347                ReActStep::new("t2", "FINAL_ANSWER", "r2"),
4348            ],
4349            0,
4350        );
4351        assert_eq!(session.last_action(), Some("FINAL_ANSWER"));
4352    }
4353
4354    #[test]
4355    fn test_first_action_none_for_empty_session() {
4356        let session = make_session(vec![], 0);
4357        assert!(session.first_action().is_none());
4358    }
4359
4360    #[test]
4361    fn test_last_action_equals_first_action_for_single_step() {
4362        let session = make_session(vec![ReActStep::new("t", "calc", "r")], 0);
4363        assert_eq!(session.first_action(), session.last_action());
4364    }
4365
4366    // ── Round 14: AgentSession::checkpoint_error_count ───────────────────────
4367
4368    #[test]
4369    fn test_checkpoint_error_count_zero_when_none() {
4370        let session = make_session(vec![], 0);
4371        assert_eq!(session.checkpoint_error_count(), 0);
4372    }
4373
4374    #[test]
4375    fn test_checkpoint_error_count_reflects_errors() {
4376        let mut session = make_session(vec![], 0);
4377        session.checkpoint_errors.push("save failed".into());
4378        session.checkpoint_errors.push("disk full".into());
4379        assert_eq!(session.checkpoint_error_count(), 2);
4380    }
4381
4382    // ── Round 27: failed_tool_call_count ─────────────────────────────────────
4383
4384    #[test]
4385    fn test_failed_tool_call_count_zero_when_no_errors() {
4386        let step = ReActStep::new("think", "search", "results found");
4387        let session = make_session(vec![step], 0);
4388        assert_eq!(session.failed_tool_call_count(), 0);
4389    }
4390
4391    #[test]
4392    fn test_failed_tool_call_count_matches_failed_steps() {
4393        let ok_step = ReActStep::new("ok", "search", "all good");
4394        let err_step = ReActStep::new("err", "lookup", "{\"error\": \"not found\"}");
4395        let session = make_session(vec![ok_step, err_step], 0);
4396        assert_eq!(session.failed_tool_call_count(), session.failed_steps().len());
4397        assert_eq!(session.failed_tool_call_count(), 1);
4398    }
4399
4400    #[test]
4401    fn test_failed_tool_call_count_counts_all_errors() {
4402        let err1 = ReActStep::new("e1", "a", "{\"error\": \"bad\"}");
4403        let err2 = ReActStep::new("e2", "b", "some \"error\" text");
4404        let ok = ReActStep::new("ok", "c", "success");
4405        let session = make_session(vec![err1, err2, ok], 0);
4406        assert_eq!(session.failed_tool_call_count(), 2);
4407    }
4408
4409    // ── Round 15: AgentSession::total_memory_hits / action_diversity ─────────
4410
4411    #[test]
4412    fn test_total_memory_hits_returns_memory_hits_field() {
4413        let mut session = make_session(vec![], 0);
4414        session.memory_hits = 7;
4415        assert_eq!(session.total_memory_hits(), 7);
4416    }
4417
4418    #[test]
4419    fn test_total_memory_hits_zero_by_default() {
4420        let session = make_session(vec![], 0);
4421        assert_eq!(session.total_memory_hits(), 0);
4422    }
4423
4424    #[test]
4425    fn test_action_diversity_all_unique_is_one() {
4426        let steps = vec![
4427            ReActStep::new("t", "search", "r"),
4428            ReActStep::new("t", "calc", "r"),
4429            ReActStep::new("t", "lookup", "r"),
4430        ];
4431        let session = make_session(steps, 0);
4432        assert!((session.action_diversity() - 1.0).abs() < 1e-9);
4433    }
4434
4435    #[test]
4436    fn test_action_diversity_all_same_is_fraction() {
4437        let steps = vec![
4438            ReActStep::new("t", "search", "r"),
4439            ReActStep::new("t", "search", "r"),
4440            ReActStep::new("t", "search", "r"),
4441        ];
4442        let session = make_session(steps, 0);
4443        // 1 unique / 3 total = 1/3
4444        assert!((session.action_diversity() - 1.0 / 3.0).abs() < 1e-9);
4445    }
4446
4447    #[test]
4448    fn test_action_diversity_zero_for_empty_session() {
4449        let session = make_session(vec![], 0);
4450        assert!((session.action_diversity() - 0.0).abs() < 1e-9);
4451    }
4452
4453    // ── Round 14: AgentSession::last_n_steps ──────────────────────────────────
4454
4455    #[test]
4456    fn test_last_n_steps_returns_last_n() {
4457        let steps = vec![
4458            ReActStep::new("t1", "a", "r1"),
4459            ReActStep::new("t2", "b", "r2"),
4460            ReActStep::new("t3", "c", "r3"),
4461        ];
4462        let session = make_session(steps, 0);
4463        let last2 = session.last_n_steps(2);
4464        assert_eq!(last2.len(), 2);
4465        assert_eq!(last2[0].action, "b");
4466        assert_eq!(last2[1].action, "c");
4467    }
4468
4469    #[test]
4470    fn test_last_n_steps_returns_all_when_n_exceeds_count() {
4471        let steps = vec![
4472            ReActStep::new("t1", "a", "r1"),
4473            ReActStep::new("t2", "b", "r2"),
4474        ];
4475        let session = make_session(steps, 0);
4476        assert_eq!(session.last_n_steps(10).len(), 2);
4477    }
4478
4479    #[test]
4480    fn test_last_n_steps_empty_for_no_steps() {
4481        let session = make_session(vec![], 0);
4482        assert!(session.last_n_steps(3).is_empty());
4483    }
4484
4485    #[test]
4486    fn test_last_n_steps_zero_returns_empty() {
4487        let steps = vec![ReActStep::new("t1", "a", "r1")];
4488        let session = make_session(steps, 0);
4489        assert!(session.last_n_steps(0).is_empty());
4490    }
4491
4492    // ── Round 16: observation_count / steps_without_observation ──────────────
4493
4494    #[test]
4495    fn test_observation_count_counts_non_empty() {
4496        let steps = vec![
4497            ReActStep::new("t", "a", "result"),
4498            ReActStep::new("t", "b", ""),
4499            ReActStep::new("t", "c", "data"),
4500        ];
4501        let session = make_session(steps, 0);
4502        assert_eq!(session.observation_count(), 2);
4503    }
4504
4505    #[test]
4506    fn test_observation_count_zero_when_all_empty() {
4507        let steps = vec![
4508            ReActStep::new("t", "a", ""),
4509            ReActStep::new("t", "b", ""),
4510        ];
4511        let session = make_session(steps, 0);
4512        assert_eq!(session.observation_count(), 0);
4513    }
4514
4515    #[test]
4516    fn test_steps_without_observation_counts_empty_obs() {
4517        let steps = vec![
4518            ReActStep::new("t", "a", ""),
4519            ReActStep::new("t", "b", "data"),
4520            ReActStep::new("t", "c", ""),
4521        ];
4522        let session = make_session(steps, 0);
4523        assert_eq!(session.steps_without_observation(), 2);
4524    }
4525
4526    #[test]
4527    fn test_steps_without_observation_zero_when_all_filled() {
4528        let steps = vec![
4529            ReActStep::new("t", "a", "r1"),
4530            ReActStep::new("t", "b", "r2"),
4531        ];
4532        let session = make_session(steps, 0);
4533        assert_eq!(session.steps_without_observation(), 0);
4534    }
4535
4536    // ── Round 17: AgentSession::throughput_steps_per_sec ─────────────────────
4537
4538    #[test]
4539    fn test_throughput_steps_per_sec_correct_ratio() {
4540        let steps = vec![
4541            ReActStep::new("t", "a", "r"),
4542            ReActStep::new("t", "b", "r"),
4543        ];
4544        // 2 steps in 1000ms = 2.0 steps/sec
4545        let session = make_session(steps, 1000);
4546        assert!((session.throughput_steps_per_sec() - 2.0).abs() < 1e-9);
4547    }
4548
4549    #[test]
4550    fn test_throughput_steps_per_sec_zero_when_no_duration() {
4551        let steps = vec![ReActStep::new("t", "a", "r")];
4552        let session = make_session(steps, 0);
4553        assert!((session.throughput_steps_per_sec() - 0.0).abs() < 1e-9);
4554    }
4555
4556    // ── Round 20: thoughts_containing, step_durations_ms, fastest_step_index ─
4557
4558    #[test]
4559    fn test_thoughts_containing_returns_matching_steps() {
4560        let session = make_session(
4561            vec![
4562                ReActStep::new("I need to search", "search", "found"),
4563                ReActStep::new("Let me calculate", "calc", "done"),
4564                ReActStep::new("search again", "search", "ok"),
4565            ],
4566            0,
4567        );
4568        let matches = session.thoughts_containing("search");
4569        assert_eq!(matches.len(), 2);
4570    }
4571
4572    #[test]
4573    fn test_thoughts_containing_is_case_insensitive() {
4574        let session = make_session(
4575            vec![ReActStep::new("SEARCH the web", "search", "r")],
4576            0,
4577        );
4578        assert_eq!(session.thoughts_containing("search").len(), 1);
4579    }
4580
4581    #[test]
4582    fn test_thoughts_containing_empty_when_no_match() {
4583        let session = make_session(vec![ReActStep::new("think about x", "a", "r")], 0);
4584        assert!(session.thoughts_containing("zebra").is_empty());
4585    }
4586
4587    #[test]
4588    fn test_step_durations_ms_returns_all_durations() {
4589        let mut steps = vec![
4590            ReActStep::new("t", "a", "r"),
4591            ReActStep::new("t", "b", "r"),
4592        ];
4593        steps[0].step_duration_ms = 100;
4594        steps[1].step_duration_ms = 200;
4595        let session = make_session(steps, 300);
4596        assert_eq!(session.step_durations_ms(), vec![100, 200]);
4597    }
4598
4599    #[test]
4600    fn test_fastest_step_index_returns_index_of_shortest_step() {
4601        let mut steps = vec![
4602            ReActStep::new("t", "a", "r"),
4603            ReActStep::new("t", "b", "r"),
4604            ReActStep::new("t", "c", "r"),
4605        ];
4606        steps[0].step_duration_ms = 300;
4607        steps[1].step_duration_ms = 50;
4608        steps[2].step_duration_ms = 200;
4609        let session = make_session(steps, 550);
4610        assert_eq!(session.fastest_step_index(), Some(1));
4611    }
4612
4613    #[test]
4614    fn test_fastest_step_index_none_for_empty_session() {
4615        let session = make_session(vec![], 0);
4616        assert!(session.fastest_step_index().is_none());
4617    }
4618
4619    // ── Round 18: most_used_action / graph_lookup_rate ────────────────────────
4620
4621    #[test]
4622    fn test_most_used_action_returns_most_frequent() {
4623        let steps = vec![
4624            ReActStep::new("t", "search", "r"),
4625            ReActStep::new("t", "calc", "r"),
4626            ReActStep::new("t", "search", "r"),
4627        ];
4628        let session = make_session(steps, 0);
4629        assert_eq!(session.most_used_action().as_deref(), Some("search"));
4630    }
4631
4632    #[test]
4633    fn test_most_used_action_none_for_empty_session() {
4634        let session = make_session(vec![], 0);
4635        assert!(session.most_used_action().is_none());
4636    }
4637
4638    #[test]
4639    fn test_graph_lookup_rate_correct_ratio() {
4640        let steps = vec![
4641            ReActStep::new("t", "a", "r"),
4642            ReActStep::new("t", "b", "r"),
4643            ReActStep::new("t", "c", "r"),
4644            ReActStep::new("t", "d", "r"),
4645        ];
4646        let mut session = make_session(steps, 0);
4647        session.graph_lookups = 2;
4648        assert!((session.graph_lookup_rate() - 0.5).abs() < 1e-9);
4649    }
4650
4651    #[test]
4652    fn test_graph_lookup_rate_zero_for_empty_session() {
4653        let session = make_session(vec![], 0);
4654        assert!((session.graph_lookup_rate() - 0.0).abs() < 1e-9);
4655    }
4656
4657    // ── Round 22: has_tool_failures, tool_call_rate, step_success_rate ────────
4658
4659    #[test]
4660    fn test_has_tool_failures_false_when_no_errors() {
4661        let steps = vec![
4662            make_step("t", "action1", "ok"),
4663            make_step("t", "action2", "done"),
4664        ];
4665        let session = make_session(steps, 0);
4666        assert!(!session.has_tool_failures());
4667    }
4668
4669    #[test]
4670    fn test_has_tool_failures_true_when_error_observation() {
4671        let steps = vec![
4672            make_step("t", "action1", "{\"error\": \"timeout\"}"),
4673        ];
4674        let session = make_session(steps, 0);
4675        assert!(session.has_tool_failures());
4676    }
4677
4678    #[test]
4679    fn test_tool_call_rate_zero_for_empty_session() {
4680        let session = make_session(vec![], 0);
4681        assert!((session.tool_call_rate() - 0.0).abs() < 1e-9);
4682    }
4683
4684    #[test]
4685    fn test_tool_call_rate_correct_ratio() {
4686        let steps = vec![
4687            make_step("t", "tool_action", "ok"),
4688            make_step("t", "FINAL_ANSWER: done", ""),
4689            make_step("t", "another_tool", "ok"),
4690        ];
4691        let session = make_session(steps, 0);
4692        // 2 tool calls out of 3 steps
4693        assert!((session.tool_call_rate() - 2.0 / 3.0).abs() < 1e-9);
4694    }
4695
4696    #[test]
4697    fn test_step_success_rate_one_for_empty_session() {
4698        let session = make_session(vec![], 0);
4699        assert!((session.step_success_rate() - 1.0).abs() < 1e-9);
4700    }
4701
4702    #[test]
4703    fn test_step_success_rate_less_than_one_when_failures() {
4704        let steps = vec![
4705            make_step("t", "act", "{\"error\": \"fail\"}"),
4706            make_step("t", "act", "success"),
4707        ];
4708        let session = make_session(steps, 0);
4709        // 1 failed out of 2 → 1.0 - 0.5 = 0.5
4710        assert!((session.step_success_rate() - 0.5).abs() < 1e-9);
4711    }
4712
4713    // ── Round 23: collection helpers and rate methods ─────────────────────────
4714
4715    #[test]
4716    fn test_avg_step_duration_ms_zero_for_empty() {
4717        let session = make_session(vec![], 0);
4718        assert!((session.avg_step_duration_ms() - 0.0).abs() < 1e-9);
4719    }
4720
4721    #[test]
4722    fn test_avg_step_duration_ms_correct_mean() {
4723        let mut s1 = make_step("t", "a", "o");
4724        s1.step_duration_ms = 100;
4725        let mut s2 = make_step("t", "b", "o");
4726        s2.step_duration_ms = 200;
4727        let session = make_session(vec![s1, s2], 0);
4728        assert!((session.avg_step_duration_ms() - 150.0).abs() < 1e-9);
4729    }
4730
4731    #[test]
4732    fn test_longest_step_none_for_empty() {
4733        let session = make_session(vec![], 0);
4734        assert!(session.longest_step().is_none());
4735    }
4736
4737    #[test]
4738    fn test_longest_step_middle_has_max_duration() {
4739        let mut s1 = make_step("t", "a", "o");
4740        s1.step_duration_ms = 10;
4741        let mut s2 = make_step("t", "b", "o");
4742        s2.step_duration_ms = 500;
4743        let mut s3 = make_step("t", "c", "o");
4744        s3.step_duration_ms = 20;
4745        let session = make_session(vec![s1, s2, s3], 0);
4746        assert_eq!(session.longest_step().unwrap().action, "b");
4747    }
4748
4749    #[test]
4750    fn test_unique_tools_used_deduplicates_and_sorts() {
4751        let steps = vec![
4752            make_step("t", "search", "o"),
4753            make_step("t", "lookup", "o"),
4754            make_step("t", "search", "o"),
4755        ];
4756        let session = make_session(steps, 0);
4757        assert_eq!(session.unique_tools_used(), vec!["lookup", "search"]);
4758    }
4759
4760    #[test]
4761    fn test_all_thoughts_collects_in_order() {
4762        let steps = vec![make_step("think1", "a", "o"), make_step("think2", "b", "o")];
4763        let session = make_session(steps, 0);
4764        assert_eq!(session.all_thoughts(), vec!["think1", "think2"]);
4765    }
4766
4767    #[test]
4768    fn test_all_actions_collects_in_order() {
4769        let steps = vec![make_step("t", "act1", "o"), make_step("t", "act2", "o")];
4770        let session = make_session(steps, 0);
4771        assert_eq!(session.all_actions(), vec!["act1", "act2"]);
4772    }
4773
4774    #[test]
4775    fn test_all_observations_collects_in_order() {
4776        let steps = vec![make_step("t", "a", "obs1"), make_step("t", "b", "obs2")];
4777        let session = make_session(steps, 0);
4778        assert_eq!(session.all_observations(), vec!["obs1", "obs2"]);
4779    }
4780
4781    #[test]
4782    fn test_action_counts_returns_frequency_map() {
4783        let steps = vec![
4784            make_step("t", "search", "o"),
4785            make_step("t", "lookup", "o"),
4786            make_step("t", "search", "o"),
4787        ];
4788        let session = make_session(steps, 0);
4789        let counts = session.action_counts();
4790        assert_eq!(counts["search"], 2);
4791        assert_eq!(counts["lookup"], 1);
4792    }
4793
4794    #[test]
4795    fn test_unique_actions_three_with_repeat_yields_two() {
4796        let steps = vec![
4797            make_step("t", "beta", "o"),
4798            make_step("t", "alpha", "o"),
4799            make_step("t", "beta", "o"),
4800        ];
4801        let session = make_session(steps, 0);
4802        assert_eq!(session.unique_actions(), vec!["alpha", "beta"]);
4803    }
4804
4805    #[test]
4806    fn test_action_diversity_zero_for_empty() {
4807        let session = make_session(vec![], 0);
4808        assert!((session.action_diversity() - 0.0).abs() < 1e-9);
4809    }
4810
4811    #[test]
4812    fn test_action_diversity_one_when_all_actions_unique() {
4813        let steps = vec![
4814            make_step("t", "a", "o"),
4815            make_step("t", "b", "o"),
4816            make_step("t", "c", "o"),
4817        ];
4818        let session = make_session(steps, 0);
4819        assert!((session.action_diversity() - 1.0).abs() < 1e-9);
4820    }
4821
4822    #[test]
4823    fn test_action_diversity_fraction_when_repeated() {
4824        let steps = vec![
4825            make_step("t", "x", "o"),
4826            make_step("t", "x", "o"),
4827        ];
4828        let session = make_session(steps, 0);
4829        assert!((session.action_diversity() - 0.5).abs() < 1e-9);
4830    }
4831
4832    #[test]
4833    fn test_has_checkpoint_errors_false_for_new_session() {
4834        let session = make_session(vec![], 0);
4835        assert!(!session.has_checkpoint_errors());
4836    }
4837
4838    #[test]
4839    fn test_has_checkpoint_errors_true_when_errors_present() {
4840        let mut session = make_session(vec![], 0);
4841        session.checkpoint_errors.push("err1".to_string());
4842        assert!(session.has_checkpoint_errors());
4843    }
4844
4845    #[test]
4846    fn test_graph_lookup_count_returns_raw_value() {
4847        let mut session = make_session(vec![make_step("t", "a", "o")], 0);
4848        session.graph_lookups = 7;
4849        assert_eq!(session.graph_lookup_count(), 7);
4850    }
4851
4852    #[test]
4853    fn test_memory_hit_rate_zero_for_empty_session() {
4854        let session = make_session(vec![], 0);
4855        assert!((session.memory_hit_rate() - 0.0).abs() < 1e-9);
4856    }
4857
4858    #[test]
4859    fn test_memory_hit_rate_correct_ratio() {
4860        let steps = vec![
4861            make_step("t", "a", "o"),
4862            make_step("t", "b", "o"),
4863            make_step("t", "c", "o"),
4864            make_step("t", "d", "o"),
4865        ];
4866        let mut session = make_session(steps, 0);
4867        session.memory_hits = 2;
4868        assert!((session.memory_hit_rate() - 0.5).abs() < 1e-9);
4869    }
4870
4871    #[test]
4872    fn test_total_memory_hits_returns_raw_value() {
4873        let mut session = make_session(vec![], 0);
4874        session.memory_hits = 13;
4875        assert_eq!(session.total_memory_hits(), 13);
4876    }
4877
4878    // ── Round 29: has_memory, has_graph, has_working_memory ──────────────────
4879
4880    #[cfg(feature = "memory")]
4881    #[test]
4882    fn test_has_memory_false_without_memory() {
4883        let runtime = AgentRuntime::builder()
4884            .with_agent_config(simple_config())
4885            .build();
4886        assert!(!runtime.has_memory());
4887    }
4888
4889    #[cfg(feature = "memory")]
4890    #[test]
4891    fn test_has_memory_true_with_memory() {
4892        let runtime = AgentRuntime::builder()
4893            .with_agent_config(simple_config())
4894            .with_memory(EpisodicStore::new())
4895            .build();
4896        assert!(runtime.has_memory());
4897    }
4898
4899    #[cfg(feature = "graph")]
4900    #[test]
4901    fn test_has_graph_false_without_graph() {
4902        let runtime = AgentRuntime::builder()
4903            .with_agent_config(simple_config())
4904            .build();
4905        assert!(!runtime.has_graph());
4906    }
4907
4908    #[cfg(feature = "graph")]
4909    #[test]
4910    fn test_has_graph_true_with_graph() {
4911        let runtime = AgentRuntime::builder()
4912            .with_agent_config(simple_config())
4913            .with_graph(GraphStore::new())
4914            .build();
4915        assert!(runtime.has_graph());
4916    }
4917
4918    #[cfg(feature = "memory")]
4919    #[test]
4920    fn test_has_working_memory_false_without_working_memory() {
4921        let runtime = AgentRuntime::builder()
4922            .with_agent_config(simple_config())
4923            .build();
4924        assert!(!runtime.has_working_memory());
4925    }
4926
4927    #[cfg(feature = "memory")]
4928    #[test]
4929    fn test_has_working_memory_true_with_working_memory() {
4930        let runtime = AgentRuntime::builder()
4931            .with_agent_config(simple_config())
4932            .with_working_memory(WorkingMemory::new(10).unwrap())
4933            .build();
4934        assert!(runtime.has_working_memory());
4935    }
4936
4937    // ── Round 23: last_observation / thought_count / observation_rate ─────────
4938
4939    #[test]
4940    fn test_last_observation_returns_most_recent_nonempty() {
4941        let steps = vec![
4942            make_step("t1", "act", "first obs"),
4943            make_step("t2", "act", ""),
4944            make_step("t3", "act", "last obs"),
4945        ];
4946        let session = make_session(steps, 0);
4947        assert_eq!(session.last_observation(), Some("last obs"));
4948    }
4949
4950    #[test]
4951    fn test_last_observation_skips_empty_steps() {
4952        let steps = vec![
4953            make_step("t1", "act", "only obs"),
4954            make_step("t2", "act", ""),
4955        ];
4956        let session = make_session(steps, 0);
4957        assert_eq!(session.last_observation(), Some("only obs"));
4958    }
4959
4960    #[test]
4961    fn test_last_observation_none_for_empty_session() {
4962        let session = make_session(vec![], 0);
4963        assert!(session.last_observation().is_none());
4964    }
4965
4966    #[test]
4967    fn test_thought_count_counts_nonempty_thoughts() {
4968        let steps = vec![
4969            make_step("think", "act", "obs"),
4970            make_step("", "act", "obs"),
4971            make_step("think again", "act", "obs"),
4972        ];
4973        let session = make_session(steps, 0);
4974        assert_eq!(session.thought_count(), 2);
4975    }
4976
4977    #[test]
4978    fn test_thought_count_zero_for_empty_session() {
4979        let session = make_session(vec![], 0);
4980        assert_eq!(session.thought_count(), 0);
4981    }
4982
4983    #[test]
4984    fn test_observation_rate_correct_fraction() {
4985        let steps = vec![
4986            make_step("t", "a", "obs"),
4987            make_step("t", "a", ""),
4988            make_step("t", "a", "obs"),
4989            make_step("t", "a", ""),
4990        ];
4991        let session = make_session(steps, 0);
4992        assert!((session.observation_rate() - 0.5).abs() < 1e-9);
4993    }
4994
4995    #[test]
4996    fn test_observation_rate_zero_for_empty_session() {
4997        let session = make_session(vec![], 0);
4998        assert!((session.observation_rate() - 0.0).abs() < 1e-9);
4999    }
5000
5001    // ── Round 24: action_repetition_rate / max_consecutive_failures / avg_thought_length
5002
5003    #[test]
5004    fn test_action_repetition_rate_zero_for_empty_session() {
5005        let session = make_session(vec![], 0);
5006        assert!((session.action_repetition_rate() - 0.0).abs() < 1e-9);
5007    }
5008
5009    #[test]
5010    fn test_action_repetition_rate_zero_for_single_step() {
5011        let session = make_session(vec![make_step("t", "search", "r")], 0);
5012        assert!((session.action_repetition_rate() - 0.0).abs() < 1e-9);
5013    }
5014
5015    #[test]
5016    fn test_action_repetition_rate_one_when_all_same() {
5017        let steps = vec![
5018            make_step("t", "search", "r"),
5019            make_step("t", "search", "r"),
5020            make_step("t", "search", "r"),
5021        ];
5022        let session = make_session(steps, 0);
5023        assert!((session.action_repetition_rate() - 1.0).abs() < 1e-9);
5024    }
5025
5026    #[test]
5027    fn test_action_repetition_rate_partial_repeats() {
5028        // [search, search, calc] → 1 repeat out of 2 transitions → 0.5
5029        let steps = vec![
5030            make_step("t", "search", "r"),
5031            make_step("t", "search", "r"),
5032            make_step("t", "calc", "r"),
5033        ];
5034        let session = make_session(steps, 0);
5035        assert!((session.action_repetition_rate() - 0.5).abs() < 1e-9);
5036    }
5037
5038    #[test]
5039    fn test_max_consecutive_failures_zero_for_no_errors() {
5040        let steps = vec![make_step("t", "a", "ok"), make_step("t", "b", "done")];
5041        let session = make_session(steps, 0);
5042        assert_eq!(session.max_consecutive_failures(), 0);
5043    }
5044
5045    #[test]
5046    fn test_max_consecutive_failures_counts_run() {
5047        let steps = vec![
5048            make_step("t", "a", "ok"),
5049            make_step("t", "b", r#"{"error":"x"}"#),
5050            make_step("t", "c", r#"{"error":"y"}"#),
5051            make_step("t", "d", "ok"),
5052        ];
5053        let session = make_session(steps, 0);
5054        assert_eq!(session.max_consecutive_failures(), 2);
5055    }
5056
5057    #[test]
5058    fn test_avg_thought_length_zero_for_empty_session() {
5059        let session = make_session(vec![], 0);
5060        assert!((session.avg_thought_length() - 0.0).abs() < 1e-9);
5061    }
5062
5063    #[test]
5064    fn test_avg_thought_length_excludes_empty_thoughts() {
5065        let steps = vec![
5066            make_step("hello", "a", "r"),  // 5 chars
5067            make_step("", "b", "r"),        // excluded
5068            make_step("hi", "c", "r"),      // 2 chars
5069        ];
5070        // mean = (5 + 2) / 2 = 3.5
5071        let session = make_session(steps, 0);
5072        assert!((session.avg_thought_length() - 3.5).abs() < 1e-9);
5073    }
5074
5075    // ── Round 25: last_n_observations / actions_in_window ─────────────────────
5076
5077    #[test]
5078    fn test_last_n_observations_empty_session() {
5079        let session = make_session(vec![], 0);
5080        assert!(session.last_n_observations(3).is_empty());
5081    }
5082
5083    #[test]
5084    fn test_last_n_observations_returns_last_n_nonempty() {
5085        let steps = vec![
5086            make_step("t", "a", "obs1"),
5087            make_step("t", "b", ""),        // skipped
5088            make_step("t", "c", "obs2"),
5089            make_step("t", "d", "obs3"),
5090        ];
5091        let session = make_session(steps, 0);
5092        let last2 = session.last_n_observations(2);
5093        assert_eq!(last2, vec!["obs2", "obs3"]);
5094    }
5095
5096    #[test]
5097    fn test_last_n_observations_returns_all_when_fewer_than_n() {
5098        let steps = vec![make_step("t", "a", "only")];
5099        let session = make_session(steps, 0);
5100        assert_eq!(session.last_n_observations(5), vec!["only"]);
5101    }
5102
5103    #[test]
5104    fn test_actions_in_window_empty_session() {
5105        let session = make_session(vec![], 0);
5106        assert!(session.actions_in_window(3).is_empty());
5107    }
5108
5109    #[test]
5110    fn test_actions_in_window_returns_last_n_steps() {
5111        let steps = vec![
5112            make_step("t", "alpha", "r"),
5113            make_step("t", "beta", "r"),
5114            make_step("t", "gamma", "r"),
5115        ];
5116        let session = make_session(steps, 0);
5117        let window = session.actions_in_window(2);
5118        assert_eq!(window, vec!["beta", "gamma"]);
5119    }
5120
5121    #[test]
5122    fn test_actions_in_window_all_when_fewer_than_n() {
5123        let steps = vec![make_step("t", "solo", "r")];
5124        let session = make_session(steps, 0);
5125        assert_eq!(session.actions_in_window(10), vec!["solo"]);
5126    }
5127
5128    // ── Round 31: observation_at, action_at ───────────────────────────────────
5129
5130    #[test]
5131    fn test_observation_at_returns_correct_observation() {
5132        let steps = vec![
5133            make_step("t1", "a1", "obs-zero"),
5134            make_step("t2", "a2", "obs-one"),
5135        ];
5136        let session = make_session(steps, 0);
5137        assert_eq!(session.observation_at(0), Some("obs-zero"));
5138        assert_eq!(session.observation_at(1), Some("obs-one"));
5139    }
5140
5141    #[test]
5142    fn test_observation_at_returns_none_out_of_bounds() {
5143        let session = make_session(vec![], 0);
5144        assert!(session.observation_at(0).is_none());
5145    }
5146
5147    #[test]
5148    fn test_action_at_returns_correct_action() {
5149        let steps = vec![
5150            make_step("t1", "first-action", "obs"),
5151            make_step("t2", "second-action", "obs"),
5152        ];
5153        let session = make_session(steps, 0);
5154        assert_eq!(session.action_at(0), Some("first-action"));
5155        assert_eq!(session.action_at(1), Some("second-action"));
5156    }
5157
5158    #[test]
5159    fn test_action_at_returns_none_out_of_bounds() {
5160        let session = make_session(vec![], 0);
5161        assert!(session.action_at(5).is_none());
5162    }
5163
5164    // ── Round 26: has_graph_lookups / consecutive_same_action_at_end ──────────
5165
5166    #[test]
5167    fn test_has_graph_lookups_false_when_zero() {
5168        let session = make_session(vec![], 0);
5169        assert!(!session.has_graph_lookups());
5170    }
5171
5172    #[test]
5173    fn test_has_graph_lookups_true_when_positive() {
5174        let session = AgentSession {
5175            session_id: "s".into(),
5176            agent_id: AgentId::new("a"),
5177            steps: vec![],
5178            memory_hits: 0,
5179            graph_lookups: 5,
5180            duration_ms: 0,
5181            checkpoint_errors: vec![],
5182        };
5183        assert!(session.has_graph_lookups());
5184    }
5185
5186    #[test]
5187    fn test_consecutive_same_action_at_end_empty_session() {
5188        let session = make_session(vec![], 0);
5189        assert_eq!(session.consecutive_same_action_at_end(), 0);
5190    }
5191
5192    #[test]
5193    fn test_consecutive_same_action_at_end_single_step() {
5194        let steps = vec![make_step("t", "act", "obs")];
5195        let session = make_session(steps, 0);
5196        assert_eq!(session.consecutive_same_action_at_end(), 0);
5197    }
5198
5199    #[test]
5200    fn test_consecutive_same_action_at_end_two_same_at_end() {
5201        let steps = vec![
5202            make_step("t", "other", "obs"),
5203            make_step("t", "repeat", "obs"),
5204            make_step("t", "repeat", "obs"),
5205        ];
5206        let session = make_session(steps, 0);
5207        assert_eq!(session.consecutive_same_action_at_end(), 1);
5208    }
5209
5210    #[test]
5211    fn test_consecutive_same_action_at_end_all_same() {
5212        let steps = vec![
5213            make_step("t", "same", "obs"),
5214            make_step("t", "same", "obs"),
5215            make_step("t", "same", "obs"),
5216        ];
5217        let session = make_session(steps, 0);
5218        assert_eq!(session.consecutive_same_action_at_end(), 2);
5219    }
5220
5221    // ── Round 27: failure_rate / unique_action_count ───────────────────────────
5222
5223    #[test]
5224    fn test_failure_rate_zero_for_empty_session() {
5225        let session = make_session(vec![], 0);
5226        assert!((session.failure_rate() - 0.0).abs() < 1e-9);
5227    }
5228
5229    #[test]
5230    fn test_failure_rate_zero_when_no_failures() {
5231        let steps = vec![
5232            make_step("t", "lookup", "ok"),
5233            make_step("t", "search", "ok"),
5234        ];
5235        let session = make_session(steps, 0);
5236        assert!((session.failure_rate() - 0.0).abs() < 1e-9);
5237    }
5238
5239    #[test]
5240    fn test_unique_action_count_zero_for_empty_session() {
5241        let session = make_session(vec![], 0);
5242        assert_eq!(session.unique_action_count(), 0);
5243    }
5244
5245    #[test]
5246    fn test_unique_action_count_counts_distinct_actions() {
5247        let steps = vec![
5248            make_step("t", "search", "r"),
5249            make_step("t", "lookup", "r"),
5250            make_step("t", "search", "r"), // duplicate
5251        ];
5252        let session = make_session(steps, 0);
5253        assert_eq!(session.unique_action_count(), 2);
5254    }
5255
5256    // ── Round 28: total_thought_length / longest_observation ──────────────────
5257
5258    #[test]
5259    fn test_total_thought_length_zero_for_empty_session() {
5260        let session = make_session(vec![], 0);
5261        assert_eq!(session.total_thought_length(), 0);
5262    }
5263
5264    #[test]
5265    fn test_total_thought_length_sums_all_thoughts() {
5266        let steps = vec![
5267            make_step("hi", "a", "r"),   // 2 bytes
5268            make_step("hello", "b", "r"), // 5 bytes
5269        ];
5270        let session = make_session(steps, 0);
5271        assert_eq!(session.total_thought_length(), 7);
5272    }
5273
5274    #[test]
5275    fn test_longest_observation_none_for_empty_session() {
5276        let session = make_session(vec![], 0);
5277        assert!(session.longest_observation().is_none());
5278    }
5279
5280    #[test]
5281    fn test_longest_observation_returns_longest() {
5282        let steps = vec![
5283            make_step("t", "a", "short"),
5284            make_step("t", "b", "a much longer observation"),
5285        ];
5286        let session = make_session(steps, 0);
5287        assert_eq!(session.longest_observation(), Some("a much longer observation"));
5288    }
5289
5290    // ── Round 29: steps_with_empty_observations / min_thought_length ──────────
5291
5292    #[test]
5293    fn test_steps_with_empty_observations_zero_when_all_filled() {
5294        let steps = vec![make_step("t", "a", "obs"), make_step("t", "b", "obs2")];
5295        let session = make_session(steps, 0);
5296        assert_eq!(session.steps_with_empty_observations(), 0);
5297    }
5298
5299    #[test]
5300    fn test_steps_with_empty_observations_counts_empty_ones() {
5301        let steps = vec![
5302            make_step("t", "a", ""),    // empty
5303            make_step("t", "b", "ok"),
5304            make_step("t", "c", ""),    // empty
5305        ];
5306        let session = make_session(steps, 0);
5307        assert_eq!(session.steps_with_empty_observations(), 2);
5308    }
5309
5310    #[test]
5311    fn test_min_thought_length_zero_for_empty_session() {
5312        let session = make_session(vec![], 0);
5313        assert_eq!(session.min_thought_length(), 0);
5314    }
5315
5316    #[test]
5317    fn test_min_thought_length_returns_shortest_non_empty() {
5318        let steps = vec![
5319            make_step("hi", "a", "r"),        // 2 bytes
5320            make_step("hello", "b", "r"),     // 5 bytes
5321            make_step("", "c", "r"),          // empty, excluded
5322        ];
5323        let session = make_session(steps, 0);
5324        assert_eq!(session.min_thought_length(), 2);
5325    }
5326
5327    // ── Round 30: observation_lengths / avg_observation_length ────────────────
5328
5329    #[test]
5330    fn test_observation_lengths_empty_for_empty_session() {
5331        let session = make_session(vec![], 0);
5332        assert!(session.observation_lengths().is_empty());
5333    }
5334
5335    #[test]
5336    fn test_observation_lengths_returns_lengths_in_order() {
5337        let steps = vec![
5338            make_step("t", "a", "hi"),    // 2
5339            make_step("t", "b", "hello"), // 5
5340        ];
5341        let session = make_session(steps, 0);
5342        assert_eq!(session.observation_lengths(), vec![2, 5]);
5343    }
5344
5345    #[test]
5346    fn test_avg_observation_length_zero_for_empty_session() {
5347        let session = make_session(vec![], 0);
5348        assert!((session.avg_observation_length() - 0.0).abs() < 1e-9);
5349    }
5350
5351    #[test]
5352    fn test_avg_observation_length_correct_mean() {
5353        let steps = vec![
5354            make_step("t", "a", "hi"),   // 2
5355            make_step("t", "b", "hello"), // 5
5356        ];
5357        let session = make_session(steps, 0);
5358        // mean = 3.5
5359        assert!((session.avg_observation_length() - 3.5).abs() < 1e-9);
5360    }
5361
5362    #[test]
5363    fn test_duration_secs_converts_ms_to_seconds() {
5364        let session = make_session(vec![], 7000);
5365        assert_eq!(session.duration_secs(), 7);
5366    }
5367
5368    #[test]
5369    fn test_steps_above_thought_length_counts_qualifying_steps() {
5370        let steps = vec![
5371            make_step("hi", "a", "obs"),
5372            make_step("a longer thought here", "b", "obs"),
5373            make_step("medium thought", "c", "obs"),
5374        ];
5375        let session = make_session(steps, 0);
5376        // "hi" (2) <= 5, "a longer thought here" (21) > 5, "medium thought" (13) > 5
5377        assert_eq!(session.steps_above_thought_length(5), 2);
5378    }
5379
5380    #[test]
5381    fn test_has_final_answer_true_when_step_has_final_answer_action() {
5382        let steps = vec![
5383            make_step("think", "search", "result"),
5384            make_step("done", "FINAL_ANSWER: 42", ""),
5385        ];
5386        let session = make_session(steps, 0);
5387        assert!(session.has_final_answer());
5388    }
5389
5390    #[test]
5391    fn test_has_final_answer_false_when_no_final_answer_step() {
5392        let steps = vec![make_step("think", "search", "result")];
5393        let session = make_session(steps, 0);
5394        assert!(!session.has_final_answer());
5395    }
5396
5397    #[test]
5398    fn test_avg_action_length_correct_mean() {
5399        let steps = vec![
5400            make_step("t", "ab", "o"),    // 2
5401            make_step("t", "abcd", "o"),  // 4
5402        ];
5403        let session = make_session(steps, 0);
5404        assert!((session.avg_action_length() - 3.0).abs() < 1e-9);
5405    }
5406
5407    #[test]
5408    fn test_avg_action_length_empty_returns_zero() {
5409        let session = make_session(vec![], 0);
5410        assert_eq!(session.avg_action_length(), 0.0);
5411    }
5412
5413    #[test]
5414    fn test_thought_lengths_returns_lengths_in_order() {
5415        let steps = vec![
5416            make_step("hi", "a", "o"),
5417            make_step("hello", "b", "o"),
5418        ];
5419        let session = make_session(steps, 0);
5420        assert_eq!(session.thought_lengths(), vec![2, 5]);
5421    }
5422
5423    #[test]
5424    fn test_most_common_action_returns_most_frequent() {
5425        let steps = vec![
5426            make_step("t", "search", "o"),
5427            make_step("t", "search", "o"),
5428            make_step("t", "other", "o"),
5429        ];
5430        let session = make_session(steps, 0);
5431        assert_eq!(session.most_common_action(), Some("search"));
5432    }
5433
5434    #[test]
5435    fn test_most_common_action_none_for_empty_session() {
5436        let session = make_session(vec![], 0);
5437        assert!(session.most_common_action().is_none());
5438    }
5439
5440    #[test]
5441    fn test_count_steps_with_action_counts_exact_matches() {
5442        let steps = vec![
5443            make_step("t", "search", "o"),
5444            make_step("t", "search", "o"),
5445            make_step("t", "other", "o"),
5446        ];
5447        let session = make_session(steps, 0);
5448        assert_eq!(session.count_steps_with_action("search"), 2);
5449        assert_eq!(session.count_steps_with_action("other"), 1);
5450        assert_eq!(session.count_steps_with_action("missing"), 0);
5451    }
5452
5453    #[test]
5454    fn test_thought_contains_count_counts_matching_steps() {
5455        let steps = vec![
5456            make_step("search for rust", "a", "o"),
5457            make_step("think about python", "b", "o"),
5458            make_step("rust is great", "c", "o"),
5459        ];
5460        let session = make_session(steps, 0);
5461        assert_eq!(session.thought_contains_count("rust"), 2);
5462        assert_eq!(session.thought_contains_count("python"), 1);
5463        assert_eq!(session.thought_contains_count("java"), 0);
5464    }
5465
5466    #[test]
5467    fn test_count_nonempty_thoughts_counts_steps_with_thoughts() {
5468        let steps = vec![
5469            make_step("hello", "a", "o"),
5470            make_step("", "b", "o"),
5471            make_step("world", "c", "o"),
5472        ];
5473        let session = make_session(steps, 0);
5474        assert_eq!(session.count_nonempty_thoughts(), 2);
5475    }
5476
5477    #[test]
5478    fn test_observation_contains_count_counts_matching_observations() {
5479        let steps = vec![
5480            make_step("t", "a", "result: success"),
5481            make_step("t", "b", "result: failure"),
5482            make_step("t", "c", "no match here"),
5483        ];
5484        let session = make_session(steps, 0);
5485        assert_eq!(session.observation_contains_count("result"), 2);
5486        assert_eq!(session.observation_contains_count("success"), 1);
5487    }
5488
5489    // ── Round 36 ──────────────────────────────────────────────────────────────
5490
5491    #[test]
5492    fn test_action_lengths_returns_byte_lengths_in_order() {
5493        let steps = vec![
5494            make_step("t", "ab", "o"),
5495            make_step("t", "hello", "o"),
5496            make_step("t", "", "o"),
5497        ];
5498        let session = make_session(steps, 0);
5499        assert_eq!(session.action_lengths(), vec![2, 5, 0]);
5500    }
5501
5502    #[test]
5503    fn test_action_lengths_empty_session_returns_empty_vec() {
5504        let session = make_session(vec![], 0);
5505        assert!(session.action_lengths().is_empty());
5506    }
5507
5508    #[test]
5509    fn test_step_success_count_excludes_failed_steps() {
5510        let steps = vec![
5511            make_step("t", "a", "ok"),
5512            make_step("t", "b", "{\"error\": \"timeout\"}"),
5513            make_step("t", "c", "ok"),
5514        ];
5515        let session = make_session(steps, 0);
5516        assert_eq!(session.step_success_count(), 2);
5517    }
5518
5519    #[test]
5520    fn test_step_success_count_all_success_when_no_failures() {
5521        let steps = vec![make_step("t", "a", "ok"), make_step("t", "b", "ok")];
5522        let session = make_session(steps, 0);
5523        assert_eq!(session.step_success_count(), 2);
5524    }
5525
5526    // ── Round 37 ──────────────────────────────────────────────────────────────
5527
5528    #[test]
5529    fn test_longest_thought_returns_step_with_most_bytes() {
5530        let steps = vec![
5531            make_step("hi", "a", "o"),
5532            make_step("hello world", "b", "o"),
5533            make_step("hey", "c", "o"),
5534        ];
5535        let session = make_session(steps, 0);
5536        assert_eq!(session.longest_thought(), Some("hello world"));
5537    }
5538
5539    #[test]
5540    fn test_longest_thought_returns_none_for_empty_session() {
5541        let session = make_session(vec![], 0);
5542        assert!(session.longest_thought().is_none());
5543    }
5544
5545    #[test]
5546    fn test_shortest_action_returns_step_with_fewest_bytes() {
5547        let steps = vec![
5548            make_step("t", "search", "o"),
5549            make_step("t", "go", "o"),
5550            make_step("t", "lookup", "o"),
5551        ];
5552        let session = make_session(steps, 0);
5553        assert_eq!(session.shortest_action(), Some("go"));
5554    }
5555
5556    #[test]
5557    fn test_shortest_action_returns_none_for_empty_session() {
5558        let session = make_session(vec![], 0);
5559        assert!(session.shortest_action().is_none());
5560    }
5561
5562    // ── Round 38 ──────────────────────────────────────────────────────────────
5563
5564    #[test]
5565    fn test_first_step_action_returns_action_of_first_step() {
5566        let steps = vec![
5567            make_step("t", "first", "o"),
5568            make_step("t", "second", "o"),
5569        ];
5570        let session = make_session(steps, 0);
5571        assert_eq!(session.first_step_action(), Some("first"));
5572    }
5573
5574    #[test]
5575    fn test_first_step_action_returns_none_for_empty_session() {
5576        let session = make_session(vec![], 0);
5577        assert!(session.first_step_action().is_none());
5578    }
5579
5580    #[test]
5581    fn test_last_step_action_returns_action_of_last_step() {
5582        let steps = vec![
5583            make_step("t", "first", "o"),
5584            make_step("t", "last_one", "o"),
5585        ];
5586        let session = make_session(steps, 0);
5587        assert_eq!(session.last_step_action(), Some("last_one"));
5588    }
5589
5590    #[test]
5591    fn test_last_step_action_returns_none_for_empty_session() {
5592        let session = make_session(vec![], 0);
5593        assert!(session.last_step_action().is_none());
5594    }
5595
5596    // ── Round 39 ──────────────────────────────────────────────────────────────
5597
5598    #[test]
5599    fn test_total_thought_bytes_sums_all_thought_lengths() {
5600        let steps = vec![
5601            make_step("hi", "a", "o"),   // thought = "hi" → 2
5602            make_step("hello", "b", "o"), // thought = "hello" → 5
5603        ];
5604        let session = make_session(steps, 0);
5605        assert_eq!(session.total_thought_bytes(), 7);
5606    }
5607
5608    #[test]
5609    fn test_total_observation_bytes_sums_all_observation_lengths() {
5610        let steps = vec![
5611            make_step("t", "a", "ok"),     // 2
5612            make_step("t", "b", "done!"),  // 5
5613        ];
5614        let session = make_session(steps, 0);
5615        assert_eq!(session.total_observation_bytes(), 7);
5616    }
5617
5618    // ── Round 40 ──────────────────────────────────────────────────────────────
5619
5620    #[test]
5621    fn test_steps_in_range_returns_correct_slice() {
5622        let steps = vec![
5623            make_step("t", "a", "o"),
5624            make_step("t", "b", "o"),
5625            make_step("t", "c", "o"),
5626        ];
5627        let session = make_session(steps, 0);
5628        let slice = session.steps_in_range(1, 3);
5629        assert_eq!(slice.len(), 2);
5630        assert_eq!(slice[0].action, "b");
5631        assert_eq!(slice[1].action, "c");
5632    }
5633
5634    #[test]
5635    fn test_steps_in_range_returns_empty_for_out_of_bounds_start() {
5636        let steps = vec![make_step("t", "a", "o")];
5637        let session = make_session(steps, 0);
5638        assert!(session.steps_in_range(5, 10).is_empty());
5639    }
5640
5641    #[test]
5642    fn test_median_step_duration_ms_odd_count() {
5643        let mut steps = vec![
5644            make_step("t", "a", "o"),
5645            make_step("t", "b", "o"),
5646            make_step("t", "c", "o"),
5647        ];
5648        steps[0].step_duration_ms = 10;
5649        steps[1].step_duration_ms = 50;
5650        steps[2].step_duration_ms = 30;
5651        let session = make_session(steps, 0);
5652        // sorted: [10, 30, 50] → median = 30
5653        assert_eq!(session.median_step_duration_ms(), 30);
5654    }
5655
5656    #[test]
5657    fn test_median_step_duration_ms_returns_zero_for_empty_session() {
5658        let session = make_session(vec![], 0);
5659        assert_eq!(session.median_step_duration_ms(), 0);
5660    }
5661
5662    // ── Round 40: into_steps, iter_steps, has_at_least_steps ─────────────────
5663
5664    #[test]
5665    fn test_into_steps_consumes_session_and_returns_owned_vec() {
5666        let steps = vec![
5667            make_step("think", "act", "obs"),
5668            make_step("think2", "act2", "obs2"),
5669        ];
5670        let session = make_session(steps, 0);
5671        let owned = session.into_steps();
5672        assert_eq!(owned.len(), 2);
5673        assert_eq!(owned[0].thought, "think");
5674        assert_eq!(owned[1].action, "act2");
5675    }
5676
5677    #[test]
5678    fn test_into_steps_returns_empty_vec_for_empty_session() {
5679        let session = make_session(vec![], 0);
5680        assert!(session.into_steps().is_empty());
5681    }
5682
5683    #[test]
5684    fn test_iter_steps_iterates_in_order() {
5685        let steps = vec![
5686            make_step("t1", "a1", "o1"),
5687            make_step("t2", "a2", "o2"),
5688        ];
5689        let session = make_session(steps, 0);
5690        let thoughts: Vec<&str> = session.iter_steps().map(|s| s.thought.as_str()).collect();
5691        assert_eq!(thoughts, vec!["t1", "t2"]);
5692    }
5693
5694    #[test]
5695    fn test_has_at_least_steps_true_when_enough_steps() {
5696        let session = make_session(vec![make_step("t", "a", "o"), make_step("t", "a", "o")], 0);
5697        assert!(session.has_at_least_steps(2));
5698        assert!(session.has_at_least_steps(1));
5699    }
5700
5701    #[test]
5702    fn test_has_at_least_steps_false_when_too_few() {
5703        let session = make_session(vec![make_step("t", "a", "o")], 0);
5704        assert!(!session.has_at_least_steps(2));
5705    }
5706
5707    #[test]
5708    fn test_has_at_least_steps_zero_always_true() {
5709        let session = make_session(vec![], 0);
5710        assert!(session.has_at_least_steps(0));
5711    }
5712
5713    // ── Round 40 ──────────────────────────────────────────────────────────────
5714
5715    #[test]
5716    fn test_p95_step_duration_ms_returns_high_percentile() {
5717        // 20 steps with durations 1..=20 ms; p95 = ⌈20 * 0.95⌉ = 19th value = 19 ms
5718        let mut steps: Vec<ReActStep> = (1u64..=20)
5719            .map(|ms| ReActStep::new("t", "a", "o").with_duration(ms))
5720            .collect();
5721        // shuffle so order doesn't matter
5722        steps.reverse();
5723        let session = make_session(steps, 0);
5724        assert_eq!(session.p95_step_duration_ms(), 19);
5725    }
5726
5727    #[test]
5728    fn test_p95_step_duration_ms_returns_zero_for_empty_session() {
5729        let session = make_session(vec![], 0);
5730        assert_eq!(session.p95_step_duration_ms(), 0);
5731    }
5732
5733    #[test]
5734    fn test_p99_step_duration_ms_returns_highest_for_small_set() {
5735        // 10 steps 1..=10; p99 = ⌈10 * 0.99⌉ = 10th value = 10 ms
5736        let steps: Vec<ReActStep> = (1u64..=10)
5737            .map(|ms| ReActStep::new("t", "a", "o").with_duration(ms))
5738            .collect();
5739        let session = make_session(steps, 0);
5740        assert_eq!(session.p99_step_duration_ms(), 10);
5741    }
5742
5743    #[test]
5744    fn test_p99_step_duration_ms_returns_zero_for_empty_session() {
5745        let session = make_session(vec![], 0);
5746        assert_eq!(session.p99_step_duration_ms(), 0);
5747    }
5748
5749    #[test]
5750    fn test_step_count_above_duration_ms_counts_slow_steps() {
5751        let steps = vec![
5752            ReActStep::new("t", "a", "o").with_duration(10),
5753            ReActStep::new("t", "b", "o").with_duration(200),
5754            ReActStep::new("t", "c", "o").with_duration(50),
5755            ReActStep::new("t", "d", "o").with_duration(300),
5756        ];
5757        let session = make_session(steps, 0);
5758        assert_eq!(session.step_count_above_duration_ms(100), 2);
5759        assert_eq!(session.step_count_above_duration_ms(500), 0);
5760    }
5761
5762    #[test]
5763    fn test_step_count_above_duration_ms_zero_for_empty_session() {
5764        let session = make_session(vec![], 0);
5765        assert_eq!(session.step_count_above_duration_ms(0), 0);
5766    }
5767
5768    // ── Round 41 ──────────────────────────────────────────────────────────────
5769
5770    #[test]
5771    fn test_total_action_bytes_sums_action_lengths() {
5772        let steps = vec![
5773            make_step("t", "ab", "o"),    // 2
5774            make_step("t", "cde", "o"),   // 3
5775        ];
5776        let session = make_session(steps, 0);
5777        assert_eq!(session.total_action_bytes(), 5);
5778    }
5779
5780    #[test]
5781    fn test_total_action_bytes_empty_session_returns_zero() {
5782        let session = make_session(vec![], 0);
5783        assert_eq!(session.total_action_bytes(), 0);
5784    }
5785
5786    #[test]
5787    fn test_step_duration_variance_ms_computed_correctly() {
5788        let mut steps = vec![
5789            make_step("t", "a", "o"),
5790            make_step("t", "b", "o"),
5791        ];
5792        steps[0].step_duration_ms = 10;
5793        steps[1].step_duration_ms = 20;
5794        let session = make_session(steps, 0);
5795        // mean = 15, variance = ((10-15)^2 + (20-15)^2) / 2 = 25
5796        assert!((session.step_duration_variance_ms() - 25.0).abs() < 1e-9);
5797    }
5798
5799    #[test]
5800    fn test_step_duration_variance_ms_zero_for_single_step() {
5801        let session = make_session(vec![make_step("t", "a", "o")], 0);
5802        assert_eq!(session.step_duration_variance_ms(), 0.0);
5803    }
5804
5805    #[test]
5806    fn test_steps_with_errors_returns_steps_containing_error() {
5807        let steps = vec![
5808            make_step("t", "a", "success"),
5809            make_step("t", "b", "error: timeout"),
5810            make_step("t", "c", "ok"),
5811            make_step("t", "d", "Error: not found"),
5812        ];
5813        let session = make_session(steps, 0);
5814        assert_eq!(session.steps_with_errors().len(), 2);
5815    }
5816
5817    #[test]
5818    fn test_steps_with_errors_empty_when_no_errors() {
5819        let steps = vec![make_step("t", "a", "ok"), make_step("t", "b", "done")];
5820        let session = make_session(steps, 0);
5821        assert!(session.steps_with_errors().is_empty());
5822    }
5823
5824    // ── Round 41: min_step_duration_ms, max_step_duration_ms ──────────────────
5825
5826    #[test]
5827    fn test_min_step_duration_ms_returns_minimum() {
5828        let mut steps = vec![
5829            make_step("t", "a", "o"),
5830            make_step("t", "b", "o"),
5831            make_step("t", "c", "o"),
5832        ];
5833        steps[0].step_duration_ms = 50;
5834        steps[1].step_duration_ms = 10;
5835        steps[2].step_duration_ms = 30;
5836        let session = make_session(steps, 0);
5837        assert_eq!(session.min_step_duration_ms(), 10);
5838    }
5839
5840    #[test]
5841    fn test_min_step_duration_ms_empty_returns_zero() {
5842        let session = make_session(vec![], 0);
5843        assert_eq!(session.min_step_duration_ms(), 0);
5844    }
5845
5846    #[test]
5847    fn test_max_step_duration_ms_returns_maximum() {
5848        let mut steps = vec![
5849            make_step("t", "a", "o"),
5850            make_step("t", "b", "o"),
5851            make_step("t", "c", "o"),
5852        ];
5853        steps[0].step_duration_ms = 50;
5854        steps[1].step_duration_ms = 10;
5855        steps[2].step_duration_ms = 30;
5856        let session = make_session(steps, 0);
5857        assert_eq!(session.max_step_duration_ms(), 50);
5858    }
5859
5860    #[test]
5861    fn test_max_step_duration_ms_empty_returns_zero() {
5862        let session = make_session(vec![], 0);
5863        assert_eq!(session.max_step_duration_ms(), 0);
5864    }
5865
5866    // ── Round 42 ──────────────────────────────────────────────────────────────
5867
5868    #[test]
5869    fn test_steps_with_long_observations_returns_steps_above_threshold() {
5870        let steps = vec![
5871            make_step("t", "a", "short"),    // 5 bytes
5872            make_step("t", "b", "this is a long observation"),  // 26 bytes
5873        ];
5874        let session = make_session(steps, 0);
5875        assert_eq!(session.steps_with_long_observations(10).len(), 1);
5876        assert_eq!(session.steps_with_long_observations(4).len(), 2);
5877    }
5878
5879    #[test]
5880    fn test_steps_with_long_observations_empty_for_high_threshold() {
5881        let steps = vec![make_step("t", "a", "hi")];
5882        let session = make_session(steps, 0);
5883        assert!(session.steps_with_long_observations(1000).is_empty());
5884    }
5885
5886    #[test]
5887    fn test_unique_observations_count_counts_distinct_values() {
5888        let steps = vec![
5889            make_step("t", "a", "ok"),
5890            make_step("t", "b", "ok"),
5891            make_step("t", "c", "done"),
5892        ];
5893        let session = make_session(steps, 0);
5894        assert_eq!(session.unique_observations_count(), 2);
5895    }
5896
5897    #[test]
5898    fn test_unique_observations_count_zero_for_empty_session() {
5899        let session = make_session(vec![], 0);
5900        assert_eq!(session.unique_observations_count(), 0);
5901    }
5902
5903    // ── Round 43 ──────────────────────────────────────────────────────────────
5904
5905    #[test]
5906    fn test_thought_max_bytes_returns_max_thought_length() {
5907        let steps = vec![
5908            make_step("hi", "a", "o"),
5909            make_step("hello world", "b", "o"),
5910        ];
5911        let session = make_session(steps, 0);
5912        assert_eq!(session.thought_max_bytes(), 11);
5913    }
5914
5915    #[test]
5916    fn test_thought_max_bytes_zero_for_empty_session() {
5917        let session = make_session(vec![], 0);
5918        assert_eq!(session.thought_max_bytes(), 0);
5919    }
5920
5921    #[test]
5922    fn test_observation_max_bytes_returns_max_observation_length() {
5923        let steps = vec![
5924            make_step("t", "a", "short"),
5925            make_step("t", "b", "much longer observation"),
5926        ];
5927        let session = make_session(steps, 0);
5928        assert_eq!(session.observation_max_bytes(), "much longer observation".len());
5929    }
5930
5931    #[test]
5932    fn test_step_count_below_duration_ms_counts_fast_steps() {
5933        let mut steps = vec![
5934            make_step("t", "a", "o"),
5935            make_step("t", "b", "o"),
5936            make_step("t", "c", "o"),
5937        ];
5938        steps[0].step_duration_ms = 5;
5939        steps[1].step_duration_ms = 50;
5940        steps[2].step_duration_ms = 500;
5941        let session = make_session(steps, 0);
5942        assert_eq!(session.step_count_below_duration_ms(100), 2);
5943        assert_eq!(session.step_count_below_duration_ms(6), 1);
5944    }
5945
5946    #[test]
5947    fn test_step_count_below_duration_ms_zero_for_empty() {
5948        let session = make_session(vec![], 0);
5949        assert_eq!(session.step_count_below_duration_ms(100), 0);
5950    }
5951
5952    // ── Round 42: total_observation_count, actions_containing, step_duration_range_ms
5953
5954    #[test]
5955    fn test_total_observation_count_counts_non_empty_observations() {
5956        let steps = vec![
5957            make_step("t", "a", "result"),
5958            make_step("t", "b", ""),
5959            make_step("t", "c", "output"),
5960        ];
5961        let session = make_session(steps, 0);
5962        assert_eq!(session.total_observation_count(), 2);
5963    }
5964
5965    #[test]
5966    fn test_total_observation_count_zero_when_all_empty() {
5967        let steps = vec![make_step("t", "a", ""), make_step("t", "b", "")];
5968        let session = make_session(steps, 0);
5969        assert_eq!(session.total_observation_count(), 0);
5970    }
5971
5972    #[test]
5973    fn test_actions_containing_returns_matching_steps() {
5974        let steps = vec![
5975            make_step("t", "search(query)", "r"),
5976            make_step("t", "write(data)", "r"),
5977            make_step("t", "search(other)", "r"),
5978        ];
5979        let session = make_session(steps, 0);
5980        assert_eq!(session.actions_containing("search").len(), 2);
5981    }
5982
5983    #[test]
5984    fn test_actions_containing_empty_when_no_match() {
5985        let steps = vec![make_step("t", "write(x)", "r")];
5986        let session = make_session(steps, 0);
5987        assert!(session.actions_containing("read").is_empty());
5988    }
5989
5990    #[test]
5991    fn test_step_duration_range_ms_returns_min_max() {
5992        let mut steps = vec![
5993            make_step("t", "a", "o"),
5994            make_step("t", "b", "o"),
5995            make_step("t", "c", "o"),
5996        ];
5997        steps[0].step_duration_ms = 10;
5998        steps[1].step_duration_ms = 50;
5999        steps[2].step_duration_ms = 30;
6000        let session = make_session(steps, 0);
6001        assert_eq!(session.step_duration_range_ms(), (10, 50));
6002    }
6003
6004    #[test]
6005    fn test_step_duration_range_ms_zero_zero_for_empty() {
6006        let session = make_session(vec![], 0);
6007        assert_eq!(session.step_duration_range_ms(), (0, 0));
6008    }
6009
6010    // ── Round 43: count_unique_thoughts, steps_with_empty_thoughts ─────────────
6011
6012    #[test]
6013    fn test_count_unique_thoughts_counts_distinct_strings() {
6014        let steps = vec![
6015            make_step("alpha", "a", "o"),
6016            make_step("beta", "b", "o"),
6017            make_step("alpha", "c", "o"), // duplicate thought
6018        ];
6019        let session = make_session(steps, 0);
6020        assert_eq!(session.count_unique_thoughts(), 2);
6021    }
6022
6023    #[test]
6024    fn test_count_unique_thoughts_zero_for_empty_session() {
6025        let session = make_session(vec![], 0);
6026        assert_eq!(session.count_unique_thoughts(), 0);
6027    }
6028
6029    #[test]
6030    fn test_steps_with_empty_thoughts_returns_matching_steps() {
6031        let steps = vec![
6032            make_step("", "a", "o"),
6033            make_step("thought", "b", "o"),
6034            make_step("", "c", "o"),
6035        ];
6036        let session = make_session(steps, 0);
6037        assert_eq!(session.steps_with_empty_thoughts().len(), 2);
6038    }
6039
6040    #[test]
6041    fn test_steps_with_empty_thoughts_returns_empty_when_all_have_thoughts() {
6042        let steps = vec![make_step("t1", "a", "o"), make_step("t2", "b", "o")];
6043        let session = make_session(steps, 0);
6044        assert!(session.steps_with_empty_thoughts().is_empty());
6045    }
6046
6047    // ── Round 44: max_action_bytes, min_action_bytes, step_throughput_per_sec ─
6048
6049    #[test]
6050    fn test_max_action_bytes_returns_longest_action() {
6051        let steps = vec![
6052            make_step("t", "short", "o"),
6053            make_step("t", "much longer action string", "o"),
6054        ];
6055        let session = make_session(steps, 0);
6056        assert_eq!(session.max_action_bytes(), "much longer action string".len());
6057    }
6058
6059    #[test]
6060    fn test_max_action_bytes_zero_for_empty_session() {
6061        let session = make_session(vec![], 0);
6062        assert_eq!(session.max_action_bytes(), 0);
6063    }
6064
6065    #[test]
6066    fn test_min_action_bytes_returns_shortest_action() {
6067        let steps = vec![
6068            make_step("t", "ab", "o"),
6069            make_step("t", "abcde", "o"),
6070        ];
6071        let session = make_session(steps, 0);
6072        assert_eq!(session.min_action_bytes(), 2);
6073    }
6074
6075    #[test]
6076    fn test_min_action_bytes_zero_for_empty_session() {
6077        let session = make_session(vec![], 0);
6078        assert_eq!(session.min_action_bytes(), 0);
6079    }
6080
6081    #[test]
6082    fn test_step_throughput_per_sec_computes_ratio() {
6083        let steps = vec![make_step("t", "a", "o"), make_step("t", "b", "o")];
6084        let session = make_session(steps, 2000); // 2000 ms
6085        assert!((session.step_throughput_per_sec() - 1.0).abs() < 1e-9);
6086    }
6087
6088    #[test]
6089    fn test_step_throughput_per_sec_zero_for_zero_duration() {
6090        let steps = vec![make_step("t", "a", "o")];
6091        let session = make_session(steps, 0);
6092        assert_eq!(session.step_throughput_per_sec(), 0.0);
6093    }
6094
6095    // ── Round 44: final_answer_step_index ────────────────────────────────────
6096
6097    #[test]
6098    fn test_final_answer_step_index_returns_correct_index() {
6099        let steps = vec![
6100            make_step("think", "search(x)", "result"),
6101            make_step("think2", "FINAL_ANSWER: done", ""),
6102        ];
6103        let session = make_session(steps, 0);
6104        assert_eq!(session.final_answer_step_index(), Some(1));
6105    }
6106
6107    #[test]
6108    fn test_final_answer_step_index_returns_none_when_no_final_answer() {
6109        let steps = vec![make_step("t", "search(x)", "result")];
6110        let session = make_session(steps, 0);
6111        assert_eq!(session.final_answer_step_index(), None);
6112    }
6113
6114    #[test]
6115    fn test_final_answer_step_index_returns_none_for_empty_session() {
6116        let session = make_session(vec![], 0);
6117        assert_eq!(session.final_answer_step_index(), None);
6118    }
6119
6120    #[test]
6121    fn test_final_answer_step_index_returns_first_occurrence() {
6122        let steps = vec![
6123            make_step("t", "FINAL_ANSWER: first", ""),
6124            make_step("t", "FINAL_ANSWER: second", ""),
6125        ];
6126        let session = make_session(steps, 0);
6127        assert_eq!(session.final_answer_step_index(), Some(0));
6128    }
6129
6130    // ── Round 45: first_n_steps, steps_with_tool, total_chars ─────────────────
6131
6132    #[test]
6133    fn test_first_n_steps_returns_first_n() {
6134        let steps = vec![
6135            make_step("t1", "a1", "o1"),
6136            make_step("t2", "a2", "o2"),
6137            make_step("t3", "a3", "o3"),
6138        ];
6139        let session = make_session(steps, 0);
6140        assert_eq!(session.first_n_steps(2).len(), 2);
6141        assert_eq!(session.first_n_steps(2)[0].thought, "t1");
6142    }
6143
6144    #[test]
6145    fn test_first_n_steps_returns_all_when_n_exceeds_count() {
6146        let steps = vec![make_step("t", "a", "o")];
6147        let session = make_session(steps, 0);
6148        assert_eq!(session.first_n_steps(10).len(), 1);
6149    }
6150
6151    #[test]
6152    fn test_first_n_steps_empty_for_n_zero() {
6153        let steps = vec![make_step("t", "a", "o")];
6154        let session = make_session(steps, 0);
6155        assert!(session.first_n_steps(0).is_empty());
6156    }
6157
6158    #[test]
6159    fn test_steps_with_tool_returns_matching_steps() {
6160        let steps = vec![
6161            make_step("t", "search(query)", "result"),
6162            make_step("t", "write(data)", "ok"),
6163            make_step("t", "search(more)", "more"),
6164        ];
6165        let session = make_session(steps, 0);
6166        assert_eq!(session.steps_with_tool("search").len(), 2);
6167        assert_eq!(session.steps_with_tool("write").len(), 1);
6168    }
6169
6170    #[test]
6171    fn test_steps_with_tool_excludes_final_answer() {
6172        let steps = vec![
6173            make_step("t", "FINAL_ANSWER: search done", ""),
6174        ];
6175        let session = make_session(steps, 0);
6176        // Final answer steps are excluded even if they contain the tool name
6177        assert!(session.steps_with_tool("search").is_empty());
6178    }
6179
6180    #[test]
6181    fn test_total_chars_sums_all_strings() {
6182        let steps = vec![
6183            make_step("abc", "de", "f"),  // 3 + 2 + 1 = 6
6184            make_step("g", "hi", "jkl"), // 1 + 2 + 3 = 6
6185        ];
6186        let session = make_session(steps, 0);
6187        assert_eq!(session.total_chars(), 12);
6188    }
6189
6190    #[test]
6191    fn test_total_chars_zero_for_empty_session() {
6192        let session = make_session(vec![], 0);
6193        assert_eq!(session.total_chars(), 0);
6194    }
6195
6196    // ── Round 45: avg_action_bytes, avg_observation_bytes ─────────────────────
6197
6198    #[test]
6199    fn test_avg_action_bytes_computes_mean() {
6200        let steps = vec![
6201            make_step("t", "ab", "o"),   // 2
6202            make_step("t", "abcd", "o"), // 4
6203        ];
6204        let session = make_session(steps, 0);
6205        assert!((session.avg_action_bytes() - 3.0).abs() < 1e-9);
6206    }
6207
6208    #[test]
6209    fn test_avg_action_bytes_zero_for_empty_session() {
6210        let session = make_session(vec![], 0);
6211        assert_eq!(session.avg_action_bytes(), 0.0);
6212    }
6213
6214    #[test]
6215    fn test_avg_observation_bytes_computes_mean() {
6216        let steps = vec![
6217            make_step("t", "a", "hi"),    // obs = 2
6218            make_step("t", "b", "world"), // obs = 5
6219        ];
6220        let session = make_session(steps, 0);
6221        assert!((session.avg_observation_bytes() - 3.5).abs() < 1e-9);
6222    }
6223
6224    #[test]
6225    fn test_avg_observation_bytes_zero_for_empty_session() {
6226        let session = make_session(vec![], 0);
6227        assert_eq!(session.avg_observation_bytes(), 0.0);
6228    }
6229
6230    // ── Round 44: steps_with_long_thoughts, action_count_containing, total_thought_count
6231
6232    #[test]
6233    fn test_steps_with_long_thoughts_returns_steps_exceeding_threshold() {
6234        let steps = vec![
6235            make_step("short", "a", "o"),
6236            make_step("this is a much longer thought string", "b", "o"),
6237            make_step("hi", "c", "o"),
6238        ];
6239        let session = make_session(steps, 0);
6240        assert_eq!(session.steps_with_long_thoughts(10).len(), 1);
6241    }
6242
6243    #[test]
6244    fn test_steps_with_long_thoughts_empty_when_none_exceed() {
6245        let steps = vec![make_step("hi", "a", "o")];
6246        let session = make_session(steps, 0);
6247        assert!(session.steps_with_long_thoughts(100).is_empty());
6248    }
6249
6250    #[test]
6251    fn test_action_count_containing_counts_matching_steps() {
6252        let steps = vec![
6253            make_step("t", "search(query)", "o"),
6254            make_step("t", "write(data)", "o"),
6255            make_step("t", "search(other)", "o"),
6256        ];
6257        let session = make_session(steps, 0);
6258        assert_eq!(session.action_count_containing("search"), 2);
6259    }
6260
6261    #[test]
6262    fn test_action_count_containing_zero_when_no_match() {
6263        let steps = vec![make_step("t", "write(x)", "o")];
6264        let session = make_session(steps, 0);
6265        assert_eq!(session.action_count_containing("read"), 0);
6266    }
6267
6268    #[test]
6269    fn test_total_thought_count_counts_non_empty_thoughts() {
6270        let steps = vec![
6271            make_step("thought", "a", "o"),
6272            make_step("", "b", "o"),
6273            make_step("another", "c", "o"),
6274        ];
6275        let session = make_session(steps, 0);
6276        assert_eq!(session.total_thought_count(), 2);
6277    }
6278
6279    #[test]
6280    fn test_total_thought_count_zero_for_empty_session() {
6281        let session = make_session(vec![], 0);
6282        assert_eq!(session.total_thought_count(), 0);
6283    }
6284
6285    // ── Round 45: has_thought_containing, steps_with_action_length_above ────────
6286
6287    #[test]
6288    fn test_has_thought_containing_true_when_substring_found() {
6289        let steps = vec![make_step("think about this", "act", "obs")];
6290        let session = make_session(steps, 0);
6291        assert!(session.has_thought_containing("think"));
6292    }
6293
6294    #[test]
6295    fn test_has_thought_containing_false_when_not_found() {
6296        let steps = vec![make_step("unrelated", "act", "obs")];
6297        let session = make_session(steps, 0);
6298        assert!(!session.has_thought_containing("xyz"));
6299    }
6300
6301    #[test]
6302    fn test_has_thought_containing_false_for_empty_session() {
6303        let session = make_session(vec![], 0);
6304        assert!(!session.has_thought_containing("any"));
6305    }
6306
6307    #[test]
6308    fn test_steps_with_action_length_above_returns_matching_steps() {
6309        let steps = vec![
6310            make_step("t", "hi", "o"),           // len=2
6311            make_step("t", "hello world", "o"),  // len=11
6312        ];
6313        let session = make_session(steps, 0);
6314        let result = session.steps_with_action_length_above(5);
6315        assert_eq!(result.len(), 1);
6316        assert_eq!(result[0].action, "hello world");
6317    }
6318
6319    #[test]
6320    fn test_steps_with_action_length_above_empty_when_none_qualify() {
6321        let steps = vec![make_step("t", "hi", "o")];
6322        let session = make_session(steps, 0);
6323        assert!(session.steps_with_action_length_above(100).is_empty());
6324    }
6325
6326    // ── Round 46: avg_thought_bytes, steps_above_action_bytes ─────────────────
6327
6328    #[test]
6329    fn test_avg_thought_bytes_computes_mean() {
6330        let steps = vec![
6331            make_step("ab", "a", "o"),    // thought = 2
6332            make_step("abcdef", "b", "o"), // thought = 6
6333        ];
6334        let session = make_session(steps, 0);
6335        assert!((session.avg_thought_bytes() - 4.0).abs() < 1e-9);
6336    }
6337
6338    #[test]
6339    fn test_avg_thought_bytes_zero_for_empty_session() {
6340        let session = make_session(vec![], 0);
6341        assert_eq!(session.avg_thought_bytes(), 0.0);
6342    }
6343
6344    #[test]
6345    fn test_steps_above_action_bytes_filters_correctly() {
6346        let steps = vec![
6347            make_step("t", "ab", "o"),       // len = 2
6348            make_step("t", "abcdefgh", "o"), // len = 8
6349            make_step("t", "abc", "o"),      // len = 3
6350        ];
6351        let session = make_session(steps, 0);
6352        assert_eq!(session.steps_above_action_bytes(3).len(), 1);
6353    }
6354
6355    #[test]
6356    fn test_steps_above_action_bytes_empty_for_empty_session() {
6357        let session = make_session(vec![], 0);
6358        assert!(session.steps_above_action_bytes(0).is_empty());
6359    }
6360
6361    // ── Round 47: steps_between, steps_with_duplicate_thoughts ────────────────
6362
6363    #[test]
6364    fn test_steps_between_returns_subslice() {
6365        let steps = vec![
6366            make_step("t", "a", "o"),
6367            make_step("t", "b", "o"),
6368            make_step("t", "c", "o"),
6369            make_step("t", "d", "o"),
6370        ];
6371        let session = make_session(steps, 0);
6372        let between = session.steps_between(1, 3);
6373        assert_eq!(between.len(), 2);
6374        assert_eq!(between[0].action, "b");
6375        assert_eq!(between[1].action, "c");
6376    }
6377
6378    #[test]
6379    fn test_steps_between_empty_when_start_ge_end() {
6380        let steps = vec![make_step("t", "a", "o"), make_step("t", "b", "o")];
6381        let session = make_session(steps, 0);
6382        assert!(session.steps_between(2, 1).is_empty());
6383    }
6384
6385    #[test]
6386    fn test_steps_with_duplicate_thoughts_returns_duplicates_only() {
6387        let steps = vec![
6388            make_step("alpha", "a", "o"),
6389            make_step("beta", "b", "o"),
6390            make_step("alpha", "c", "o"), // duplicate of first
6391        ];
6392        let session = make_session(steps, 0);
6393        let dupes = session.steps_with_duplicate_thoughts();
6394        assert_eq!(dupes.len(), 1);
6395        assert_eq!(dupes[0].action, "c");
6396    }
6397
6398    #[test]
6399    fn test_steps_with_duplicate_thoughts_empty_when_all_unique() {
6400        let steps = vec![
6401            make_step("t1", "a", "o"),
6402            make_step("t2", "b", "o"),
6403        ];
6404        let session = make_session(steps, 0);
6405        assert!(session.steps_with_duplicate_thoughts().is_empty());
6406    }
6407
6408    // ── Round 48: step_observation_rate, steps_below_thought_bytes, tool_count ─
6409
6410    #[test]
6411    fn test_step_observation_rate_returns_fraction_with_observations() {
6412        let steps = vec![
6413            make_step("t", "a", "obs"),
6414            make_step("t", "b", ""),
6415            make_step("t", "c", "obs2"),
6416        ];
6417        let session = make_session(steps, 0);
6418        let rate = session.step_observation_rate();
6419        assert!((rate - 2.0 / 3.0).abs() < 1e-9);
6420    }
6421
6422    #[test]
6423    fn test_step_observation_rate_zero_for_empty_session() {
6424        let session = make_session(vec![], 0);
6425        assert_eq!(session.step_observation_rate(), 0.0);
6426    }
6427
6428    #[test]
6429    fn test_steps_below_thought_bytes_filters_by_threshold() {
6430        let steps = vec![
6431            make_step("hi", "a", "o"),
6432            make_step("hello world", "b", "o"),
6433        ];
6434        let session = make_session(steps, 0);
6435        let below = session.steps_below_thought_bytes(6);
6436        assert_eq!(below.len(), 1);
6437        assert_eq!(below[0].action, "a");
6438    }
6439
6440    #[test]
6441    fn test_steps_below_thought_bytes_empty_when_all_exceed() {
6442        let steps = vec![make_step("long thought text", "a", "o")];
6443        let session = make_session(steps, 0);
6444        assert!(session.steps_below_thought_bytes(3).is_empty());
6445    }
6446
6447    #[test]
6448    fn test_agent_runtime_tool_count_reflects_registered_tools() {
6449        let rt = AgentRuntime::quick(1, "model");
6450        assert_eq!(rt.tool_count(), 0);
6451    }
6452
6453    // ── Round 49: max_thought_bytes, steps_above_observation_bytes, tool_names ──
6454
6455    #[test]
6456    fn test_max_thought_bytes_returns_longest_thought_length() {
6457        let steps = vec![
6458            make_step("hi", "a", "o"),
6459            make_step("hello world", "b", "o"),
6460        ];
6461        let session = make_session(steps, 0);
6462        assert_eq!(session.max_thought_bytes(), 11);
6463    }
6464
6465    #[test]
6466    fn test_max_thought_bytes_zero_for_empty_session() {
6467        let session = make_session(vec![], 0);
6468        assert_eq!(session.max_thought_bytes(), 0);
6469    }
6470
6471    #[test]
6472    fn test_steps_above_observation_bytes_filters_by_threshold() {
6473        let steps = vec![
6474            make_step("t", "a", "tiny"),
6475            make_step("t", "b", "a much longer observation"),
6476        ];
6477        let session = make_session(steps, 0);
6478        let above = session.steps_above_observation_bytes(5);
6479        assert_eq!(above.len(), 1);
6480        assert_eq!(above[0].action, "b");
6481    }
6482
6483    #[test]
6484    fn test_steps_above_observation_bytes_empty_when_all_below() {
6485        let steps = vec![make_step("t", "a", "hi")];
6486        let session = make_session(steps, 0);
6487        assert!(session.steps_above_observation_bytes(100).is_empty());
6488    }
6489
6490    #[test]
6491    fn test_agent_runtime_tool_names_empty_when_no_tools() {
6492        let rt = AgentRuntime::quick(1, "model");
6493        assert!(rt.tool_names().is_empty());
6494    }
6495
6496    // ── Round 47: steps_between, has_duplicate_actions, step_indices_with_tool ──
6497
6498    #[test]
6499    fn test_steps_between_returns_correct_slice() {
6500        let steps = vec![
6501            make_step("t0", "a0", "o0"),
6502            make_step("t1", "a1", "o1"),
6503            make_step("t2", "a2", "o2"),
6504            make_step("t3", "a3", "o3"),
6505        ];
6506        let session = make_session(steps, 0);
6507        let slice = session.steps_between(1, 3);
6508        assert_eq!(slice.len(), 2);
6509        assert_eq!(slice[0].thought, "t1");
6510        assert_eq!(slice[1].thought, "t2");
6511    }
6512
6513    #[test]
6514    fn test_steps_between_returns_empty_when_start_ge_end() {
6515        let steps = vec![make_step("t", "a", "o"), make_step("t2", "a2", "o2")];
6516        let session = make_session(steps, 0);
6517        assert!(session.steps_between(2, 1).is_empty());
6518        assert!(session.steps_between(1, 1).is_empty());
6519    }
6520
6521    #[test]
6522    fn test_steps_between_clamps_to_step_count() {
6523        let steps = vec![make_step("t", "a", "o")];
6524        let session = make_session(steps, 0);
6525        let slice = session.steps_between(0, 100);
6526        assert_eq!(slice.len(), 1);
6527    }
6528
6529    #[test]
6530    fn test_has_duplicate_actions_true_when_repeated() {
6531        let steps = vec![
6532            make_step("t", "search[foo]", "o"),
6533            make_step("t", "search[foo]", "o"),
6534        ];
6535        let session = make_session(steps, 0);
6536        assert!(session.has_duplicate_actions());
6537    }
6538
6539    #[test]
6540    fn test_has_duplicate_actions_false_when_all_unique() {
6541        let steps = vec![
6542            make_step("t", "search[foo]", "o"),
6543            make_step("t", "lookup[bar]", "o"),
6544        ];
6545        let session = make_session(steps, 0);
6546        assert!(!session.has_duplicate_actions());
6547    }
6548
6549    #[test]
6550    fn test_has_duplicate_actions_false_for_empty_session() {
6551        let session = make_session(vec![], 0);
6552        assert!(!session.has_duplicate_actions());
6553    }
6554
6555    #[test]
6556    fn test_step_indices_with_tool_returns_correct_indices() {
6557        let steps = vec![
6558            make_step("t", "search[x]", "o"),
6559            make_step("t", "lookup[y]", "o"),
6560            make_step("t", "search[z]", "o"),
6561        ];
6562        let session = make_session(steps, 0);
6563        let indices = session.step_indices_with_tool("search");
6564        assert_eq!(indices, vec![0, 2]);
6565    }
6566
6567    #[test]
6568    fn test_step_indices_with_tool_empty_when_no_match() {
6569        let steps = vec![make_step("t", "lookup[x]", "o")];
6570        let session = make_session(steps, 0);
6571        assert!(session.step_indices_with_tool("search").is_empty());
6572    }
6573
6574    // ── Round 49: observations_above_bytes, total_step_chars ──────────────────
6575
6576    #[test]
6577    fn test_observations_above_bytes_returns_matching_steps() {
6578        let steps = vec![
6579            make_step("t", "a", "hi"),        // obs len=2
6580            make_step("t", "a", "hello world"), // obs len=11
6581        ];
6582        let session = make_session(steps, 0);
6583        let result = session.observations_above_bytes(5);
6584        assert_eq!(result.len(), 1);
6585        assert_eq!(result[0].observation, "hello world");
6586    }
6587
6588    #[test]
6589    fn test_observations_above_bytes_empty_for_empty_session() {
6590        let session = make_session(vec![], 0);
6591        assert!(session.observations_above_bytes(0).is_empty());
6592    }
6593
6594    #[test]
6595    fn test_total_step_chars_sums_all_fields() {
6596        let steps = vec![
6597            make_step("ab", "cd", "ef"), // 2+2+2=6
6598            make_step("x", "y", "z"),    // 1+1+1=3
6599        ];
6600        let session = make_session(steps, 0);
6601        assert_eq!(session.total_step_chars(), 9);
6602    }
6603
6604    #[test]
6605    fn test_total_step_chars_zero_for_empty_session() {
6606        let session = make_session(vec![], 0);
6607        assert_eq!(session.total_step_chars(), 0);
6608    }
6609
6610    // ── Round 50: steps_by_action_prefix, action_count ────────────────────────
6611
6612    #[test]
6613    fn test_steps_by_action_prefix_returns_matching_steps() {
6614        let steps = vec![
6615            make_step("t", "search_web", "o"),
6616            make_step("t", "search_db", "o"),
6617            make_step("t", "write_file", "o"),
6618        ];
6619        let session = make_session(steps, 0);
6620        let result = session.steps_by_action_prefix("search");
6621        assert_eq!(result.len(), 2);
6622    }
6623
6624    #[test]
6625    fn test_steps_by_action_prefix_empty_when_no_match() {
6626        let steps = vec![make_step("t", "write_file", "o")];
6627        let session = make_session(steps, 0);
6628        assert!(session.steps_by_action_prefix("search").is_empty());
6629    }
6630
6631    #[test]
6632    fn test_action_count_counts_tool_call_steps() {
6633        let steps = vec![
6634            make_step("t", "search_web", "o"),
6635            make_step("t", "FINAL_ANSWER: done", "o"),
6636        ];
6637        let session = make_session(steps, 0);
6638        assert_eq!(session.action_count(), 1);
6639    }
6640
6641    #[test]
6642    fn test_action_count_zero_for_empty_session() {
6643        let session = make_session(vec![], 0);
6644        assert_eq!(session.action_count(), 0);
6645    }
6646
6647    // ── Round 47: total_thought_bytes, total_observation_bytes ─────────────────
6648
6649    #[test]
6650    fn test_total_thought_bytes_sums_thought_lengths() {
6651        let steps = vec![
6652            make_step("ab", "a", "o"),    // 2
6653            make_step("abcde", "b", "o"), // 5
6654        ];
6655        let session = make_session(steps, 0);
6656        assert_eq!(session.total_thought_bytes(), 7);
6657    }
6658
6659    #[test]
6660    fn test_total_thought_bytes_zero_for_empty_session() {
6661        let session = make_session(vec![], 0);
6662        assert_eq!(session.total_thought_bytes(), 0);
6663    }
6664
6665    #[test]
6666    fn test_total_observation_bytes_sums_observation_lengths() {
6667        let steps = vec![
6668            make_step("t", "a", "hello"), // obs=5
6669            make_step("t", "b", "world"), // obs=5
6670        ];
6671        let session = make_session(steps, 0);
6672        assert_eq!(session.total_observation_bytes(), 10);
6673    }
6674
6675    #[test]
6676    fn test_total_observation_bytes_zero_for_empty_session() {
6677        let session = make_session(vec![], 0);
6678        assert_eq!(session.total_observation_bytes(), 0);
6679    }
6680
6681    // ── Round 50: proportion_tool_calls, thought_density ─────────────────────
6682
6683    #[test]
6684    fn test_proportion_tool_calls_all_tool_calls() {
6685        let steps = vec![
6686            make_step("t", "search[x]", "o"),
6687            make_step("t", "lookup[y]", "o"),
6688        ];
6689        let session = make_session(steps, 0);
6690        assert!((session.proportion_tool_calls() - 1.0).abs() < 1e-9);
6691    }
6692
6693    #[test]
6694    fn test_proportion_tool_calls_zero_for_empty_session() {
6695        let session = make_session(vec![], 0);
6696        assert_eq!(session.proportion_tool_calls(), 0.0);
6697    }
6698
6699    #[test]
6700    fn test_thought_density_returns_thought_fraction_of_total_bytes() {
6701        let steps = vec![make_step("ab", "cd", "ef")]; // 2+2+2=6, thought=2
6702        let session = make_session(steps, 0);
6703        let density = session.thought_density();
6704        assert!((density - 1.0 / 3.0).abs() < 1e-9);
6705    }
6706
6707    #[test]
6708    fn test_thought_density_zero_for_empty_session() {
6709        let session = make_session(vec![], 0);
6710        assert_eq!(session.thought_density(), 0.0);
6711    }
6712
6713    // ── Round 51: steps_matching_observation, step_action_lengths ─────────────
6714
6715    #[test]
6716    fn test_steps_matching_observation_returns_matching_steps() {
6717        let steps = vec![
6718            make_step("t", "a", "found: result"),
6719            make_step("t", "b", "no match here"),
6720            make_step("t", "c", "found: another"),
6721        ];
6722        let session = make_session(steps, 0);
6723        let result = session.steps_matching_observation("found:");
6724        assert_eq!(result.len(), 2);
6725    }
6726
6727    #[test]
6728    fn test_steps_matching_observation_empty_when_no_match() {
6729        let steps = vec![make_step("t", "a", "nothing")];
6730        let session = make_session(steps, 0);
6731        assert!(session.steps_matching_observation("found:").is_empty());
6732    }
6733
6734    #[test]
6735    fn test_step_action_lengths_returns_lengths_in_order() {
6736        let steps = vec![
6737            make_step("t", "ab", "o"),
6738            make_step("t", "cdef", "o"),
6739        ];
6740        let session = make_session(steps, 0);
6741        assert_eq!(session.step_action_lengths(), vec![2, 4]);
6742    }
6743
6744    #[test]
6745    fn test_step_action_lengths_empty_for_empty_session() {
6746        let session = make_session(vec![], 0);
6747        assert!(session.step_action_lengths().is_empty());
6748    }
6749
6750    // ── Round 52: has_thought_starting_with, step_count_above_action_bytes, config ──
6751
6752    #[test]
6753    fn test_has_thought_starting_with_true_when_match() {
6754        let steps = vec![
6755            make_step("Plan: do something", "act", "obs"),
6756        ];
6757        let session = make_session(steps, 0);
6758        assert!(session.has_thought_starting_with("Plan:"));
6759    }
6760
6761    #[test]
6762    fn test_has_thought_starting_with_false_when_no_match() {
6763        let steps = vec![make_step("think", "act", "obs")];
6764        let session = make_session(steps, 0);
6765        assert!(!session.has_thought_starting_with("Plan:"));
6766    }
6767
6768    #[test]
6769    fn test_has_thought_starting_with_false_for_empty_session() {
6770        let session = make_session(vec![], 0);
6771        assert!(!session.has_thought_starting_with("Plan:"));
6772    }
6773
6774    #[test]
6775    fn test_step_count_above_action_bytes_counts_correctly() {
6776        let steps = vec![
6777            make_step("t", "short", "o"),
6778            make_step("t", "a_very_long_action_string", "o"),
6779        ];
6780        let session = make_session(steps, 0);
6781        assert_eq!(session.step_count_above_action_bytes(5), 1);
6782    }
6783
6784    #[test]
6785    fn test_step_count_above_action_bytes_zero_when_all_small() {
6786        let steps = vec![make_step("t", "ab", "o")];
6787        let session = make_session(steps, 0);
6788        assert_eq!(session.step_count_above_action_bytes(100), 0);
6789    }
6790
6791    #[test]
6792    fn test_runtime_config_returns_agent_config() {
6793        let rt = AgentRuntime::quick(3, "test-model");
6794        assert_eq!(rt.config().max_iterations, 3);
6795    }
6796
6797    // ── Round 53: total_thought_chars, total_action_chars, total_observation_chars, model_name ──
6798
6799    #[test]
6800    fn test_total_thought_chars_sums_all_thoughts() {
6801        let steps = vec![
6802            make_step("ab", "x", "y"),
6803            make_step("cde", "x", "y"),
6804        ];
6805        let session = make_session(steps, 0);
6806        assert_eq!(session.total_thought_chars(), 5);
6807    }
6808
6809    #[test]
6810    fn test_total_thought_chars_zero_for_empty_session() {
6811        let session = make_session(vec![], 0);
6812        assert_eq!(session.total_thought_chars(), 0);
6813    }
6814
6815    #[test]
6816    fn test_total_action_chars_sums_all_actions() {
6817        let steps = vec![
6818            make_step("t", "hello", "o"),
6819            make_step("t", "world", "o"),
6820        ];
6821        let session = make_session(steps, 0);
6822        assert_eq!(session.total_action_chars(), 10);
6823    }
6824
6825    #[test]
6826    fn test_total_observation_chars_sums_all_observations() {
6827        let steps = vec![
6828            make_step("t", "a", "abc"),
6829            make_step("t", "a", "de"),
6830        ];
6831        let session = make_session(steps, 0);
6832        assert_eq!(session.total_observation_chars(), 5);
6833    }
6834
6835    #[test]
6836    fn test_model_name_returns_configured_model() {
6837        let rt = AgentRuntime::quick(5, "gpt-4o");
6838        assert_eq!(rt.model_name(), "gpt-4o");
6839    }
6840
6841    // ── Round 48 ──────────────────────────────────────────────────────────────
6842
6843    #[test]
6844    fn test_min_observation_bytes_returns_smallest_nonempty() {
6845        let steps = vec![
6846            make_step("t", "a", "hello"),
6847            make_step("t", "a", "hi"),
6848            make_step("t", "a", ""),
6849        ];
6850        let session = make_session(steps, 0);
6851        assert_eq!(session.min_observation_bytes(), 2);
6852    }
6853
6854    #[test]
6855    fn test_min_observation_bytes_zero_when_all_empty() {
6856        let steps = vec![make_step("t", "a", "")];
6857        let session = make_session(steps, 0);
6858        assert_eq!(session.min_observation_bytes(), 0);
6859    }
6860
6861    #[test]
6862    fn test_min_thought_bytes_returns_smallest_nonempty() {
6863        let steps = vec![
6864            make_step("abc", "a", "o"),
6865            make_step("xy", "a", "o"),
6866            make_step("", "a", "o"),
6867        ];
6868        let session = make_session(steps, 0);
6869        assert_eq!(session.min_thought_bytes(), 2);
6870    }
6871
6872    #[test]
6873    fn test_min_thought_bytes_zero_for_empty_session() {
6874        let session = make_session(vec![], 0);
6875        assert_eq!(session.min_thought_bytes(), 0);
6876    }
6877
6878    #[test]
6879    fn test_proportion_empty_thoughts_all_empty() {
6880        let steps = vec![
6881            make_step("", "a", "o"),
6882            make_step("", "a", "o"),
6883        ];
6884        let session = make_session(steps, 0);
6885        assert!((session.proportion_empty_thoughts() - 1.0).abs() < f64::EPSILON);
6886    }
6887
6888    #[test]
6889    fn test_proportion_empty_thoughts_none_empty() {
6890        let steps = vec![make_step("think", "a", "o")];
6891        let session = make_session(steps, 0);
6892        assert!((session.proportion_empty_thoughts()).abs() < f64::EPSILON);
6893    }
6894
6895    #[test]
6896    fn test_proportion_empty_thoughts_zero_for_empty_session() {
6897        let session = make_session(vec![], 0);
6898        assert!((session.proportion_empty_thoughts()).abs() < f64::EPSILON);
6899    }
6900
6901    #[test]
6902    fn test_has_failed_steps_true_when_error_observation() {
6903        let steps = vec![make_step("t", "a", "[error] something broke")];
6904        let session = make_session(steps, 0);
6905        assert!(session.has_failed_steps());
6906    }
6907
6908    #[test]
6909    fn test_has_failed_steps_false_when_no_errors() {
6910        let steps = vec![make_step("t", "a", "success")];
6911        let session = make_session(steps, 0);
6912        assert!(!session.has_failed_steps());
6913    }
6914
6915    #[test]
6916    fn test_has_failed_steps_false_for_empty_session() {
6917        let session = make_session(vec![], 0);
6918        assert!(!session.has_failed_steps());
6919    }
6920
6921    // ── Round 53 ──────────────────────────────────────────────────────────────
6922
6923    #[test]
6924    fn test_all_observations_non_empty_true_when_all_have_obs() {
6925        let steps = vec![
6926            make_step("t1", "a1", "result1"),
6927            make_step("t2", "a2", "result2"),
6928        ];
6929        let session = make_session(steps, 0);
6930        assert!(session.all_observations_non_empty());
6931    }
6932
6933    #[test]
6934    fn test_all_observations_non_empty_false_when_one_is_empty() {
6935        let steps = vec![
6936            make_step("t1", "a1", "result1"),
6937            make_step("t2", "a2", ""),
6938        ];
6939        let session = make_session(steps, 0);
6940        assert!(!session.all_observations_non_empty());
6941    }
6942
6943    #[test]
6944    fn test_all_observations_non_empty_true_for_empty_session() {
6945        let session = make_session(vec![], 0);
6946        assert!(session.all_observations_non_empty());
6947    }
6948
6949    #[test]
6950    fn test_avg_combined_step_bytes_correct() {
6951        let steps = vec![
6952            make_step("hi", "go", "ok"),
6953            make_step("hello", "world", "result"),
6954        ];
6955        let session = make_session(steps, 0);
6956        // step1: 2+2+2=6, step2: 5+5+6=16, avg=11
6957        let avg = session.avg_combined_step_bytes();
6958        assert!((avg - 11.0).abs() < 1e-9);
6959    }
6960
6961    #[test]
6962    fn test_avg_combined_step_bytes_zero_for_empty_session() {
6963        let session = make_session(vec![], 0);
6964        assert_eq!(session.avg_combined_step_bytes(), 0.0);
6965    }
6966
6967    #[test]
6968    fn test_shortest_observation_step_returns_shortest() {
6969        let steps = vec![
6970            make_step("t1", "a1", "longer observation"),
6971            make_step("t2", "a2", "short"),
6972        ];
6973        let session = make_session(steps, 0);
6974        let s = session.shortest_observation_step().unwrap();
6975        assert_eq!(s.observation, "short");
6976    }
6977
6978    #[test]
6979    fn test_shortest_observation_step_none_for_empty_session() {
6980        let session = make_session(vec![], 0);
6981        assert!(session.shortest_observation_step().is_none());
6982    }
6983
6984    // ── Round 54: steps_with_empty_action, observation_starts_with_any, has_repeated_actions, session_max_iterations ──
6985
6986    #[test]
6987    fn test_steps_with_empty_action_returns_matching_steps() {
6988        let steps = vec![
6989            make_step("t", "", "obs"),
6990            make_step("t", "act", "obs"),
6991            make_step("t", "", "obs"),
6992        ];
6993        let session = make_session(steps, 0);
6994        assert_eq!(session.steps_with_empty_action().len(), 2);
6995    }
6996
6997    #[test]
6998    fn test_steps_with_empty_action_empty_when_all_have_actions() {
6999        let steps = vec![make_step("t", "act", "obs")];
7000        let session = make_session(steps, 0);
7001        assert!(session.steps_with_empty_action().is_empty());
7002    }
7003
7004    #[test]
7005    fn test_observation_starts_with_any_true_when_prefix_matches() {
7006        let steps = vec![make_step("t", "a", "ERROR: something went wrong")];
7007        let session = make_session(steps, 0);
7008        assert!(session.observation_starts_with_any(&["ERROR:", "WARN:"]));
7009    }
7010
7011    #[test]
7012    fn test_observation_starts_with_any_false_when_no_match() {
7013        let steps = vec![make_step("t", "a", "success")];
7014        let session = make_session(steps, 0);
7015        assert!(!session.observation_starts_with_any(&["ERROR:", "WARN:"]));
7016    }
7017
7018    #[test]
7019    fn test_has_repeated_actions_true_when_duplicate_exists() {
7020        let steps = vec![
7021            make_step("t", "search", "obs"),
7022            make_step("t", "search", "obs"),
7023        ];
7024        let session = make_session(steps, 0);
7025        assert!(session.has_repeated_actions());
7026    }
7027
7028    #[test]
7029    fn test_has_repeated_actions_false_when_all_unique() {
7030        let steps = vec![
7031            make_step("t", "search", "obs"),
7032            make_step("t", "read", "obs"),
7033        ];
7034        let session = make_session(steps, 0);
7035        assert!(!session.has_repeated_actions());
7036    }
7037
7038    #[test]
7039    fn test_session_max_iterations_returns_config_value() {
7040        let rt = AgentRuntime::quick(7, "model");
7041        assert_eq!(rt.session_max_iterations(), 7);
7042    }
7043
7044    // ── Round 55: has_action_containing, max_observation_chars, step_index_of_longest_thought, observation_word_counts ──
7045
7046    #[test]
7047    fn test_has_action_containing_true_when_substr_matches() {
7048        let steps = vec![make_step("t", "search[query]", "obs")];
7049        let session = make_session(steps, 0);
7050        assert!(session.has_action_containing("search"));
7051    }
7052
7053    #[test]
7054    fn test_has_action_containing_false_when_no_match() {
7055        let steps = vec![make_step("t", "read_file", "obs")];
7056        let session = make_session(steps, 0);
7057        assert!(!session.has_action_containing("write"));
7058    }
7059
7060    #[test]
7061    fn test_max_observation_chars_returns_longest_observation() {
7062        let steps = vec![
7063            make_step("t", "a", "hi"),
7064            make_step("t", "a", "hello world"),
7065        ];
7066        let session = make_session(steps, 0);
7067        assert_eq!(session.max_observation_chars(), 11);
7068    }
7069
7070    #[test]
7071    fn test_max_observation_chars_zero_for_empty_session() {
7072        let session = make_session(vec![], 0);
7073        assert_eq!(session.max_observation_chars(), 0);
7074    }
7075
7076    #[test]
7077    fn test_step_index_of_longest_thought_returns_correct_index() {
7078        let steps = vec![
7079            make_step("short", "a", "o"),
7080            make_step("a very long thought string", "a", "o"),
7081            make_step("mid", "a", "o"),
7082        ];
7083        let session = make_session(steps, 0);
7084        assert_eq!(session.step_index_of_longest_thought(), Some(1));
7085    }
7086
7087    #[test]
7088    fn test_step_index_of_longest_thought_none_for_empty_session() {
7089        let session = make_session(vec![], 0);
7090        assert_eq!(session.step_index_of_longest_thought(), None);
7091    }
7092
7093    #[test]
7094    fn test_observation_word_counts_returns_word_counts_in_order() {
7095        let steps = vec![
7096            make_step("t", "a", "one two three"),
7097            make_step("t", "a", "single"),
7098        ];
7099        let session = make_session(steps, 0);
7100        assert_eq!(session.observation_word_counts(), vec![3, 1]);
7101    }
7102
7103    // ── Round 49 ──────────────────────────────────────────────────────────────
7104
7105    #[test]
7106    fn test_action_byte_variance_zero_for_equal_lengths() {
7107        let steps = vec![
7108            make_step("t", "abc", "o"),
7109            make_step("t", "def", "o"),
7110        ];
7111        let session = make_session(steps, 0);
7112        assert!((session.action_byte_variance()).abs() < f64::EPSILON);
7113    }
7114
7115    #[test]
7116    fn test_action_byte_variance_nonzero_for_different_lengths() {
7117        let steps = vec![
7118            make_step("t", "a", "o"),
7119            make_step("t", "abcde", "o"),
7120        ];
7121        let session = make_session(steps, 0);
7122        assert!(session.action_byte_variance() > 0.0);
7123    }
7124
7125    #[test]
7126    fn test_action_byte_variance_zero_for_single_step() {
7127        let steps = vec![make_step("t", "hello", "o")];
7128        let session = make_session(steps, 0);
7129        assert!((session.action_byte_variance()).abs() < f64::EPSILON);
7130    }
7131
7132    #[test]
7133    fn test_thought_byte_variance_nonzero_for_different_lengths() {
7134        let steps = vec![
7135            make_step("a", "act", "o"),
7136            make_step("abcde", "act", "o"),
7137        ];
7138        let session = make_session(steps, 0);
7139        assert!(session.thought_byte_variance() > 0.0);
7140    }
7141
7142    #[test]
7143    fn test_thought_byte_variance_zero_for_empty_session() {
7144        let session = make_session(vec![], 0);
7145        assert!((session.thought_byte_variance()).abs() < f64::EPSILON);
7146    }
7147
7148    #[test]
7149    fn test_steps_above_thought_bytes_filters_correctly() {
7150        let steps = vec![
7151            make_step("hi", "a", "o"),
7152            make_step("hello world", "a", "o"),
7153            make_step("x", "a", "o"),
7154        ];
7155        let session = make_session(steps, 0);
7156        let above = session.steps_above_thought_bytes(4);
7157        assert_eq!(above.len(), 1);
7158        assert_eq!(above[0].thought, "hello world");
7159    }
7160
7161    #[test]
7162    fn test_steps_above_thought_bytes_empty_when_none_qualify() {
7163        let steps = vec![make_step("hi", "a", "o")];
7164        let session = make_session(steps, 0);
7165        assert!(session.steps_above_thought_bytes(100).is_empty());
7166    }
7167
7168    // ── Round 54 ──────────────────────────────────────────────────────────────
7169
7170    #[test]
7171    fn test_unique_observation_count_counts_distinct_observations() {
7172        let steps = vec![
7173            make_step("t1", "a1", "result"),
7174            make_step("t2", "a2", "result"),
7175            make_step("t3", "a3", "other"),
7176        ];
7177        let session = make_session(steps, 0);
7178        assert_eq!(session.unique_observation_count(), 2);
7179    }
7180
7181    #[test]
7182    fn test_unique_observation_count_zero_for_empty_session() {
7183        let session = make_session(vec![], 0);
7184        assert_eq!(session.unique_observation_count(), 0);
7185    }
7186
7187    #[test]
7188    fn test_avg_thought_word_count_computes_correctly() {
7189        let steps = vec![
7190            make_step("one word", "a", "o"),       // 2 words
7191            make_step("three word count", "a", "o"), // 3 words
7192        ];
7193        let session = make_session(steps, 0);
7194        let avg = session.avg_thought_word_count();
7195        assert!((avg - 2.5).abs() < 1e-9);
7196    }
7197
7198    #[test]
7199    fn test_avg_thought_word_count_zero_for_empty_session() {
7200        let session = make_session(vec![], 0);
7201        assert_eq!(session.avg_thought_word_count(), 0.0);
7202    }
7203
7204    // ── Round 56: thought_starts_with_any, action_word_count, steps_above_thought_chars ──
7205
7206    #[test]
7207    fn test_thought_starts_with_any_true_when_prefix_matches() {
7208        let steps = vec![make_step("Plan: do it", "act", "obs")];
7209        let session = make_session(steps, 0);
7210        assert!(session.thought_starts_with_any(&["Plan:", "Think:"]));
7211    }
7212
7213    #[test]
7214    fn test_thought_starts_with_any_false_when_no_match() {
7215        let steps = vec![make_step("just thinking", "act", "obs")];
7216        let session = make_session(steps, 0);
7217        assert!(!session.thought_starts_with_any(&["Plan:", "Think:"]));
7218    }
7219
7220    #[test]
7221    fn test_action_word_count_sums_words_across_steps() {
7222        let steps = vec![
7223            make_step("t", "search for answer", "obs"),
7224            make_step("t", "write result", "obs"),
7225        ];
7226        let session = make_session(steps, 0);
7227        assert_eq!(session.action_word_count(), 5);
7228    }
7229
7230    #[test]
7231    fn test_action_word_count_zero_for_empty_session() {
7232        let session = make_session(vec![], 0);
7233        assert_eq!(session.action_word_count(), 0);
7234    }
7235
7236    #[test]
7237    fn test_steps_above_thought_chars_counts_correctly() {
7238        let steps = vec![
7239            make_step("short", "a", "o"),
7240            make_step("a very long thought here", "a", "o"),
7241        ];
7242        let session = make_session(steps, 0);
7243        assert_eq!(session.steps_above_thought_chars(5), 1);
7244    }
7245
7246    #[test]
7247    fn test_steps_above_thought_chars_zero_for_empty_session() {
7248        let session = make_session(vec![], 0);
7249        assert_eq!(session.steps_above_thought_chars(0), 0);
7250    }
7251
7252    // ── Round 50 ──────────────────────────────────────────────────────────────
7253
7254    #[test]
7255    fn test_total_empty_steps_counts_fully_empty_steps() {
7256        let steps = vec![
7257            make_step("", "", ""),
7258            make_step("t", "a", "o"),
7259            make_step("", "", ""),
7260        ];
7261        let session = make_session(steps, 0);
7262        assert_eq!(session.total_empty_steps(), 2);
7263    }
7264
7265    #[test]
7266    fn test_total_empty_steps_zero_when_no_empty_steps() {
7267        let steps = vec![make_step("t", "a", "o")];
7268        let session = make_session(steps, 0);
7269        assert_eq!(session.total_empty_steps(), 0);
7270    }
7271
7272    #[test]
7273    fn test_action_starts_with_count_correct() {
7274        let steps = vec![
7275            make_step("t", "search:foo", "o"),
7276            make_step("t", "search:bar", "o"),
7277            make_step("t", "write:baz", "o"),
7278        ];
7279        let session = make_session(steps, 0);
7280        assert_eq!(session.action_starts_with_count("search"), 2);
7281    }
7282
7283    #[test]
7284    fn test_action_starts_with_count_zero_for_no_match() {
7285        let steps = vec![make_step("t", "write:x", "o")];
7286        let session = make_session(steps, 0);
7287        assert_eq!(session.action_starts_with_count("read"), 0);
7288    }
7289
7290    #[test]
7291    fn test_longest_action_returns_longest() {
7292        let steps = vec![
7293            make_step("t", "short", "o"),
7294            make_step("t", "much_longer_action", "o"),
7295            make_step("t", "mid", "o"),
7296        ];
7297        let session = make_session(steps, 0);
7298        assert_eq!(session.longest_action(), Some("much_longer_action"));
7299    }
7300
7301    #[test]
7302    fn test_longest_action_none_for_empty_session() {
7303        let session = make_session(vec![], 0);
7304        assert_eq!(session.longest_action(), None);
7305    }
7306
7307    #[test]
7308    fn test_thought_completeness_all_non_empty() {
7309        let steps = vec![
7310            make_step("think", "a", "o"),
7311            make_step("also thinking", "a", "o"),
7312        ];
7313        let session = make_session(steps, 0);
7314        assert!((session.thought_completeness() - 1.0).abs() < f64::EPSILON);
7315    }
7316
7317    #[test]
7318    fn test_thought_completeness_half_empty() {
7319        let steps = vec![
7320            make_step("think", "a", "o"),
7321            make_step("", "a", "o"),
7322        ];
7323        let session = make_session(steps, 0);
7324        assert!((session.thought_completeness() - 0.5).abs() < f64::EPSILON);
7325    }
7326
7327    #[test]
7328    fn test_thought_completeness_zero_for_empty_session() {
7329        let session = make_session(vec![], 0);
7330        assert!((session.thought_completeness()).abs() < f64::EPSILON);
7331    }
7332
7333    // ── Round 57: steps_with_non_empty_observation, observations_containing, thought_observation_ratio ──
7334
7335    #[test]
7336    fn test_steps_with_non_empty_observation_returns_matching_steps() {
7337        let steps = vec![
7338            make_step("t", "a", "result"),
7339            make_step("t", "a", ""),
7340            make_step("t", "a", "more"),
7341        ];
7342        let session = make_session(steps, 0);
7343        assert_eq!(session.steps_with_non_empty_observation().len(), 2);
7344    }
7345
7346    #[test]
7347    fn test_steps_with_non_empty_observation_empty_for_all_empty() {
7348        let steps = vec![make_step("t", "a", "")];
7349        let session = make_session(steps, 0);
7350        assert!(session.steps_with_non_empty_observation().is_empty());
7351    }
7352
7353    #[test]
7354    fn test_observations_containing_returns_matching_steps() {
7355        let steps = vec![
7356            make_step("t", "a", "found the answer"),
7357            make_step("t", "a", "no match"),
7358        ];
7359        let session = make_session(steps, 0);
7360        assert_eq!(session.observations_containing("found").len(), 1);
7361    }
7362
7363    #[test]
7364    fn test_thought_observation_ratio_returns_correct_ratio() {
7365        let steps = vec![make_step("ab", "x", "abcd")];
7366        let session = make_session(steps, 0);
7367        assert_eq!(session.thought_observation_ratio(), 0.5);
7368    }
7369
7370    #[test]
7371    fn test_thought_observation_ratio_zero_for_empty_session() {
7372        let session = make_session(vec![], 0);
7373        assert_eq!(session.thought_observation_ratio(), 0.0);
7374    }
7375
7376    // ── Round 51 ──────────────────────────────────────────────────────────────
7377
7378    #[test]
7379    fn test_non_empty_action_count_correct() {
7380        let steps = vec![
7381            make_step("t", "search", "o"),
7382            make_step("t", "", "o"),
7383            make_step("t", "write", "o"),
7384        ];
7385        let session = make_session(steps, 0);
7386        assert_eq!(session.non_empty_action_count(), 2);
7387    }
7388
7389    #[test]
7390    fn test_non_empty_action_count_zero_for_all_empty() {
7391        let steps = vec![make_step("t", "", "o"), make_step("t", "", "o")];
7392        let session = make_session(steps, 0);
7393        assert_eq!(session.non_empty_action_count(), 0);
7394    }
7395
7396    #[test]
7397    fn test_total_step_bytes_sums_all_fields() {
7398        let steps = vec![make_step("abc", "de", "f")];
7399        let session = make_session(steps, 0);
7400        assert_eq!(session.total_step_bytes(), 6); // 3 + 2 + 1
7401    }
7402
7403    #[test]
7404    fn test_total_step_bytes_zero_for_empty_session() {
7405        let session = make_session(vec![], 0);
7406        assert_eq!(session.total_step_bytes(), 0);
7407    }
7408
7409    #[test]
7410    fn test_last_thought_bytes_returns_last_step_thought() {
7411        let steps = vec![
7412            make_step("short", "a", "o"),
7413            make_step("much longer thought", "a", "o"),
7414        ];
7415        let session = make_session(steps, 0);
7416        assert_eq!(session.last_thought_bytes(), "much longer thought".len());
7417    }
7418
7419    #[test]
7420    fn test_last_thought_bytes_zero_for_empty_session() {
7421        let session = make_session(vec![], 0);
7422        assert_eq!(session.last_thought_bytes(), 0);
7423    }
7424
7425    #[test]
7426    fn test_first_observation_bytes_returns_first_step_observation() {
7427        let steps = vec![
7428            make_step("t", "a", "first obs"),
7429            make_step("t", "a", "second obs is longer"),
7430        ];
7431        let session = make_session(steps, 0);
7432        assert_eq!(session.first_observation_bytes(), "first obs".len());
7433    }
7434
7435    #[test]
7436    fn test_first_observation_bytes_zero_for_empty_session() {
7437        let session = make_session(vec![], 0);
7438        assert_eq!(session.first_observation_bytes(), 0);
7439    }
7440
7441    // ── Round 59: has_step_with_empty_observation, thought_to_action_byte_ratio ──
7442
7443    #[test]
7444    fn test_has_step_with_empty_observation_true() {
7445        let steps = vec![make_step("t", "a", "")];
7446        let session = make_session(steps, 0);
7447        assert!(session.has_step_with_empty_observation());
7448    }
7449
7450    #[test]
7451    fn test_has_step_with_empty_observation_false_when_all_nonempty() {
7452        let steps = vec![make_step("t", "a", "obs")];
7453        let session = make_session(steps, 0);
7454        assert!(!session.has_step_with_empty_observation());
7455    }
7456
7457    #[test]
7458    fn test_has_step_with_empty_observation_false_for_empty_session() {
7459        let session = make_session(vec![], 0);
7460        assert!(!session.has_step_with_empty_observation());
7461    }
7462
7463    #[test]
7464    fn test_thought_to_action_byte_ratio_correct() {
7465        // thought = "hello" (5 bytes), action = "hi" (2 bytes) → 5/2 = 2.5
7466        let steps = vec![make_step("hello", "hi", "o")];
7467        let session = make_session(steps, 0);
7468        assert!((session.thought_to_action_byte_ratio() - 2.5).abs() < 1e-9);
7469    }
7470
7471    #[test]
7472    fn test_thought_to_action_byte_ratio_zero_when_no_action_bytes() {
7473        let steps = vec![make_step("thought", "", "o")];
7474        let session = make_session(steps, 0);
7475        assert_eq!(session.thought_to_action_byte_ratio(), 0.0);
7476    }
7477
7478    #[test]
7479    fn test_thought_to_action_byte_ratio_zero_for_empty_session() {
7480        let session = make_session(vec![], 0);
7481        assert_eq!(session.thought_to_action_byte_ratio(), 0.0);
7482    }
7483
7484    // ── Round 60: observation_above_bytes_count, steps_with_both_thought_and_action ──
7485
7486    #[test]
7487    fn test_observation_above_bytes_count_correct() {
7488        let steps = vec![
7489            make_step("t", "a", "short"),
7490            make_step("t", "a", "this is quite long"),
7491            make_step("t", "a", "x"),
7492        ];
7493        let session = make_session(steps, 0);
7494        assert_eq!(session.observation_above_bytes_count(5), 1);
7495    }
7496
7497    #[test]
7498    fn test_observation_above_bytes_count_zero_for_empty_session() {
7499        let session = make_session(vec![], 0);
7500        assert_eq!(session.observation_above_bytes_count(0), 0);
7501    }
7502
7503    #[test]
7504    fn test_steps_with_both_thought_and_action_correct() {
7505        let steps = vec![
7506            make_step("think", "act", "obs"),
7507            make_step("", "act", "obs"),
7508            make_step("think", "", "obs"),
7509        ];
7510        let session = make_session(steps, 0);
7511        assert_eq!(session.steps_with_both_thought_and_action(), 1);
7512    }
7513
7514    #[test]
7515    fn test_steps_with_both_thought_and_action_zero_for_empty_session() {
7516        let session = make_session(vec![], 0);
7517        assert_eq!(session.steps_with_both_thought_and_action(), 0);
7518    }
7519
7520    // ── Round 61: steps_with_observation_prefix, observation_bytes_total, first_thought_chars ──
7521
7522    #[test]
7523    fn test_steps_with_observation_prefix_correct() {
7524        let steps = vec![
7525            make_step("t", "a", "[error] bad"),
7526            make_step("t", "a", "ok"),
7527            make_step("t", "a", "[error] also bad"),
7528        ];
7529        let session = make_session(steps, 0);
7530        assert_eq!(session.steps_with_observation_prefix("[error]"), 2);
7531    }
7532
7533    #[test]
7534    fn test_steps_with_observation_prefix_zero_when_none_match() {
7535        let steps = vec![make_step("t", "a", "ok")];
7536        let session = make_session(steps, 0);
7537        assert_eq!(session.steps_with_observation_prefix("[error]"), 0);
7538    }
7539
7540    #[test]
7541    fn test_observation_bytes_total_sums_all_observations() {
7542        let steps = vec![
7543            make_step("t", "a", "abc"),   // 3
7544            make_step("t", "a", "de"),    // 2
7545        ];
7546        let session = make_session(steps, 0);
7547        assert_eq!(session.observation_bytes_total(), 5);
7548    }
7549
7550    #[test]
7551    fn test_observation_bytes_total_zero_for_empty_session() {
7552        let session = make_session(vec![], 0);
7553        assert_eq!(session.observation_bytes_total(), 0);
7554    }
7555
7556    #[test]
7557    fn test_first_thought_chars_returns_first_step_count() {
7558        let steps = vec![make_step("héllo", "a", "o"), make_step("ignored", "a", "o")];
7559        let session = make_session(steps, 0);
7560        // "héllo" has 5 chars (é is one char)
7561        assert_eq!(session.first_thought_chars(), 5);
7562    }
7563
7564    #[test]
7565    fn test_first_thought_chars_zero_for_empty_session() {
7566        let session = make_session(vec![], 0);
7567        assert_eq!(session.first_thought_chars(), 0);
7568    }
7569
7570    // ── Round 62: last_observation_chars, observation_word_count_total ────────
7571
7572    #[test]
7573    fn test_last_observation_chars_returns_last_step() {
7574        let steps = vec![
7575            make_step("t", "a", "first"),
7576            make_step("t", "a", "last one"),
7577        ];
7578        let session = make_session(steps, 0);
7579        assert_eq!(session.last_observation_chars(), "last one".chars().count());
7580    }
7581
7582    #[test]
7583    fn test_last_observation_chars_zero_for_empty_session() {
7584        let session = make_session(vec![], 0);
7585        assert_eq!(session.last_observation_chars(), 0);
7586    }
7587
7588    #[test]
7589    fn test_observation_word_count_total_sums_all_words() {
7590        let steps = vec![
7591            make_step("t", "a", "one two"),    // 2
7592            make_step("t", "a", "three"),      // 1
7593        ];
7594        let session = make_session(steps, 0);
7595        assert_eq!(session.observation_word_count_total(), 3);
7596    }
7597
7598    #[test]
7599    fn test_observation_word_count_total_zero_for_empty_session() {
7600        let session = make_session(vec![], 0);
7601        assert_eq!(session.observation_word_count_total(), 0);
7602    }
7603
7604    // ── Round 63: action_ends_with_count, avg_observation_words ──────────────
7605
7606    #[test]
7607    fn test_action_ends_with_count_correct() {
7608        let steps = vec![
7609            make_step("t", "search.", "o"),
7610            make_step("t", "browse.", "o"),
7611            make_step("t", "calculate", "o"),
7612        ];
7613        let session = make_session(steps, 0);
7614        assert_eq!(session.action_ends_with_count("."), 2);
7615    }
7616
7617    #[test]
7618    fn test_action_ends_with_count_zero_when_none_match() {
7619        let steps = vec![make_step("t", "nope", "o")];
7620        let session = make_session(steps, 0);
7621        assert_eq!(session.action_ends_with_count("."), 0);
7622    }
7623
7624    #[test]
7625    fn test_avg_observation_words_correct() {
7626        let steps = vec![
7627            make_step("t", "a", "one two"),    // 2
7628            make_step("t", "a", "three four five"), // 3
7629        ];
7630        let session = make_session(steps, 0);
7631        assert!((session.avg_observation_words() - 2.5).abs() < 1e-9);
7632    }
7633
7634    #[test]
7635    fn test_avg_observation_words_zero_for_empty_session() {
7636        let session = make_session(vec![], 0);
7637        assert_eq!(session.avg_observation_words(), 0.0);
7638    }
7639
7640    // ── Round 58: steps_matching_thought, median_observation_chars, cumulative_thought_chars, count_steps_with_thought_containing ──
7641
7642    #[test]
7643    fn test_steps_matching_thought_returns_correct_steps() {
7644        let steps = vec![
7645            make_step("I need to search", "a", "o"),
7646            make_step("I found the answer", "a", "o"),
7647            make_step("no match here", "a", "o"),
7648        ];
7649        let session = make_session(steps, 0);
7650        assert_eq!(session.steps_matching_thought("I").len(), 2);
7651    }
7652
7653    #[test]
7654    fn test_median_observation_chars_returns_middle_value() {
7655        let steps = vec![
7656            make_step("t", "a", "ab"),
7657            make_step("t", "a", "abcde"),
7658            make_step("t", "a", "abc"),
7659        ];
7660        let session = make_session(steps, 0);
7661        // sorted: [2, 3, 5] → median at index 1 = 3
7662        assert_eq!(session.median_observation_chars(), 3);
7663    }
7664
7665    #[test]
7666    fn test_median_observation_chars_zero_for_empty_session() {
7667        let session = make_session(vec![], 0);
7668        assert_eq!(session.median_observation_chars(), 0);
7669    }
7670
7671    #[test]
7672    fn test_cumulative_thought_chars_accumulates_correctly() {
7673        let steps = vec![
7674            make_step("ab", "a", "o"),
7675            make_step("cde", "a", "o"),
7676        ];
7677        let session = make_session(steps, 0);
7678        assert_eq!(session.cumulative_thought_chars(), vec![2, 5]);
7679    }
7680
7681    #[test]
7682    fn test_count_steps_with_thought_containing_counts_matches() {
7683        let steps = vec![
7684            make_step("call function foo", "a", "o"),
7685            make_step("call function bar", "a", "o"),
7686            make_step("nothing", "a", "o"),
7687        ];
7688        let session = make_session(steps, 0);
7689        assert_eq!(session.count_steps_with_thought_containing("function"), 2);
7690    }
7691
7692    // ── Round 57: observation_contains_any ───────────────────────────────────
7693
7694    #[test]
7695    fn test_observation_contains_any_true_when_term_present() {
7696        let steps = vec![
7697            make_step("t", "a", "result: success"),
7698            make_step("t", "a", "result: failure"),
7699        ];
7700        let session = make_session(steps, 0);
7701        assert!(session.observation_contains_any(&["success", "error"]));
7702    }
7703
7704    #[test]
7705    fn test_observation_contains_any_false_when_no_match() {
7706        let steps = vec![make_step("t", "a", "nothing here")];
7707        let session = make_session(steps, 0);
7708        assert!(!session.observation_contains_any(&["success", "error"]));
7709    }
7710
7711    #[test]
7712    fn test_observation_contains_any_false_for_empty_session() {
7713        let session = make_session(vec![], 0);
7714        assert!(!session.observation_contains_any(&["anything"]));
7715    }
7716
7717    #[test]
7718    fn test_observation_contains_any_false_for_empty_terms() {
7719        let steps = vec![make_step("t", "a", "something")];
7720        let session = make_session(steps, 0);
7721        assert!(!session.observation_contains_any(&[]));
7722    }
7723
7724    // ── Round 59: step_at_index, thought_contains_all, action_contains_any, max_thought_chars, min_thought_chars, is_registered_tool ──
7725
7726    #[test]
7727    fn test_step_at_index_returns_correct_step() {
7728        let steps = vec![
7729            make_step("first", "a1", "o1"),
7730            make_step("second", "a2", "o2"),
7731        ];
7732        let session = make_session(steps, 0);
7733        assert_eq!(session.step_at_index(1).map(|s| s.thought.as_str()), Some("second"));
7734    }
7735
7736    #[test]
7737    fn test_step_at_index_returns_none_out_of_bounds() {
7738        let session = make_session(vec![], 0);
7739        assert!(session.step_at_index(0).is_none());
7740    }
7741
7742    #[test]
7743    fn test_thought_contains_all_true_when_all_present_in_one_step() {
7744        let steps = vec![
7745            make_step("alpha beta gamma", "a", "o"),
7746            make_step("alpha only", "a", "o"),
7747        ];
7748        let session = make_session(steps, 0);
7749        assert!(session.thought_contains_all(&["alpha", "beta"]));
7750    }
7751
7752    #[test]
7753    fn test_thought_contains_all_false_when_no_single_step_has_all() {
7754        let steps = vec![
7755            make_step("alpha", "a", "o"),
7756            make_step("beta", "a", "o"),
7757        ];
7758        let session = make_session(steps, 0);
7759        assert!(!session.thought_contains_all(&["alpha", "beta"]));
7760    }
7761
7762    #[test]
7763    fn test_action_contains_any_true_when_present() {
7764        let steps = vec![
7765            make_step("t", "search(query)", "o"),
7766            make_step("t", "read(file)", "o"),
7767        ];
7768        let session = make_session(steps, 0);
7769        assert!(session.action_contains_any(&["search", "write"]));
7770    }
7771
7772    #[test]
7773    fn test_action_contains_any_false_when_not_present() {
7774        let steps = vec![make_step("t", "read(file)", "o")];
7775        let session = make_session(steps, 0);
7776        assert!(!session.action_contains_any(&["search", "write"]));
7777    }
7778
7779    #[test]
7780    fn test_max_thought_chars_returns_longest() {
7781        let steps = vec![
7782            make_step("hi", "a", "o"),
7783            make_step("hello world", "a", "o"),
7784            make_step("hey", "a", "o"),
7785        ];
7786        let session = make_session(steps, 0);
7787        assert_eq!(session.max_thought_chars(), 11);
7788    }
7789
7790    #[test]
7791    fn test_max_thought_chars_zero_for_empty_session() {
7792        let session = make_session(vec![], 0);
7793        assert_eq!(session.max_thought_chars(), 0);
7794    }
7795
7796    #[test]
7797    fn test_min_thought_chars_returns_shortest_non_empty() {
7798        let steps = vec![
7799            make_step("", "a", "o"),
7800            make_step("ab", "a", "o"),
7801            make_step("abcd", "a", "o"),
7802        ];
7803        let session = make_session(steps, 0);
7804        assert_eq!(session.min_thought_chars(), 2);
7805    }
7806
7807    #[test]
7808    fn test_min_thought_chars_zero_when_all_empty() {
7809        let steps = vec![make_step("", "a", "o")];
7810        let session = make_session(steps, 0);
7811        assert_eq!(session.min_thought_chars(), 0);
7812    }
7813
7814    #[test]
7815    fn test_is_registered_tool_true_for_registered_tool() {
7816        let spec = crate::agent::ToolSpec::new("calculator", "Does math", |_| {
7817            serde_json::json!("ok")
7818        });
7819        let rt = AgentRuntime::builder()
7820            .with_agent_config(AgentConfig::new(3, "m"))
7821            .register_tool(spec)
7822            .build();
7823        assert!(rt.is_registered_tool("calculator"));
7824    }
7825
7826    #[test]
7827    fn test_is_registered_tool_false_for_unknown_tool() {
7828        let rt = AgentRuntime::quick(3, "m");
7829        assert!(!rt.is_registered_tool("nonexistent"));
7830    }
7831
7832    // ── Round 58: registered_tool_names ───────────────────────────────────────
7833
7834    #[test]
7835    fn test_registered_tool_names_returns_owned_sorted_names() {
7836        let rt = AgentRuntime::builder()
7837            .with_agent_config(AgentConfig::new(3, "test-model"))
7838            .register_tool(crate::agent::ToolSpec::new("beta", "b", |_| {
7839                serde_json::json!("ok")
7840            }))
7841            .register_tool(crate::agent::ToolSpec::new("alpha", "a", |_| {
7842                serde_json::json!("ok")
7843            }))
7844            .build();
7845        let names = rt.registered_tool_names();
7846        assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]);
7847    }
7848
7849    #[test]
7850    fn test_registered_tool_names_empty_when_no_tools() {
7851        let rt = AgentRuntime::quick(3, "test-model");
7852        assert!(rt.registered_tool_names().is_empty());
7853    }
7854
7855    // ── Round 60: avg_action_chars, avg_observation_chars, step_with_longest_action ──
7856
7857    #[test]
7858    fn test_avg_action_chars_correct() {
7859        let steps = vec![
7860            make_step("t", "ab", "o"),
7861            make_step("t", "abcd", "o"),
7862        ];
7863        let session = make_session(steps, 0);
7864        assert!((session.avg_action_chars() - 3.0).abs() < 1e-9);
7865    }
7866
7867    #[test]
7868    fn test_avg_action_chars_zero_for_empty_session() {
7869        let session = make_session(vec![], 0);
7870        assert_eq!(session.avg_action_chars(), 0.0);
7871    }
7872
7873    #[test]
7874    fn test_avg_observation_chars_correct() {
7875        let steps = vec![
7876            make_step("t", "a", "hello"),
7877            make_step("t", "a", "hi"),
7878        ];
7879        let session = make_session(steps, 0);
7880        // (5 + 2) / 2 = 3.5
7881        assert!((session.avg_observation_chars() - 3.5).abs() < 1e-9);
7882    }
7883
7884    #[test]
7885    fn test_step_with_longest_action_returns_correct_step() {
7886        let steps = vec![
7887            make_step("t", "short", "o"),
7888            make_step("t", "much longer action string", "o"),
7889            make_step("t", "medium act", "o"),
7890        ];
7891        let session = make_session(steps, 0);
7892        assert_eq!(
7893            session.step_with_longest_action().map(|s| s.action.as_str()),
7894            Some("much longer action string")
7895        );
7896    }
7897
7898    #[test]
7899    fn test_step_with_longest_action_none_for_empty_session() {
7900        let session = make_session(vec![], 0);
7901        assert!(session.step_with_longest_action().is_none());
7902    }
7903
7904    // ── Round 59: step_count_with_observation_longer_than ─────────────────────
7905
7906    #[test]
7907    fn test_step_count_with_observation_longer_than_counts_correctly() {
7908        let steps = vec![
7909            make_step("t", "a", "short"),           // 5 bytes
7910            make_step("t", "a", "a longer string"), // 14 bytes
7911            make_step("t", "a", "x"),               // 1 byte
7912        ];
7913        let session = make_session(steps, 0);
7914        assert_eq!(session.step_count_with_observation_longer_than(5), 1);
7915    }
7916
7917    #[test]
7918    fn test_step_count_with_observation_longer_than_zero_for_empty_session() {
7919        let session = make_session(vec![], 0);
7920        assert_eq!(session.step_count_with_observation_longer_than(0), 0);
7921    }
7922
7923    // ── Round 61: action_ends_with, thought_ends_with, has_step_with_both ──────
7924
7925    #[test]
7926    fn test_action_ends_with_true_when_present() {
7927        let steps = vec![make_step("t", "search(query)", "o")];
7928        let session = make_session(steps, 0);
7929        assert!(session.action_ends_with(")"));
7930    }
7931
7932    #[test]
7933    fn test_action_ends_with_false_when_absent() {
7934        let steps = vec![make_step("t", "search(query)", "o")];
7935        let session = make_session(steps, 0);
7936        assert!(!session.action_ends_with("!"));
7937    }
7938
7939    #[test]
7940    fn test_thought_ends_with_true_when_present() {
7941        let steps = vec![make_step("I should search.", "a", "o")];
7942        let session = make_session(steps, 0);
7943        assert!(session.thought_ends_with("."));
7944    }
7945
7946    #[test]
7947    fn test_thought_ends_with_false_for_empty_session() {
7948        let session = make_session(vec![], 0);
7949        assert!(!session.thought_ends_with("x"));
7950    }
7951
7952    #[test]
7953    fn test_has_step_with_both_true_when_step_matches_both() {
7954        let steps = vec![
7955            make_step("need to search", "search(foo)", "o"),
7956            make_step("done", "noop", "o"),
7957        ];
7958        let session = make_session(steps, 0);
7959        assert!(session.has_step_with_both("search", "foo"));
7960    }
7961
7962    #[test]
7963    fn test_has_step_with_both_false_when_no_step_matches_both() {
7964        let steps = vec![
7965            make_step("need to search", "noop", "o"),
7966            make_step("done", "search(foo)", "o"),
7967        ];
7968        let session = make_session(steps, 0);
7969        assert!(!session.has_step_with_both("search", "foo"));
7970    }
7971
7972    // ── Round 62: thought_word_counts, steps_sorted_by_thought_len, steps_with_thought_longer_than ──
7973
7974    #[test]
7975    fn test_thought_word_counts_returns_per_step_counts() {
7976        let steps = vec![
7977            make_step("one two three", "a", "o"),
7978            make_step("hello", "a", "o"),
7979        ];
7980        let session = make_session(steps, 0);
7981        assert_eq!(session.thought_word_counts(), vec![3, 1]);
7982    }
7983
7984    #[test]
7985    fn test_thought_word_counts_empty_for_empty_session() {
7986        let session = make_session(vec![], 0);
7987        assert!(session.thought_word_counts().is_empty());
7988    }
7989
7990    #[test]
7991    fn test_steps_sorted_by_thought_len_ascending_order() {
7992        let steps = vec![
7993            make_step("longest thought here", "a", "o"),
7994            make_step("hi", "a", "o"),
7995            make_step("medium thought", "a", "o"),
7996        ];
7997        let session = make_session(steps, 0);
7998        let sorted = session.steps_sorted_by_thought_len();
7999        assert!(sorted[0].thought.len() <= sorted[1].thought.len());
8000        assert!(sorted[1].thought.len() <= sorted[2].thought.len());
8001    }
8002
8003    #[test]
8004    fn test_steps_with_thought_longer_than_filters_correctly() {
8005        let steps = vec![
8006            make_step("short", "a", "o"),
8007            make_step("this is a longer thought", "a", "o"),
8008        ];
8009        let session = make_session(steps, 0);
8010        assert_eq!(session.steps_with_thought_longer_than(5).len(), 1);
8011    }
8012
8013    // ── Round 63: steps_with_action_containing, observation_max_chars, observation_min_chars ──
8014
8015    #[test]
8016    fn test_steps_with_action_containing_returns_matching_steps() {
8017        let steps = vec![
8018            make_step("t", "search(foo)", "o"),
8019            make_step("t", "read(file)", "o"),
8020            make_step("t", "search(bar)", "o"),
8021        ];
8022        let session = make_session(steps, 0);
8023        assert_eq!(session.steps_with_action_containing("search").len(), 2);
8024    }
8025
8026    #[test]
8027    fn test_steps_with_action_containing_empty_when_no_match() {
8028        let steps = vec![make_step("t", "read(file)", "o")];
8029        let session = make_session(steps, 0);
8030        assert!(session.steps_with_action_containing("search").is_empty());
8031    }
8032
8033    #[test]
8034    fn test_observation_max_chars_returns_longest() {
8035        let steps = vec![
8036            make_step("t", "a", "hi"),
8037            make_step("t", "a", "hello world"),
8038        ];
8039        let session = make_session(steps, 0);
8040        assert_eq!(session.observation_max_chars(), 11);
8041    }
8042
8043    #[test]
8044    fn test_observation_min_chars_skips_empty_observations() {
8045        let steps = vec![
8046            make_step("t", "a", ""),
8047            make_step("t", "a", "abcd"),
8048            make_step("t", "a", "ab"),
8049        ];
8050        let session = make_session(steps, 0);
8051        assert_eq!(session.observation_min_chars(), 2);
8052    }
8053
8054    #[test]
8055    fn test_observation_min_chars_zero_when_all_empty() {
8056        let steps = vec![make_step("t", "a", "")];
8057        let session = make_session(steps, 0);
8058        assert_eq!(session.observation_min_chars(), 0);
8059    }
8060
8061    // ── Round 64: action_word_counts, thought_avg_chars, thought_byte_range ──
8062
8063    #[test]
8064    fn test_action_word_counts_returns_per_step_counts() {
8065        let steps = vec![
8066            make_step("t1", "one two three", "o1"),
8067            make_step("t2", "hello", "o2"),
8068            make_step("t3", "a b", "o3"),
8069        ];
8070        let session = make_session(steps, 0);
8071        assert_eq!(session.action_word_counts(), vec![3, 1, 2]);
8072    }
8073
8074    #[test]
8075    fn test_action_word_counts_empty_for_no_steps() {
8076        let session = make_session(vec![], 0);
8077        assert!(session.action_word_counts().is_empty());
8078    }
8079
8080    #[test]
8081    fn test_thought_avg_chars_returns_average() {
8082        let steps = vec![
8083            make_step("ab", "a", "o"),   // 2 chars
8084            make_step("abcd", "a", "o"), // 4 chars
8085        ];
8086        let session = make_session(steps, 0);
8087        assert_eq!(session.thought_avg_chars(), 3.0);
8088    }
8089
8090    #[test]
8091    fn test_thought_avg_chars_zero_for_empty() {
8092        let session = make_session(vec![], 0);
8093        assert_eq!(session.thought_avg_chars(), 0.0);
8094    }
8095
8096    #[test]
8097    fn test_thought_byte_range_returns_min_max() {
8098        let steps = vec![
8099            make_step("hi", "a", "o"),     // 2 bytes
8100            make_step("hello", "a", "o"),  // 5 bytes
8101            make_step("hey", "a", "o"),    // 3 bytes
8102        ];
8103        let session = make_session(steps, 0);
8104        assert_eq!(session.thought_byte_range(), (2, 5));
8105    }
8106
8107    #[test]
8108    fn test_thought_byte_range_zero_zero_for_empty() {
8109        let session = make_session(vec![], 0);
8110        assert_eq!(session.thought_byte_range(), (0, 0));
8111    }
8112}