Skip to main content

skiagram_core/adapters/
codex.rs

1//! Codex CLI adapter — roadmap v0.4, "new"-generation schema (Codex >= 0.44).
2//!
3//! Reads `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl` and
4//! `~/.codex/archived_sessions/*.jsonl` (READ-ONLY). `$CODEX_HOME` overrides the
5//! `~/.codex` root and may be a comma-separated list of roots (we scan all).
6//!
7//! Schema (the generation present on real installs sampled for v0.4). Every line
8//! is `{"timestamp": ..., "type": ..., "payload": {...}}`:
9//! - `session_meta` — `payload.{id, timestamp, cwd, originator, cli_version,
10//!   model_provider, base_instructions}`. Source of session id / project / start.
11//! - `turn_context` — `payload.model` (e.g. `"gpt-5.5"`) + `cwd`,
12//!   `workspace_roots`. Tells us the model in effect for the following requests.
13//! - `response_item` — `payload.type` ∈ {`message` (role user/assistant, with
14//!   `content:[{type,text}]`), `reasoning`, `function_call`,
15//!   `function_call_output`, …}. We map `message` to User/Assistant text and
16//!   `function_call` to a [`ToolCall`] (MCP names `mcp__server__tool` resolve via
17//!   [`ToolCall::server_from_name`]).
18//! - `event_msg` — `payload.type` ∈ {`task_started`, `user_message`,
19//!   `agent_message`, `token_count`, `task_complete`, `patch_apply_end`,
20//!   `mcp_tool_call_end`, `context_compacted`, …}.
21//!
22//! THE CRITICAL CORRECTNESS RULE (Codex analog of CLAUDE.md §8.1):
23//! token usage lives ONLY in `event_msg` where `payload.type == "token_count"`,
24//! under `payload.info`:
25//!   - `total_token_usage` is **cumulative, monotonic non-decreasing** over the
26//!     whole session. Summing it across the (often hundreds of) token_count
27//!     events would overcount by ~100x.
28//!   - `last_token_usage` is the **per-request delta** (verified: consecutive
29//!     `total` deltas equal `last`).
30//!
31//! So we emit ONE [`EventKind::Assistant`] event per `token_count`, carrying
32//! `last_token_usage` mapped DISJOINTLY into [`Usage`] (see [`map_last_usage`]) so
33//! that summing every per-request `known_total()` reconstructs the FINAL
34//! cumulative `total_token_usage.total_tokens`. No `request_id` is attached —
35//! these are genuinely distinct requests and downstream dedup keys each one
36//! uniquely, so they are never wrongly merged.
37//!
38//! Reconciliation invariant: `Σ event.usage.known_total() == final
39//! total_token_usage.total_tokens`. A `context_compacted` mid-session can leave a
40//! tiny gap versus the final cumulative (compaction rewrites the window); that
41//! gap is acceptable and surfaced, never hidden.
42//!
43//! Codex models are `gpt-*` and are NOT in skiagram's embedded pricing snapshot,
44//! so their cost renders as "unpriced" — that is correct and honest (§8.7): we
45//! never guess a price.
46
47use std::collections::BTreeMap;
48use std::fs::File;
49use std::io::{BufRead, BufReader};
50use std::path::{Path, PathBuf};
51
52use serde::Deserialize;
53
54use crate::adapters::Adapter;
55use crate::error::CoreError;
56use crate::model::{Event, EventKind, Session, SessionRef, ToolCall, Usage};
57
58/// Codex CLI (`~/.codex`).
59pub struct Codex;
60
61/// Data roots: `$CODEX_HOME` when set (comma-separated list honored), else
62/// `~/.codex` resolved via the `directories` crate — never a hardcoded `~`.
63/// Tests point `CODEX_HOME` at the fixtures dir.
64fn codex_roots() -> Vec<PathBuf> {
65    match std::env::var("CODEX_HOME") {
66        Ok(val) if !val.trim().is_empty() => val
67            .split(',')
68            .map(str::trim)
69            .filter(|s| !s.is_empty())
70            .map(PathBuf::from)
71            .collect(),
72        _ => directories::BaseDirs::new()
73            .map(|b| vec![b.home_dir().join(".codex")])
74            .unwrap_or_default(),
75    }
76}
77
78/// The two transcript directories under a Codex root.
79fn session_dirs(root: &Path) -> [PathBuf; 2] {
80    [root.join("sessions"), root.join("archived_sessions")]
81}
82
83impl Adapter for Codex {
84    fn id(&self) -> &'static str {
85        "codex"
86    }
87
88    fn detect(&self) -> bool {
89        codex_roots()
90            .iter()
91            .any(|r| session_dirs(r).iter().any(|d| d.is_dir()))
92    }
93
94    fn discover(&self) -> anyhow::Result<Vec<SessionRef>> {
95        let roots = codex_roots();
96        if roots.is_empty() {
97            anyhow::bail!("could not determine the home directory (set CODEX_HOME)");
98        }
99        let mut refs = Vec::new();
100        for root in &roots {
101            for dir in session_dirs(root) {
102                if !dir.is_dir() {
103                    continue;
104                }
105                for entry in walkdir::WalkDir::new(&dir).follow_links(false) {
106                    let entry = match entry {
107                        Ok(e) => e,
108                        Err(e) => {
109                            tracing::debug!("skipping unreadable directory entry: {e}");
110                            continue;
111                        }
112                    };
113                    if !entry.file_type().is_file()
114                        || entry.path().extension().and_then(|x| x.to_str()) != Some("jsonl")
115                    {
116                        continue;
117                    }
118                    let meta = entry.metadata().ok();
119                    refs.push(SessionRef {
120                        path: entry.path().to_path_buf(),
121                        agent: self.id().to_string(),
122                        // Project filled in from `session_meta.cwd` at parse time;
123                        // the on-disk path carries only date buckets, not the cwd.
124                        project: None,
125                        size_bytes: meta.as_ref().map_or(0, |m| m.len()),
126                        modified: meta
127                            .and_then(|m| m.modified().ok())
128                            .and_then(|t| jiff::Timestamp::try_from(t).ok()),
129                    });
130                }
131            }
132        }
133        refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
134        Ok(refs)
135    }
136
137    fn parse(&self, r: &SessionRef) -> anyhow::Result<Session> {
138        let file = File::open(&r.path).map_err(|source| CoreError::Io {
139            path: r.path.clone(),
140            source,
141        })?;
142
143        let mut session = Session {
144            id: session_id_for(&r.path),
145            agent: self.id().to_string(),
146            project: r.project.clone(),
147            model: None,
148            parent_session: None,
149            started_at: None,
150            ended_at: None,
151            events: Vec::new(),
152            sub_agents: Vec::new(),
153            skipped_lines: 0,
154        };
155        // Model in effect, learned from session_meta / turn_context as we stream;
156        // each token_count event is tagged with the most recent one. BTreeMap so
157        // the session-level winner is a deterministic tie-break.
158        let mut current_model: Option<String> = None;
159        let mut model_counts: BTreeMap<String, u64> = BTreeMap::new();
160
161        for (idx, line) in BufReader::new(file).lines().enumerate() {
162            let line = match line {
163                Ok(l) => l,
164                Err(e) => {
165                    session.skipped_lines += 1;
166                    tracing::debug!("{}:{}: unreadable line: {e}", r.path.display(), idx + 1);
167                    continue;
168                }
169            };
170            if line.trim().is_empty() {
171                continue;
172            }
173            let raw: RawLine = match serde_json::from_str(&line) {
174                Ok(v) => v,
175                Err(e) => {
176                    session.skipped_lines += 1;
177                    tracing::debug!("{}:{}: unparseable JSON: {e}", r.path.display(), idx + 1);
178                    continue;
179                }
180            };
181            let ts = parse_ts(&raw.timestamp);
182            match raw.kind.as_deref() {
183                Some("session_meta") => apply_session_meta(&raw, &mut session, &mut current_model),
184                Some("turn_context") => {
185                    if let Some(model) = raw.payload.model.as_deref() {
186                        if !model.is_empty() {
187                            current_model = Some(model.to_string());
188                        }
189                    }
190                }
191                Some("response_item") => push_response_item(&raw, ts, &mut session),
192                Some("event_msg") => {
193                    push_event_msg(&raw, ts, &current_model, &mut session, &mut model_counts)
194                }
195                other => {
196                    session.skipped_lines += 1;
197                    tracing::debug!(
198                        "{}:{}: unknown line type {:?}",
199                        r.path.display(),
200                        idx + 1,
201                        other
202                    );
203                }
204            }
205        }
206
207        session.started_at = session.events.iter().filter_map(|e| e.ts).min();
208        session.ended_at = session.events.iter().filter_map(|e| e.ts).max();
209        // Prefer the most-used model seen on token_count events; fall back to the
210        // last model context if no usage was ever reported.
211        session.model = model_counts
212            .into_iter()
213            .max_by_key(|(_, count)| *count)
214            .map(|(model, _)| model)
215            .or(current_model);
216        Ok(session)
217    }
218}
219
220/// Session id = file stem (`rollout-<ts>-<uuid>`). The embedded `session_meta.id`
221/// is the same uuid but the stem is unique on disk and matches discovery.
222fn session_id_for(path: &Path) -> String {
223    path.file_stem()
224        .map(|s| s.to_string_lossy().into_owned())
225        .unwrap_or_else(|| path.to_string_lossy().into_owned())
226}
227
228fn parse_ts(raw: &Option<String>) -> Option<jiff::Timestamp> {
229    raw.as_deref().and_then(|t| t.parse().ok())
230}
231
232/// Display-only first non-empty line, capped at 80 chars.
233fn snippet(text: &str) -> Option<String> {
234    let first = text.lines().find(|l| !l.trim().is_empty())?;
235    let s: String = first.trim().chars().take(80).collect();
236    (!s.is_empty()).then_some(s)
237}
238
239/// Join the `content:[{type,text}]` array of a `message` payload into plain text.
240fn message_text(content: &serde_json::Value) -> String {
241    if let Some(text) = content.as_str() {
242        return text.to_string();
243    }
244    let Some(blocks) = content.as_array() else {
245        return String::new();
246    };
247    let mut out = String::new();
248    for block in blocks {
249        if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
250            if !out.is_empty() {
251                out.push('\n');
252            }
253            out.push_str(text);
254        }
255    }
256    out
257}
258
259/// `session_meta` carries the cwd (project) and start. Project is the cwd as the
260/// agent recorded it, mirroring how Claude Code labels by working directory.
261fn apply_session_meta(raw: &RawLine, session: &mut Session, current_model: &mut Option<String>) {
262    if session.project.is_none() {
263        if let Some(cwd) = raw.payload.cwd.as_deref() {
264            if !cwd.is_empty() {
265                session.project = Some(cwd.to_string());
266            }
267        }
268    }
269    // session_meta has no `model` field on the generation seen; turn_context does.
270    // Read it defensively in case a future generation moves it here.
271    if current_model.is_none() {
272        if let Some(model) = raw.payload.model.as_deref() {
273            if !model.is_empty() {
274                *current_model = Some(model.to_string());
275            }
276        }
277    }
278}
279
280/// Map a `response_item` to an event. `message` → User/Assistant text (content
281/// chars for summaries), `function_call` → a ToolCall on an Assistant event.
282/// `reasoning` / `function_call_output` and other shapes carry no billable usage
283/// (usage is reported separately on token_count) — we keep them out of the spend
284/// path and don't count them as skipped.
285fn push_response_item(raw: &RawLine, ts: Option<jiff::Timestamp>, session: &mut Session) {
286    match raw.payload.item_type.as_deref() {
287        Some("message") => {
288            let text = message_text(&raw.payload.content);
289            let kind = match raw.payload.role.as_deref() {
290                Some("assistant") => EventKind::Assistant,
291                // user / tool / developer / unknown → treat as user-side input.
292                _ => EventKind::User,
293            };
294            session.events.push(Event {
295                kind,
296                ts,
297                request_id: None,
298                model: None,
299                // Usage is NEVER on response_item — only token_count carries it.
300                usage: None,
301                tool_calls: Vec::new(),
302                sidechain: false,
303                content_summary: snippet(&text),
304                content_chars: text.chars().count() as u64,
305                thinking_chars: 0,
306                has_thinking: false,
307                tool_use_id: None,
308                attachment_kind: None,
309                item_count: 0,
310            });
311        }
312        Some("function_call") => {
313            let name = raw
314                .payload
315                .name
316                .clone()
317                .unwrap_or_else(|| "unknown".to_string());
318            let id = raw.payload.call_id.clone().unwrap_or_default();
319            let input_bytes = raw
320                .payload
321                .arguments
322                .as_ref()
323                .map_or(0, |a| a.chars().count() as u64);
324            session.events.push(Event {
325                kind: EventKind::ToolCall,
326                ts,
327                request_id: None,
328                model: None,
329                usage: None,
330                tool_calls: vec![ToolCall {
331                    server: ToolCall::server_from_name(&name),
332                    id,
333                    name,
334                    input_bytes,
335                }],
336                sidechain: false,
337                content_summary: None,
338                content_chars: input_bytes,
339                thinking_chars: 0,
340                has_thinking: false,
341                tool_use_id: None,
342                attachment_kind: None,
343                item_count: 0,
344            });
345        }
346        // `reasoning`, `function_call_output`, and any other response_item shapes
347        // do not carry billable usage; ignore without counting as "skipped".
348        _ => {}
349    }
350}
351
352/// Map an `event_msg`. The ONLY billable one is `token_count` (see module docs);
353/// `context_compacted` → Compaction; tool/patch ends → tool-call events; the rest
354/// are control-flow markers ignored without counting as skipped.
355fn push_event_msg(
356    raw: &RawLine,
357    ts: Option<jiff::Timestamp>,
358    current_model: &Option<String>,
359    session: &mut Session,
360    model_counts: &mut BTreeMap<String, u64>,
361) {
362    match raw.payload.item_type.as_deref() {
363        Some("token_count") => {
364            // Per-request delta only — NEVER `total_token_usage` (cumulative).
365            let Some(info) = raw.payload.info.as_ref() else {
366                session.skipped_lines += 1;
367                return;
368            };
369            let Some(last) = info.last_token_usage.as_ref() else {
370                // A token_count with no per-request delta we can attribute (e.g.
371                // an initial 0-usage marker) — nothing to bill, not an error.
372                return;
373            };
374            let usage = map_last_usage(last);
375            let has_thinking = last.reasoning_output_tokens.unwrap_or(0) > 0;
376            if let Some(model) = current_model {
377                *model_counts.entry(model.clone()).or_default() += 1;
378            }
379            session.events.push(Event {
380                kind: EventKind::Assistant,
381                ts,
382                // No request_id: these are distinct requests; downstream dedup
383                // gives each a unique key so they are not merged (module docs).
384                request_id: None,
385                model: current_model.clone(),
386                usage: Some(usage),
387                tool_calls: Vec::new(),
388                sidechain: false,
389                content_summary: None,
390                content_chars: 0,
391                thinking_chars: 0,
392                has_thinking,
393                tool_use_id: None,
394                attachment_kind: None,
395                item_count: 0,
396            });
397        }
398        Some("context_compacted") => session.events.push(Event {
399            kind: EventKind::Compaction,
400            ts,
401            request_id: None,
402            model: None,
403            usage: None,
404            tool_calls: Vec::new(),
405            sidechain: false,
406            content_summary: Some("context compacted".to_string()),
407            content_chars: 0,
408            thinking_chars: 0,
409            has_thinking: false,
410            tool_use_id: None,
411            attachment_kind: None,
412            item_count: 0,
413        }),
414        // A completed MCP / patch / generic tool call reported as an event_msg.
415        // We synthesize a ToolCall event so tool + MCP-server attribution sees it
416        // even when the matching `response_item` function_call was absent.
417        Some("mcp_tool_call_end") => {
418            if let Some(call) = mcp_tool_call(raw) {
419                session.events.push(Event {
420                    kind: EventKind::ToolCall,
421                    ts,
422                    request_id: None,
423                    model: None,
424                    usage: None,
425                    tool_calls: vec![call],
426                    sidechain: false,
427                    content_summary: None,
428                    content_chars: 0,
429                    thinking_chars: 0,
430                    has_thinking: false,
431                    tool_use_id: None,
432                    attachment_kind: None,
433                    item_count: 0,
434                });
435            }
436        }
437        // Control-flow / progress markers with no token spend: task_started,
438        // task_complete, user_message, agent_message (text already captured from
439        // response_item messages), patch_apply_end, etc. Ignored without counting
440        // as "skipped" so the skip stat means *unexpected*.
441        _ => {}
442    }
443}
444
445/// Build a `ToolCall` from an `mcp_tool_call_end` event. Prefers the explicit
446/// `invocation.{server,tool}`, falling back to a flat `tool` name; reconstructs
447/// the canonical `mcp__server__tool` name so [`ToolCall::server_from_name`]
448/// attributes it to the right MCP server.
449fn mcp_tool_call(raw: &RawLine) -> Option<ToolCall> {
450    let inv = raw.payload.invocation.as_ref();
451    let server = inv.and_then(|i| i.server.clone());
452    let tool = inv
453        .and_then(|i| i.tool.clone())
454        .or_else(|| raw.payload.tool.clone());
455    let name = match (&server, &tool) {
456        (Some(s), Some(t)) => format!("mcp__{s}__{t}"),
457        (None, Some(t)) => t.clone(),
458        _ => return None,
459    };
460    Some(ToolCall {
461        server: server.or_else(|| ToolCall::server_from_name(&name)),
462        id: raw.payload.call_id.clone().unwrap_or_default(),
463        name,
464        input_bytes: 0,
465    })
466}
467
468/// Map a Codex `last_token_usage` (a per-request delta) DISJOINTLY into [`Usage`]
469/// so `known_total()` equals its `total_tokens` and summing per-request totals
470/// reconstructs the session's cumulative `total_tokens`.
471///
472/// OpenAI convention: `cached_input_tokens ⊆ input_tokens` and
473/// `reasoning_output_tokens ⊆ output_tokens`, with
474/// `total_tokens == input_tokens + output_tokens`. We therefore split the
475/// overlapping subsets out so nothing is double counted:
476///   - `cache_read = cached_input_tokens`
477///   - `input      = input_tokens − cached_input_tokens`   (saturating)
478///   - `thinking   = reasoning_output_tokens`
479///   - `output     = output_tokens − reasoning_output_tokens` (saturating)
480///
481/// `saturating_sub` guards the (schema-violating but possible) underflow so we
482/// never panic on bad data — CLAUDE.md §9.
483fn map_last_usage(last: &RawTokenUsage) -> Usage {
484    let cached = last.cached_input_tokens.unwrap_or(0);
485    let reasoning = last.reasoning_output_tokens.unwrap_or(0);
486    Usage {
487        input: last.input_tokens.map(|i| i.saturating_sub(cached)),
488        output: last.output_tokens.map(|o| o.saturating_sub(reasoning)),
489        cache_creation: None,
490        cache_creation_5m: None,
491        cache_creation_1h: None,
492        cache_read: last.cached_input_tokens,
493        thinking: last.reasoning_output_tokens,
494    }
495}
496
497// ---- raw line shapes (lenient: unknown fields ignored everywhere) ----
498
499#[derive(Deserialize)]
500struct RawLine {
501    #[serde(rename = "type")]
502    kind: Option<String>,
503    timestamp: Option<String>,
504    #[serde(default)]
505    payload: RawPayload,
506}
507
508/// One `payload` object. Codex overloads this across every `type`, so all fields
509/// are optional and we read only the ones relevant to the line's `type`.
510#[derive(Deserialize, Default)]
511struct RawPayload {
512    /// Inner discriminator on `response_item` / `event_msg` lines.
513    #[serde(rename = "type")]
514    item_type: Option<String>,
515    /// `session_meta.id`.
516    #[allow(dead_code)]
517    id: Option<String>,
518    /// `session_meta.cwd` / `turn_context.cwd` — the project working directory.
519    cwd: Option<String>,
520    /// `turn_context.model` (and defensively any future `session_meta.model`).
521    model: Option<String>,
522    /// `message.role` (user / assistant / …).
523    role: Option<String>,
524    /// `message.content` — string or `[{type,text}]`.
525    #[serde(default)]
526    content: serde_json::Value,
527    /// `function_call.name` (e.g. `shell`, `mcp__server__tool`).
528    name: Option<String>,
529    /// `function_call.arguments` (serialized JSON string).
530    arguments: Option<String>,
531    /// `function_call.call_id` / `*_tool_call_end.call_id`.
532    call_id: Option<String>,
533    /// `token_count.info`.
534    info: Option<RawTokenInfo>,
535    /// `mcp_tool_call_end.invocation`.
536    invocation: Option<RawInvocation>,
537    /// Flat `tool` name fallback on some tool-end events.
538    tool: Option<String>,
539}
540
541/// `token_count.info` — the only place usage lives.
542#[derive(Deserialize)]
543struct RawTokenInfo {
544    /// Cumulative, monotonic — NOT summed (would overcount ~100x). Kept for
545    /// potential cross-checks; the per-request delta is what we bill.
546    #[allow(dead_code)]
547    total_token_usage: Option<RawTokenUsage>,
548    /// Per-request delta — the value we map into [`Usage`].
549    last_token_usage: Option<RawTokenUsage>,
550}
551
552/// A Codex token-usage block (OpenAI convention; see [`map_last_usage`]).
553#[derive(Deserialize)]
554struct RawTokenUsage {
555    input_tokens: Option<u64>,
556    cached_input_tokens: Option<u64>,
557    output_tokens: Option<u64>,
558    reasoning_output_tokens: Option<u64>,
559    #[allow(dead_code)]
560    total_tokens: Option<u64>,
561}
562
563/// `mcp_tool_call_end.invocation` — server + tool name of a completed MCP call.
564#[derive(Deserialize)]
565struct RawInvocation {
566    server: Option<String>,
567    tool: Option<String>,
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    /// The reconciliation core: a per-request delta maps DISJOINTLY so its
575    /// `known_total()` equals `total_tokens` (no double counting of the cached /
576    /// reasoning subsets).
577    #[test]
578    fn last_usage_maps_disjointly_to_total_tokens() {
579        let last = RawTokenUsage {
580            input_tokens: Some(2000),
581            cached_input_tokens: Some(500),
582            output_tokens: Some(300),
583            reasoning_output_tokens: Some(100),
584            total_tokens: Some(2300),
585        };
586        let u = map_last_usage(&last);
587        assert_eq!(u.cache_read, Some(500));
588        assert_eq!(u.input, Some(1500), "input minus cached subset");
589        assert_eq!(u.thinking, Some(100));
590        assert_eq!(u.output, Some(200), "output minus reasoning subset");
591        assert_eq!(u.cache_creation, None, "Codex has no cache-creation split");
592        assert_eq!(
593            u.known_total(),
594            2300,
595            "Σ disjoint fields == total_tokens for the request"
596        );
597    }
598
599    /// Schema-violating underflow (cached > input) must saturate, never panic.
600    #[test]
601    fn underflow_saturates_instead_of_panicking() {
602        let last = RawTokenUsage {
603            input_tokens: Some(100),
604            cached_input_tokens: Some(250),
605            output_tokens: Some(40),
606            reasoning_output_tokens: Some(90),
607            total_tokens: Some(140),
608        };
609        let u = map_last_usage(&last);
610        assert_eq!(u.input, Some(0));
611        assert_eq!(u.output, Some(0));
612        assert_eq!(u.cache_read, Some(250));
613        assert_eq!(u.thinking, Some(90));
614    }
615
616    /// `CODEX_HOME` may be a comma-separated list of roots; each contributes its
617    /// `sessions` + `archived_sessions` dirs. Empties and whitespace are dropped.
618    #[test]
619    fn codex_home_parses_comma_separated_roots() {
620        std::env::set_var("CODEX_HOME", " /a/one , /b/two ,, /c/three ");
621        let roots = codex_roots();
622        std::env::remove_var("CODEX_HOME");
623        assert_eq!(
624            roots,
625            vec![
626                PathBuf::from("/a/one"),
627                PathBuf::from("/b/two"),
628                PathBuf::from("/c/three"),
629            ],
630            "trimmed, empties dropped"
631        );
632    }
633
634    /// A token_count event becomes exactly one Assistant event tagged with the
635    /// current model, and a context_compacted becomes a Compaction.
636    #[test]
637    fn token_count_and_compaction_map_to_events() {
638        let mut session = Session {
639            id: "s".into(),
640            agent: "codex".into(),
641            project: None,
642            model: None,
643            parent_session: None,
644            started_at: None,
645            ended_at: None,
646            events: Vec::new(),
647            sub_agents: Vec::new(),
648            skipped_lines: 0,
649        };
650        let mut counts: BTreeMap<String, u64> = BTreeMap::new();
651        let model = Some("gpt-5.5".to_string());
652
653        let tc: RawLine = serde_json::from_str(
654            r#"{"timestamp":"2026-06-15T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":0,"output_tokens":200,"reasoning_output_tokens":50,"total_tokens":1200},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":0,"output_tokens":200,"reasoning_output_tokens":50,"total_tokens":1200}}}}"#,
655        )
656        .unwrap();
657        push_event_msg(
658            &tc,
659            parse_ts(&tc.timestamp),
660            &model,
661            &mut session,
662            &mut counts,
663        );
664
665        let cc: RawLine = serde_json::from_str(
666            r#"{"timestamp":"2026-06-15T10:00:01Z","type":"event_msg","payload":{"type":"context_compacted"}}"#,
667        )
668        .unwrap();
669        push_event_msg(
670            &cc,
671            parse_ts(&cc.timestamp),
672            &model,
673            &mut session,
674            &mut counts,
675        );
676
677        assert_eq!(session.events.len(), 2);
678        assert_eq!(session.events[0].kind, EventKind::Assistant);
679        assert_eq!(session.events[0].model.as_deref(), Some("gpt-5.5"));
680        assert!(session.events[0].has_thinking, "reasoning_output_tokens>0");
681        assert_eq!(session.events[0].usage.unwrap().known_total(), 1200);
682        assert_eq!(session.events[1].kind, EventKind::Compaction);
683        assert_eq!(counts.get("gpt-5.5"), Some(&1));
684    }
685
686    /// `mcp_tool_call_end` synthesizes a server-attributed ToolCall.
687    #[test]
688    fn mcp_tool_call_end_attributes_server() {
689        let raw: RawLine = serde_json::from_str(
690            r#"{"timestamp":"2026-06-15T10:00:00Z","type":"event_msg","payload":{"type":"mcp_tool_call_end","invocation":{"server":"acme-db","tool":"query"},"call_id":"c1"}}"#,
691        )
692        .unwrap();
693        let call = mcp_tool_call(&raw).expect("a tool call");
694        assert_eq!(call.name, "mcp__acme-db__query");
695        assert_eq!(call.server.as_deref(), Some("acme-db"));
696    }
697}