Skip to main content

skiagram_core/adapters/
gemini.rs

1//! Gemini CLI adapter — roadmap v0.4, VERIFIED real schema (2026-06-17).
2//!
3//! Reads `~/.gemini/tmp/<project>/chats/session-<ISO8601>-<short>.jsonl`
4//! (READ-ONLY). `$GEMINI_HOME` overrides the `~/.gemini` root (tests point it at
5//! fixtures; `directories::BaseDirs` ignores `$HOME` on Windows, so an explicit
6//! override is required for testability — mirrors Claude Code's
7//! `CLAUDE_CONFIG_DIR` and Codex's `CODEX_HOME`).
8//!
9//! HISTORY: through 2026-06-16 this was a stub because the only `~/.gemini` data
10//! on the test machine was Google **Antigravity** (an IDE), which writes
11//! `antigravity*/` + `tmp/<hash>/logs.json` (UI telemetry, no token transcript).
12//! Once the real Gemini CLI was installed and run (2026-06-17) it wrote genuine,
13//! billable per-message token usage under `tmp/<project>/chats/` — so this is now
14//! a real adapter. [`detect`] looks specifically for a `chats/session-*.jsonl`
15//! file (which Antigravity never writes), so it no longer false-positives on an
16//! Antigravity-only install.
17//!
18//! ## Schema (VERIFIED on real local files, Gemini CLI, gemini-3-flash-preview)
19//!
20//! One JSON object per line. Three line shapes, interleaved:
21//! - **header** (first line): `{sessionId, projectHash, startTime, lastUpdated,
22//!   kind:"main"}` — no `type`. Source of `started_at`.
23//! - **`$set` snapshot**: `{"$set": {...}}` — a UI/state checkpoint (holds the
24//!   initial `session_context` message, periodically re-set). The running
25//!   transcript is NOT here; we ignore `$set` lines entirely.
26//! - **message**: `{id, timestamp, type, content, ...}`:
27//!   - `type:"user"` — `content:[{text}]` (a prompt) or `content:[{functionResponse}]`
28//!     (a tool result, linked by `functionResponse.id`).
29//!   - `type:"gemini"` (assistant) — `content` (string, the visible answer),
30//!     `thoughts:[{subject,description,timestamp}]` (PLAINTEXT reasoning — unlike
31//!     Claude's mostly-encrypted thinking), `model` (e.g. `gemini-3-flash-preview`),
32//!     `toolCalls:[{id,name,args,...}]`, and the all-important `tokens` block.
33//!   - `type:"info"` / `type:"error"` — UI notices, no usage (ignored, not skipped).
34//!
35//! ## THE token block & correctness rules (CLAUDE.md §8.1/§8.2)
36//!
37//! Each `gemini` message carries
38//! `tokens: {input, output, cached, thoughts, tool, total}`. VERIFIED on real
39//! data: `total == input + output + thoughts` (so `thoughts` is DISJOINT from
40//! `output` — the Codex `reasoning_output_tokens` case, not Claude's
41//! already-included thinking) and `cached ⊆ input` (OpenAI/Gemini convention).
42//! `tool` was observed 0 and is not part of `total`; we map only the verified
43//! fields. We therefore split the overlapping subsets out (see [`map_tokens`]) so
44//! nothing is double-counted and `known_total() == total`.
45//!
46//! **Whole-message re-serialization (the Gemini dedup case).** Gemini rewrites the
47//! ENTIRE message line as it streams / resolves tool calls: the same `id` appears
48//! 2+ times, with content growing, `toolCalls` appended, and `tokens` finalizing.
49//! This is NOT Claude's "one line per complementary content block" — it is the
50//! same message superseded. So we dedup by message `id` AT PARSE TIME with
51//! last-wins content / max-merge usage (a later line never *loses* tokens), rather
52//! than relying on the downstream requestId char-summing dedup (which assumes
53//! complementary blocks and would double-count `thoughts`). Each surviving message
54//! still gets `request_id = Some(id)` for traceability; downstream dedup then sees
55//! one line per request and is a no-op.
56//!
57//! `gemini-*` models are NOT in skiagram's embedded pricing snapshot, so their
58//! cost renders as "unpriced" — correct and honest (§8.7): we never guess a price.
59
60use std::collections::HashMap;
61use std::fs::File;
62use std::io::{BufRead, BufReader};
63use std::path::{Path, PathBuf};
64
65use serde::Deserialize;
66
67use crate::adapters::Adapter;
68use crate::error::CoreError;
69use crate::model::{Event, EventKind, Session, SessionRef, ToolCall, Usage};
70
71/// Gemini CLI (`~/.gemini`).
72pub struct Gemini;
73
74/// Data root: `$GEMINI_HOME` when set (tests use it to point at fixtures), else
75/// `~/.gemini` resolved via the `directories` crate — never a hardcoded `~`. An
76/// explicit override is required because `directories::BaseDirs` ignores `$HOME`
77/// on Windows (so the integration test could not otherwise relocate it).
78fn gemini_root() -> Option<PathBuf> {
79    match std::env::var("GEMINI_HOME") {
80        Ok(dir) if !dir.trim().is_empty() => Some(PathBuf::from(dir)),
81        _ => directories::BaseDirs::new().map(|b| b.home_dir().join(".gemini")),
82    }
83}
84
85/// Is this a Gemini CLI chat transcript, i.e. `.../chats/session-*.jsonl`? This is
86/// the precise signal that distinguishes the real CLI from an Antigravity-only
87/// install (which never writes a `chats/` dir).
88fn is_session_file(path: &Path) -> bool {
89    path.extension().and_then(|x| x.to_str()) == Some("jsonl")
90        && path
91            .file_name()
92            .and_then(|n| n.to_str())
93            .is_some_and(|n| n.starts_with("session-"))
94        && path
95            .parent()
96            .and_then(|d| d.file_name())
97            .and_then(|n| n.to_str())
98            == Some("chats")
99}
100
101/// Project label = the `<project>` directory under `tmp/`, i.e. the parent of the
102/// `chats/` dir holding the session file (Gemini's own friendly project name,
103/// e.g. `skiagram`). The on-disk file carries only an opaque `projectHash`.
104fn project_from_path(path: &Path) -> Option<String> {
105    path.parent() // .../chats
106        .and_then(|chats| chats.parent()) // .../<project>
107        .and_then(|p| p.file_name())
108        .map(|n| n.to_string_lossy().into_owned())
109}
110
111impl Adapter for Gemini {
112    fn id(&self) -> &'static str {
113        "gemini"
114    }
115
116    fn detect(&self) -> bool {
117        let Some(tmp) = gemini_root().map(|r| r.join("tmp")) else {
118            return false;
119        };
120        // Bounded walk (tmp/<project>/chats/session-*.jsonl is depth 3); stop at
121        // the first real session file so detection stays cheap.
122        walkdir::WalkDir::new(&tmp)
123            .max_depth(3)
124            .follow_links(false)
125            .into_iter()
126            .filter_map(Result::ok)
127            .any(|e| e.file_type().is_file() && is_session_file(e.path()))
128    }
129
130    fn discover(&self) -> anyhow::Result<Vec<SessionRef>> {
131        let root = gemini_root().ok_or_else(|| {
132            anyhow::anyhow!("could not determine the home directory (set GEMINI_HOME)")
133        })?;
134        let tmp = root.join("tmp");
135        let mut refs = Vec::new();
136        if tmp.is_dir() {
137            for entry in walkdir::WalkDir::new(&tmp).max_depth(3).follow_links(false) {
138                let entry = match entry {
139                    Ok(e) => e,
140                    Err(e) => {
141                        tracing::debug!("skipping unreadable directory entry: {e}");
142                        continue;
143                    }
144                };
145                if !entry.file_type().is_file() || !is_session_file(entry.path()) {
146                    continue;
147                }
148                let meta = entry.metadata().ok();
149                refs.push(SessionRef {
150                    path: entry.path().to_path_buf(),
151                    agent: self.id().to_string(),
152                    project: project_from_path(entry.path()),
153                    size_bytes: meta.as_ref().map_or(0, |m| m.len()),
154                    modified: meta
155                        .and_then(|m| m.modified().ok())
156                        .and_then(|t| jiff::Timestamp::try_from(t).ok()),
157                });
158            }
159        }
160        refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
161        Ok(refs)
162    }
163
164    fn parse(&self, r: &SessionRef) -> anyhow::Result<Session> {
165        let file = File::open(&r.path).map_err(|source| CoreError::Io {
166            path: r.path.clone(),
167            source,
168        })?;
169        let id = session_id_for(&r.path);
170        let project = r.project.clone().or_else(|| project_from_path(&r.path));
171        Ok(parse_reader(
172            BufReader::new(file),
173            id,
174            project,
175            self.id(),
176            &r.path,
177        ))
178    }
179}
180
181/// Session id = file stem (`session-<ISO8601>-<short>`), unique on disk and stable
182/// across discovery. The embedded `sessionId` is a uuid but the stem is what
183/// discovery keys on.
184fn session_id_for(path: &Path) -> String {
185    path.file_stem()
186        .map(|s| s.to_string_lossy().into_owned())
187        .unwrap_or_else(|| path.to_string_lossy().into_owned())
188}
189
190/// Core parse: stream the JSONL, deduping `gemini` messages by `id` (last-wins
191/// content, max-merge usage) so re-serialized lines collapse into one request.
192/// Factored out of [`Gemini::parse`] so tests can feed an in-memory reader.
193fn parse_reader<R: BufRead>(
194    reader: R,
195    id: String,
196    project: Option<String>,
197    agent: &str,
198    path_for_logs: &Path,
199) -> Session {
200    let mut session = Session {
201        id,
202        agent: agent.to_string(),
203        project,
204        model: None,
205        parent_session: None,
206        started_at: None,
207        ended_at: None,
208        events: Vec::new(),
209        sub_agents: Vec::new(),
210        skipped_lines: 0,
211    };
212    // message id -> index of its Assistant event in `session.events`, so a
213    // re-serialized line updates the existing event in place instead of adding a
214    // duplicate request.
215    let mut gemini_idx: HashMap<String, usize> = HashMap::new();
216    let mut model_counts: HashMap<String, u64> = HashMap::new();
217    let mut header_start: Option<jiff::Timestamp> = None;
218
219    for (lineno, line) in reader.lines().enumerate() {
220        let line = match line {
221            Ok(l) => l,
222            Err(e) => {
223                session.skipped_lines += 1;
224                tracing::debug!(
225                    "{}:{}: unreadable line: {e}",
226                    path_for_logs.display(),
227                    lineno + 1
228                );
229                continue;
230            }
231        };
232        if line.trim().is_empty() {
233            continue;
234        }
235        let raw: RawLine = match serde_json::from_str(&line) {
236            Ok(v) => v,
237            Err(e) => {
238                session.skipped_lines += 1;
239                tracing::debug!(
240                    "{}:{}: unparseable JSON: {e}",
241                    path_for_logs.display(),
242                    lineno + 1
243                );
244                continue;
245            }
246        };
247        // `$set` is a UI/state snapshot, not transcript — ignore (not "skipped").
248        if raw.set.is_some() {
249            continue;
250        }
251        let ts = parse_ts(&raw.timestamp);
252        match raw.msg_type.as_deref() {
253            Some("gemini") => {
254                apply_gemini(&raw, ts, &mut session, &mut gemini_idx, &mut model_counts)
255            }
256            Some("user") => apply_user(&raw, ts, &mut session),
257            // Known UI notices with no token spend — ignored, not counted as skips
258            // (so `skipped_lines` keeps meaning "unexpected").
259            Some("info") | Some("error") => {}
260            Some(_) => {
261                session.skipped_lines += 1;
262                tracing::debug!(
263                    "{}:{}: unknown message type {:?}",
264                    path_for_logs.display(),
265                    lineno + 1,
266                    raw.msg_type
267                );
268            }
269            None => {
270                // The header line has no `type` but carries `sessionId`+`startTime`.
271                if raw.session_id.is_some() {
272                    if header_start.is_none() {
273                        header_start = parse_ts(&raw.start_time);
274                    }
275                } else {
276                    session.skipped_lines += 1;
277                    tracing::debug!(
278                        "{}:{}: unrecognized line shape",
279                        path_for_logs.display(),
280                        lineno + 1
281                    );
282                }
283            }
284        }
285    }
286
287    session.started_at = header_start.or_else(|| session.events.iter().filter_map(|e| e.ts).min());
288    session.ended_at = session.events.iter().filter_map(|e| e.ts).max();
289    session.model = model_counts
290        .into_iter()
291        .max_by_key(|(_, count)| *count)
292        .map(|(model, _)| model);
293    session
294}
295
296/// Apply a `gemini` (assistant) line: create its Assistant event, or update the
297/// existing one when this `id` was already seen (whole-message re-serialization).
298fn apply_gemini(
299    raw: &RawLine,
300    ts: Option<jiff::Timestamp>,
301    session: &mut Session,
302    gemini_idx: &mut HashMap<String, usize>,
303    model_counts: &mut HashMap<String, u64>,
304) {
305    let usage = raw.tokens.as_ref().map(map_tokens);
306    let content = raw.content.as_str().unwrap_or_default();
307    let visible_chars = content.chars().count() as u64;
308    let thinking_chars = raw
309        .thoughts
310        .iter()
311        .flatten()
312        .map(RawThought::chars)
313        .sum::<u64>();
314    let has_thinking = raw.thoughts.as_ref().is_some_and(|t| !t.is_empty())
315        || raw.tokens.as_ref().and_then(|t| t.thoughts).unwrap_or(0) > 0;
316    let tool_calls: Vec<ToolCall> = raw
317        .tool_calls
318        .iter()
319        .flatten()
320        .map(RawToolCall::to_tool_call)
321        .collect();
322    // `content_chars` follows the model contract (visible text + thinking +
323    // tool-call JSON, with `thinking_chars` a SUBSET), exactly as the Claude Code /
324    // Copilot adapters build it — so `analysis::context` can recover the visible-text
325    // share by `content_chars − thinking_chars − tool-call bytes`. Counting only the
326    // visible answer here (as a prior version did) made that subtraction underflow to
327    // 0 whenever thoughts/tool JSON outweighed the answer, dropping Gemini assistant
328    // text from the context breakdown entirely.
329    let content_chars =
330        visible_chars + thinking_chars + tool_calls.iter().map(|c| c.input_bytes).sum::<u64>();
331
332    let key = raw.id.clone().unwrap_or_default();
333    if let Some(&idx) = gemini_idx.get(&key) {
334        // Re-serialized superset of an earlier line: max-merge usage (never lose
335        // tokens), last-wins content / thoughts / tool calls (they grow).
336        let ev = &mut session.events[idx];
337        ev.usage = match (ev.usage, usage) {
338            (Some(a), Some(b)) => Some(a.merge_max(b)),
339            (a, b) => a.or(b),
340        };
341        ev.thinking_chars = thinking_chars;
342        ev.has_thinking |= has_thinking;
343        if let Some(s) = snippet(content) {
344            ev.content_summary = Some(s);
345        }
346        if !tool_calls.is_empty() {
347            ev.tool_calls = tool_calls;
348        }
349        // Recompute against the FINAL tool set — when this line re-sent no tool calls
350        // the earlier set is kept, so basing the byte share on `ev.tool_calls` keeps
351        // `content_chars` consistent with the `tool_calls` that downstream subtracts.
352        ev.content_chars = visible_chars
353            + thinking_chars
354            + ev.tool_calls.iter().map(|c| c.input_bytes).sum::<u64>();
355        return;
356    }
357
358    if let Some(model) = raw.model.as_deref().filter(|m| !m.is_empty()) {
359        *model_counts.entry(model.to_string()).or_default() += 1;
360    }
361    gemini_idx.insert(key.clone(), session.events.len());
362    session.events.push(Event {
363        kind: EventKind::Assistant,
364        ts,
365        // The message id is the dedup key (see module docs); a unique id per
366        // request means downstream dedup is a no-op rather than a wrong merge.
367        request_id: (!key.is_empty()).then_some(key),
368        model: raw.model.clone().filter(|m| !m.is_empty()),
369        usage,
370        tool_calls,
371        sidechain: false,
372        content_summary: snippet(content),
373        content_chars,
374        thinking_chars,
375        has_thinking,
376        tool_use_id: None,
377        attachment_kind: None,
378        item_count: 0,
379    });
380}
381
382/// Apply a `user` line: plain text → a User event; a `functionResponse` block → a
383/// ToolResult event linked by `tool_use_id`. Neither carries billable usage.
384fn apply_user(raw: &RawLine, ts: Option<jiff::Timestamp>, session: &mut Session) {
385    let blocks = match raw.content.as_array() {
386        Some(b) => b,
387        None => return,
388    };
389    // A tool result (`functionResponse`) — link it back to the call by id.
390    if let Some(fr) = blocks.iter().find_map(|b| b.get("functionResponse")) {
391        let tool_use_id = fr
392            .get("id")
393            .and_then(|v| v.as_str())
394            .map(str::to_string)
395            .filter(|s| !s.is_empty());
396        let output = fr
397            .get("response")
398            .and_then(|r| r.get("output"))
399            .map(value_text)
400            .unwrap_or_default();
401        session.events.push(Event {
402            kind: EventKind::ToolResult,
403            ts,
404            request_id: None,
405            model: None,
406            usage: None,
407            tool_calls: Vec::new(),
408            sidechain: false,
409            content_summary: snippet(&output),
410            content_chars: output.chars().count() as u64,
411            thinking_chars: 0,
412            has_thinking: false,
413            tool_use_id,
414            attachment_kind: None,
415            item_count: 0,
416        });
417        return;
418    }
419    // Otherwise a normal user prompt.
420    let text = blocks
421        .iter()
422        .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
423        .collect::<Vec<_>>()
424        .join("\n");
425    session.events.push(Event {
426        kind: EventKind::User,
427        ts,
428        request_id: None,
429        model: None,
430        usage: None,
431        tool_calls: Vec::new(),
432        sidechain: false,
433        content_summary: snippet(&text),
434        content_chars: text.chars().count() as u64,
435        thinking_chars: 0,
436        has_thinking: false,
437        tool_use_id: None,
438        attachment_kind: None,
439        item_count: 0,
440    });
441}
442
443/// Map a Gemini `tokens` block DISJOINTLY into [`Usage`] so `known_total()` equals
444/// its `total` (see module docs): `cached ⊆ input`, `thoughts` disjoint from
445/// `output`, `total == input + output + thoughts`.
446///   - `cache_read = cached`
447///   - `input      = input − cached`     (saturating)
448///   - `output     = output`             (already visible-only)
449///   - `thinking   = thoughts`
450///
451/// `saturating_sub` guards a schema-violating `cached > input` so we never panic
452/// on bad data (CLAUDE.md §9). `tool` is observed 0 and not part of `total`, so it
453/// is intentionally unmapped.
454fn map_tokens(t: &RawTokens) -> Usage {
455    let cached = t.cached.unwrap_or(0);
456    Usage {
457        input: t.input.map(|i| i.saturating_sub(cached)),
458        output: t.output,
459        cache_creation: None,
460        cache_creation_5m: None,
461        cache_creation_1h: None,
462        cache_read: t.cached,
463        thinking: t.thoughts,
464    }
465}
466
467fn parse_ts(raw: &Option<String>) -> Option<jiff::Timestamp> {
468    raw.as_deref().and_then(|t| t.parse().ok())
469}
470
471/// Display-only first non-empty line, capped at 80 chars.
472fn snippet(text: &str) -> Option<String> {
473    let first = text.lines().find(|l| !l.trim().is_empty())?;
474    let s: String = first.trim().chars().take(80).collect();
475    (!s.is_empty()).then_some(s)
476}
477
478/// Flatten a JSON value to plain text for size measurement (string as-is, else its
479/// compact serialization).
480fn value_text(v: &serde_json::Value) -> String {
481    match v.as_str() {
482        Some(s) => s.to_string(),
483        None => v.to_string(),
484    }
485}
486
487// ---- raw line shapes (lenient: unknown fields ignored everywhere) ----
488
489#[derive(Deserialize, Default)]
490struct RawLine {
491    /// Presence marks a `$set` UI/state snapshot line (ignored).
492    #[serde(rename = "$set")]
493    set: Option<serde::de::IgnoredAny>,
494    /// Header lines carry `sessionId` (+ `startTime`) and no `type`.
495    #[serde(rename = "sessionId")]
496    session_id: Option<String>,
497    #[serde(rename = "startTime")]
498    start_time: Option<String>,
499    /// Message discriminator: `user` / `gemini` / `info` / `error`.
500    #[serde(rename = "type")]
501    msg_type: Option<String>,
502    /// Message id — the dedup key for `gemini` messages.
503    id: Option<String>,
504    timestamp: Option<String>,
505    /// `gemini` → string; `user` → `[{text}]` or `[{functionResponse}]`.
506    #[serde(default)]
507    content: serde_json::Value,
508    /// `gemini.thoughts` — plaintext reasoning blocks.
509    thoughts: Option<Vec<RawThought>>,
510    /// `gemini.tokens` — the only place usage lives.
511    tokens: Option<RawTokens>,
512    /// `gemini.model` (e.g. `gemini-3-flash-preview`).
513    model: Option<String>,
514    /// `gemini.toolCalls`.
515    #[serde(rename = "toolCalls")]
516    tool_calls: Option<Vec<RawToolCall>>,
517}
518
519/// A Gemini per-message token block. `total == input + output + thoughts`;
520/// `cached ⊆ input`; `tool` observed 0 (see [`map_tokens`]).
521#[derive(Deserialize, Clone, Copy)]
522struct RawTokens {
523    input: Option<u64>,
524    output: Option<u64>,
525    cached: Option<u64>,
526    thoughts: Option<u64>,
527    #[allow(dead_code)]
528    tool: Option<u64>,
529    #[allow(dead_code)]
530    total: Option<u64>,
531}
532
533#[derive(Deserialize)]
534struct RawThought {
535    subject: Option<String>,
536    description: Option<String>,
537}
538
539impl RawThought {
540    /// Measurable thinking chars = subject + description (Gemini thoughts are
541    /// plaintext, so unlike Claude this is a real measurement, not a lower bound).
542    fn chars(&self) -> u64 {
543        let s = self.subject.as_deref().map_or(0, |x| x.chars().count());
544        let d = self.description.as_deref().map_or(0, |x| x.chars().count());
545        (s + d) as u64
546    }
547}
548
549#[derive(Deserialize)]
550struct RawToolCall {
551    id: Option<String>,
552    name: Option<String>,
553    args: Option<serde_json::Value>,
554}
555
556impl RawToolCall {
557    fn to_tool_call(&self) -> ToolCall {
558        let name = self.name.clone().unwrap_or_else(|| "unknown".to_string());
559        let input_bytes = self
560            .args
561            .as_ref()
562            .map_or(0, |a| value_text(a).chars().count() as u64);
563        ToolCall {
564            server: ToolCall::server_from_name(&name),
565            id: self.id.clone().unwrap_or_default(),
566            name,
567            input_bytes,
568        }
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use std::io::Cursor;
576
577    /// The reconciliation core: a `tokens` block maps DISJOINTLY so its
578    /// `known_total()` equals `total` (no double-count of cached / thoughts).
579    #[test]
580    fn tokens_map_disjointly_to_total() {
581        let t = RawTokens {
582            input: Some(1000),
583            output: Some(50),
584            cached: Some(200),
585            thoughts: Some(80),
586            tool: Some(0),
587            total: Some(1130),
588        };
589        let u = map_tokens(&t);
590        assert_eq!(u.cache_read, Some(200));
591        assert_eq!(u.input, Some(800), "input minus cached subset");
592        assert_eq!(u.output, Some(50), "output is already visible-only");
593        assert_eq!(u.thinking, Some(80), "thoughts disjoint from output");
594        assert_eq!(u.cache_creation, None);
595        assert_eq!(
596            u.known_total(),
597            1130,
598            "Σ disjoint fields == total (input+output+thoughts)"
599        );
600    }
601
602    /// `cached > input` (schema-violating) must saturate, never panic.
603    #[test]
604    fn underflow_saturates_instead_of_panicking() {
605        let t = RawTokens {
606            input: Some(100),
607            output: Some(40),
608            cached: Some(250),
609            thoughts: Some(10),
610            tool: None,
611            total: Some(150),
612        };
613        let u = map_tokens(&t);
614        assert_eq!(u.input, Some(0));
615        assert_eq!(u.cache_read, Some(250));
616    }
617
618    /// Whole-message re-serialization: the SAME `id` on two lines collapses to one
619    /// Assistant event (max usage, last-wins tool calls), not two requests.
620    #[test]
621    fn repeated_gemini_id_collapses_last_wins() {
622        let lines = [
623            r#"{"sessionId":"s1","startTime":"2026-06-17T10:00:00.000Z","kind":"main"}"#,
624            r#"{"id":"g1","timestamp":"2026-06-17T10:00:02.000Z","type":"gemini","content":"","thoughts":[{"subject":"Plan","description":"do it"}],"tokens":{"input":1000,"output":50,"cached":200,"thoughts":80,"tool":0,"total":1130},"model":"gemini-3-flash-preview"}"#,
625            r#"{"id":"g1","timestamp":"2026-06-17T10:00:02.500Z","type":"gemini","content":"done","thoughts":[{"subject":"Plan","description":"do it"}],"tokens":{"input":1000,"output":50,"cached":200,"thoughts":80,"tool":0,"total":1130},"model":"gemini-3-flash-preview","toolCalls":[{"id":"tc1","name":"mcp__acme-db__query","args":{"sql":"select 1"}}]}"#,
626        ]
627        .join("\n");
628        let s = parse_reader(
629            Cursor::new(lines),
630            "s1".into(),
631            Some("demo".into()),
632            "gemini",
633            Path::new("mem://test"),
634        );
635        let assistants: Vec<_> = s
636            .events
637            .iter()
638            .filter(|e| e.kind == EventKind::Assistant)
639            .collect();
640        assert_eq!(assistants.len(), 1, "two lines, one request");
641        let a = assistants[0];
642        assert_eq!(a.usage.unwrap().known_total(), 1130);
643        // content_chars follows the model contract: visible "done" (4) + thoughts
644        // "Plan"+"do it" (9) + tool-call JSON {"sql":"select 1"} (18) = 31, so
645        // analysis::context recovers the 4 visible chars by subtraction.
646        assert_eq!(
647            a.thinking_chars, 9,
648            "thoughts are a subset of content_chars"
649        );
650        assert_eq!(
651            a.content_chars, 31,
652            "visible(4) + thinking(9) + tool JSON(18)"
653        );
654        assert_eq!(a.tool_calls.len(), 1, "tool call from the later line");
655        assert_eq!(a.tool_calls[0].server.as_deref(), Some("acme-db"));
656        assert_eq!(s.model.as_deref(), Some("gemini-3-flash-preview"));
657        assert_eq!(
658            s.started_at.map(|t| t.to_string()).as_deref(),
659            Some("2026-06-17T10:00:00Z"),
660            "started_at from the header line"
661        );
662    }
663
664    /// `$set` snapshots are ignored; malformed JSON and unknown types are skipped.
665    #[test]
666    fn set_ignored_unknown_and_malformed_skipped() {
667        let lines = [
668            r#"{"$set":{"messages":[{"id":"u0","type":"user","content":[{"text":"ctx"}]}]}}"#,
669            r#"{"id":"u1","type":"user","content":[{"text":"hi"}]}"#,
670            r#"{"id":"i1","type":"info","content":[{"text":"model set"}]}"#,
671            r#"{"id":"z1","type":"telemetry_blob","data":1}"#,
672            r#"{"id":"broken","type":"gemini","#,
673        ]
674        .join("\n");
675        let s = parse_reader(
676            Cursor::new(lines),
677            "s2".into(),
678            None,
679            "gemini",
680            Path::new("mem://test"),
681        );
682        assert_eq!(s.skipped_lines, 2, "unknown type + malformed JSON");
683        // Only the real user prompt became an event ($set + info contributed none).
684        assert_eq!(
685            s.events
686                .iter()
687                .filter(|e| e.kind == EventKind::User)
688                .count(),
689            1
690        );
691    }
692
693    /// `$GEMINI_HOME` overrides the root.
694    #[test]
695    fn gemini_home_overrides_root() {
696        std::env::set_var("GEMINI_HOME", "/tmp/fake-gemini");
697        let root = gemini_root();
698        std::env::remove_var("GEMINI_HOME");
699        assert_eq!(root, Some(PathBuf::from("/tmp/fake-gemini")));
700    }
701}