1use 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
69pub 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
75pub fn tmp_dir() -> Option<PathBuf> {
77 gemini_dir().map(|d| d.join("tmp"))
78}
79
80#[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
91pub fn recent_sessions(since: SystemTime) -> Vec<Session> {
93 let Some(root) = tmp_dir() else { return Vec::new() };
94 sessions_under(&root, since)
95}
96
97pub(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
126pub 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
133pub 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
146pub fn subagents_dir(transcript: &Path, session_id: &str) -> Option<PathBuf> {
149 Some(transcript.parent()?.join(session_id))
150}
151
152fn 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
165pub(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#[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#[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
244struct Parser {
247 reader: TailReader,
248 summary: SessionSummary,
249 subagent: bool,
251 messages: BTreeMap<String, Contrib>,
255 prompts: HashSet<String>,
258 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 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 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 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 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 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 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 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 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
502fn 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
508pub 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
521fn 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
535pub 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 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 pub fn with_prices(mut self, prices: &'static Table) -> Self {
566 self.prices = prices;
567 self
568 }
569
570 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 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 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 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 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 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 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 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 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 std::fs::write(chats.join("0a1b2c3d-0000-4000-8000-000000000001").join("b2c3.jsonl"), format!("{META}\n")).unwrap();
866 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 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 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}