Skip to main content

llmtrim_ledger/
breakdown_db.rs

1//! Read-only query layer for the cost-breakdown TUI.
2//!
3//! Opens the same `tracking.db` the proxy writes to (WAL mode lets a reader and the
4//! daemon's writer proceed together) and serves the aggregates the two screens need:
5//! the session list for the Sessions tab, and the per-source occupancy + cost rows for
6//! the Detail drill-down. All dollar figures are computed in SQL from each turn's frozen
7//! rates, so a historical session always prices at what it actually cost.
8
9use anyhow::{Context, Result};
10use rusqlite::{Connection, params};
11
12/// One session aggregate for the Sessions tab tree (grouped agent → project → session
13/// in the UI; this row is the leaf).
14#[derive(Debug, Clone)]
15pub struct SessionRow {
16    pub session_id: String,
17    pub cc_session_id: Option<String>,
18    pub agent: String,
19    pub project: Option<String>,
20    pub session_name: Option<String>,
21    pub turns: i64,
22    /// All tokens seen this session (fresh + cache read + cache write + output).
23    pub tokens: i64,
24    /// Cache-hit fraction: cache-read over total input (0.0 when no input billed).
25    pub cache_hit: f64,
26    /// Cache-hit fraction of the session's *most recent* turn alone, matching the per-turn
27    /// figure Claude Code reports in `current_usage`. `None` when that turn billed no input.
28    /// Distinct from `cache_hit`, which averages the whole session (and so is dragged down by
29    /// the cold first turn) — the status line wants the last turn's rate, not the average.
30    pub last_cache_hit: Option<f64>,
31    /// Input tokens of that same most-recent turn (fresh + cache read + cache write) — the
32    /// context occupancy Claude Code reports as `total_input_tokens`, which it omits from the
33    /// blob mid-turn. `0` when no turn has landed.
34    pub last_input_tokens: i64,
35    /// Total bill in micro-USD (frozen per turn, summed).
36    pub bill_micros: i64,
37    /// Input-side counterfactual savings in micro-USD (blend, frozen rates), summed.
38    /// Same formula as [`crate::money::turn_saved_micros`]; 0 when meters missing.
39    pub saved_micros: i64,
40    /// Input tokens before / after compression, summed — the session's size trim.
41    pub input_before: i64,
42    pub input_after: i64,
43    /// rfc3339 timestamp of the most recent turn, for sorting newest-first.
44    pub last_ts: String,
45    /// The `sub` backend that served the session's *most recent* turn (`codex`/`kimi`), or `None`
46    /// when Anthropic served it. Per-turn rather than per-session because in fallback mode the
47    /// backend can change from one turn to the next.
48    pub last_sub_provider: Option<String>,
49    /// The model id of that same most-recent turn — the upstream model (e.g. `gpt-5.6-terra`)
50    /// when `last_sub_provider` is set, else the Claude model.
51    pub last_model: Option<String>,
52}
53
54impl SessionRow {
55    pub fn bill_usd(&self) -> f64 {
56        self.bill_micros as f64 / 1_000_000.0
57    }
58
59    /// Percentage of input tokens llmtrim trimmed this session (0 when nothing measured).
60    /// Token size reduction — not dollars. Prefer the name [`Self::trim_pct`].
61    pub fn saved_pct(&self) -> f64 {
62        self.trim_pct()
63    }
64
65    /// Token size trim % (before→after). Distinct from money savings (`saved_micros`).
66    pub fn trim_pct(&self) -> f64 {
67        if self.input_before > 0 {
68            (self.input_before - self.input_after).max(0) as f64 / self.input_before as f64 * 100.0
69        } else {
70            0.0
71        }
72    }
73
74    pub fn saved_usd(&self) -> f64 {
75        self.saved_micros as f64 / 1_000_000.0
76    }
77}
78
79/// One per-source occupancy row of the latest turn (Detail, top pane).
80#[derive(Debug, Clone)]
81pub struct OccupancyRow {
82    pub group_label: String,
83    pub label: String,
84    pub mcp_server: Option<String>,
85    pub tool_name: Option<String>,
86    pub tokens: i64,
87}
88
89/// One per-source cost row aggregated over a session (Detail, bottom pane). Dollars are
90/// already priced from the turns' frozen rates.
91#[derive(Debug, Clone)]
92pub struct CostRow {
93    pub group_label: String,
94    pub label: String,
95    pub mcp_server: Option<String>,
96    pub tool_name: Option<String>,
97    /// Total cost of this source ($).
98    pub usd: f64,
99    /// Cache-read share ($).
100    pub read_usd: f64,
101    /// Cache-write share ($).
102    pub write_usd: f64,
103    /// Fresh-input + output share ($) — the "new" spend.
104    pub new_usd: f64,
105}
106
107pub struct BreakdownDb {
108    pub(crate) conn: Connection,
109}
110
111impl BreakdownDb {
112    /// Open the ledger for reading. Goes through `Tracker`, which creates the file if
113    /// absent (fresh install, before any proxy run — the TUI then just shows no sessions),
114    /// runs the schema migration, and sets WAL + a busy timeout. WAL lets this reader run
115    /// alongside the daemon's writer without blocking it; opening read-write (we only ever
116    /// `SELECT`) sidesteps the read-only-on-WAL pitfalls of a bare `SQLITE_OPEN_READ_ONLY`.
117    pub fn open() -> Result<Self> {
118        Ok(Self {
119            conn: crate::tracking::Tracker::open_reader()?.into_connection(),
120        })
121    }
122
123    /// Test/embedding seam: wrap an already-open connection (e.g. an in-memory ledger).
124    pub fn from_connection(conn: Connection) -> Self {
125        Self { conn }
126    }
127
128    /// All sessions with at least one recorded turn, newest activity first.
129    pub fn sessions(&self) -> Result<Vec<SessionRow>> {
130        // Shared formula with money_totals — bare columns inside the CTE select list.
131        let saved_expr = crate::money::saved_micros_sql("");
132        let sql = format!(
133            // One pass, two rankings — instead of correlated subqueries that re-scanned the
134            // table once per session group. `rn` prefers a *named* row (so the session keeps
135            // its latest `session_name`), while `recent` is strictly newest-first: the backend
136            // that served the last turn must come from the newest row, not the newest *named*
137            // one.
138            "WITH ranked AS (
139                 SELECT session_id, cc_session_id, agent, project, session_name, ts, id,
140                        fresh_input, cache_read, cache_write, output_tok,
141                        bill_micros, input_before, input_after, sub_provider, model,
142                        input_rate, cache_read_rate, cache_write_rate,
143                        ({saved_expr}) AS saved_micros,
144                        ROW_NUMBER() OVER (
145                            PARTITION BY session_id, cc_session_id, agent, project
146                            ORDER BY (session_name IS NOT NULL) DESC, id DESC
147                        ) AS rn,
148                        ROW_NUMBER() OVER (
149                            PARTITION BY session_id, cc_session_id, agent, project
150                            ORDER BY id DESC
151                        ) AS recent
152                 FROM breakdown_turns
153             )
154             SELECT session_id, agent, project,
155                    MAX(CASE WHEN rn = 1 THEN session_name END),
156                    COUNT(*),
157                    COALESCE(SUM(fresh_input + cache_read + cache_write + output_tok), 0),
158                    COALESCE(SUM(cache_read), 0),
159                    COALESCE(SUM(fresh_input + cache_read + cache_write), 0),
160                    COALESCE(SUM(bill_micros), 0),
161                    COALESCE(SUM(saved_micros), 0),
162                    COALESCE(SUM(input_before), 0),
163                    COALESCE(SUM(input_after), 0),
164                    MAX(ts),
165                    cc_session_id,
166                    MAX(CASE WHEN recent = 1 THEN sub_provider END),
167                    MAX(CASE WHEN recent = 1 THEN model END),
168                    COALESCE(MAX(CASE WHEN recent = 1 THEN cache_read END), 0),
169                    COALESCE(
170                        MAX(CASE WHEN recent = 1 THEN fresh_input + cache_read + cache_write END),
171                        0
172                    )
173             FROM ranked
174             GROUP BY session_id, cc_session_id, agent, project
175             ORDER BY MAX(ts) DESC"
176        );
177        let mut stmt = self
178            .conn
179            .prepare(&sql)
180            .context("failed to prepare sessions query")?;
181        let rows = stmt
182            .query_map([], |r| {
183                let cache_read: i64 = r.get(6)?;
184                let total_in: i64 = r.get(7)?;
185                let last_read: i64 = r.get(16)?;
186                let last_total_in: i64 = r.get(17)?;
187                Ok(SessionRow {
188                    session_id: r.get(0)?,
189                    agent: r.get(1)?,
190                    project: r.get(2)?,
191                    session_name: r.get(3)?,
192                    turns: r.get(4)?,
193                    tokens: r.get(5)?,
194                    cache_hit: if total_in > 0 {
195                        cache_read as f64 / total_in as f64
196                    } else {
197                        0.0
198                    },
199                    last_cache_hit: (last_total_in > 0)
200                        .then(|| last_read as f64 / last_total_in as f64),
201                    last_input_tokens: last_total_in,
202                    bill_micros: r.get(8)?,
203                    saved_micros: r.get(9)?,
204                    input_before: r.get(10)?,
205                    input_after: r.get(11)?,
206                    last_ts: r.get(12)?,
207                    cc_session_id: r.get(13)?,
208                    last_sub_provider: r.get(14)?,
209                    last_model: r.get(15)?,
210                })
211            })
212            .context("failed to query sessions")?;
213        rows.collect::<rusqlite::Result<Vec<_>>>()
214            .context("failed to read sessions")
215    }
216
217    /// The id and context window of a session's most recent turn (for occupancy %).
218    pub fn latest_turn(&self, session_id: &str) -> Result<Option<(i64, i64)>> {
219        let mut stmt = self
220            .conn
221            .prepare(
222                "SELECT id, window FROM breakdown_turns
223                 WHERE session_id = ?1 ORDER BY id DESC LIMIT 1",
224            )
225            .context("failed to prepare latest-turn query")?;
226        let row = stmt
227            .query_row(params![session_id], |r| Ok((r.get(0)?, r.get(1)?)))
228            .ok();
229        Ok(row)
230    }
231
232    /// Per-source input-token occupancy of one turn, largest first.
233    pub fn occupancy(&self, turn_id: i64) -> Result<Vec<OccupancyRow>> {
234        let mut stmt = self
235            .conn
236            .prepare(
237                "SELECT group_label, label, mcp_server, tool_name, SUM(raw_tokens)
238                 FROM breakdown_blocks
239                 WHERE turn_id = ?1 AND zone = 'input'
240                 GROUP BY group_label, label, mcp_server, tool_name
241                 ORDER BY SUM(raw_tokens) DESC",
242            )
243            .context("failed to prepare occupancy query")?;
244        let rows = stmt
245            .query_map(params![turn_id], |r| {
246                Ok(OccupancyRow {
247                    group_label: r.get(0)?,
248                    label: r.get(1)?,
249                    mcp_server: r.get(2)?,
250                    tool_name: r.get(3)?,
251                    tokens: r.get(4)?,
252                })
253            })
254            .context("failed to query occupancy")?;
255        rows.collect::<rusqlite::Result<Vec<_>>>()
256            .context("failed to read occupancy")
257    }
258
259    /// Per-source cumulative cost across a whole session, priced from the turns' frozen
260    /// rates, largest first.
261    pub fn cost(&self, session_id: &str) -> Result<Vec<CostRow>> {
262        self.cost_grouped(Some(session_id))
263    }
264
265    /// Per-source cost aggregated across *all* sessions, largest first — the corpus-wide
266    /// "where did the spend go" view used by the `--json`/`--csv` exports.
267    pub fn all_sources(&self) -> Result<Vec<CostRow>> {
268        self.cost_grouped(None)
269    }
270
271    /// Shared per-source cost aggregation; `session` filters to one session, `None` spans all.
272    fn cost_grouped(&self, session: Option<&str>) -> Result<Vec<CostRow>> {
273        let where_clause = if session.is_some() {
274            "WHERE t.session_id = ?1"
275        } else {
276            ""
277        };
278        let sql = format!(
279            "SELECT b.group_label, b.label, b.mcp_server, b.tool_name,
280                    SUM(b.cache_read_tok * t.cache_read_rate) / 1e6 AS read_usd,
281                    SUM(b.cache_write_tok * t.cache_write_rate) / 1e6 AS write_usd,
282                    SUM(b.fresh_tok * t.input_rate + b.output_tok * t.output_rate) / 1e6 AS new_usd
283             FROM breakdown_blocks b JOIN breakdown_turns t ON b.turn_id = t.id
284             {where_clause}
285             GROUP BY b.group_label, b.label, b.mcp_server, b.tool_name
286             ORDER BY (read_usd + write_usd + new_usd) DESC"
287        );
288        let mut stmt = self
289            .conn
290            .prepare(&sql)
291            .context("failed to prepare cost query")?;
292        let map = |r: &rusqlite::Row| {
293            let read_usd: f64 = r.get(4)?;
294            let write_usd: f64 = r.get(5)?;
295            let new_usd: f64 = r.get(6)?;
296            Ok(CostRow {
297                group_label: r.get(0)?,
298                label: r.get(1)?,
299                mcp_server: r.get(2)?,
300                tool_name: r.get(3)?,
301                usd: read_usd + write_usd + new_usd,
302                read_usd,
303                write_usd,
304                new_usd,
305            })
306        };
307        let rows = match session {
308            Some(id) => stmt.query_map(params![id], map),
309            None => stmt.query_map([], map),
310        }
311        .context("failed to query cost")?;
312        rows.collect::<rusqlite::Result<Vec<_>>>()
313            .context("failed to read cost")
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::tracking::{BreakdownBlock, BreakdownTurn, Tracker};
321
322    fn turn(session: &str) -> BreakdownTurn {
323        BreakdownTurn {
324            session_id: session.to_string(),
325            cc_session_id: None,
326            agent: "claude-code".to_string(),
327            project: Some("/proj".to_string()),
328            session_name: Some("my session".to_string()),
329            provider: "anthropic".to_string(),
330            model: Some("claude-sonnet-4".to_string()),
331            sub_provider: None,
332            window: 200_000,
333            fresh_input: 50,
334            cache_read: 120,
335            cache_write: 30,
336            output_tok: 40,
337            input_rate: 3.0,
338            output_rate: 15.0,
339            cache_read_rate: 0.3,
340            cache_write_rate: 3.75,
341            bill_micros: 1_000,
342            input_before: 300,
343            input_after: 200,
344        }
345    }
346
347    fn block(group: &str, label: &str, mcp: Option<&str>, fresh: f64, read: f64) -> BreakdownBlock {
348        BreakdownBlock {
349            zone: "input".to_string(),
350            section: "static".to_string(),
351            bucket: "schema".to_string(),
352            group_label: group.to_string(),
353            label: label.to_string(),
354            mcp_server: mcp.map(str::to_string),
355            tool_name: None,
356            role: None,
357            msg_index: None,
358            raw_tokens: (fresh + read) as i64,
359            fresh_tok: fresh,
360            cache_read_tok: read,
361            cache_write_tok: 0.0,
362            output_tok: 0.0,
363        }
364    }
365
366    /// Build a populated in-memory ledger, then re-wrap its connection for read queries.
367    /// (One connection both writes and reads — fine for the in-memory test.)
368    fn seeded_db() -> BreakdownDb {
369        let tracker = Tracker::open_in_memory().unwrap();
370        tracker
371            .record_breakdown(
372                &turn("sess-a"),
373                &[
374                    block("Static", "System prompt", None, 50.0, 100.0),
375                    block("Static", "MCP tools", Some("github"), 0.0, 20.0),
376                ],
377            )
378            .unwrap();
379        tracker
380            .record_breakdown(
381                &turn("sess-a"),
382                &[block("Static", "System prompt", None, 50.0, 100.0)],
383            )
384            .unwrap();
385        BreakdownDb::from_connection(tracker.into_connection())
386    }
387
388    #[test]
389    fn sessions_round_trip_the_cc_session_id() {
390        // The real Claude Code session id (a dashed UUID, distinct from the system-prompt-hash
391        // `session_id`) survives insert → aggregate, so the status line can match a live session.
392        let tracker = Tracker::open_in_memory().unwrap();
393        let mut t = turn("sess-a");
394        t.cc_session_id = Some("968ad7ea-6e4a-430e-a708-4eda80c8b858".to_string());
395        tracker
396            .record_breakdown(&t, &[block("Static", "System prompt", None, 50.0, 100.0)])
397            .unwrap();
398        let db = BreakdownDb::from_connection(tracker.into_connection());
399        let rows = db.sessions().unwrap();
400        assert_eq!(rows.len(), 1);
401        assert_eq!(
402            rows[0].cc_session_id.as_deref(),
403            Some("968ad7ea-6e4a-430e-a708-4eda80c8b858")
404        );
405    }
406
407    #[test]
408    fn sessions_keep_claude_code_windows_separate() {
409        let tracker = Tracker::open_in_memory().unwrap();
410        let mut first = turn("shared-prompt-hash");
411        first.cc_session_id = Some("window-a".to_string());
412        first.fresh_input = 12_000;
413        first.cache_read = 30_000;
414        first.cache_write = 0;
415        tracker
416            .record_breakdown(
417                &first,
418                &[block("Static", "System prompt", None, 12_000.0, 30_000.0)],
419            )
420            .unwrap();
421
422        let mut second = turn("shared-prompt-hash");
423        second.cc_session_id = Some("window-b".to_string());
424        second.fresh_input = 3_000;
425        second.cache_read = 240_000;
426        second.cache_write = 0;
427        tracker
428            .record_breakdown(
429                &second,
430                &[block("Static", "System prompt", None, 3_000.0, 240_000.0)],
431            )
432            .unwrap();
433
434        let db = BreakdownDb::from_connection(tracker.into_connection());
435        let rows = db.sessions().unwrap();
436        assert_eq!(rows.len(), 2);
437        let tokens_for = |id: &str| {
438            rows.iter()
439                .find(|row| row.cc_session_id.as_deref() == Some(id))
440                .map(|row| row.last_input_tokens)
441        };
442        assert_eq!(tokens_for("window-a"), Some(42_000));
443        assert_eq!(tokens_for("window-b"), Some(243_000));
444    }
445
446    #[test]
447    fn a_ledger_written_before_sub_provider_existed_still_queries() {
448        // The upgrade path: `sessions()` now SELECTs `sub_provider`, so a ledger created by an
449        // older llmtrim must gain the column on open or every status-line render would error out.
450        let path = std::env::temp_dir().join(format!(
451            "llmtrim-legacy-ledger-{}-{}.db",
452            std::process::id(),
453            std::time::SystemTime::now()
454                .duration_since(std::time::UNIX_EPOCH)
455                .unwrap()
456                .as_nanos()
457        ));
458        let legacy = Connection::open(&path).unwrap();
459        legacy
460            .execute_batch(
461                "CREATE TABLE breakdown_turns (
462                     id INTEGER PRIMARY KEY, ts TEXT NOT NULL, session_id TEXT NOT NULL,
463                     agent TEXT NOT NULL, project TEXT, session_name TEXT,
464                     provider TEXT NOT NULL, model TEXT, window INTEGER NOT NULL,
465                     fresh_input INTEGER NOT NULL, cache_read INTEGER NOT NULL,
466                     cache_write INTEGER NOT NULL, output_tok INTEGER NOT NULL,
467                     input_rate REAL NOT NULL, output_rate REAL NOT NULL,
468                     cache_read_rate REAL NOT NULL, cache_write_rate REAL NOT NULL,
469                     bill_micros INTEGER NOT NULL
470                 );
471                 INSERT INTO breakdown_turns
472                     (ts, session_id, agent, provider, model, window, fresh_input, cache_read,
473                      cache_write, output_tok, input_rate, output_rate, cache_read_rate,
474                      cache_write_rate, bill_micros)
475                 VALUES ('2026-07-01T00:00:00+00:00', 'sess-old', 'claude-code', 'anthropic',
476                         'claude-sonnet-4', 200000, 10, 0, 0, 5, 3.0, 15.0, 0.3, 3.75, 100);",
477            )
478            .unwrap();
479        drop(legacy);
480
481        let tracker = Tracker::open_reader_at(&path).unwrap();
482        let db = BreakdownDb::from_connection(tracker.into_connection());
483        let rows = db.sessions().unwrap();
484        assert_eq!(rows.len(), 1);
485        // The pre-existing turn has no recorded backend — Anthropic served it, as far as we know.
486        assert_eq!(rows[0].last_sub_provider, None);
487        assert_eq!(rows[0].last_model.as_deref(), Some("claude-sonnet-4"));
488
489        let _ = std::fs::remove_file(&path);
490    }
491
492    #[test]
493    fn sessions_report_the_backend_that_served_the_newest_turn() {
494        // The `turn()` fixture carries a `session_name`, and the name ranking (`rn`) prefers a
495        // *named* row — so a "latest turn" read off that ranking would answer with whichever named
496        // row sorted first, not the newest one. Anthropic serves turn 1, a fallback to codex fires
497        // on turn 2: the session must report codex, and the model it actually ran.
498        let tracker = Tracker::open_in_memory().unwrap();
499        tracker
500            .record_breakdown(
501                &turn("sess-a"),
502                &[block("Static", "System prompt", None, 50.0, 100.0)],
503            )
504            .unwrap();
505        let mut fired = turn("sess-a");
506        fired.sub_provider = Some("codex".to_string());
507        fired.model = Some("gpt-5.6-terra".to_string());
508        tracker
509            .record_breakdown(
510                &fired,
511                &[block("Static", "System prompt", None, 50.0, 100.0)],
512            )
513            .unwrap();
514
515        let db = BreakdownDb::from_connection(tracker.into_connection());
516        let rows = db.sessions().unwrap();
517        assert_eq!(rows.len(), 1);
518        assert_eq!(rows[0].last_sub_provider.as_deref(), Some("codex"));
519        assert_eq!(rows[0].last_model.as_deref(), Some("gpt-5.6-terra"));
520    }
521
522    #[test]
523    fn sessions_report_no_backend_when_anthropic_served_the_newest_turn() {
524        // The reverse order: the fallback fired once, then Anthropic served the latest turn. The
525        // arrow must go away again rather than latch on the older rerouted turn.
526        let tracker = Tracker::open_in_memory().unwrap();
527        let mut fired = turn("sess-a");
528        fired.sub_provider = Some("codex".to_string());
529        fired.model = Some("gpt-5.6-terra".to_string());
530        tracker
531            .record_breakdown(
532                &fired,
533                &[block("Static", "System prompt", None, 50.0, 100.0)],
534            )
535            .unwrap();
536        tracker
537            .record_breakdown(
538                &turn("sess-a"),
539                &[block("Static", "System prompt", None, 50.0, 100.0)],
540            )
541            .unwrap();
542
543        let db = BreakdownDb::from_connection(tracker.into_connection());
544        let rows = db.sessions().unwrap();
545        assert_eq!(rows[0].last_sub_provider, None);
546        assert_eq!(rows[0].last_model.as_deref(), Some("claude-sonnet-4"));
547    }
548
549    #[test]
550    fn sessions_aggregate_turns_and_cache_hit() {
551        let db = seeded_db();
552        let rows = db.sessions().unwrap();
553        assert_eq!(rows.len(), 1);
554        let s = &rows[0];
555        assert_eq!(s.session_id, "sess-a");
556        assert_eq!(s.turns, 2);
557        assert_eq!(s.session_name.as_deref(), Some("my session"));
558        // cache_read 120*2 over total_in (50+120+30)*2 = 240/400 = 0.6.
559        assert!((s.cache_hit - 0.6).abs() < 1e-6);
560    }
561
562    #[test]
563    fn occupancy_uses_latest_turn_only() {
564        let db = seeded_db();
565        let (turn_id, window) = db.latest_turn("sess-a").unwrap().expect("a turn");
566        assert_eq!(window, 200_000);
567        let occ = db.occupancy(turn_id).unwrap();
568        // Latest turn had only the System prompt block.
569        assert_eq!(occ.len(), 1);
570        assert_eq!(occ[0].label, "System prompt");
571        assert_eq!(occ[0].tokens, 150);
572    }
573
574    #[test]
575    fn all_sources_aggregates_across_sessions() {
576        let db = seeded_db();
577        let all = db.all_sources().unwrap();
578        // System prompt appears in both turns; its tokens/cost aggregate across the session.
579        let sys = all.iter().find(|r| r.label == "System prompt").unwrap();
580        assert!(sys.usd > 0.0);
581        // The github MCP source from the first turn is present corpus-wide.
582        assert!(
583            all.iter()
584                .any(|r| r.mcp_server.as_deref() == Some("github"))
585        );
586    }
587
588    #[test]
589    fn cost_prices_from_frozen_rates_and_splits_mcp() {
590        let db = seeded_db();
591        let rows = db.cost("sess-a").unwrap();
592        let mcp = rows
593            .iter()
594            .find(|r| r.mcp_server.as_deref() == Some("github"))
595            .unwrap();
596        // 20 cache-read tokens * 0.3/1e6 = 6e-6.
597        assert!((mcp.read_usd - 20.0 * 0.3 / 1e6).abs() < 1e-12);
598        assert!(mcp.new_usd.abs() < 1e-12);
599    }
600
601    /// Regression anchor: sessions() output on the seeded in-memory DB must be
602    /// byte-identical before and after the ledger extraction (Step A0 gate).
603    #[test]
604    fn sessions_snapshot_anchor() {
605        let db = seeded_db();
606        let rows = db.sessions().unwrap();
607        // Render to a deterministic string (session_id, agent, turns, bill_micros).
608        let out = rows
609            .iter()
610            .map(|r| {
611                format!(
612                    "session={} agent={} turns={} bill_micros={}",
613                    r.session_id, r.agent, r.turns, r.bill_micros
614                )
615            })
616            .collect::<Vec<_>>()
617            .join("\n");
618        insta::assert_snapshot!(out);
619    }
620}