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