Skip to main content

context_bar_core/
aggregate.rs

1//! Deterministic transforms — slice 2 of folding `usage_signal.py` into Rust
2//! (ROADMAP E1). Ported 1:1 from `empty_metrics`/`_add_metrics`,
3//! `split_logical_sessions`, `_empty_bucket`/`_accumulate`, `bucket_aggregates`,
4//! and `project_name_from_cwd`. Pure given (events, NOW, UTC offset) — pinned by
5//! a golden fixture generated from the Python (`tests/aggregate_golden.rs`).
6//!
7//! Day/week/month bucketing uses a fixed UTC offset (seconds east of UTC). The
8//! Python uses the system local tz via `astimezone()`; for fixed-offset zones
9//! (e.g. Türkiye, permanent UTC+3) that is identical, and the golden pins it
10//! with `TZ=UTC` / offset 0. DST-aware per-timestamp offsets are a later
11//! refinement (would need a tz database); documented in COST_MODEL/ROADMAP.
12
13use time::{Date, OffsetDateTime, UtcOffset};
14
15use crate::usage_signal::{DailyInstance, NamedBucket, SessionRecord, TimeBucket};
16
17/// Idle gap (seconds) that splits one transcript file into logical sessions —
18/// matches Claude's 5h window, which resets 5h after the *first* turn.
19pub const SESSION_IDLE_GAP: f64 = 5.0 * 3600.0;
20/// 30-day rolling window in seconds.
21pub const WIN_30D: f64 = 30.0 * 86400.0;
22
23/// Per-turn token buckets + estimated cost. `total` is the stats token total
24/// (fresh_in + output) the parser computes; the four buckets feed the cost view.
25#[derive(Clone, Copy, Debug, Default, PartialEq)]
26pub struct TurnMetrics {
27    pub total: u64,
28    pub cache_read: u64,
29    pub input: u64,
30    pub output: u64,
31    pub cache_creation: u64,
32    pub cost: f64,
33}
34
35impl TurnMetrics {
36    fn add(&mut self, m: &TurnMetrics) {
37        self.total += m.total;
38        self.cache_read += m.cache_read;
39        self.input += m.input;
40        self.output += m.output;
41        self.cache_creation += m.cache_creation;
42        self.cost += m.cost;
43    }
44}
45
46/// One transcript file's per-turn events plus its resolved model/cwd.
47#[derive(Clone, Debug, Default)]
48pub struct FileEvents {
49    pub model: Option<String>,
50    pub cwd: Option<String>,
51    /// (epoch seconds, metrics) — sorted by ts inside [`split_logical_sessions`].
52    pub events: Vec<(f64, TurnMetrics)>,
53}
54
55/// A logical session chunk that feeds [`bucket_aggregates`].
56#[derive(Clone, Debug)]
57pub struct Session {
58    pub tokens: u64,
59    pub cache_read: u64,
60    pub input: u64,
61    pub output: u64,
62    pub cache_creation: u64,
63    pub cost: f64,
64    pub last_ts: f64,
65    pub first_ts: f64,
66    pub model: Option<String>,
67    pub cwd: Option<String>,
68}
69
70/// `project_name_from_cwd`: the git repository name for the cwd when it lives in
71/// a repo (origin remote name, else the toplevel directory), otherwise the
72/// directory's own basename, or `—` when absent. Keeping it git-aware means the
73/// menubar shows the actual repo (e.g. `context-bar`) rather than whichever
74/// sub-directory the agent happened to start in (e.g. `backend`).
75///
76/// MUST stay in lock-step with the same function in `usage_signal.py`.
77pub fn project_name_from_cwd(cwd: Option<&str>) -> String {
78    match cwd {
79        None => "—".to_string(),
80        Some(c) if c.is_empty() => "—".to_string(),
81        Some(c) => repo_name_from_cwd(c).unwrap_or_else(|| basename_of(c)),
82    }
83}
84
85fn basename_of(c: &str) -> String {
86    let trimmed = c.trim_end_matches('/');
87    let base = trimmed.rsplit('/').next().unwrap_or("");
88    if base.is_empty() {
89        c.to_string()
90    } else {
91        base.to_string()
92    }
93}
94
95/// Walk up from `cwd` to the nearest `.git` and return the repository name:
96/// origin's URL basename when present, else the toplevel directory's basename.
97/// `None` when `cwd` is not absolute or not inside a git repo (callers fall back
98/// to the plain basename). Pure filesystem — no `git` subprocess required.
99fn repo_name_from_cwd(cwd: &str) -> Option<String> {
100    let start = std::path::Path::new(cwd);
101    if !start.is_absolute() {
102        return None;
103    }
104    let mut dir = Some(start);
105    while let Some(d) = dir {
106        let dotgit = d.join(".git");
107        if dotgit.exists() {
108            if let Some(cfg) = git_config_path(&dotgit) {
109                if let Ok(text) = std::fs::read_to_string(&cfg) {
110                    if let Some(name) = origin_repo_name(&text) {
111                        return Some(name);
112                    }
113                }
114            }
115            return d.file_name().and_then(|n| n.to_str()).map(str::to_string);
116        }
117        dir = d.parent();
118    }
119    None
120}
121
122/// The `config` path for a `.git` entry — a directory for normal repos, or a
123/// `gitdir:`-pointer file for linked worktrees / submodules (resolved via the
124/// `commondir` so the shared config is read).
125fn git_config_path(dotgit: &std::path::Path) -> Option<std::path::PathBuf> {
126    if dotgit.is_dir() {
127        return Some(dotgit.join("config"));
128    }
129    let text = std::fs::read_to_string(dotgit).ok()?;
130    let gitdir = text
131        .lines()
132        .find_map(|l| l.strip_prefix("gitdir:").map(str::trim))?;
133    let gitdir_path = {
134        let p = std::path::Path::new(gitdir);
135        if p.is_absolute() {
136            p.to_path_buf()
137        } else {
138            dotgit.parent()?.join(p)
139        }
140    };
141    if let Ok(common) = std::fs::read_to_string(gitdir_path.join("commondir")) {
142        let common = common.trim();
143        let base = if std::path::Path::new(common).is_absolute() {
144            std::path::PathBuf::from(common)
145        } else {
146            gitdir_path.join(common)
147        };
148        return Some(base.join("config"));
149    }
150    Some(gitdir_path.join("config"))
151}
152
153/// Repo name from the `[remote "origin"]` url in a git config, if any.
154fn origin_repo_name(config: &str) -> Option<String> {
155    let mut in_origin = false;
156    for line in config.lines() {
157        let t = line.trim();
158        if t.starts_with('[') {
159            in_origin = t == "[remote \"origin\"]";
160            continue;
161        }
162        if in_origin {
163            if let Some(rest) = t.strip_prefix("url") {
164                if let Some(eq) = rest.trim_start().strip_prefix('=') {
165                    return repo_name_from_url(eq.trim());
166                }
167            }
168        }
169    }
170    None
171}
172
173/// Last path/scp segment of a remote URL with a trailing `.git` removed.
174/// Handles `git@host:owner/repo.git` and `https://host/owner/repo.git`.
175fn repo_name_from_url(url: &str) -> Option<String> {
176    let url = url.trim().trim_end_matches('/');
177    let seg = url.rsplit(|c| c == '/' || c == ':').next()?;
178    let seg = seg.strip_suffix(".git").unwrap_or(seg);
179    if seg.is_empty() {
180        None
181    } else {
182        Some(seg.to_string())
183    }
184}
185
186/// `basename(path)` with the final extension removed (mirrors
187/// `os.path.basename(path).rsplit(".", 1)[0]`).
188fn session_base_id(path: &str) -> String {
189    let base = path.trim_end_matches('/').rsplit('/').next().unwrap_or(path);
190    match base.rsplit_once('.') {
191        Some((stem, _ext)) if !stem.is_empty() => stem.to_string(),
192        _ => base.to_string(),
193    }
194}
195
196// Python's round() is banker's rounding (ties to even); mirror it so the
197// rounded cost/duration values are byte-identical.
198fn round6(x: f64) -> f64 {
199    (x * 1e6).round_ties_even() / 1e6
200}
201
202fn round1(x: f64) -> f64 {
203    (x * 10.0).round_ties_even() / 10.0
204}
205
206/// Format an epoch-seconds value as a UTC ISO8601 string ending in `Z`.
207/// Subsecond is emitted as 6 digits only when nonzero (mirrors Python's
208/// `datetime.fromtimestamp(ts, tz=utc).isoformat().replace("+00:00","Z")`).
209pub fn iso_utc(ts: f64) -> String {
210    let whole = ts.floor() as i64;
211    let micros = ((ts - ts.floor()) * 1_000_000.0).round() as i64;
212    let (whole, micros) = if micros >= 1_000_000 {
213        (whole + 1, 0)
214    } else {
215        (whole, micros)
216    };
217    let dt = OffsetDateTime::from_unix_timestamp(whole)
218        .unwrap_or(OffsetDateTime::UNIX_EPOCH)
219        .to_offset(UtcOffset::UTC);
220    let base = format!(
221        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
222        dt.year(),
223        u8::from(dt.month()),
224        dt.day(),
225        dt.hour(),
226        dt.minute(),
227        dt.second(),
228    );
229    if micros == 0 {
230        format!("{base}Z")
231    } else {
232        format!("{base}.{micros:06}Z")
233    }
234}
235
236/// Parse an ISO-8601 / RFC-3339 timestamp to epoch seconds (mirrors Python
237/// `datetime.fromisoformat(...).timestamp()` for the `Z`/offset forms used in
238/// transcripts). Returns `None` on empty/unparseable input.
239pub fn parse_iso(value: Option<&str>) -> Option<f64> {
240    let s = value?;
241    if s.is_empty() {
242        return None;
243    }
244    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
245        .ok()
246        .map(|dt| dt.unix_timestamp_nanos() as f64 / 1e9)
247}
248
249/// Local civil date/labels for an epoch second at a fixed UTC offset.
250fn local_dt(ts: f64, offset: UtcOffset) -> OffsetDateTime {
251    OffsetDateTime::from_unix_timestamp(ts.floor() as i64)
252        .unwrap_or(OffsetDateTime::UNIX_EPOCH)
253        .to_offset(offset)
254}
255
256fn day_key(dt: OffsetDateTime) -> String {
257    format!("{:04}-{:02}-{:02}", dt.year(), u8::from(dt.month()), dt.day())
258}
259
260fn week_key(dt: OffsetDateTime) -> String {
261    let (iso_year, week, _) = dt.to_iso_week_date();
262    format!("{iso_year}-W{week:02}")
263}
264
265fn month_key(dt: OffsetDateTime) -> String {
266    format!("{:04}-{:02}", dt.year(), u8::from(dt.month()))
267}
268
269/// Insertion-ordered string-keyed accumulator, so a later stable sort
270/// reproduces Python's dict-insertion-order tie-breaking exactly.
271#[derive(Default)]
272struct OrderedBuckets {
273    order: Vec<String>,
274    idx: std::collections::HashMap<String, usize>,
275    buckets: Vec<Bucket>,
276}
277
278#[derive(Clone, Default)]
279struct Bucket {
280    tokens: u64,
281    sessions: u64,
282    cache_read: u64,
283    input: u64,
284    output: u64,
285    cache_creation: u64,
286    cost: f64,
287}
288
289impl Bucket {
290    fn accumulate(&mut self, s: &Session) {
291        self.tokens += s.tokens;
292        self.sessions += 1;
293        self.cache_read += s.cache_read;
294        self.input += s.input;
295        self.output += s.output;
296        self.cache_creation += s.cache_creation;
297        self.cost += s.cost;
298    }
299}
300
301impl OrderedBuckets {
302    fn entry(&mut self, key: &str) -> &mut Bucket {
303        if let Some(&i) = self.idx.get(key) {
304            return &mut self.buckets[i];
305        }
306        let i = self.buckets.len();
307        self.idx.insert(key.to_string(), i);
308        self.order.push(key.to_string());
309        self.buckets.push(Bucket::default());
310        &mut self.buckets[i]
311    }
312}
313
314/// Result of [`bucket_aggregates`] — mirrors the snapshot's aggregate fields.
315#[derive(Clone, Debug, Default)]
316pub struct Buckets {
317    pub total_tokens_30d: u64,
318    pub total_sessions_30d: u64,
319    pub total_cost_30d: f64,
320    pub total_input_30d: u64,
321    pub total_output_30d: u64,
322    pub cost_today: f64,
323    pub max_session_minutes: f64,
324    pub by_day: Vec<TimeBucket>,
325    pub by_week: Vec<TimeBucket>,
326    pub by_month: Vec<TimeBucket>,
327    pub by_model: Vec<NamedBucket>,
328    pub by_project: Vec<NamedBucket>,
329    pub by_day_project: Vec<DailyInstance>,
330}
331
332/// Split each file's events into logical sessions on the 5h idle gap, returning
333/// `(sessions, recent)` — sessions feed [`bucket_aggregates`], recent feeds
334/// `recent_sessions`. `files` is iterated in its existing order (use a
335/// `BTreeMap` for determinism). Mirrors `split_logical_sessions`.
336pub fn split_logical_sessions(
337    files: &std::collections::BTreeMap<String, FileEvents>,
338) -> (Vec<Session>, Vec<SessionRecord>) {
339    let mut sessions = Vec::new();
340    let mut recent = Vec::new();
341
342    for (path, fe) in files {
343        let mut events = fe.events.clone();
344        events.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
345        if events.is_empty() {
346            continue;
347        }
348        // Split into chunks: a new chunk starts when an event is more than
349        // SESSION_IDLE_GAP after the current chunk's FIRST turn.
350        let mut chunks: Vec<Vec<(f64, TurnMetrics)>> = Vec::new();
351        let mut cur: Vec<(f64, TurnMetrics)> = vec![events[0]];
352        let mut session_start = events[0].0;
353        for &nxt in &events[1..] {
354            if nxt.0 - session_start > SESSION_IDLE_GAP {
355                chunks.push(std::mem::take(&mut cur));
356                cur = vec![nxt];
357                session_start = nxt.0;
358            } else {
359                cur.push(nxt);
360            }
361        }
362        chunks.push(cur);
363
364        let base_id = session_base_id(path);
365        let multi = chunks.len() > 1;
366        for (i, chunk) in chunks.iter().enumerate() {
367            let first_ts = chunk[0].0;
368            let last_ts = chunk[chunk.len() - 1].0;
369            let mut agg = TurnMetrics::default();
370            for (_, m) in chunk {
371                agg.add(m);
372            }
373            sessions.push(Session {
374                tokens: agg.total,
375                cache_read: agg.cache_read,
376                input: agg.input,
377                output: agg.output,
378                cache_creation: agg.cache_creation,
379                cost: agg.cost,
380                last_ts,
381                first_ts,
382                model: fe.model.clone(),
383                cwd: fe.cwd.clone(),
384            });
385            recent.push(SessionRecord {
386                id: if multi {
387                    format!("{base_id}#{}", i + 1)
388                } else {
389                    base_id.clone()
390                },
391                started_at: iso_utc(first_ts),
392                ended_at: iso_utc(last_ts),
393                duration_minutes: round1((last_ts - first_ts) / 60.0),
394                tokens: agg.total,
395                cache_read: agg.cache_read,
396                input: agg.input,
397                output: agg.output,
398                cache_creation: agg.cache_creation,
399                cost: round6(agg.cost),
400                model: fe.model.clone().unwrap_or_else(|| "—".to_string()),
401                project: project_name_from_cwd(fe.cwd.as_deref()),
402            });
403        }
404    }
405    (sessions, recent)
406}
407
408#[allow(clippy::too_many_arguments)]
409fn time_bucket(date: String, b: &Bucket) -> TimeBucket {
410    TimeBucket {
411        date,
412        tokens: b.tokens,
413        sessions: b.sessions,
414        input: b.input,
415        output: b.output,
416        cache_creation: b.cache_creation,
417        cache_read: b.cache_read,
418        cost: round6(b.cost),
419    }
420}
421
422fn named_bucket(model: String, b: &Bucket) -> NamedBucket {
423    NamedBucket {
424        model,
425        tokens: b.tokens,
426        sessions: b.sessions,
427        input: b.input,
428        output: b.output,
429        cache_creation: b.cache_creation,
430        cache_read: b.cache_read,
431        cost: round6(b.cost),
432    }
433}
434
435/// Stable top-N by tokens descending (ties keep insertion order).
436fn take_by_tokens(buckets: &OrderedBuckets, n: usize) -> Vec<(String, Bucket)> {
437    let mut items: Vec<(String, Bucket)> = buckets
438        .order
439        .iter()
440        .enumerate()
441        .map(|(i, k)| (k.clone(), buckets.buckets[i].clone()))
442        .collect();
443    items.sort_by(|a, b| b.1.tokens.cmp(&a.1.tokens));
444    items.truncate(n);
445    items
446}
447
448/// Stable top-N by key string descending (for by_week / by_month).
449fn take_by_key(buckets: &OrderedBuckets, n: usize) -> Vec<(String, Bucket)> {
450    let mut items: Vec<(String, Bucket)> = buckets
451        .order
452        .iter()
453        .enumerate()
454        .map(|(i, k)| (k.clone(), buckets.buckets[i].clone()))
455        .collect();
456    items.sort_by(|a, b| b.0.cmp(&a.0));
457    items.truncate(n);
458    items
459}
460
461/// Roll sessions into day/week/month/model/project buckets + the day×project
462/// cross-tab + 30d totals. `now` is epoch seconds; `offset` is the fixed local
463/// UTC offset. Mirrors `bucket_aggregates` (days=365, weeks=52, months=24,
464/// instance_days=30, instance_rows=200).
465pub fn bucket_aggregates(sessions: &[Session], now: f64, offset: UtcOffset) -> Buckets {
466    let mut by_day = OrderedBuckets::default();
467    let mut by_week = OrderedBuckets::default();
468    let mut by_month = OrderedBuckets::default();
469    let mut by_model = OrderedBuckets::default();
470    let mut by_project = OrderedBuckets::default();
471    // (day, project) -> (bucket, ordered models). Keep insertion order.
472    let mut dp_order: Vec<(String, String)> = Vec::new();
473    let mut dp_idx: std::collections::HashMap<(String, String), usize> = Default::default();
474    let mut dp_buckets: Vec<Bucket> = Vec::new();
475    let mut dp_models: Vec<Vec<String>> = Vec::new();
476
477    let mut total30: u64 = 0;
478    let mut sessions30: u64 = 0;
479    let mut cost30: f64 = 0.0;
480    let mut input30: u64 = 0;
481    let mut output30: u64 = 0;
482    let cutoff30 = now - WIN_30D;
483    let today_key = day_key(local_dt(now, offset));
484
485    for s in sessions {
486        let ts = s.last_ts;
487        let dt = local_dt(ts, offset);
488        let day = day_key(dt);
489        let week = week_key(dt);
490        let month = month_key(dt);
491        let proj = project_name_from_cwd(s.cwd.as_deref());
492
493        by_day.entry(&day).accumulate(s);
494        by_week.entry(&week).accumulate(s);
495        by_month.entry(&month).accumulate(s);
496        if let Some(model) = &s.model {
497            if !model.is_empty() {
498                by_model.entry(model).accumulate(s);
499            }
500        }
501        by_project.entry(&proj).accumulate(s);
502
503        // Per (day × project) cross-tab, scoped to the recent 30-day window.
504        if now - ts <= 30.0 * 86400.0 {
505            let key = (day.clone(), proj.clone());
506            let i = match dp_idx.get(&key) {
507                Some(&i) => i,
508                None => {
509                    let i = dp_buckets.len();
510                    dp_idx.insert(key.clone(), i);
511                    dp_order.push(key.clone());
512                    dp_buckets.push(Bucket::default());
513                    dp_models.push(Vec::new());
514                    i
515                }
516            };
517            dp_buckets[i].accumulate(s);
518            if let Some(model) = &s.model {
519                if !model.is_empty() && !dp_models[i].contains(model) {
520                    dp_models[i].push(model.clone());
521                }
522            }
523        }
524
525        if ts >= cutoff30 {
526            total30 += s.tokens;
527            sessions30 += 1;
528            cost30 += s.cost;
529            input30 += s.input;
530            output30 += s.output;
531        }
532    }
533
534    // by_day: pad every calendar day in the 365-day window, newest first.
535    let today_local = local_dt(now, offset);
536    let today_date = today_local.date();
537    let mut padded_day = Vec::with_capacity(365);
538    for i in 0..365i64 {
539        let d: Date = today_date.saturating_sub(time::Duration::days(i));
540        let key = format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day());
541        match by_day.idx.get(&key) {
542            Some(&j) => padded_day.push(time_bucket(key.clone(), &by_day.buckets[j])),
543            None => padded_day.push(time_bucket(key.clone(), &Bucket::default())),
544        }
545    }
546
547    let by_week_out = take_by_key(&by_week, 52)
548        .into_iter()
549        .map(|(k, b)| time_bucket(k, &b))
550        .collect();
551    let by_month_out = take_by_key(&by_month, 24)
552        .into_iter()
553        .map(|(k, b)| time_bucket(k, &b))
554        .collect();
555    let by_model_out = take_by_tokens(&by_model, 20)
556        .into_iter()
557        .map(|(k, b)| named_bucket(k, &b))
558        .collect();
559    let by_project_out = take_by_tokens(&by_project, 20)
560        .into_iter()
561        .map(|(k, b)| named_bucket(k, &b))
562        .collect();
563
564    // by_day_project: newest day first, within a day by cost desc; cap 200.
565    let mut instances: Vec<DailyInstance> = dp_order
566        .iter()
567        .enumerate()
568        .map(|(i, (day, proj))| {
569            let b = &dp_buckets[i];
570            let mut models = dp_models[i].clone();
571            models.sort();
572            DailyInstance {
573                date: day.clone(),
574                project: proj.clone(),
575                models,
576                tokens: b.tokens,
577                sessions: b.sessions,
578                input: b.input,
579                output: b.output,
580                cache_creation: b.cache_creation,
581                cache_read: b.cache_read,
582                cost: round6(b.cost),
583            }
584        })
585        .collect();
586    // Python: sort(key=(date, cost), reverse=True) — a single stable sort with a
587    // composite key, descending on both.
588    instances.sort_by(|a, b| {
589        b.date
590            .cmp(&a.date)
591            .then(b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal))
592    });
593    instances.truncate(200);
594
595    // Longest single session across all history (minutes).
596    let mut max_session_minutes = 0.0f64;
597    for s in sessions {
598        let dur = (s.last_ts - s.first_ts) / 60.0;
599        if dur > max_session_minutes {
600            max_session_minutes = dur;
601        }
602    }
603
604    let cost_today = by_day
605        .idx
606        .get(&today_key)
607        .map(|&j| by_day.buckets[j].cost)
608        .unwrap_or(0.0);
609
610    Buckets {
611        total_tokens_30d: total30,
612        total_sessions_30d: sessions30,
613        total_cost_30d: round6(cost30),
614        total_input_30d: input30,
615        total_output_30d: output30,
616        cost_today: round6(cost_today),
617        max_session_minutes: round1(max_session_minutes),
618        by_day: padded_day,
619        by_week: by_week_out,
620        by_month: by_month_out,
621        by_model: by_model_out,
622        by_project: by_project_out,
623        by_day_project: instances,
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn project_name_basename_rules() {
633        assert_eq!(project_name_from_cwd(None), "—");
634        assert_eq!(project_name_from_cwd(Some("")), "—");
635        assert_eq!(project_name_from_cwd(Some("/a/b/c")), "c");
636        assert_eq!(project_name_from_cwd(Some("/a/b/c/")), "c");
637        assert_eq!(project_name_from_cwd(Some("/")), "/");
638    }
639
640    #[test]
641    fn session_id_strips_extension() {
642        assert_eq!(session_base_id("/x/y/abc.jsonl"), "abc");
643        assert_eq!(session_base_id("/x/y/a.b.jsonl"), "a.b");
644        assert_eq!(session_base_id("noext"), "noext");
645    }
646
647    #[test]
648    fn idle_gap_splits_from_first_turn() {
649        let mut files = std::collections::BTreeMap::new();
650        let m = TurnMetrics { total: 10, input: 5, output: 5, ..Default::default() };
651        files.insert(
652            "/p/s.jsonl".to_string(),
653            FileEvents {
654                model: Some("claude-opus-4-8".into()),
655                cwd: Some("/home/proj".into()),
656                // t=0, t=1h (same session, <5h from start), t=6h (new session).
657                events: vec![(0.0, m), (3600.0, m), (6.0 * 3600.0, m)],
658            },
659        );
660        let (sessions, recent) = split_logical_sessions(&files);
661        assert_eq!(sessions.len(), 2);
662        assert_eq!(recent.len(), 2);
663        assert_eq!(recent[0].id, "s#1");
664        assert_eq!(sessions[0].tokens, 20); // first two turns
665        assert_eq!(sessions[1].tokens, 10);
666    }
667}