Skip to main content

agent_top_core/harness/
gemini.rs

1//! Gemini CLI: `~/.gemini/tmp/<project>/chats/session-<YYYY-MM-DDTHH-MM>-<id8>.jsonl`.
2//!
3//! Format notes (read from the recorder in `@google/gemini-cli-core` 0.58.0,
4//! `services/chatRecordingService.js`, 2026-09-05; the fixture in
5//! `tests/fixtures/gemini-0.58.jsonl` was written by that recorder, driven
6//! with a scripted conversation, not captured from a live session):
7//! * `<project>` is a slug of the working directory's basename (`agent-top`,
8//!   `agent-top-1` on a clash); older versions used a SHA-256 of the path.
9//!   `<project>/.project_root` holds the working directory the slug stands
10//!   for. `~/.gemini/projects.json` maps paths to slugs too, but the marker
11//!   is enough and is also what the CLI trusts when the two disagree.
12//! * The first line is metadata: `sessionId`, `projectHash` (a SHA-256 of
13//!   the working directory, not the slug), `startTime`, `lastUpdated`,
14//!   `kind` (`main` or `subagent`). `{"$set": {...}}` lines update it;
15//!   `lastUpdated` is rewritten after every message.
16//! * A message is `{"id", "timestamp", "type", "content", ...}`, `type` one of
17//!   `user`, `gemini`, `info`, `error`, `warning`. A `gemini` message carries
18//!   `model`, `tokens` and, once its tool calls have completed, `toolCalls`.
19//!   A message is appended again in full whenever the recorder updates it,
20//!   so the same `id` appears more than once and the latest line wins.
21//! * `tokens` is one API response's usage: `input` (`promptTokenCount`,
22//!   which includes the cached part), `cached`, `output`
23//!   (`candidatesTokenCount`, thoughts excluded), `thoughts`, `tool`
24//!   (`toolUsePromptTokenCount`) and `total`. Google bills thoughts as
25//!   output and tool-use prompt tokens as input, so that is how they are
26//!   folded here; the resulting total equals `total`.
27//! * A tool result is recorded as a `user` message whose content parts carry
28//!   `functionResponse`; a human prompt has `text` parts. Only the part keys
29//!   are looked at, never the text.
30//! * `toolCalls[]` entries have `id`, `name`, `status` (`success`, `error`,
31//!   `cancelled`, ...) and one `timestamp`, stamped when the call completed.
32//!   The span runs from the `gemini` message that issued the call to that
33//!   completion, so a call still running is not visible until it returns.
34//!   `google_web_search` is the built-in web search and is counted as one.
35//! * `{"$rewindTo": "<id>"}` removes that message and everything after it
36//!   from the conversation. The tokens were spent regardless, so nothing is
37//!   subtracted; messages are deduplicated by id so a `$set.messages`
38//!   checkpoint that re-lists them does not double count either.
39//! * A subagent gets its own file at `chats/<parent sessionId>/<id>.jsonl`
40//!   with `kind: "subagent"`. Those are tailed and folded into the parent,
41//!   as Claude Code's are.
42//! * Turns and inferences are reconstructed from line order: a prompt starts
43//!   a turn and an inference, a tool result starts an inference, and each
44//!   `gemini` message ends the inference and moves the end of the turn.
45//!   There is no end-of-turn marker, so a turn is as long as its last reply.
46//! * Legacy `session-*.json` files (one JSON document, rewritten on every
47//!   update) are not read; the CLI converts them on resume.
48//! * Context by source: a message's `toolCalls` are filed as results when
49//!   they first appear, which is before the next `gemini` message, and that
50//!   message's `tokens` sizes them. No compaction marker was seen in the
51//!   recorder; the ledger's halving rule stands in. See `ContextLedger`.
52//!
53//! Gemini CLI does not hold the file open between writes and publishes no
54//! registry of its processes, so a process is matched to its conversation by
55//! working directory and start time, and the row says so.
56
57use super::{
58    AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, SessionSummary, SessionTracker, SpanLog, SpanRetention, parse_rfc3339_utc,
59};
60use crate::jsonl::TailReader;
61use crate::model::{Activity, Attribution, ContextOrigin, CostBreakdown, Harness, ProcNode, SpanKind, TokenUsage};
62use crate::pricing::{self, Table};
63use crate::process::RawProc;
64use serde_json::Value;
65use std::collections::{BTreeMap, HashSet};
66use std::path::{Path, PathBuf};
67use std::time::{Duration, SystemTime};
68
69/// `~/.gemini`, or `$GEMINI_CLI_HOME/.gemini`, which is the CLI's own override.
70pub fn gemini_dir() -> Option<PathBuf> {
71    let home = std::env::var_os("GEMINI_CLI_HOME").or_else(|| std::env::var_os("HOME"))?;
72    Some(PathBuf::from(home).join(".gemini"))
73}
74
75/// Where every project's chats live: one directory per project slug.
76pub fn tmp_dir() -> Option<PathBuf> {
77    gemini_dir().map(|d| d.join("tmp"))
78}
79
80/// One main conversation on disk: its file, the working directory its project
81/// directory stands for (absent for a pre-slug hashed directory) and the start
82/// time from its header.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Session {
85    pub path: PathBuf,
86    pub cwd: Option<PathBuf>,
87    pub started: Option<SystemTime>,
88    pub session_id: Option<String>,
89}
90
91/// Every main conversation written since `since`.
92pub fn recent_sessions(since: SystemTime) -> Vec<Session> {
93    let Some(root) = tmp_dir() else { return Vec::new() };
94    sessions_under(&root, since)
95}
96
97/// Walk `<root>/<project>/chats/*.jsonl`. Subagent files sit one level deeper
98/// under the parent's id and are not sessions of their own, so the walk does
99/// not descend. `.project_root` names the working directory.
100pub(crate) fn sessions_under(root: &Path, since: SystemTime) -> Vec<Session> {
101    let mut out = Vec::new();
102    let Ok(projects) = std::fs::read_dir(root) else { return out };
103    for proj in projects.flatten() {
104        let dir = proj.path();
105        let Ok(chats) = std::fs::read_dir(dir.join("chats")) else { continue };
106        let cwd = project_root(&dir);
107        for f in chats.flatten() {
108            let p = f.path();
109            let Ok(md) = f.metadata() else { continue };
110            if !md.is_file() || p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
111                continue;
112            }
113            if !md.modified().map(|m| m >= since).unwrap_or(false) {
114                continue;
115            }
116            let (session_id, started) = match read_meta(&p) {
117                Some((id, ts)) => (Some(id), Some(ts)),
118                None => (None, None),
119            };
120            out.push(Session { path: p, cwd: cwd.clone(), started, session_id });
121        }
122    }
123    out
124}
125
126/// The working directory a project directory stands for.
127pub fn project_root(project_dir: &Path) -> Option<PathBuf> {
128    let s = std::fs::read_to_string(project_dir.join(".project_root")).ok()?;
129    let s = s.trim();
130    if s.is_empty() { None } else { Some(PathBuf::from(s)) }
131}
132
133/// Cheap header read: session id and start time from the first line only.
134pub fn read_meta(path: &Path) -> Option<(String, SystemTime)> {
135    use std::io::{BufRead, BufReader};
136    let f = std::fs::File::open(path).ok()?;
137    let mut first = String::new();
138    BufReader::new(f).read_line(&mut first).ok()?;
139    let v: Value = serde_json::from_str(&first).ok()?;
140    v.get("projectHash")?;
141    let id = v.get("sessionId").and_then(Value::as_str)?.to_string();
142    let ts = v.get("startTime").and_then(Value::as_str).and_then(parse_rfc3339_utc)?;
143    Some((id, ts))
144}
145
146/// Where a session's subagent transcripts live: a directory named after the
147/// session id, next to the session file.
148pub fn subagents_dir(transcript: &Path, session_id: &str) -> Option<PathBuf> {
149    Some(transcript.parent()?.join(session_id))
150}
151
152/// Same path, as written. `.project_root` holds `path.resolve(cwd)`, and the
153/// process table reports the cwd the kernel knows, which on macOS may be the
154/// resolved form of a symlinked path; so both are canonicalised when they can
155/// be and compared as given when they cannot.
156fn same_dir(a: &Path, b: &Path) -> bool {
157    if a == b {
158        return true;
159    }
160    let ca = std::fs::canonicalize(a).unwrap_or_else(|_| a.to_path_buf());
161    let cb = std::fs::canonicalize(b).unwrap_or_else(|_| b.to_path_buf());
162    ca == cb
163}
164
165/// The conversation belonging to a Gemini CLI process: the newest session in
166/// the process's working directory that started after the process did and
167/// that no other process has claimed. One process runs one conversation; a
168/// `/chat resume` keeps writing to the resumed file, which the mtime order
169/// picks up.
170pub(crate) fn attribute(
171    cwd: Option<&Path>,
172    proc_start: SystemTime,
173    recent: &[Session],
174    taken: &HashSet<PathBuf>,
175) -> (Vec<PathBuf>, Attribution) {
176    let Some(cwd) = cwd else { return (Vec::new(), Attribution::None) };
177    let slack = Duration::from_secs(60);
178    let mut mine: Vec<&Session> = recent
179        .iter()
180        .filter(|s| !taken.contains(&s.path))
181        .filter(|s| s.cwd.as_deref().is_some_and(|c| same_dir(c, cwd)))
182        .filter(|s| s.started.is_none_or(|ts| ts + slack >= proc_start))
183        .collect();
184    mine.sort_by_key(|s| std::cmp::Reverse(std::fs::metadata(&s.path).and_then(|m| m.modified()).ok()));
185    match mine.first() {
186        Some(s) => (vec![s.path.clone()], Attribution::CwdHeuristic),
187        None => (Vec::new(), Attribution::None),
188    }
189}
190
191/// The Gemini CLI adapter. See the module notes for the layout it reads.
192#[derive(Default)]
193pub struct GeminiAdapter {
194    recent: Vec<Session>,
195}
196
197impl HarnessAdapter for GeminiAdapter {
198    fn harness(&self) -> Harness {
199        Harness::Gemini
200    }
201
202    fn rescan(&mut self, since: SystemTime) {
203        self.recent = recent_sessions(since);
204    }
205
206    fn attribute(&self, _root: &ProcNode, _raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
207        attribute(ctx.cwd, ctx.proc_start, &self.recent, ctx.attached)
208    }
209
210    fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
211        self.recent.iter().filter(|s| !attached.contains(&s.path)).map(|s| s.path.clone()).collect()
212    }
213
214    fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
215        Box::new(GeminiTranscript::new(path).with_spans(spans))
216    }
217
218    fn detect(&self, path: &Path) -> bool {
219        read_meta(path).is_some()
220    }
221
222    fn transcripts(&self) -> Vec<(String, PathBuf)> {
223        recent_sessions(SystemTime::UNIX_EPOCH)
224            .into_iter()
225            .map(|s| {
226                let id = s.session_id.unwrap_or_else(|| s.path.file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default());
227                (id, s.path)
228            })
229            .collect()
230    }
231}
232
233/// What one `gemini` message added to the summary, so a later line for the
234/// same id can replace it.
235#[derive(Debug, Clone, Copy, Default)]
236struct Contrib {
237    usage: TokenUsage,
238    cost: CostBreakdown,
239    unpriced: u64,
240    has_record: bool,
241    empty_record: bool,
242}
243
244/// One JSONL file being tailed into a `SessionSummary`: the main
245/// conversation, or one subagent's.
246struct Parser {
247    reader: TailReader,
248    summary: SessionSummary,
249    /// Whether this file is a subagent's, which marks its spans as sidechain.
250    subagent: bool,
251    /// Every `gemini` message seen, by id, with what it contributed. A message
252    /// is appended again when its tool calls complete, and re-listed by a
253    /// checkpoint, so this is what stops double counting.
254    messages: BTreeMap<String, Contrib>,
255    /// `user` message ids already seen, so a re-appended prompt is not a
256    /// second prompt.
257    prompts: HashSet<String>,
258    /// Tool call ids already turned into spans.
259    calls: HashSet<String>,
260    inference: Option<String>,
261    turn: Option<String>,
262    inferences: u64,
263    turns: u64,
264    prev_ts: Option<SystemTime>,
265}
266
267impl Parser {
268    fn new(path: impl Into<PathBuf>, spans: SpanRetention, subagent: bool) -> Self {
269        Parser {
270            reader: TailReader::new(path),
271            summary: SessionSummary { harness: Some(Harness::Gemini), spans: spans.log(), ..Default::default() },
272            subagent,
273            messages: BTreeMap::new(),
274            prompts: HashSet::new(),
275            calls: HashSet::new(),
276            inference: None,
277            turn: None,
278            inferences: 0,
279            turns: 0,
280            prev_ts: None,
281        }
282    }
283
284    /// Returns how many lines were ingested and whether more are waiting.
285    fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
286        let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
287        for l in &lines {
288            self.ingest(l, prices);
289        }
290        Ok((lines.len(), more))
291    }
292
293    fn ingest(&mut self, line: &str, prices: &Table) {
294        let Ok(v) = serde_json::from_str::<Value>(line) else { return };
295        if let Some(set) = v.get("$set").filter(|s| s.is_object()) {
296            self.ingest_metadata(set);
297            if let Some(msgs) = set.get("messages").and_then(Value::as_array) {
298                for m in msgs {
299                    self.ingest_message(m, prices);
300                }
301            }
302            return;
303        }
304        if v.get("$rewindTo").is_some() {
305            // The conversation was cut back; the model owes nothing until the
306            // next prompt.
307            self.summary.activity = Activity::Waiting;
308            return;
309        }
310        if v.get("projectHash").is_some() {
311            self.ingest_metadata(&v);
312            if let Some(msgs) = v.get("messages").and_then(Value::as_array) {
313                for m in msgs {
314                    self.ingest_message(m, prices);
315                }
316            }
317            return;
318        }
319        if v.get("id").and_then(Value::as_str).is_some() {
320            self.ingest_message(&v, prices);
321        }
322    }
323
324    fn ingest_metadata(&mut self, m: &Value) {
325        if let Some(id) = m.get("sessionId").and_then(Value::as_str) {
326            self.summary.session_id = Some(id.to_string());
327        }
328        if let Some(ts) = m.get("startTime").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
329            self.summary.started_at = Some(self.summary.started_at.map_or(ts, |s| s.min(ts)));
330            if self.summary.last_activity.is_none() {
331                self.summary.last_activity = Some(ts);
332            }
333        }
334        if m.get("kind").and_then(Value::as_str) == Some("subagent") {
335            self.subagent = true;
336        }
337    }
338
339    fn ingest_message(&mut self, m: &Value, prices: &Table) {
340        let Some(id) = m.get("id").and_then(Value::as_str) else { return };
341        let ts = m.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
342        if let Some(ts) = ts {
343            if self.summary.started_at.is_none_or(|s| ts < s) {
344                self.summary.started_at = Some(ts);
345            }
346            if self.summary.last_activity.is_none_or(|l| ts > l) {
347                self.summary.last_activity = Some(ts);
348            }
349        }
350        match m.get("type").and_then(Value::as_str).unwrap_or("") {
351            "user" => self.ingest_user(id, m, ts),
352            "gemini" => self.ingest_gemini(id, m, ts, prices),
353            _ => {}
354        }
355        if ts.is_some() {
356            self.prev_ts = ts;
357        }
358    }
359
360    /// A prompt or a tool result: in both cases the model owes a response.
361    fn ingest_user(&mut self, id: &str, m: &Value, ts: Option<SystemTime>) {
362        if !self.prompts.insert(id.to_string()) {
363            return;
364        }
365        self.summary.activity = Activity::Working;
366        let Some(ts) = ts else { return };
367        if !is_tool_result(m) {
368            self.begin_turn(ts);
369        }
370        self.begin_inference(ts);
371    }
372
373    fn ingest_gemini(&mut self, id: &str, m: &Value, ts: Option<SystemTime>, prices: &Table) {
374        if let Some(model) = m.get("model").and_then(Value::as_str).filter(|s| !s.is_empty()) {
375            self.summary.model = Some(model.to_string());
376        }
377        let first_time = !self.messages.contains_key(id);
378        if first_time {
379            self.summary.health.billable_messages += 1;
380            self.summary.turns += 1;
381            if self.subagent {
382                self.summary.subagent_turns += 1;
383            }
384            // The reply is written in one line, so the inference ends here
385            // and the turn now reaches at least this far.
386            self.summary.activity = Activity::Waiting;
387            if let Some(ts) = ts {
388                if let Some(inf) = self.inference.take() {
389                    self.summary.spans.end_at(&inf, ts);
390                }
391                if let Some(turn) = self.turn.as_deref() {
392                    self.summary.spans.end_at(turn, ts);
393                }
394            }
395        }
396        let c = self.account(id, m, prices);
397        if first_time {
398            self.summary.context.response(&c.usage, &c.cost);
399        }
400        self.ingest_tool_calls(m, ts);
401    }
402
403    /// Replace whatever this message contributed before with what it says
404    /// now, and return it.
405    fn account(&mut self, id: &str, m: &Value, prices: &Table) -> Contrib {
406        let model = m.get("model").and_then(Value::as_str).map(str::to_string).or_else(|| self.summary.model.clone());
407        let mut c = Contrib::default();
408        if let Some(t) = m.get("tokens").filter(|t| t.is_object()) {
409            c.has_record = true;
410            c.usage = parse_tokens(t);
411            c.empty_record = c.usage.total() == 0;
412            match model.as_deref().and_then(|m| prices.lookup(m)) {
413                Some(p) => c.cost = p.breakdown(&c.usage),
414                None => c.unpriced = c.usage.total(),
415            }
416        }
417        let s = &mut self.summary;
418        if let Some(old) = self.messages.insert(id.to_string(), c) {
419            s.usage.sub(&old.usage);
420            s.cost_usd -= old.cost.total();
421            s.cost_breakdown.sub(&old.cost);
422            s.unpriced_tokens = s.unpriced_tokens.saturating_sub(old.unpriced);
423            s.health.usage_records -= u64::from(old.has_record);
424            s.health.empty_usage_records -= u64::from(old.empty_record);
425        }
426        s.usage.add(&c.usage);
427        s.cost_usd += c.cost.total();
428        s.cost_breakdown.add(&c.cost);
429        s.unpriced_tokens += c.unpriced;
430        s.health.usage_records += u64::from(c.has_record);
431        s.health.empty_usage_records += u64::from(c.empty_record);
432        c
433    }
434
435    /// Completed tool calls, appended to the message that issued them. Each
436    /// becomes a closed span from the message to the call's own timestamp,
437    /// and the tool results are about to be submitted, so the model is owed
438    /// a response again.
439    fn ingest_tool_calls(&mut self, m: &Value, msg_ts: Option<SystemTime>) {
440        let Some(calls) = m.get("toolCalls").and_then(Value::as_array) else { return };
441        for c in calls {
442            let Some(id) = c.get("id").and_then(Value::as_str).filter(|s| !s.is_empty()) else { continue };
443            if !self.calls.insert(id.to_string()) {
444                continue;
445            }
446            self.summary.tool_calls += 1;
447            self.summary.activity = Activity::Working;
448            let name = c.get("name").and_then(Value::as_str).unwrap_or("tool");
449            if name == "google_web_search" {
450                self.summary.web_searches += 1;
451            }
452            let ended = c.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
453            let error = c.get("status").and_then(Value::as_str) == Some("error");
454            match mcp_server_of(name) {
455                Some(server) => {
456                    let u = self.summary.mcp.entry(server.to_string()).or_default();
457                    u.calls += 1;
458                    u.errors += u64::from(error);
459                    u.last_call = u.last_call.max(ended.or(msg_ts));
460                    self.summary.context.result(id, ContextOrigin::Mcp, server);
461                }
462                None => self.summary.context.result(id, ContextOrigin::Tool, name),
463            }
464            let Some(started) = msg_ts.or(ended) else { continue };
465            let ended = ended.unwrap_or(started);
466            self.summary.spans.open(id.to_string(), name.to_string(), started.min(ended), self.subagent);
467            self.summary.spans.close(id, ended, error);
468            if self.summary.last_activity.is_none_or(|l| ended > l) {
469                self.summary.last_activity = Some(ended);
470            }
471        }
472    }
473
474    /// A prompt starts a turn. One the model never answered is ended where
475    /// the last line before this prompt was written.
476    fn begin_turn(&mut self, ts: SystemTime) {
477        if let Some(id) = self.turn.take()
478            && self.summary.spans.open_of_kind(SpanKind::Turn).is_some_and(|s| s.id == id)
479        {
480            let ended = self.prev_ts.unwrap_or(ts).min(ts);
481            self.summary.spans.end_at(&id, ended);
482        }
483        self.turns += 1;
484        let id = format!("turn:{}", self.turns);
485        self.summary.spans.open_kind(id.clone(), "turn".into(), ts, self.subagent, SpanKind::Turn);
486        self.turn = Some(id);
487    }
488
489    /// A submission that got no reply before the next one was not an
490    /// inference and is dropped.
491    fn begin_inference(&mut self, ts: SystemTime) {
492        if let Some(id) = self.inference.take() {
493            self.summary.spans.discard_open(&id);
494        }
495        self.inferences += 1;
496        let id = format!("inference:{}", self.inferences);
497        self.summary.spans.open_kind(id.clone(), "inference".into(), ts, self.subagent, SpanKind::Inference);
498        self.inference = Some(id);
499    }
500}
501
502/// A `user` message answering tool calls carries `functionResponse` parts.
503/// Only the keys are inspected.
504fn is_tool_result(m: &Value) -> bool {
505    m.get("content").and_then(Value::as_array).is_some_and(|parts| parts.iter().any(|p| p.get("functionResponse").is_some()))
506}
507
508/// The server behind a Gemini MCP tool name. Gemini registers an MCP tool as
509/// `mcp_<server>_<tool>` (the `mcp_` prefix is forced, and characters the
510/// Gemini API rejects become `_`). Gemini's own parser takes the first
511/// underscore-separated segment after the prefix as the server, so this does
512/// the same; a server name that itself contains an underscore is split the
513/// same (wrong) way Gemini splits it, which keeps the grouping consistent
514/// with what the CLI shows.
515pub fn mcp_server_of(tool_name: &str) -> Option<&str> {
516    let rest = tool_name.strip_prefix("mcp_")?;
517    let server = rest.split_once('_').map(|(a, _)| a).unwrap_or(rest);
518    if server.is_empty() { None } else { Some(server) }
519}
520
521/// Gemini's usage, folded the way Google bills it: thoughts are output,
522/// tool-use prompt tokens are input, and `input` already includes the cached
523/// part, which is priced separately.
524fn parse_tokens(t: &Value) -> TokenUsage {
525    let g = |k: &str| t.get(k).and_then(Value::as_u64).unwrap_or(0);
526    let cached = g("cached");
527    TokenUsage {
528        input: g("input").saturating_sub(cached) + g("tool"),
529        cache_read: cached,
530        output: g("output") + g("thoughts"),
531        ..Default::default()
532    }
533}
534
535/// A Gemini CLI session: the main conversation plus every subagent file
536/// under `chats/<sessionId>/`, folded into one summary. A subagent may run a
537/// different model from its parent; each message is priced by the model it
538/// names.
539pub struct GeminiTranscript {
540    main: Parser,
541    subagents: BTreeMap<PathBuf, Parser>,
542    prices: &'static Table,
543    retention: SpanRetention,
544    summary: SessionSummary,
545}
546
547impl GeminiTranscript {
548    pub fn new(path: impl Into<PathBuf>) -> Self {
549        let retention = SpanRetention::Recent;
550        let path = path.into();
551        let mut main = Parser::new(&path, retention, false);
552        // The transcript never names its working directory; the project
553        // directory two levels up does, in `.project_root`.
554        main.summary.cwd = path.parent().and_then(Path::parent).and_then(project_root);
555        GeminiTranscript {
556            main,
557            subagents: BTreeMap::new(),
558            prices: pricing::table(),
559            retention,
560            summary: SessionSummary { harness: Some(Harness::Gemini), ..Default::default() },
561        }
562    }
563
564    /// See `ClaudeTranscript::with_prices`.
565    pub fn with_prices(mut self, prices: &'static Table) -> Self {
566        self.prices = prices;
567        self
568    }
569
570    /// Keep every span instead of the newest `MAX_SPANS`. See `SpanRetention`.
571    pub fn with_spans(mut self, retention: SpanRetention) -> Self {
572        self.retention = retention;
573        self.main.summary.spans = retention.log();
574        for p in self.subagents.values_mut() {
575            p.summary.spans = retention.log();
576        }
577        self
578    }
579
580    /// Pick up subagent files that appeared since the last look: one
581    /// directory listing per refresh, and for most sessions a single failed
582    /// `open`.
583    fn discover_subagents(&mut self) {
584        let Some(id) = self.main.summary.session_id.as_deref() else { return };
585        let Some(dir) = subagents_dir(self.main.reader.path(), id) else { return };
586        let Ok(rd) = std::fs::read_dir(&dir) else { return };
587        for e in rd.flatten() {
588            let p = e.path();
589            if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
590                continue;
591            }
592            let parser = Parser::new(&p, self.retention, true);
593            self.subagents.insert(p, parser);
594        }
595    }
596
597    fn fold(&mut self) {
598        let mut s = self.main.summary.clone();
599        for c in self.subagents.values() {
600            let t = &c.summary;
601            s.usage.add(&t.usage);
602            s.cost_usd += t.cost_usd;
603            s.cost_breakdown.add(&t.cost_breakdown);
604            s.unpriced_tokens += t.unpriced_tokens;
605            s.turns += t.turns;
606            s.subagent_turns += t.subagent_turns;
607            s.tool_calls += t.tool_calls;
608            s.web_searches += t.web_searches;
609            s.health.billable_messages += t.health.billable_messages;
610            s.health.usage_records += t.health.usage_records;
611            s.health.empty_usage_records += t.health.empty_usage_records;
612            s.last_activity = s.last_activity.max(t.last_activity);
613            for (server, u) in &t.mcp {
614                s.mcp.entry(server.clone()).or_default().add(u);
615            }
616            s.context.merge(&t.context);
617        }
618        if !self.subagents.is_empty() {
619            let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
620            s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
621        }
622        self.summary = s;
623    }
624}
625
626impl SessionTracker for GeminiTranscript {
627    fn refresh(&mut self) -> anyhow::Result<bool> {
628        let (mut ingested, mut more) = self.main.refresh(self.prices)?;
629        self.discover_subagents();
630        for c in self.subagents.values_mut() {
631            // One unreadable subagent file must not take the session with it.
632            if let Ok((n, m)) = c.refresh(self.prices) {
633                ingested += n;
634                more |= m;
635            }
636        }
637        if ingested > 0 || self.summary.session_id.is_none() {
638            self.fold();
639        }
640        Ok(more)
641    }
642
643    fn summary(&self) -> &SessionSummary {
644        &self.summary
645    }
646
647    fn path(&self) -> &Path {
648        self.main.reader.path()
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use std::io::Write;
656
657    fn scratch(name: &str) -> PathBuf {
658        let dir = std::env::temp_dir().join(format!("agent-top-gemini-{name}-{}", std::process::id()));
659        let _ = std::fs::remove_dir_all(&dir);
660        std::fs::create_dir_all(&dir).unwrap();
661        dir
662    }
663
664    const META: &str = r#"{"sessionId":"0a1b2c3d-0000-4000-8000-000000000001","projectHash":"604d","startTime":"2026-09-05T09:00:00.000Z","lastUpdated":"2026-09-05T09:00:00.000Z","kind":"main"}"#;
665
666    #[test]
667    fn names_the_server_behind_a_gemini_mcp_tool() {
668        assert_eq!(mcp_server_of("mcp_filesystem_read_file"), Some("filesystem"));
669        assert_eq!(mcp_server_of("mcp_chrome-devtools_take_screenshot"), Some("chrome-devtools"));
670        // Gemini flattens with single underscores and splits on the first, so a
671        // server whose name has an underscore is split its way, not ours.
672        assert_eq!(mcp_server_of("mcp_google_workspace_search"), Some("google"));
673        assert_eq!(mcp_server_of("read_file"), None);
674        assert_eq!(mcp_server_of("mcp_"), None);
675    }
676
677    #[test]
678    fn counts_gemini_mcp_calls_per_server() {
679        let dir = scratch("mcp");
680        let path = dir.join("session.jsonl");
681        let mut f = std::fs::File::create(&path).unwrap();
682        writeln!(f, "{META}").unwrap();
683        writeln!(f, r#"{{"id":"u1","timestamp":"2026-09-05T09:00:00.000Z","type":"user","content":[{{"text":"p"}}]}}"#).unwrap();
684        writeln!(f, r#"{{"id":"g1","timestamp":"2026-09-05T09:00:03.000Z","type":"gemini","content":"","tokens":{{"input":10,"output":1,"cached":0,"thoughts":0,"tool":0,"total":11}},"model":"gemini-2.5-pro","toolCalls":[{{"id":"c1","name":"mcp_filesystem_read_file","status":"success","timestamp":"2026-09-05T09:00:05.000Z"}},{{"id":"c2","name":"mcp_filesystem_list_directory","status":"error","timestamp":"2026-09-05T09:00:06.000Z"}},{{"id":"c3","name":"google_web_search","status":"success","timestamp":"2026-09-05T09:00:07.000Z"}}]}}"#).unwrap();
685        let mut t = GeminiTranscript::new(&path).with_prices(pricing::builtin_table());
686        t.refresh().unwrap();
687        let s = t.summary();
688        assert_eq!(s.tool_calls, 3);
689        assert_eq!(s.web_searches, 1);
690        assert_eq!(s.mcp.len(), 1, "the web search is not an MCP server");
691        let fs = &s.mcp["filesystem"];
692        assert_eq!((fs.calls, fs.errors), (2, 1));
693        assert_eq!(fs.last_call, parse_rfc3339_utc("2026-09-05T09:00:06.000Z"));
694        let _ = std::fs::remove_dir_all(&dir);
695    }
696
697    #[test]
698    fn sizes_context_per_tool_from_the_next_reply() {
699        let dir = scratch("context");
700        let path = dir.join("session.jsonl");
701        let mut f = std::fs::File::create(&path).unwrap();
702        writeln!(f, r#"{{"sessionId":"s","startTime":"2026-09-05T09:00:00.000Z","kind":"main"}}"#).unwrap();
703        writeln!(f, r#"{{"id":"u1","timestamp":"2026-09-05T09:00:01.000Z","type":"user","content":[{{"text":"go"}}]}}"#).unwrap();
704        let g1 = r#""tokens":{"input":10000,"output":100,"cached":0,"thoughts":0,"tool":0,"total":10100},"model":"gemini-2.5-pro""#;
705        writeln!(f, r#"{{"id":"g1","timestamp":"2026-09-05T09:00:03.000Z","type":"gemini","content":"",{g1}}}"#).unwrap();
706        // Re-appended with its calls once they completed.
707        writeln!(f, r#"{{"id":"g1","timestamp":"2026-09-05T09:00:03.000Z","type":"gemini","content":"",{g1},"toolCalls":[{{"id":"c1","name":"read_file","status":"success","timestamp":"2026-09-05T09:00:05.000Z"}},{{"id":"c2","name":"mcp_fs_list","status":"success","timestamp":"2026-09-05T09:00:05.000Z"}}]}}"#).unwrap();
708        writeln!(f, r#"{{"id":"u2","timestamp":"2026-09-05T09:00:05.100Z","type":"user","content":[{{"functionResponse":{{"id":"c1"}}}},{{"functionResponse":{{"id":"c2"}}}}]}}"#).unwrap();
709        // 10_000 + 100 reply + 5_000 of results.
710        writeln!(f, r#"{{"id":"g2","timestamp":"2026-09-05T09:00:09.000Z","type":"gemini","content":"done","tokens":{{"input":15100,"output":5,"cached":15000,"thoughts":0,"tool":0,"total":15105}},"model":"gemini-2.5-pro"}}"#).unwrap();
711        let mut t = GeminiTranscript::new(&path).with_prices(pricing::builtin_table());
712        t.refresh().unwrap();
713        let c: std::collections::HashMap<String, crate::model::ContextSource> =
714            t.summary().context.sources().into_iter().map(|c| (c.name.clone(), c)).collect();
715        assert_eq!(c["read_file"].tokens, 2_500);
716        assert_eq!((c["fs"].tokens, c["fs"].origin), (2_500, ContextOrigin::Mcp));
717        assert_eq!(c["other"].tokens, 10_100);
718        let prompt_cost = {
719            let b = &t.summary().cost_breakdown;
720            b.input + b.cache_read + b.cache_write_5m + b.cache_write_1h
721        };
722        let attributed: f64 = c.values().map(|x| x.cost_usd).sum();
723        assert!((attributed - prompt_cost).abs() < 1e-9, "{attributed} vs {prompt_cost}");
724        let _ = std::fs::remove_dir_all(&dir);
725    }
726
727    #[test]
728    fn folds_tokens_the_way_google_bills_them_and_dedupes_by_id() {
729        let dir = scratch("tokens");
730        let path = dir.join("session-2026-09-05T09-00-0a1b2c3d.jsonl");
731        let mut f = std::fs::File::create(&path).unwrap();
732        writeln!(f, "{META}").unwrap();
733        writeln!(f, r#"{{"id":"u1","timestamp":"2026-09-05T09:00:02.000Z","type":"user","content":[{{"text":"p"}}]}}"#).unwrap();
734        writeln!(f, r#"{{"$set":{{"lastUpdated":"2026-09-05T09:00:02.000Z"}}}}"#).unwrap();
735        let g1 = r#"{"id":"g1","timestamp":"2026-09-05T09:00:05.000Z","type":"gemini","content":"","tokens":{"input":12000,"output":40,"cached":9000,"thoughts":300,"tool":0,"total":12340},"model":"gemini-2.5-pro"}"#;
736        writeln!(f, "{g1}").unwrap();
737        // The same message again, now carrying its completed tool call.
738        let with_call = g1.replace(
739            r#""model":"gemini-2.5-pro"}"#,
740            r#""model":"gemini-2.5-pro","toolCalls":[{"id":"call-1","name":"read_file","status":"success","timestamp":"2026-09-05T09:00:09.000Z"}]}"#,
741        );
742        writeln!(f, "{with_call}").unwrap();
743        let mut t = GeminiTranscript::new(&path).with_prices(pricing::builtin_table());
744        t.refresh().unwrap();
745        let s = t.summary();
746        assert_eq!(s.session_id.as_deref(), Some("0a1b2c3d-0000-4000-8000-000000000001"));
747        assert_eq!(s.model.as_deref(), Some("gemini-2.5-pro"));
748        assert_eq!(s.turns, 1, "one reply, appended twice");
749        assert_eq!(s.usage.input, 3000, "prompt tokens minus the cached part");
750        assert_eq!(s.usage.cache_read, 9000);
751        assert_eq!(s.usage.output, 340, "thoughts are billed as output");
752        assert_eq!(s.usage.total(), 12340, "and the fold adds back up to Gemini's total");
753        // gemini-2.5-pro: 3000*1.25 + 9000*0.125 + 340*10 = 3750 + 1125 + 3400 micro-dollars
754        assert!((s.cost_usd - 0.008275).abs() < 1e-9, "{}", s.cost_usd);
755        assert_eq!(s.unpriced_tokens, 0);
756        assert_eq!(s.tool_calls, 1);
757        assert_eq!(s.activity, Activity::Working, "a completed call means results are about to be submitted");
758        let tools: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
759        assert_eq!(tools.len(), 1);
760        assert_eq!(tools[0].name, "read_file");
761        assert_eq!(tools[0].duration_ms, Some(4_000), "from the message that issued it to its completion");
762        assert_eq!(read_meta(&path).unwrap().0, "0a1b2c3d-0000-4000-8000-000000000001");
763        let _ = std::fs::remove_dir_all(&dir);
764    }
765
766    #[test]
767    fn reconstructs_turns_inferences_and_counts_searches() {
768        let dir = scratch("turns");
769        let path = dir.join("session.jsonl");
770        let mut f = std::fs::File::create(&path).unwrap();
771        writeln!(f, "{META}").unwrap();
772        writeln!(f, r#"{{"id":"u1","timestamp":"2026-09-05T09:00:00.000Z","type":"user","content":[{{"text":"p"}}]}}"#).unwrap();
773        writeln!(f, r#"{{"id":"g1","timestamp":"2026-09-05T09:00:03.000Z","type":"gemini","content":"","tokens":{{"input":10,"output":1,"cached":0,"thoughts":0,"tool":0,"total":11}},"model":"gemini-2.5-flash"}}"#).unwrap();
774        writeln!(f, r#"{{"id":"g1","timestamp":"2026-09-05T09:00:03.000Z","type":"gemini","content":"","tokens":{{"input":10,"output":1,"cached":0,"thoughts":0,"tool":0,"total":11}},"model":"gemini-2.5-flash","toolCalls":[{{"id":"c1","name":"google_web_search","status":"success","timestamp":"2026-09-05T09:00:06.000Z"}},{{"id":"c2","name":"run_shell_command","status":"error","timestamp":"2026-09-05T09:00:10.000Z"}}]}}"#).unwrap();
775        writeln!(f, r#"{{"id":"u2","timestamp":"2026-09-05T09:00:10.100Z","type":"user","content":[{{"functionResponse":{{"id":"c1"}}}},{{"functionResponse":{{"id":"c2"}}}}]}}"#).unwrap();
776        writeln!(f, r#"{{"id":"g2","timestamp":"2026-09-05T09:00:15.000Z","type":"gemini","content":"done","tokens":{{"input":20,"output":5,"cached":10,"thoughts":0,"tool":0,"total":25}},"model":"gemini-2.5-flash"}}"#).unwrap();
777        let mut t = GeminiTranscript::new(&path).with_prices(pricing::builtin_table());
778        t.refresh().unwrap();
779        let s = t.summary();
780        assert_eq!(s.turns, 2);
781        assert_eq!(s.tool_calls, 2);
782        assert_eq!(s.web_searches, 1);
783        assert_eq!(s.activity, Activity::Waiting);
784        let all = s.spans.to_vec();
785        let tools: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
786        assert_eq!(tools.len(), 2);
787        assert_eq!(tools[0].duration_ms, Some(3_000));
788        assert!(!tools[0].error);
789        assert_eq!(tools[1].duration_ms, Some(7_000));
790        assert!(tools[1].error);
791        let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
792        assert_eq!(inf.len(), 2);
793        assert_eq!(inf[0].duration_ms, Some(3_000), "prompt at :00, reply at :03");
794        assert_eq!(inf[1].duration_ms, Some(4_900), "tool results at :10.1, reply at :15");
795        let turns: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Turn).collect();
796        assert_eq!(turns.len(), 1, "one prompt; the tool results are not a new turn");
797        assert_eq!(turns[0].duration_ms, Some(15_000), "the turn reaches the last reply");
798        assert_eq!(s.health.billable_messages, 2);
799        assert_eq!(s.health.usage_records, 2);
800        assert!(!s.health.fields_unrecognised());
801        let _ = std::fs::remove_dir_all(&dir);
802    }
803
804    #[test]
805    fn folds_subagent_files_named_after_the_parent() {
806        let dir = scratch("sub");
807        let path = dir.join("session-2026-09-05T09-00-0a1b2c3d.jsonl");
808        let mut f = std::fs::File::create(&path).unwrap();
809        writeln!(f, "{META}").unwrap();
810        writeln!(f, r#"{{"id":"u1","timestamp":"2026-09-05T09:00:00.000Z","type":"user","content":[{{"text":"p"}}]}}"#).unwrap();
811        writeln!(f, r#"{{"id":"g1","timestamp":"2026-09-05T09:00:03.000Z","type":"gemini","content":"","tokens":{{"input":100,"output":10,"cached":0,"thoughts":0,"tool":0,"total":110}},"model":"gemini-2.5-pro"}}"#).unwrap();
812        let mut t = GeminiTranscript::new(&path).with_prices(pricing::builtin_table());
813        t.refresh().unwrap();
814        assert_eq!(t.summary().usage.total(), 110);
815        assert_eq!(t.summary().subagent_turns, 0);
816
817        let sub = subagents_dir(&path, "0a1b2c3d-0000-4000-8000-000000000001").unwrap();
818        std::fs::create_dir_all(&sub).unwrap();
819        let mut g = std::fs::File::create(sub.join("b2c3.jsonl")).unwrap();
820        writeln!(g, r#"{{"sessionId":"b2c3","projectHash":"604d","startTime":"2026-09-05T09:00:04.000Z","lastUpdated":"2026-09-05T09:00:04.000Z","kind":"subagent"}}"#).unwrap();
821        writeln!(g, r#"{{"id":"su1","timestamp":"2026-09-05T09:00:04.000Z","type":"user","content":[{{"text":"t"}}]}}"#).unwrap();
822        writeln!(g, r#"{{"id":"sg1","timestamp":"2026-09-05T09:00:08.000Z","type":"gemini","content":"","tokens":{{"input":50,"output":5,"cached":0,"thoughts":0,"tool":0,"total":55}},"model":"gemini-2.5-flash","toolCalls":[{{"id":"sc1","name":"grep_search","status":"success","timestamp":"2026-09-05T09:00:09.000Z"}}]}}"#).unwrap();
823        t.refresh().unwrap();
824        let s = t.summary();
825        assert_eq!(s.usage.total(), 165);
826        assert_eq!(s.turns, 2);
827        assert_eq!(s.subagent_turns, 1);
828        assert_eq!(s.tool_calls, 1);
829        assert_eq!(s.model.as_deref(), Some("gemini-2.5-pro"), "the parent's model, not the subagent's");
830        let sidechain: Vec<_> = s.spans.iter().filter(|sp| sp.sidechain).collect();
831        assert_eq!(sidechain.len(), 3, "the subagent's turn, inference and tool call");
832        // 100*1.25 + 10*10 (pro) + 50*0.30 + 5*2.5 (flash) = 125 + 100 + 15 + 12.5 micro-dollars
833        assert!((s.cost_usd - 0.0002525).abs() < 1e-9, "{}", s.cost_usd);
834        let _ = std::fs::remove_dir_all(&dir);
835    }
836
837    #[test]
838    fn a_rewind_and_a_checkpoint_do_not_change_what_was_spent() {
839        let dir = scratch("rewind");
840        let path = dir.join("session.jsonl");
841        let mut f = std::fs::File::create(&path).unwrap();
842        writeln!(f, "{META}").unwrap();
843        let g1 = r#"{"id":"g1","timestamp":"2026-09-05T09:00:03.000Z","type":"gemini","content":"","tokens":{"input":100,"output":10,"cached":0,"thoughts":0,"tool":0,"total":110},"model":"gemini-2.5-pro"}"#;
844        writeln!(f, "{g1}").unwrap();
845        writeln!(f, r#"{{"$rewindTo":"g1"}}"#).unwrap();
846        writeln!(f, r#"{{"$set":{{"messages":[{g1}]}}}}"#).unwrap();
847        let mut t = GeminiTranscript::new(&path).with_prices(pricing::builtin_table());
848        t.refresh().unwrap();
849        assert_eq!(t.summary().usage.total(), 110);
850        assert_eq!(t.summary().turns, 1);
851        assert_eq!(t.summary().activity, Activity::Waiting);
852        let _ = std::fs::remove_dir_all(&dir);
853    }
854
855    #[test]
856    fn finds_sessions_and_attributes_them_by_working_directory() {
857        let root = scratch("tmp");
858        let one = root.join("example");
859        let chats = one.join("chats");
860        std::fs::create_dir_all(chats.join("0a1b2c3d-0000-4000-8000-000000000001")).unwrap();
861        std::fs::write(one.join(".project_root"), "/Users/dev/code/example\n").unwrap();
862        let main = chats.join("session-2026-09-05T09-00-0a1b2c3d.jsonl");
863        std::fs::write(&main, format!("{META}\n")).unwrap();
864        // A subagent file, one level down: not a session of its own.
865        std::fs::write(chats.join("0a1b2c3d-0000-4000-8000-000000000001").join("b2c3.jsonl"), format!("{META}\n")).unwrap();
866        // A legacy hashed directory with no marker and a legacy .json file.
867        let legacy = root.join("5b2dd62b9d0bddd4");
868        std::fs::create_dir_all(legacy.join("chats")).unwrap();
869        std::fs::write(legacy.join("chats").join("session-2026-01-01T00-00-abcd1234.json"), "{}").unwrap();
870
871        let mut t = GeminiTranscript::new(&main);
872        t.refresh().unwrap();
873        assert_eq!(t.summary().cwd.as_deref(), Some(Path::new("/Users/dev/code/example")), "from the marker two levels up");
874
875        let found = sessions_under(&root, SystemTime::UNIX_EPOCH);
876        assert_eq!(found.len(), 1);
877        assert_eq!(found[0].path, main);
878        assert_eq!(found[0].cwd.as_deref(), Some(Path::new("/Users/dev/code/example")));
879        assert_eq!(found[0].session_id.as_deref(), Some("0a1b2c3d-0000-4000-8000-000000000001"));
880        let started = found[0].started.unwrap();
881
882        let (paths, a) = attribute(Some(Path::new("/Users/dev/code/example")), started - Duration::from_secs(5), &found, &HashSet::new());
883        assert_eq!(paths, vec![main.clone()]);
884        assert_eq!(a, Attribution::CwdHeuristic, "labelled as the guess it is");
885        // Another directory, or a process that started after the session, gets nothing.
886        assert!(attribute(Some(Path::new("/Users/dev/code/other")), started, &found, &HashSet::new()).0.is_empty());
887        assert!(
888            attribute(Some(Path::new("/Users/dev/code/example")), started + Duration::from_secs(120), &found, &HashSet::new()).0.is_empty()
889        );
890        // Nor does a process claim a session another one already has.
891        let taken: HashSet<PathBuf> = [main.clone()].into_iter().collect();
892        assert_eq!(attribute(Some(Path::new("/Users/dev/code/example")), started, &found, &taken).1, Attribution::None);
893        assert_eq!(attribute(None, started, &found, &HashSet::new()).1, Attribution::None);
894        let _ = std::fs::remove_dir_all(&root);
895    }
896}