Skip to main content

toolpath_codex/
provider.rs

1//! Implementation of `toolpath-convo` traits for Codex sessions.
2//!
3//! The hard part is mapping Codex's **streaming** event model onto
4//! `toolpath_convo::Turn`, which is message-shaped. The approach:
5//!
6//! 1. Walk the rollout lines in order.
7//! 2. `response_item.message` creates a new `Turn`. Role/content are
8//!    mapped straightforwardly; `developer` becomes `Role::System`.
9//! 3. `response_item.reasoning` is buffered and attached to the next
10//!    assistant turn's `thinking` field.
11//! 4. `response_item.function_call` / `custom_tool_call` attach to the
12//!    **current turn** (or a synthetic carrier if no message preceded
13//!    them) as `ToolInvocation` entries. Output is back-filled when we
14//!    see the matching `*_output` by `call_id`.
15//! 5. `event_msg.exec_command_end` enriches the already-emitted tool
16//!    invocation with the exit code / stdout / stderr.
17//! 6. `event_msg.patch_apply_end` is captured on the current turn's
18//!    `extra["codex"]["patch_changes"]` — the derive layer consumes it
19//!    for file-artifact sibling changes.
20//! 7. Token accounting. `turn_context` / `task_started` open an API round
21//!    (`turn_id`); assistant turns in it share that ID as `Turn.group_id`.
22//!    `event_msg.token_count` carries the SESSION-cumulative
23//!    `total_token_usage`; each step's spend is the increase since the
24//!    previous count — differencing the cumulative is dedup-safe (Codex
25//!    emits each count twice; a repeated total is a 0 delta) where summing
26//!    `last_token_usage` would double. Each delta is attributed to the step
27//!    it follows (`Turn.attributed_token_usage`); `finalize_usage` then
28//!    sets each group's total `Turn.token_usage` to the sum of its
29//!    attributions, on the group's final turn — one source of truth, so
30//!    `Σ token_usage == Σ attributed ==` session total.
31//! 8. Everything else (`task_started`, `task_complete`, `turn_context`,
32//!    `user_message`/`agent_message` duplicates, unknown events) lands
33//!    in `ConversationView.events` as a typed [`ConversationEvent`].
34
35use std::collections::HashMap;
36
37use crate::io::ConvoIO;
38use crate::types::{
39    EventMsg, ExecCommandEnd, Message, PatchApplyEnd, PatchChange, ResponseItem, RolloutItem,
40    Session, TokenCountInfo,
41};
42use serde_json::Value;
43use toolpath_convo::{
44    ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, ConvoError,
45    EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory,
46    ToolInvocation, ToolResult, Turn,
47};
48
49/// Provider for Codex sessions.
50#[derive(Debug, Clone, Default)]
51pub struct CodexConvo {
52    io: ConvoIO,
53}
54
55impl CodexConvo {
56    pub fn new() -> Self {
57        Self { io: ConvoIO::new() }
58    }
59
60    pub fn with_resolver(resolver: crate::paths::PathResolver) -> Self {
61        Self {
62            io: ConvoIO::with_resolver(resolver),
63        }
64    }
65
66    pub fn io(&self) -> &ConvoIO {
67        &self.io
68    }
69
70    pub fn resolver(&self) -> &crate::paths::PathResolver {
71        self.io.resolver()
72    }
73
74    /// Read one session into a [`Session`] struct (raw lines).
75    pub fn read_session(&self, session_id: &str) -> crate::Result<Session> {
76        self.io.read_session(session_id)
77    }
78
79    /// List all sessions, newest first.
80    pub fn list_sessions(&self) -> crate::Result<Vec<crate::types::SessionMetadata>> {
81        self.io.list_sessions()
82    }
83
84    /// List every session id, newest first, without reading any files.
85    pub fn list_session_ids(&self) -> crate::Result<Vec<String>> {
86        self.io.list_session_ids()
87    }
88
89    /// Most recent session (by last activity), if any.
90    pub fn most_recent_session(&self) -> crate::Result<Option<Session>> {
91        let metas = self.list_sessions()?;
92        match metas.first() {
93            Some(m) => Ok(Some(self.read_session(&m.id)?)),
94            None => Ok(None),
95        }
96    }
97
98    /// Read all sessions into memory (expensive on large histories).
99    pub fn read_all_sessions(&self) -> crate::Result<Vec<Session>> {
100        let metas = self.list_sessions()?;
101        let mut out = Vec::with_capacity(metas.len());
102        for m in metas {
103            match self.read_session(&m.id) {
104                Ok(s) => out.push(s),
105                Err(e) => eprintln!("Warning: could not read session {}: {}", m.id, e),
106            }
107        }
108        Ok(out)
109    }
110}
111
112// ── Tool classification ─────────────────────────────────────────────
113
114/// Classify a Codex tool name into toolpath's category ontology.
115pub fn tool_category(name: &str) -> Option<ToolCategory> {
116    match name {
117        "read_file" | "read_many_files" | "list_dir" | "view_image" | "mcp_resource" => {
118            Some(ToolCategory::FileRead)
119        }
120        "glob" | "grep_search" | "search_file_content" | "tool_search" | "tool_suggest" => {
121            Some(ToolCategory::FileSearch)
122        }
123        "write_file" | "apply_patch" | "replace" | "edit" => Some(ToolCategory::FileWrite),
124        "shell" | "exec_command" | "unified_exec" | "write_stdin" | "js_repl" => {
125            Some(ToolCategory::Shell)
126        }
127        "web_fetch" | "web_search" | "google_web_search" => Some(ToolCategory::Network),
128        "spawn_agent" | "close_agent" | "wait_agent" | "resume_agent" | "send_message"
129        | "followup_task" | "list_agents" | "agent_jobs" | "task" | "activate_skill" => {
130            Some(ToolCategory::Delegation)
131        }
132        _ => None,
133    }
134}
135
136/// Reverse of [`tool_category`]: pick Codex's preferred native tool name
137/// for a generic [`ToolCategory`], using call args to disambiguate.
138///
139/// Used by [`crate::project::CodexProjector`] when projecting tool calls
140/// from foreign harnesses. Notably, FileWrite always maps to `write_file`
141/// (not `apply_patch`) — `apply_patch` takes a free-form V4A patch
142/// string rather than JSON args, so projecting JSON-shape edits as
143/// `apply_patch` would emit a malformed CustomToolCall. Same-harness
144/// round-trips preserve the source name verbatim before reaching this
145/// fallback.
146pub fn native_name(category: ToolCategory, args: &Value) -> Option<&'static str> {
147    match category {
148        ToolCategory::Shell => Some("exec_command"),
149        ToolCategory::FileRead => Some(if args.get("file_paths").is_some() {
150            "read_many_files"
151        } else if args.get("path").is_some() && args.get("file_path").is_none() {
152            "list_dir"
153        } else {
154            "read_file"
155        }),
156        ToolCategory::FileSearch => Some(if args.get("pattern").is_some() {
157            "grep_search"
158        } else {
159            "glob"
160        }),
161        ToolCategory::FileWrite => Some("write_file"),
162        ToolCategory::Network => Some(if args.get("url").is_some() {
163            "web_fetch"
164        } else {
165            "web_search"
166        }),
167        ToolCategory::Delegation => Some("spawn_agent"),
168    }
169}
170
171// ── Session → ConversationView ─────────────────────────────────────
172
173/// Convert a parsed Codex [`Session`] to the provider-agnostic
174/// [`ConversationView`] shape.
175pub fn to_view(session: &Session) -> ConversationView {
176    Builder::new(session).build()
177}
178
179/// Convert one rollout line to a "best-effort" `Turn`, if it carries
180/// one. Used by consumers who want per-line processing without the
181/// cross-line assembly that [`to_view`] does.
182pub fn to_turn(line_payload: &ResponseItem) -> Option<Turn> {
183    if let ResponseItem::Message(m) = line_payload {
184        Some(message_to_turn(m, "", None, None))
185    } else {
186        None
187    }
188}
189
190struct Builder<'a> {
191    session: &'a Session,
192    turns: Vec<Turn>,
193    events: Vec<ConversationEvent>,
194    /// Plaintext reasoning summaries (rare — only in configurations where
195    /// OpenAI exposes public reasoning). These land on `Turn.thinking`.
196    pending_reasoning_plaintext: Vec<String>,
197    /// The current API round (Codex "turn"), from `turn_context` /
198    /// `task_started`. Assistant turns emitted during a round share it as
199    /// their `group_id`.
200    current_round_id: Option<String>,
201    /// Per-step spend awaiting an assistant turn to attach to (a token_count
202    /// arriving before this round's first assistant turn exists).
203    pending_attributed: Option<TokenUsage>,
204    working_dir: Option<String>,
205    current_model: Option<String>,
206    call_index: HashMap<String, (usize, usize)>,
207    total_usage: TokenUsage,
208    total_usage_set: bool,
209    files_changed_order: Vec<String>,
210    files_changed_seen: std::collections::HashSet<String>,
211}
212
213impl<'a> Builder<'a> {
214    fn new(session: &'a Session) -> Self {
215        Self {
216            session,
217            turns: Vec::new(),
218            events: Vec::new(),
219            pending_reasoning_plaintext: Vec::new(),
220            current_round_id: None,
221            pending_attributed: None,
222            working_dir: None,
223            current_model: None,
224            call_index: HashMap::new(),
225            total_usage: TokenUsage::default(),
226            total_usage_set: false,
227            files_changed_order: Vec::new(),
228            files_changed_seen: std::collections::HashSet::new(),
229        }
230    }
231
232    fn build(mut self) -> ConversationView {
233        for line in &self.session.lines {
234            match line.item() {
235                RolloutItem::SessionMeta(m) => {
236                    self.working_dir = Some(m.cwd.to_string_lossy().to_string());
237                    self.events.push(event_from_raw(
238                        &line.timestamp,
239                        "session_meta",
240                        &line.payload,
241                    ));
242                }
243                RolloutItem::TurnContext(tc) => {
244                    self.start_round(&tc.turn_id);
245                    if let Some(m) = &tc.model {
246                        self.current_model = Some(m.clone());
247                    }
248                    let wd = tc.cwd.to_string_lossy().to_string();
249                    if !wd.is_empty() {
250                        self.working_dir = Some(wd);
251                    }
252                    self.events.push(event_from_raw(
253                        &line.timestamp,
254                        "turn_context",
255                        &line.payload,
256                    ));
257                }
258                RolloutItem::ResponseItem(ri) => self.handle_response_item(&line.timestamp, ri),
259                RolloutItem::EventMsg(ev) => {
260                    self.handle_event_msg(&line.timestamp, ev, &line.payload)
261                }
262                RolloutItem::SessionState(payload) => {
263                    self.events
264                        .push(event_from_raw(&line.timestamp, "session_state", &payload));
265                }
266                RolloutItem::Compacted(payload) => {
267                    self.events
268                        .push(event_from_raw(&line.timestamp, "compacted", &payload));
269                }
270                RolloutItem::Unknown { kind, payload } => {
271                    self.events
272                        .push(event_from_raw(&line.timestamp, &kind, &payload));
273                }
274            }
275        }
276
277        // Compute message-group totals from per-step attributions.
278        self.finalize_usage();
279
280        // Path-level base context from session_meta (cwd + git).
281        let meta = self.session.meta();
282        let base = {
283            let wd = meta
284                .as_ref()
285                .map(|m| m.cwd.to_string_lossy().to_string())
286                .filter(|s| !s.is_empty())
287                .or_else(|| self.working_dir.clone());
288            let git = meta.as_ref().and_then(|m| m.git.as_ref());
289            let revision = git.and_then(|g| g.commit_hash.clone());
290            let branch = git.and_then(|g| g.branch.clone());
291            let remote = git.and_then(|g| g.repository_url.clone());
292            if wd.is_some() || revision.is_some() || branch.is_some() || remote.is_some() {
293                Some(SessionBase {
294                    working_dir: wd,
295                    vcs_revision: revision,
296                    vcs_branch: branch,
297                    vcs_remote: remote,
298                })
299            } else {
300                None
301            }
302        };
303
304        // Producer (originator + cli_version) lifts onto the typed view
305        // field. `model_provider` already lives on each assistant
306        // `ActorDefinition.provider`. Codex's `source` and `forked_from_id`
307        // are wire-level fields with no cross-harness analog — the codex
308        // projector hard-codes defaults on the return path, so we let them
309        // drop on this side.
310        let producer = meta.as_ref().map(|m| ProducerInfo {
311            name: m.originator.clone(),
312            version: Some(m.cli_version.clone()),
313        });
314
315        // Filter empty carrier turns (no text, no thinking, no tool calls).
316        // Previously done inside `derive_path_from_view`; moved here so the
317        // canonical `derive_path` sees only meaningful turns.
318        self.turns
319            .retain(|t| !(t.text.is_empty() && t.thinking.is_none() && t.tool_uses.is_empty()));
320
321        // Assign synthetic ids to turns whose source message didn't carry
322        // one, then link sequentially via `parent_id` so the shared
323        // `derive_path` can walk a connected DAG. Codex turns don't carry
324        // explicit parent ids on the wire; this preserves the linear
325        // ordering the old `derive_path_from_view` produced.
326        for (idx, t) in self.turns.iter_mut().enumerate() {
327            if t.id.is_empty() {
328                t.id = format!("codex-turn-{:04}", idx + 1);
329            }
330        }
331        let mut prev: Option<String> = None;
332        for t in self.turns.iter_mut() {
333            if t.parent_id.is_none() {
334                t.parent_id = prev.clone();
335            }
336            prev = Some(t.id.clone());
337        }
338
339        // Disambiguate event ids. `event_from_raw` synthesizes
340        // `<event_type>-<timestamp>`, which collides when codex emits
341        // multiple events of the same type at the same timestamp (rare
342        // but real). Suffix duplicates with their position so each step
343        // gets a unique ID.
344        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
345        for t in &self.turns {
346            seen.insert(t.id.clone());
347        }
348        for (i, e) in self.events.iter_mut().enumerate() {
349            if !seen.insert(e.id.clone()) {
350                e.id = format!("{}-{:04}", e.id, i);
351                seen.insert(e.id.clone());
352            }
353        }
354
355        ConversationView {
356            id: self.session.id.clone(),
357            started_at: self.session.started_at(),
358            last_activity: self.session.last_activity(),
359            turns: self.turns,
360            total_usage: if self.total_usage_set {
361                Some(self.total_usage)
362            } else {
363                None
364            },
365            provider_id: Some("codex".into()),
366            files_changed: self.files_changed_order,
367            session_ids: vec![],
368            events: self.events,
369            base,
370            producer,
371        }
372    }
373
374    fn handle_response_item(&mut self, timestamp: &str, ri: ResponseItem) {
375        match ri {
376            ResponseItem::Message(msg) => {
377                let turn = message_to_turn(
378                    &msg,
379                    timestamp,
380                    self.working_dir.as_deref(),
381                    self.current_model.as_deref(),
382                );
383                self.push_turn(turn);
384            }
385            ResponseItem::Reasoning(r) => {
386                // Plaintext content (rare) → Turn.thinking.
387                if let Some(Value::Array(arr)) = r.content.as_ref() {
388                    for v in arr {
389                        if let Some(s) = v.get("text").and_then(|t| t.as_str()) {
390                            self.pending_reasoning_plaintext.push(s.to_string());
391                        }
392                    }
393                }
394                // Plaintext summary items — same treatment.
395                for v in &r.summary {
396                    if let Some(s) = v.get("text").and_then(|t| t.as_str()) {
397                        self.pending_reasoning_plaintext.push(s.to_string());
398                    }
399                }
400            }
401            ResponseItem::FunctionCall(fc) => {
402                let name = fc.name.clone();
403                let input = fc.arguments_as_json();
404                let input = if input.is_null() {
405                    Value::String(fc.arguments.clone())
406                } else {
407                    input
408                };
409                self.attach_tool_call(timestamp, fc.call_id, name, input);
410            }
411            ResponseItem::FunctionCallOutput(out) => {
412                let is_error = out
413                    .extra
414                    .get("is_error")
415                    .and_then(Value::as_bool)
416                    .unwrap_or(false);
417                self.attach_tool_output(&out.call_id, &out.output, is_error);
418            }
419            ResponseItem::CustomToolCall(ct) => {
420                let input = Value::String(ct.input.clone());
421                self.attach_tool_call(timestamp, ct.call_id, ct.name, input);
422            }
423            ResponseItem::CustomToolCallOutput(out) => {
424                let is_error = out
425                    .extra
426                    .get("is_error")
427                    .and_then(Value::as_bool)
428                    .unwrap_or(false);
429                self.attach_tool_output(&out.call_id, &out.output, is_error);
430            }
431            ResponseItem::Other { kind, payload } => {
432                self.events.push(ConversationEvent {
433                    id: synthetic_event_id(timestamp, &kind),
434                    timestamp: timestamp.to_string(),
435                    parent_id: None,
436                    event_type: format!("response_item.{}", kind),
437                    data: data_from_value(&payload),
438                });
439            }
440        }
441    }
442
443    fn handle_event_msg(&mut self, timestamp: &str, ev: EventMsg, raw_payload: &Value) {
444        match ev {
445            EventMsg::TokenCount(tc) => {
446                if let Some(info) = tc.info.as_ref() {
447                    // `total_token_usage` is the SESSION-cumulative counter;
448                    // the spend of the step that just completed is the
449                    // increase since the previous count. Differencing the
450                    // cumulative (not summing `last_token_usage`) is
451                    // dedup-safe: Codex emits each token_count twice, so a
452                    // repeated total contributes a 0 delta instead of
453                    // double-counting. The delta accrues to the round total
454                    // (a per-step `token_usage` sum can't exceed it) and is
455                    // attributed to the step it follows — for Codex every
456                    // field is per-step, since each call re-sends context.
457                    let prev_total = self.total_usage.clone();
458                    apply_token_count(&mut self.total_usage, info);
459                    self.total_usage_set = true;
460                    let delta = usage_delta(&self.total_usage, &prev_total);
461                    if !is_usage_zero(&delta) {
462                        self.attribute_delta(delta);
463                    }
464                }
465                self.events
466                    .push(event_from_raw(timestamp, "token_count", raw_payload));
467            }
468            EventMsg::ExecCommandEnd(exec) => {
469                self.apply_exec_command_end(&exec);
470                self.events
471                    .push(event_from_raw(timestamp, "exec_command_end", raw_payload));
472            }
473            EventMsg::PatchApplyEnd(patch) => {
474                self.apply_patch_apply_end(&patch);
475                self.events
476                    .push(event_from_raw(timestamp, "patch_apply_end", raw_payload));
477            }
478            EventMsg::TaskStarted(payload) => {
479                if let Some(tid) = payload.get("turn_id").and_then(|v| v.as_str()) {
480                    self.start_round(tid);
481                }
482                self.events
483                    .push(event_from_raw(timestamp, "task_started", raw_payload));
484            }
485            EventMsg::TaskComplete(_) => {
486                // Round over: anything after the boundary is outside the
487                // round, so the grouping key resets. Totals are computed
488                // once in `finalize_usage`.
489                self.current_round_id = None;
490                self.events
491                    .push(event_from_raw(timestamp, "task_complete", raw_payload));
492            }
493            EventMsg::AgentMessage(_) | EventMsg::UserMessage(_) => {
494                self.events
495                    .push(event_from_raw(timestamp, ev.kind(), raw_payload));
496            }
497            EventMsg::Other { kind, payload } => {
498                self.events.push(event_from_raw(timestamp, &kind, &payload));
499            }
500        }
501    }
502
503    fn attach_tool_call(&mut self, timestamp: &str, call_id: String, name: String, input: Value) {
504        let category = tool_category(&name);
505        let invocation = ToolInvocation {
506            id: call_id.clone(),
507            name,
508            input,
509            result: None,
510            category,
511        };
512
513        let turn_idx = match self.last_assistant_turn_index() {
514            Some(idx) => idx,
515            None => {
516                let t = synthetic_assistant_turn(
517                    timestamp,
518                    self.working_dir.as_deref(),
519                    self.current_model.as_deref(),
520                );
521                self.push_turn(t);
522                self.turns.len() - 1
523            }
524        };
525        let tool_idx = self.turns[turn_idx].tool_uses.len();
526        self.turns[turn_idx].tool_uses.push(invocation);
527        self.call_index.insert(call_id, (turn_idx, tool_idx));
528    }
529
530    fn attach_tool_output(&mut self, call_id: &str, output: &str, is_error: bool) {
531        if let Some((turn_idx, tool_idx)) = self.call_index.get(call_id).copied() {
532            let turn = &mut self.turns[turn_idx];
533            if let Some(inv) = turn.tool_uses.get_mut(tool_idx) {
534                let prior_error = inv.result.as_ref().map(|r| r.is_error).unwrap_or(false);
535                let merged = match inv.result.as_ref() {
536                    Some(existing) => format!("{}\n{}", existing.content, output),
537                    None => output.to_string(),
538                };
539                inv.result = Some(ToolResult {
540                    content: merged,
541                    is_error: is_error || prior_error,
542                });
543            }
544        }
545    }
546
547    fn apply_exec_command_end(&mut self, exec: &ExecCommandEnd) {
548        if let Some((turn_idx, tool_idx)) = self.call_index.get(&exec.call_id).copied() {
549            let turn = &mut self.turns[turn_idx];
550            if let Some(inv) = turn.tool_uses.get_mut(tool_idx) {
551                let is_error = exec.exit_code.map(|c| c != 0).unwrap_or(false);
552                if inv.result.is_none() {
553                    let body = if !exec.aggregated_output.is_empty() {
554                        exec.aggregated_output.clone()
555                    } else if !exec.stdout.is_empty() || !exec.stderr.is_empty() {
556                        let mut s = String::new();
557                        if !exec.stdout.is_empty() {
558                            s.push_str(&exec.stdout);
559                        }
560                        if !exec.stderr.is_empty() {
561                            if !s.is_empty() {
562                                s.push('\n');
563                            }
564                            s.push_str(&exec.stderr);
565                        }
566                        s
567                    } else {
568                        format!("(exit {})", exec.exit_code.unwrap_or_default())
569                    };
570                    inv.result = Some(ToolResult {
571                        content: body,
572                        is_error,
573                    });
574                } else if is_error && let Some(r) = inv.result.as_mut() {
575                    r.is_error = true;
576                }
577            }
578        }
579    }
580
581    fn apply_patch_apply_end(&mut self, patch: &PatchApplyEnd) {
582        let loc = self.call_index.get(&patch.call_id).copied();
583
584        // `patch.changes` is a HashMap — iterate in sorted order so the
585        // derived order is deterministic across runs.
586        let mut paths: Vec<&String> = patch.changes.keys().collect();
587        paths.sort();
588
589        // Populate `turn.file_mutations` on the matching turn, with
590        // `tool_id` set to the `call_id` so `derive_path` can link the
591        // sibling `file.write` change back to this specific tool call.
592        if let Some((turn_idx, _tool_idx)) = loc {
593            let turn = &mut self.turns[turn_idx];
594            for path in &paths {
595                if let Some(change) = patch.changes.get(*path) {
596                    let mut fm = patch_change_to_file_mutation(path, change);
597                    fm.tool_id = Some(patch.call_id.clone());
598                    turn.file_mutations.push(fm);
599                }
600            }
601        }
602
603        for path in paths {
604            if self.files_changed_seen.insert(path.clone()) {
605                self.files_changed_order.push(path.clone());
606            }
607        }
608    }
609
610    fn push_turn(&mut self, mut turn: Turn) {
611        self.drain_pending_onto(&mut turn);
612        if turn.role == Role::Assistant && turn.group_id.is_none() {
613            turn.group_id = self.current_round_id.clone();
614        }
615        self.turns.push(turn);
616    }
617
618    fn drain_pending_onto(&mut self, turn: &mut Turn) {
619        if turn.role != Role::Assistant {
620            return;
621        }
622        // Plaintext reasoning summaries are safe to render as thinking.
623        if !self.pending_reasoning_plaintext.is_empty() {
624            turn.thinking = Some(self.pending_reasoning_plaintext.join("\n\n"));
625            self.pending_reasoning_plaintext.clear();
626        }
627        // A step's spend that arrived before any assistant turn existed
628        // attaches to this, the first one.
629        if let Some(pending) = self.pending_attributed.take() {
630            add_usage(
631                turn.attributed_token_usage
632                    .get_or_insert_with(TokenUsage::default),
633                &pending,
634            );
635        }
636    }
637
638    /// Attribute one step's spend to the most recent assistant turn **of the
639    /// current round** (the step the `token_count` followed). If this round
640    /// has no assistant turn yet, buffer it for the round's first one —
641    /// never leak a round's spend onto a prior round's turn.
642    fn attribute_delta(&mut self, delta: TokenUsage) {
643        let target = self
644            .turns
645            .iter()
646            .enumerate()
647            .rev()
648            .find(|(_, t)| t.role == Role::Assistant)
649            .filter(|(_, t)| t.group_id == self.current_round_id)
650            .map(|(i, _)| i);
651        match target {
652            Some(idx) => add_usage(
653                self.turns[idx]
654                    .attributed_token_usage
655                    .get_or_insert_with(TokenUsage::default),
656                &delta,
657            ),
658            None => match &mut self.pending_attributed {
659                Some(acc) => add_usage(acc, &delta),
660                None => self.pending_attributed = Some(delta),
661            },
662        }
663    }
664
665    /// Begin a new API round; later assistant turns share `round_id` as
666    /// their `group_id`. Totals are computed once in [`Self::finalize_usage`].
667    fn start_round(&mut self, round_id: &str) {
668        if round_id.is_empty() || self.current_round_id.as_deref() == Some(round_id) {
669            return;
670        }
671        self.current_round_id = Some(round_id.to_string());
672    }
673
674    /// Set each message group's total `token_usage` to the sum of its
675    /// turns' per-step attributions, on the group's final turn (the kind's
676    /// once-per-group rule). One source of truth — the group total and its
677    /// per-step shares can't drift, and `Σ token_usage == Σ attributed ==`
678    /// session total. A run of assistant turns sharing a `group_id` is one
679    /// round; an assistant turn without one is its own group.
680    fn finalize_usage(&mut self) {
681        // A step's spend that arrived after the last assistant turn (no
682        // later turn to drain onto) still belongs to that turn.
683        if let Some(pending) = self.pending_attributed.take()
684            && let Some(idx) = self.turns.iter().rposition(|t| t.role == Role::Assistant)
685        {
686            add_usage(
687                self.turns[idx]
688                    .attributed_token_usage
689                    .get_or_insert_with(TokenUsage::default),
690                &pending,
691            );
692        }
693
694        let assistants: Vec<usize> = (0..self.turns.len())
695            .filter(|&i| self.turns[i].role == Role::Assistant)
696            .collect();
697        let mut k = 0;
698        while k < assistants.len() {
699            let start = k;
700            let mid = self.turns[assistants[k]].group_id.clone();
701            if mid.is_some() {
702                while k + 1 < assistants.len() && self.turns[assistants[k + 1]].group_id == mid {
703                    k += 1;
704                }
705            }
706            let mut total: Option<TokenUsage> = None;
707            for &gi in &assistants[start..=k] {
708                if let Some(a) = &self.turns[gi].attributed_token_usage {
709                    add_usage(total.get_or_insert_with(TokenUsage::default), a);
710                }
711            }
712            if let Some(total) = total {
713                self.turns[assistants[k]].token_usage = Some(total);
714            }
715            k += 1;
716        }
717    }
718
719    fn last_assistant_turn_index(&self) -> Option<usize> {
720        self.turns
721            .iter()
722            .rposition(|t| t.role == Role::Assistant)
723            .or_else(|| self.turns.len().checked_sub(1))
724    }
725}
726
727// ── Patch → FileMutation conversion ─────────────────────────────────
728
729fn patch_change_to_file_mutation(path: &str, change: &PatchChange) -> FileMutation {
730    let mut fm = FileMutation {
731        path: path.to_string(),
732        ..Default::default()
733    };
734    match change {
735        PatchChange::Add { content, .. } => {
736            fm.operation = Some("add".into());
737            fm.after = Some(content.clone());
738            fm.raw_diff = Some(synth_add_diff(content));
739        }
740        PatchChange::Update {
741            unified_diff,
742            move_path,
743            ..
744        } => {
745            fm.operation = Some("update".into());
746            fm.raw_diff = Some(unified_diff.clone());
747            fm.rename_to = move_path.clone();
748        }
749        PatchChange::Delete {
750            original_content, ..
751        } => {
752            fm.operation = Some("delete".into());
753            fm.before = original_content.clone();
754            fm.raw_diff = original_content.as_deref().map(synth_delete_diff);
755        }
756        PatchChange::Unknown => {
757            fm.operation = Some("unknown".into());
758        }
759    }
760    fm
761}
762
763fn synth_add_diff(content: &str) -> String {
764    let lines: Vec<&str> = content.split('\n').collect();
765    let effective: &[&str] = if lines.last() == Some(&"") {
766        &lines[..lines.len().saturating_sub(1)]
767    } else {
768        &lines[..]
769    };
770    let mut buf = format!("@@ -0,0 +1,{} @@\n", effective.len());
771    for l in effective {
772        buf.push('+');
773        buf.push_str(l);
774        buf.push('\n');
775    }
776    buf
777}
778
779fn synth_delete_diff(original: &str) -> String {
780    let lines: Vec<&str> = original.split('\n').collect();
781    let effective: &[&str] = if lines.last() == Some(&"") {
782        &lines[..lines.len().saturating_sub(1)]
783    } else {
784        &lines[..]
785    };
786    let mut buf = format!("@@ -1,{} +0,0 @@\n", effective.len());
787    for l in effective {
788        buf.push('-');
789        buf.push_str(l);
790        buf.push('\n');
791    }
792    buf
793}
794
795fn message_to_turn(
796    msg: &Message,
797    timestamp: &str,
798    working_dir: Option<&str>,
799    model: Option<&str>,
800) -> Turn {
801    let role = match msg.role.as_str() {
802        "user" => Role::User,
803        "assistant" => Role::Assistant,
804        "developer" | "system" => Role::System,
805        other => Role::Other(other.to_string()),
806    };
807
808    let text = msg.text();
809
810    let environment = working_dir.map(|wd| EnvironmentSnapshot {
811        working_dir: Some(wd.to_string()),
812        vcs_branch: None,
813        vcs_revision: None,
814    });
815
816    Turn {
817        id: msg.id.clone().unwrap_or_default(),
818        parent_id: None,
819        group_id: None,
820        role: role.clone(),
821        timestamp: timestamp.to_string(),
822        text,
823        thinking: None,
824        tool_uses: Vec::new(),
825        model: if role == Role::Assistant {
826            model.map(str::to_string)
827        } else {
828            None
829        },
830        stop_reason: None,
831        token_usage: None,
832        attributed_token_usage: None,
833        environment,
834        delegations: Vec::new(),
835        file_mutations: Vec::new(),
836    }
837}
838
839fn synthetic_assistant_turn(
840    timestamp: &str,
841    working_dir: Option<&str>,
842    model: Option<&str>,
843) -> Turn {
844    Turn {
845        id: format!("synth-{}", timestamp),
846        parent_id: None,
847        group_id: None,
848        role: Role::Assistant,
849        timestamp: timestamp.to_string(),
850        text: String::new(),
851        thinking: None,
852        tool_uses: Vec::new(),
853        model: model.map(str::to_string),
854        stop_reason: None,
855        token_usage: None,
856        attributed_token_usage: None,
857        environment: working_dir.map(|wd| EnvironmentSnapshot {
858            working_dir: Some(wd.to_string()),
859            vcs_branch: None,
860            vcs_revision: None,
861        }),
862        delegations: Vec::new(),
863        file_mutations: Vec::new(),
864    }
865}
866
867/// Component-wise `acc += delta`, treating `None` as 0 on the addend.
868/// Breakdowns merge key-wise (inner values add) rather than overwrite, so a
869/// round's per-call reasoning slices accumulate into the group total.
870fn add_usage(acc: &mut TokenUsage, delta: &TokenUsage) {
871    let add = |a: &mut Option<u32>, b: Option<u32>| {
872        if let Some(b) = b {
873            *a = Some(a.unwrap_or(0) + b);
874        }
875    };
876    add(&mut acc.input_tokens, delta.input_tokens);
877    add(&mut acc.output_tokens, delta.output_tokens);
878    add(&mut acc.cache_read_tokens, delta.cache_read_tokens);
879    add(&mut acc.cache_write_tokens, delta.cache_write_tokens);
880    for (class, inner) in &delta.breakdowns {
881        let target = acc.breakdowns.entry(class.clone()).or_default();
882        for (sub, n) in inner {
883            *target.entry(sub.clone()).or_insert(0) += *n;
884        }
885    }
886}
887
888/// True when every counter is absent or zero (no real spend to record).
889fn is_usage_zero(u: &TokenUsage) -> bool {
890    [
891        u.input_tokens,
892        u.output_tokens,
893        u.cache_read_tokens,
894        u.cache_write_tokens,
895    ]
896    .iter()
897    .all(|f| f.unwrap_or(0) == 0)
898}
899
900/// Component-wise `current - prev`, for recovering a round's spend from
901/// successive cumulative totals. Saturating: a counter reset (e.g. after
902/// compaction) yields 0 rather than wrapping.
903fn usage_delta(current: &TokenUsage, prev: &TokenUsage) -> TokenUsage {
904    let sub = |c: Option<u32>, p: Option<u32>| c.map(|c| c.saturating_sub(p.unwrap_or(0)));
905    let mut delta = TokenUsage {
906        input_tokens: sub(current.input_tokens, prev.input_tokens),
907        output_tokens: sub(current.output_tokens, prev.output_tokens),
908        cache_read_tokens: sub(current.cache_read_tokens, prev.cache_read_tokens),
909        cache_write_tokens: sub(current.cache_write_tokens, prev.cache_write_tokens),
910        ..Default::default()
911    };
912    // Breakdowns (e.g. output→reasoning) are cumulative subsets of their
913    // parent class, so difference them the same saturating way. Only retain
914    // sub-classes whose delta is > 0 so a flat round stays breakdown-free.
915    for (class, inner) in &current.breakdowns {
916        let prev_inner = prev.breakdowns.get(class);
917        let mut diffed: std::collections::BTreeMap<String, u32> = Default::default();
918        for (sub, cur) in inner {
919            let p = prev_inner.and_then(|m| m.get(sub)).copied().unwrap_or(0);
920            let d = cur.saturating_sub(p);
921            if d > 0 {
922                diffed.insert(sub.clone(), d);
923            }
924        }
925        if !diffed.is_empty() {
926            delta.breakdowns.insert(class.clone(), diffed);
927        }
928    }
929    delta
930}
931
932fn apply_token_count(total: &mut TokenUsage, info: &TokenCountInfo) {
933    if let Some(t) = info.total_token_usage.as_ref() {
934        total.input_tokens = t.input_tokens.or(total.input_tokens);
935        total.output_tokens = t.output_tokens.or(total.output_tokens);
936        total.cache_read_tokens = t.cached_input_tokens.or(total.cache_read_tokens);
937        // `reasoning_output_tokens` ⊆ `output_tokens` (informational); carry the
938        // cumulative reasoning counter under breakdowns["output"]["reasoning"]
939        // so `usage_delta` differences it per call just like the others. Only
940        // record it when present and > 0 to keep zero-reasoning rounds clean.
941        if let Some(r) = t.reasoning_output_tokens.filter(|&r| r > 0) {
942            total
943                .breakdowns
944                .entry("output".to_string())
945                .or_default()
946                .insert("reasoning".to_string(), r);
947        }
948    }
949}
950
951fn event_from_raw(timestamp: &str, event_type: &str, payload: &Value) -> ConversationEvent {
952    ConversationEvent {
953        id: synthetic_event_id(timestamp, event_type),
954        timestamp: timestamp.to_string(),
955        parent_id: None,
956        event_type: event_type.to_string(),
957        data: data_from_value(payload),
958    }
959}
960
961fn synthetic_event_id(timestamp: &str, kind: &str) -> String {
962    format!("{}-{}", kind, timestamp)
963}
964
965fn data_from_value(v: &Value) -> HashMap<String, Value> {
966    match v {
967        Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
968        _ => {
969            let mut m = HashMap::new();
970            m.insert("value".into(), v.clone());
971            m
972        }
973    }
974}
975
976// ── ConversationProvider trait impl ────────────────────────────────
977
978impl ConversationProvider for CodexConvo {
979    fn list_conversations(&self, _project: &str) -> toolpath_convo::Result<Vec<String>> {
980        let metas = self
981            .list_sessions()
982            .map_err(|e| ConvoError::Provider(e.to_string()))?;
983        Ok(metas.into_iter().map(|m| m.id).collect())
984    }
985
986    fn load_conversation(
987        &self,
988        _project: &str,
989        conversation_id: &str,
990    ) -> toolpath_convo::Result<ConversationView> {
991        let session = self
992            .read_session(conversation_id)
993            .map_err(|e| ConvoError::Provider(e.to_string()))?;
994        Ok(to_view(&session))
995    }
996
997    fn load_metadata(
998        &self,
999        _project: &str,
1000        conversation_id: &str,
1001    ) -> toolpath_convo::Result<ConversationMeta> {
1002        let path = self
1003            .io
1004            .resolver()
1005            .find_rollout_file(conversation_id)
1006            .map_err(|e| ConvoError::Provider(e.to_string()))?;
1007        let meta = self
1008            .io
1009            .read_metadata(path)
1010            .map_err(|e| ConvoError::Provider(e.to_string()))?;
1011        Ok(ConversationMeta {
1012            id: meta.id,
1013            started_at: meta.started_at,
1014            last_activity: meta.last_activity,
1015            message_count: meta.line_count,
1016            file_path: Some(meta.file_path),
1017            predecessor: None,
1018            successor: None,
1019        })
1020    }
1021
1022    fn list_metadata(&self, _project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
1023        let metas = self
1024            .list_sessions()
1025            .map_err(|e| ConvoError::Provider(e.to_string()))?;
1026        Ok(metas
1027            .into_iter()
1028            .map(|m| ConversationMeta {
1029                id: m.id,
1030                started_at: m.started_at,
1031                last_activity: m.last_activity,
1032                message_count: m.line_count,
1033                file_path: Some(m.file_path),
1034                predecessor: None,
1035                successor: None,
1036            })
1037            .collect())
1038    }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use std::fs;
1045    use tempfile::TempDir;
1046
1047    fn setup_session_fixture(body: &str) -> (TempDir, CodexConvo, String) {
1048        let temp = TempDir::new().unwrap();
1049        let codex = temp.path().join(".codex");
1050        let day = codex.join("sessions/2026/04/20");
1051        fs::create_dir_all(&day).unwrap();
1052        let name = "rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d";
1053        fs::write(day.join(format!("{}.jsonl", name)), body).unwrap();
1054        let resolver = crate::paths::PathResolver::new().with_codex_dir(&codex);
1055        (temp, CodexConvo::with_resolver(resolver), name.to_string())
1056    }
1057
1058    fn minimal_session() -> String {
1059        [
1060            r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli","git":{"commit_hash":"abc","branch":"main"}}}"#,
1061            r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"t1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#,
1062            r#"{"timestamp":"2026-04-20T16:44:37.775Z","type":"event_msg","payload":{"type":"task_started","turn_id":"t1"}}"#,
1063            r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"please do a thing"}]}}"#,
1064            r#"{"timestamp":"2026-04-20T16:44:38.000Z","type":"response_item","payload":{"type":"reasoning","summary":[],"content":null,"encrypted_content":"encrypted-blob-1"}}"#,
1065            r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"working on it"}],"phase":"commentary"}}"#,
1066            r#"{"timestamp":"2026-04-20T16:44:38.200Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}","call_id":"call_1"}}"#,
1067            r#"{"timestamp":"2026-04-20T16:44:38.300Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"Command: pwd\nOutput:\n/tmp/proj\n"}}"#,
1068            r#"{"timestamp":"2026-04-20T16:44:38.400Z","type":"event_msg","payload":{"type":"exec_command_end","call_id":"call_1","command":["/bin/bash","-lc","pwd"],"stdout":"/tmp/proj\n","exit_code":0,"aggregated_output":"/tmp/proj\n"}}"#,
1069            r#"{"timestamp":"2026-04-20T16:44:38.500Z","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_2","name":"apply_patch","input":"*** Begin Patch\n*** Add File: /tmp/proj/a.rs\n+fn main() {}\n*** End Patch"}}"#,
1070            r#"{"timestamp":"2026-04-20T16:44:38.600Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_2","output":"{\"output\":\"ok\"}"}}"#,
1071            r#"{"timestamp":"2026-04-20T16:44:38.700Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"call_2","success":true,"changes":{"/tmp/proj/a.rs":{"type":"add","content":"fn main() {}\n"}}}}"#,
1072            r#"{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"output_tokens":20,"cached_input_tokens":10,"total_tokens":130}}}}"#,
1073            r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}],"phase":"final","end_turn":true}}"#,
1074            r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"t1","last_agent_message":"done"}}"#,
1075        ]
1076        .join("\n")
1077    }
1078
1079    #[test]
1080    fn build_view_basic() {
1081        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1082        let session = mgr.read_session(&id).unwrap();
1083        let view = to_view(&session);
1084
1085        assert_eq!(view.id, "019dabc6-8fef-7681-a054-b5bb75fcb97d");
1086        assert_eq!(view.provider_id.as_deref(), Some("codex"));
1087        assert_eq!(view.turns.len(), 3);
1088        assert_eq!(view.turns[0].role, Role::User);
1089        assert_eq!(view.turns[0].text, "please do a thing");
1090        assert_eq!(view.turns[1].role, Role::Assistant);
1091        assert_eq!(view.turns[1].text, "working on it");
1092        assert_eq!(view.turns[1].model.as_deref(), Some("gpt-5.4"));
1093    }
1094
1095    /// Two API rounds. Codex's `token_count` events carry cumulative
1096    /// session totals in `total_token_usage` and the round's own spend in
1097    /// `last_token_usage`; per-turn accounting must use the latter.
1098    fn two_round_session(with_last: bool) -> String {
1099        let last1 = r#","last_token_usage":{"input_tokens":100,"output_tokens":20,"cached_input_tokens":10,"total_tokens":130}"#;
1100        let last2 = r#","last_token_usage":{"input_tokens":200,"output_tokens":30,"cached_input_tokens":30,"total_tokens":260}"#;
1101        [
1102            r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli"}}"#.to_string(),
1103            r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"t1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1104            r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"round one"}]}}"#.to_string(),
1105            format!(
1106                r#"{{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":100,"output_tokens":20,"cached_input_tokens":10,"total_tokens":130}}{}}}}}}}"#,
1107                if with_last { last1 } else { "" }
1108            ),
1109            r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first"}],"phase":"final","end_turn":true}}"#.to_string(),
1110            r#"{"timestamp":"2026-04-20T16:44:39.700Z","type":"turn_context","payload":{"turn_id":"t2","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1111            r#"{"timestamp":"2026-04-20T16:44:39.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"round two"}]}}"#.to_string(),
1112            format!(
1113                r#"{{"timestamp":"2026-04-20T16:44:40.800Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":300,"output_tokens":50,"cached_input_tokens":40,"total_tokens":390}}{}}}}}}}"#,
1114                if with_last { last2 } else { "" }
1115            ),
1116            r#"{"timestamp":"2026-04-20T16:44:40.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"second"}],"phase":"final","end_turn":true}}"#.to_string(),
1117        ]
1118        .join("\n")
1119    }
1120
1121    #[test]
1122    fn turn_usage_is_per_round_delta_from_last_token_usage() {
1123        let (_t, mgr, id) = setup_session_fixture(&two_round_session(true));
1124        let view = to_view(&mgr.read_session(&id).unwrap());
1125
1126        let first = view.turns[1].token_usage.as_ref().unwrap();
1127        assert_eq!(first.input_tokens, Some(100));
1128        assert_eq!(first.output_tokens, Some(20));
1129        assert_eq!(first.cache_read_tokens, Some(10));
1130
1131        let second = view.turns[3].token_usage.as_ref().unwrap();
1132        assert_eq!(second.input_tokens, Some(200));
1133        assert_eq!(second.output_tokens, Some(30));
1134        assert_eq!(second.cache_read_tokens, Some(30));
1135
1136        // Session total stays the final cumulative counter.
1137        let total = view.total_usage.as_ref().unwrap();
1138        assert_eq!(total.input_tokens, Some(300));
1139        assert_eq!(total.output_tokens, Some(50));
1140    }
1141
1142    #[test]
1143    fn per_step_attribution_from_deduped_cumulative_deltas() {
1144        // Real Codex emits each token_count TWICE (identical values). Per-step
1145        // spend must come from differencing the cumulative total — a repeated
1146        // total yields a 0 delta — never from summing, which would double.
1147        // Two tool calls in one round: cumulative output 0->40->100, so the
1148        // steps cost 40 and 60; the round total is 100.
1149        let dup = |total_out: u32, total_in: u32| {
1150            format!(
1151                r#"{{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":{total_in},"output_tokens":{total_out},"cached_input_tokens":0,"total_tokens":{}}}}}}}}}"#,
1152                total_in + total_out
1153            )
1154        };
1155        let body = [
1156            r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli"}}"#.to_string(),
1157            r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"r1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1158            r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}"#.to_string(),
1159            r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"step one"}],"phase":"commentary"}}"#.to_string(),
1160            dup(40, 10), dup(40, 10),       // step 1: out 40 (emitted twice)
1161            r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"step two"}],"phase":"final","end_turn":true}}"#.to_string(),
1162            dup(100, 20), dup(100, 20),     // step 2: out 100-40=60 (emitted twice)
1163            r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"r1"}}"#.to_string(),
1164        ].join("\n");
1165        let (_t, mgr, id) = setup_session_fixture(&body);
1166        let view = to_view(&mgr.read_session(&id).unwrap());
1167
1168        let assistants: Vec<&Turn> = view
1169            .turns
1170            .iter()
1171            .filter(|t| t.role == Role::Assistant)
1172            .collect();
1173        assert_eq!(assistants.len(), 2);
1174        // Per-step attribution: 40 then 60 — NOT 80/120 (which doubling gives).
1175        assert_eq!(
1176            assistants[0]
1177                .attributed_token_usage
1178                .as_ref()
1179                .unwrap()
1180                .output_tokens,
1181            Some(40)
1182        );
1183        assert_eq!(
1184            assistants[1]
1185                .attributed_token_usage
1186                .as_ref()
1187                .unwrap()
1188                .output_tokens,
1189            Some(60)
1190        );
1191        // Σ attributed == round total on the final turn.
1192        assert_eq!(
1193            assistants[1].token_usage.as_ref().unwrap().output_tokens,
1194            Some(100)
1195        );
1196        let sum: u32 = assistants
1197            .iter()
1198            .filter_map(|t| t.attributed_token_usage.as_ref()?.output_tokens)
1199            .sum();
1200        assert_eq!(sum, 100);
1201    }
1202
1203    /// Read the `breakdowns["output"]["reasoning"]` slice off a usage, or None.
1204    fn reasoning_of(u: Option<&TokenUsage>) -> Option<u32> {
1205        u?.breakdowns.get("output")?.get("reasoning").copied()
1206    }
1207
1208    #[test]
1209    fn reasoning_breakdown_is_per_step_delta_and_round_sum() {
1210        // `reasoning_output_tokens` is a SUBSET of output and rides on the
1211        // cumulative `total_token_usage`. It must be differenced exactly like
1212        // output: cumulative reasoning 0->100->260 ⇒ step deltas 100 then 160,
1213        // and the round total carries their sum (260) under
1214        // breakdowns["output"]["reasoning"]. Each token_count is emitted twice
1215        // (dedup-safe: a repeated total yields a 0 delta).
1216        let dup = |total_out: u32, total_reason: u32| {
1217            format!(
1218                r#"{{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":10,"output_tokens":{total_out},"reasoning_output_tokens":{total_reason},"cached_input_tokens":0,"total_tokens":{}}}}}}}}}"#,
1219                10 + total_out
1220            )
1221        };
1222        let body = [
1223            r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli"}}"#.to_string(),
1224            r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"r1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1225            r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}"#.to_string(),
1226            r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"step one"}],"phase":"commentary"}}"#.to_string(),
1227            dup(200, 100), dup(200, 100),   // step 1: output 200, reasoning 100
1228            r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"step two"}],"phase":"final","end_turn":true}}"#.to_string(),
1229            dup(500, 260), dup(500, 260),   // step 2: output 300, reasoning 160
1230            r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"r1"}}"#.to_string(),
1231        ].join("\n");
1232        let (_t, mgr, id) = setup_session_fixture(&body);
1233        let view = to_view(&mgr.read_session(&id).unwrap());
1234
1235        let assistants: Vec<&Turn> = view
1236            .turns
1237            .iter()
1238            .filter(|t| t.role == Role::Assistant)
1239            .collect();
1240        assert_eq!(assistants.len(), 2);
1241        // Per-step reasoning deltas, NOT cumulative (100/260) and NOT doubled.
1242        assert_eq!(
1243            reasoning_of(assistants[0].attributed_token_usage.as_ref()),
1244            Some(100)
1245        );
1246        assert_eq!(
1247            reasoning_of(assistants[1].attributed_token_usage.as_ref()),
1248            Some(160)
1249        );
1250        // Round total breakdown is the sum of attributions.
1251        let round = assistants[1].token_usage.as_ref().unwrap();
1252        assert_eq!(reasoning_of(Some(round)), Some(260));
1253        // Invariant: Σ(reasoning) ≤ output.
1254        assert!(260 <= round.output_tokens.unwrap());
1255    }
1256
1257    #[test]
1258    fn zero_reasoning_produces_no_breakdown_entry() {
1259        // A round whose cumulative reasoning never rises (absent or 0) must
1260        // leave breakdowns empty so the field is omitted on the wire.
1261        let dup = |total_out: u32| {
1262            format!(
1263                r#"{{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":10,"output_tokens":{total_out},"reasoning_output_tokens":0,"cached_input_tokens":0,"total_tokens":{}}}}}}}}}"#,
1264                10 + total_out
1265            )
1266        };
1267        let body = [
1268            r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli"}}"#.to_string(),
1269            r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"r1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#.to_string(),
1270            r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}"#.to_string(),
1271            r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}],"phase":"final","end_turn":true}}"#.to_string(),
1272            dup(40), dup(40),
1273            r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"r1"}}"#.to_string(),
1274        ].join("\n");
1275        let (_t, mgr, id) = setup_session_fixture(&body);
1276        let view = to_view(&mgr.read_session(&id).unwrap());
1277        let a = view
1278            .turns
1279            .iter()
1280            .find(|t| t.role == Role::Assistant)
1281            .unwrap();
1282        assert!(
1283            a.attributed_token_usage
1284                .as_ref()
1285                .unwrap()
1286                .breakdowns
1287                .is_empty()
1288        );
1289        assert!(a.token_usage.as_ref().unwrap().breakdowns.is_empty());
1290    }
1291
1292    #[test]
1293    fn round_turns_share_group_id_and_usage_lands_on_round_final_turn() {
1294        // One round emitting two assistant messages (commentary + final).
1295        // Both belong to one API round, so they share a group_id (the
1296        // round's turn_id) and the round total sits on the round's final
1297        // assistant turn only — never on an interior turn, and never as a
1298        // singleton claim on a turn whose siblings shared the spend.
1299        let body = [
1300            r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli"}}"#,
1301            r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"round-1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#,
1302            r#"{"timestamp":"2026-04-20T16:44:37.775Z","type":"event_msg","payload":{"type":"task_started","turn_id":"round-1"}}"#,
1303            r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}"#,
1304            r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"working on it"}],"phase":"commentary"}}"#,
1305            r#"{"timestamp":"2026-04-20T16:44:38.800Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"output_tokens":20,"cached_input_tokens":10,"total_tokens":130},"last_token_usage":{"input_tokens":100,"output_tokens":20,"cached_input_tokens":10,"total_tokens":130}}}}"#,
1306            r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}],"phase":"final","end_turn":true}}"#,
1307            r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"round-1","last_agent_message":"done"}}"#,
1308        ]
1309        .join("\n");
1310        let (_t, mgr, id) = setup_session_fixture(&body);
1311        let view = to_view(&mgr.read_session(&id).unwrap());
1312
1313        assert_eq!(view.turns.len(), 3);
1314        assert!(view.turns[0].group_id.is_none(), "user turn ungrouped");
1315        assert_eq!(view.turns[1].group_id.as_deref(), Some("round-1"));
1316        assert_eq!(view.turns[2].group_id.as_deref(), Some("round-1"));
1317        assert!(
1318            view.turns[1].token_usage.is_none(),
1319            "interior turn of the round must not carry usage"
1320        );
1321        let total = view.turns[2].token_usage.as_ref().unwrap();
1322        assert_eq!(total.output_tokens, Some(20));
1323        assert_eq!(total.input_tokens, Some(100));
1324    }
1325
1326    #[test]
1327    fn turn_usage_delta_is_computed_when_last_token_usage_missing() {
1328        // Older rollouts carry only cumulative totals; the per-turn value
1329        // must be the difference between successive totals, not the total.
1330        let (_t, mgr, id) = setup_session_fixture(&two_round_session(false));
1331        let view = to_view(&mgr.read_session(&id).unwrap());
1332
1333        let second = view.turns[3].token_usage.as_ref().unwrap();
1334        assert_eq!(second.input_tokens, Some(200));
1335        assert_eq!(second.output_tokens, Some(30));
1336        assert_eq!(second.cache_read_tokens, Some(30));
1337    }
1338
1339    #[test]
1340    fn encrypted_reasoning_does_not_land_on_thinking() {
1341        // The fixture only has encrypted_content. That must NOT be rendered
1342        // as `Turn.thinking` (which would be opaque ciphertext). Since
1343        // Turn.extra was removed, encrypted ciphertext is simply dropped.
1344        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1345        let view = to_view(&mgr.read_session(&id).unwrap());
1346        let assistant = &view.turns[1];
1347        assert!(
1348            assistant.thinking.is_none(),
1349            "encrypted ciphertext must not appear as thinking"
1350        );
1351    }
1352
1353    #[test]
1354    fn plaintext_reasoning_lands_on_thinking() {
1355        // Craft a session with a `content[*].text` reasoning item — this
1356        // is the rare public-reasoning case.
1357        let body = [
1358            r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/p","originator":"x","cli_version":"1","source":"cli"}}"#,
1359            r#"{"timestamp":"t","type":"response_item","payload":{"type":"reasoning","summary":[],"content":[{"type":"text","text":"I should check the file"}]}}"#,
1360            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"checking"}]}}"#,
1361        ]
1362        .join("\n");
1363        let (_t, mgr, id) = setup_session_fixture(&body);
1364        let view = to_view(&mgr.read_session(&id).unwrap());
1365        assert_eq!(
1366            view.turns[0].thinking.as_deref(),
1367            Some("I should check the file")
1368        );
1369    }
1370
1371    #[test]
1372    fn function_call_pairs_with_output() {
1373        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1374        let view = to_view(&mgr.read_session(&id).unwrap());
1375        let assistant = &view.turns[1];
1376        assert_eq!(assistant.tool_uses.len(), 2);
1377        let exec = &assistant.tool_uses[0];
1378        assert_eq!(exec.name, "exec_command");
1379        assert_eq!(exec.category, Some(ToolCategory::Shell));
1380        assert!(exec.result.is_some());
1381        assert!(exec.result.as_ref().unwrap().content.contains("/tmp/proj"));
1382    }
1383
1384    #[test]
1385    fn custom_tool_call_preserves_raw_input() {
1386        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1387        let view = to_view(&mgr.read_session(&id).unwrap());
1388        let assistant = &view.turns[1];
1389        let apply = &assistant.tool_uses[1];
1390        assert_eq!(apply.name, "apply_patch");
1391        assert_eq!(apply.category, Some(ToolCategory::FileWrite));
1392        let input_str = apply.input.as_str().unwrap();
1393        assert!(input_str.contains("*** Begin Patch"));
1394    }
1395
1396    #[test]
1397    fn patch_apply_end_aggregates_files_changed() {
1398        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1399        let view = to_view(&mgr.read_session(&id).unwrap());
1400        assert_eq!(view.files_changed, vec!["/tmp/proj/a.rs".to_string()]);
1401    }
1402
1403    #[test]
1404    fn files_changed_order_is_deterministic() {
1405        // patch.changes is a HashMap; iteration order in Rust is
1406        // randomized. Derive must still produce a stable ordering.
1407        let body = [
1408            r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/p","originator":"x","cli_version":"1","source":"cli"}}"#,
1409            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"go"}]}}"#,
1410            r#"{"timestamp":"t","type":"response_item","payload":{"type":"custom_tool_call","call_id":"c","name":"apply_patch","input":""}}"#,
1411            r#"{"timestamp":"t","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"c","success":true,"changes":{"/p/z.rs":{"type":"add","content":"z"},"/p/a.rs":{"type":"add","content":"a"},"/p/m.rs":{"type":"add","content":"m"}}}}"#,
1412        ]
1413        .join("\n");
1414        let (_t, mgr, id) = setup_session_fixture(&body);
1415        let view = to_view(&mgr.read_session(&id).unwrap());
1416        assert_eq!(
1417            view.files_changed,
1418            vec![
1419                "/p/a.rs".to_string(),
1420                "/p/m.rs".to_string(),
1421                "/p/z.rs".to_string(),
1422            ]
1423        );
1424    }
1425
1426    #[test]
1427    fn patch_apply_end_populates_turn_file_mutations() {
1428        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1429        let view = to_view(&mgr.read_session(&id).unwrap());
1430        // Find the turn that hosts the `apply_patch` file mutation. The
1431        // mutation's `tool_id` should link back to the apply_patch tool.
1432        let apply_patch_id = view
1433            .turns
1434            .iter()
1435            .flat_map(|t| t.tool_uses.iter())
1436            .find(|tu| tu.name == "apply_patch")
1437            .map(|tu| tu.id.clone())
1438            .expect("apply_patch tool invocation present");
1439        let fm = view
1440            .turns
1441            .iter()
1442            .flat_map(|t| t.file_mutations.iter())
1443            .find(|fm| fm.path == "/tmp/proj/a.rs")
1444            .expect("file mutation present");
1445        assert_eq!(fm.tool_id.as_ref(), Some(&apply_patch_id));
1446        assert_eq!(fm.operation.as_deref(), Some("add"));
1447        assert!(fm.raw_diff.is_some());
1448    }
1449
1450    #[test]
1451    fn total_usage_populated() {
1452        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1453        let view = to_view(&mgr.read_session(&id).unwrap());
1454        let u = view.total_usage.as_ref().unwrap();
1455        assert_eq!(u.input_tokens, Some(100));
1456        assert_eq!(u.output_tokens, Some(20));
1457        assert_eq!(u.cache_read_tokens, Some(10));
1458    }
1459
1460    #[test]
1461    fn events_preserve_non_turn_content() {
1462        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1463        let view = to_view(&mgr.read_session(&id).unwrap());
1464        let kinds: Vec<&str> = view.events.iter().map(|e| e.event_type.as_str()).collect();
1465        assert!(kinds.contains(&"session_meta"));
1466        assert!(kinds.contains(&"turn_context"));
1467        assert!(kinds.contains(&"task_started"));
1468        assert!(kinds.contains(&"task_complete"));
1469        assert!(kinds.contains(&"exec_command_end"));
1470        assert!(kinds.contains(&"patch_apply_end"));
1471        assert!(kinds.contains(&"token_count"));
1472    }
1473
1474    #[test]
1475    fn tool_category_mapping() {
1476        assert_eq!(tool_category("exec_command"), Some(ToolCategory::Shell));
1477        assert_eq!(tool_category("apply_patch"), Some(ToolCategory::FileWrite));
1478        assert_eq!(tool_category("read_file"), Some(ToolCategory::FileRead));
1479        assert_eq!(tool_category("grep_search"), Some(ToolCategory::FileSearch));
1480        assert_eq!(tool_category("web_fetch"), Some(ToolCategory::Network));
1481        assert_eq!(tool_category("spawn_agent"), Some(ToolCategory::Delegation));
1482        assert_eq!(tool_category("unknown_xyz"), None);
1483    }
1484
1485    #[test]
1486    fn provider_trait_list_load() {
1487        let (_t, mgr, _name) = setup_session_fixture(&minimal_session());
1488        let ids = ConversationProvider::list_conversations(&mgr, "").unwrap();
1489        // `list_conversations` returns inner session_meta.id, not filename stems.
1490        assert_eq!(
1491            ids,
1492            vec!["019dabc6-8fef-7681-a054-b5bb75fcb97d".to_string()]
1493        );
1494        let view = ConversationProvider::load_conversation(
1495            &mgr,
1496            "",
1497            "019dabc6-8fef-7681-a054-b5bb75fcb97d",
1498        )
1499        .unwrap();
1500        assert_eq!(view.turns.len(), 3);
1501    }
1502
1503    #[test]
1504    fn developer_role_becomes_system() {
1505        let body = [
1506            r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/","originator":"x","cli_version":"1","source":"cli"}}"#,
1507            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"system instructions"}]}}"#,
1508        ]
1509        .join("\n");
1510        let (_t, mgr, id) = setup_session_fixture(&body);
1511        let view = to_view(&mgr.read_session(&id).unwrap());
1512        assert_eq!(view.turns[0].role, Role::System);
1513    }
1514}