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. `event_msg.token_count` populates `Turn.token_usage` on the next
21//!    assistant turn emitted.
22//! 8. Everything else (`task_started`, `task_complete`, `turn_context`,
23//!    `user_message`/`agent_message` duplicates, unknown events) lands
24//!    in `ConversationView.events` as a typed [`ConversationEvent`].
25
26use std::collections::HashMap;
27
28use crate::io::ConvoIO;
29use crate::types::{
30    EventMsg, ExecCommandEnd, Message, PatchApplyEnd, PatchChange, ResponseItem, RolloutItem,
31    Session, TokenCountInfo, TokenUsage as CodexTokenUsage,
32};
33use serde_json::Value;
34use toolpath_convo::{
35    ConversationEvent, ConversationMeta, ConversationProvider, ConversationView, ConvoError,
36    EnvironmentSnapshot, FileMutation, ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory,
37    ToolInvocation, ToolResult, Turn,
38};
39
40/// Provider for Codex sessions.
41#[derive(Debug, Clone, Default)]
42pub struct CodexConvo {
43    io: ConvoIO,
44}
45
46impl CodexConvo {
47    pub fn new() -> Self {
48        Self { io: ConvoIO::new() }
49    }
50
51    pub fn with_resolver(resolver: crate::paths::PathResolver) -> Self {
52        Self {
53            io: ConvoIO::with_resolver(resolver),
54        }
55    }
56
57    pub fn io(&self) -> &ConvoIO {
58        &self.io
59    }
60
61    pub fn resolver(&self) -> &crate::paths::PathResolver {
62        self.io.resolver()
63    }
64
65    /// Read one session into a [`Session`] struct (raw lines).
66    pub fn read_session(&self, session_id: &str) -> crate::Result<Session> {
67        self.io.read_session(session_id)
68    }
69
70    /// List all sessions, newest first.
71    pub fn list_sessions(&self) -> crate::Result<Vec<crate::types::SessionMetadata>> {
72        self.io.list_sessions()
73    }
74
75    /// Most recent session (by last activity), if any.
76    pub fn most_recent_session(&self) -> crate::Result<Option<Session>> {
77        let metas = self.list_sessions()?;
78        match metas.first() {
79            Some(m) => Ok(Some(self.read_session(&m.id)?)),
80            None => Ok(None),
81        }
82    }
83
84    /// Read all sessions into memory (expensive on large histories).
85    pub fn read_all_sessions(&self) -> crate::Result<Vec<Session>> {
86        let metas = self.list_sessions()?;
87        let mut out = Vec::with_capacity(metas.len());
88        for m in metas {
89            match self.read_session(&m.id) {
90                Ok(s) => out.push(s),
91                Err(e) => eprintln!("Warning: could not read session {}: {}", m.id, e),
92            }
93        }
94        Ok(out)
95    }
96}
97
98// ── Tool classification ─────────────────────────────────────────────
99
100/// Classify a Codex tool name into toolpath's category ontology.
101pub fn tool_category(name: &str) -> Option<ToolCategory> {
102    match name {
103        "read_file" | "read_many_files" | "list_dir" | "view_image" | "mcp_resource" => {
104            Some(ToolCategory::FileRead)
105        }
106        "glob" | "grep_search" | "search_file_content" | "tool_search" | "tool_suggest" => {
107            Some(ToolCategory::FileSearch)
108        }
109        "write_file" | "apply_patch" | "replace" | "edit" => Some(ToolCategory::FileWrite),
110        "shell" | "exec_command" | "unified_exec" | "write_stdin" | "js_repl" => {
111            Some(ToolCategory::Shell)
112        }
113        "web_fetch" | "web_search" | "google_web_search" => Some(ToolCategory::Network),
114        "spawn_agent" | "close_agent" | "wait_agent" | "resume_agent" | "send_message"
115        | "followup_task" | "list_agents" | "agent_jobs" | "task" | "activate_skill" => {
116            Some(ToolCategory::Delegation)
117        }
118        _ => None,
119    }
120}
121
122/// Reverse of [`tool_category`]: pick Codex's preferred native tool name
123/// for a generic [`ToolCategory`], using call args to disambiguate.
124///
125/// Used by [`crate::project::CodexProjector`] when projecting tool calls
126/// from foreign harnesses. Notably, FileWrite always maps to `write_file`
127/// (not `apply_patch`) — `apply_patch` takes a free-form V4A patch
128/// string rather than JSON args, so projecting JSON-shape edits as
129/// `apply_patch` would emit a malformed CustomToolCall. Same-harness
130/// round-trips preserve the source name verbatim before reaching this
131/// fallback.
132pub fn native_name(category: ToolCategory, args: &Value) -> Option<&'static str> {
133    match category {
134        ToolCategory::Shell => Some("exec_command"),
135        ToolCategory::FileRead => Some(if args.get("file_paths").is_some() {
136            "read_many_files"
137        } else if args.get("path").is_some() && args.get("file_path").is_none() {
138            "list_dir"
139        } else {
140            "read_file"
141        }),
142        ToolCategory::FileSearch => Some(if args.get("pattern").is_some() {
143            "grep_search"
144        } else {
145            "glob"
146        }),
147        ToolCategory::FileWrite => Some("write_file"),
148        ToolCategory::Network => Some(if args.get("url").is_some() {
149            "web_fetch"
150        } else {
151            "web_search"
152        }),
153        ToolCategory::Delegation => Some("spawn_agent"),
154    }
155}
156
157// ── Session → ConversationView ─────────────────────────────────────
158
159/// Convert a parsed Codex [`Session`] to the provider-agnostic
160/// [`ConversationView`] shape.
161pub fn to_view(session: &Session) -> ConversationView {
162    Builder::new(session).build()
163}
164
165/// Convert one rollout line to a "best-effort" `Turn`, if it carries
166/// one. Used by consumers who want per-line processing without the
167/// cross-line assembly that [`to_view`] does.
168pub fn to_turn(line_payload: &ResponseItem) -> Option<Turn> {
169    if let ResponseItem::Message(m) = line_payload {
170        Some(message_to_turn(m, "", None, None))
171    } else {
172        None
173    }
174}
175
176struct Builder<'a> {
177    session: &'a Session,
178    turns: Vec<Turn>,
179    events: Vec<ConversationEvent>,
180    /// Plaintext reasoning summaries (rare — only in configurations where
181    /// OpenAI exposes public reasoning). These land on `Turn.thinking`.
182    pending_reasoning_plaintext: Vec<String>,
183    pending_token_usage: Option<TokenUsage>,
184    working_dir: Option<String>,
185    current_model: Option<String>,
186    call_index: HashMap<String, (usize, usize)>,
187    total_usage: TokenUsage,
188    total_usage_set: bool,
189    files_changed_order: Vec<String>,
190    files_changed_seen: std::collections::HashSet<String>,
191}
192
193impl<'a> Builder<'a> {
194    fn new(session: &'a Session) -> Self {
195        Self {
196            session,
197            turns: Vec::new(),
198            events: Vec::new(),
199            pending_reasoning_plaintext: Vec::new(),
200            pending_token_usage: None,
201            working_dir: None,
202            current_model: None,
203            call_index: HashMap::new(),
204            total_usage: TokenUsage::default(),
205            total_usage_set: false,
206            files_changed_order: Vec::new(),
207            files_changed_seen: std::collections::HashSet::new(),
208        }
209    }
210
211    fn build(mut self) -> ConversationView {
212        for line in &self.session.lines {
213            match line.item() {
214                RolloutItem::SessionMeta(m) => {
215                    self.working_dir = Some(m.cwd.to_string_lossy().to_string());
216                    self.events.push(event_from_raw(
217                        &line.timestamp,
218                        "session_meta",
219                        &line.payload,
220                    ));
221                }
222                RolloutItem::TurnContext(tc) => {
223                    if let Some(m) = &tc.model {
224                        self.current_model = Some(m.clone());
225                    }
226                    let wd = tc.cwd.to_string_lossy().to_string();
227                    if !wd.is_empty() {
228                        self.working_dir = Some(wd);
229                    }
230                    self.events.push(event_from_raw(
231                        &line.timestamp,
232                        "turn_context",
233                        &line.payload,
234                    ));
235                }
236                RolloutItem::ResponseItem(ri) => self.handle_response_item(&line.timestamp, ri),
237                RolloutItem::EventMsg(ev) => {
238                    self.handle_event_msg(&line.timestamp, ev, &line.payload)
239                }
240                RolloutItem::SessionState(payload) => {
241                    self.events
242                        .push(event_from_raw(&line.timestamp, "session_state", &payload));
243                }
244                RolloutItem::Compacted(payload) => {
245                    self.events
246                        .push(event_from_raw(&line.timestamp, "compacted", &payload));
247                }
248                RolloutItem::Unknown { kind, payload } => {
249                    self.events
250                        .push(event_from_raw(&line.timestamp, &kind, &payload));
251                }
252            }
253        }
254
255        // Path-level base context from session_meta (cwd + git).
256        let meta = self.session.meta();
257        let base = {
258            let wd = meta
259                .as_ref()
260                .map(|m| m.cwd.to_string_lossy().to_string())
261                .filter(|s| !s.is_empty())
262                .or_else(|| self.working_dir.clone());
263            let git = meta.as_ref().and_then(|m| m.git.as_ref());
264            let revision = git.and_then(|g| g.commit_hash.clone());
265            let branch = git.and_then(|g| g.branch.clone());
266            let remote = git.and_then(|g| g.repository_url.clone());
267            if wd.is_some() || revision.is_some() || branch.is_some() || remote.is_some() {
268                Some(SessionBase {
269                    working_dir: wd,
270                    vcs_revision: revision,
271                    vcs_branch: branch,
272                    vcs_remote: remote,
273                })
274            } else {
275                None
276            }
277        };
278
279        // Producer (originator + cli_version) lifts onto the typed view
280        // field. `model_provider` already lives on each assistant
281        // `ActorDefinition.provider`. Codex's `source` and `forked_from_id`
282        // are wire-level fields with no cross-harness analog — the codex
283        // projector hard-codes defaults on the return path, so we let them
284        // drop on this side.
285        let producer = meta.as_ref().map(|m| ProducerInfo {
286            name: m.originator.clone(),
287            version: Some(m.cli_version.clone()),
288        });
289
290        // Filter empty carrier turns (no text, no thinking, no tool calls).
291        // Previously done inside `derive_path_from_view`; moved here so the
292        // canonical `derive_path` sees only meaningful turns.
293        self.turns
294            .retain(|t| !(t.text.is_empty() && t.thinking.is_none() && t.tool_uses.is_empty()));
295
296        // Assign synthetic ids to turns whose source message didn't carry
297        // one, then link sequentially via `parent_id` so the shared
298        // `derive_path` can walk a connected DAG. Codex turns don't carry
299        // explicit parent ids on the wire; this preserves the linear
300        // ordering the old `derive_path_from_view` produced.
301        for (idx, t) in self.turns.iter_mut().enumerate() {
302            if t.id.is_empty() {
303                t.id = format!("codex-turn-{:04}", idx + 1);
304            }
305        }
306        let mut prev: Option<String> = None;
307        for t in self.turns.iter_mut() {
308            if t.parent_id.is_none() {
309                t.parent_id = prev.clone();
310            }
311            prev = Some(t.id.clone());
312        }
313
314        // Disambiguate event ids. `event_from_raw` synthesizes
315        // `<event_type>-<timestamp>`, which collides when codex emits
316        // multiple events of the same type at the same timestamp (rare
317        // but real). Suffix duplicates with their position so each step
318        // gets a unique id.
319        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
320        for t in &self.turns {
321            seen.insert(t.id.clone());
322        }
323        for (i, e) in self.events.iter_mut().enumerate() {
324            if !seen.insert(e.id.clone()) {
325                e.id = format!("{}-{:04}", e.id, i);
326                seen.insert(e.id.clone());
327            }
328        }
329
330        ConversationView {
331            id: self.session.id.clone(),
332            started_at: self.session.started_at(),
333            last_activity: self.session.last_activity(),
334            turns: self.turns,
335            total_usage: if self.total_usage_set {
336                Some(self.total_usage)
337            } else {
338                None
339            },
340            provider_id: Some("codex".into()),
341            files_changed: self.files_changed_order,
342            session_ids: vec![],
343            events: self.events,
344            base,
345            producer,
346        }
347    }
348
349    fn handle_response_item(&mut self, timestamp: &str, ri: ResponseItem) {
350        match ri {
351            ResponseItem::Message(msg) => {
352                let turn = message_to_turn(
353                    &msg,
354                    timestamp,
355                    self.working_dir.as_deref(),
356                    self.current_model.as_deref(),
357                );
358                self.push_turn(turn);
359            }
360            ResponseItem::Reasoning(r) => {
361                // Plaintext content (rare) → Turn.thinking.
362                if let Some(Value::Array(arr)) = r.content.as_ref() {
363                    for v in arr {
364                        if let Some(s) = v.get("text").and_then(|t| t.as_str()) {
365                            self.pending_reasoning_plaintext.push(s.to_string());
366                        }
367                    }
368                }
369                // Plaintext summary items — same treatment.
370                for v in &r.summary {
371                    if let Some(s) = v.get("text").and_then(|t| t.as_str()) {
372                        self.pending_reasoning_plaintext.push(s.to_string());
373                    }
374                }
375            }
376            ResponseItem::FunctionCall(fc) => {
377                let name = fc.name.clone();
378                let input = fc.arguments_as_json();
379                let input = if input.is_null() {
380                    Value::String(fc.arguments.clone())
381                } else {
382                    input
383                };
384                self.attach_tool_call(timestamp, fc.call_id, name, input);
385            }
386            ResponseItem::FunctionCallOutput(out) => {
387                let is_error = out
388                    .extra
389                    .get("is_error")
390                    .and_then(Value::as_bool)
391                    .unwrap_or(false);
392                self.attach_tool_output(&out.call_id, &out.output, is_error);
393            }
394            ResponseItem::CustomToolCall(ct) => {
395                let input = Value::String(ct.input.clone());
396                self.attach_tool_call(timestamp, ct.call_id, ct.name, input);
397            }
398            ResponseItem::CustomToolCallOutput(out) => {
399                let is_error = out
400                    .extra
401                    .get("is_error")
402                    .and_then(Value::as_bool)
403                    .unwrap_or(false);
404                self.attach_tool_output(&out.call_id, &out.output, is_error);
405            }
406            ResponseItem::Other { kind, payload } => {
407                self.events.push(ConversationEvent {
408                    id: synthetic_event_id(timestamp, &kind),
409                    timestamp: timestamp.to_string(),
410                    parent_id: None,
411                    event_type: format!("response_item.{}", kind),
412                    data: data_from_value(&payload),
413                });
414            }
415        }
416    }
417
418    fn handle_event_msg(&mut self, timestamp: &str, ev: EventMsg, raw_payload: &Value) {
419        match ev {
420            EventMsg::TokenCount(tc) => {
421                if let Some(info) = tc.info.as_ref() {
422                    apply_token_count(&mut self.total_usage, info);
423                    self.total_usage_set = true;
424                    if let Some(total) = info.total_token_usage.as_ref() {
425                        self.pending_token_usage = Some(codex_usage_to_convo(total));
426                    }
427                }
428                self.events
429                    .push(event_from_raw(timestamp, "token_count", raw_payload));
430            }
431            EventMsg::ExecCommandEnd(exec) => {
432                self.apply_exec_command_end(&exec);
433                self.events
434                    .push(event_from_raw(timestamp, "exec_command_end", raw_payload));
435            }
436            EventMsg::PatchApplyEnd(patch) => {
437                self.apply_patch_apply_end(&patch);
438                self.events
439                    .push(event_from_raw(timestamp, "patch_apply_end", raw_payload));
440            }
441            EventMsg::AgentMessage(_)
442            | EventMsg::UserMessage(_)
443            | EventMsg::TaskStarted(_)
444            | EventMsg::TaskComplete(_) => {
445                self.events
446                    .push(event_from_raw(timestamp, ev.kind(), raw_payload));
447            }
448            EventMsg::Other { kind, payload } => {
449                self.events.push(event_from_raw(timestamp, &kind, &payload));
450            }
451        }
452    }
453
454    fn attach_tool_call(&mut self, timestamp: &str, call_id: String, name: String, input: Value) {
455        let category = tool_category(&name);
456        let invocation = ToolInvocation {
457            id: call_id.clone(),
458            name,
459            input,
460            result: None,
461            category,
462        };
463
464        let turn_idx = match self.last_assistant_turn_index() {
465            Some(idx) => idx,
466            None => {
467                let mut t = synthetic_assistant_turn(
468                    timestamp,
469                    self.working_dir.as_deref(),
470                    self.current_model.as_deref(),
471                );
472                self.drain_pending_onto(&mut t);
473                self.turns.push(t);
474                self.turns.len() - 1
475            }
476        };
477        let tool_idx = self.turns[turn_idx].tool_uses.len();
478        self.turns[turn_idx].tool_uses.push(invocation);
479        self.call_index.insert(call_id, (turn_idx, tool_idx));
480    }
481
482    fn attach_tool_output(&mut self, call_id: &str, output: &str, is_error: bool) {
483        if let Some((turn_idx, tool_idx)) = self.call_index.get(call_id).copied() {
484            let turn = &mut self.turns[turn_idx];
485            if let Some(inv) = turn.tool_uses.get_mut(tool_idx) {
486                let prior_error = inv.result.as_ref().map(|r| r.is_error).unwrap_or(false);
487                let merged = match inv.result.as_ref() {
488                    Some(existing) => format!("{}\n{}", existing.content, output),
489                    None => output.to_string(),
490                };
491                inv.result = Some(ToolResult {
492                    content: merged,
493                    is_error: is_error || prior_error,
494                });
495            }
496        }
497    }
498
499    fn apply_exec_command_end(&mut self, exec: &ExecCommandEnd) {
500        if let Some((turn_idx, tool_idx)) = self.call_index.get(&exec.call_id).copied() {
501            let turn = &mut self.turns[turn_idx];
502            if let Some(inv) = turn.tool_uses.get_mut(tool_idx) {
503                let is_error = exec.exit_code.map(|c| c != 0).unwrap_or(false);
504                if inv.result.is_none() {
505                    let body = if !exec.aggregated_output.is_empty() {
506                        exec.aggregated_output.clone()
507                    } else if !exec.stdout.is_empty() || !exec.stderr.is_empty() {
508                        let mut s = String::new();
509                        if !exec.stdout.is_empty() {
510                            s.push_str(&exec.stdout);
511                        }
512                        if !exec.stderr.is_empty() {
513                            if !s.is_empty() {
514                                s.push('\n');
515                            }
516                            s.push_str(&exec.stderr);
517                        }
518                        s
519                    } else {
520                        format!("(exit {})", exec.exit_code.unwrap_or_default())
521                    };
522                    inv.result = Some(ToolResult {
523                        content: body,
524                        is_error,
525                    });
526                } else if is_error && let Some(r) = inv.result.as_mut() {
527                    r.is_error = true;
528                }
529            }
530        }
531    }
532
533    fn apply_patch_apply_end(&mut self, patch: &PatchApplyEnd) {
534        let loc = self.call_index.get(&patch.call_id).copied();
535
536        // `patch.changes` is a HashMap — iterate in sorted order so the
537        // derived order is deterministic across runs.
538        let mut paths: Vec<&String> = patch.changes.keys().collect();
539        paths.sort();
540
541        // Populate `turn.file_mutations` on the matching turn, with
542        // `tool_id` set to the `call_id` so `derive_path` can link the
543        // sibling `file.write` change back to this specific tool call.
544        if let Some((turn_idx, _tool_idx)) = loc {
545            let turn = &mut self.turns[turn_idx];
546            for path in &paths {
547                if let Some(change) = patch.changes.get(*path) {
548                    let mut fm = patch_change_to_file_mutation(path, change);
549                    fm.tool_id = Some(patch.call_id.clone());
550                    turn.file_mutations.push(fm);
551                }
552            }
553        }
554
555        for path in paths {
556            if self.files_changed_seen.insert(path.clone()) {
557                self.files_changed_order.push(path.clone());
558            }
559        }
560    }
561
562    fn push_turn(&mut self, mut turn: Turn) {
563        self.drain_pending_onto(&mut turn);
564        self.turns.push(turn);
565    }
566
567    fn drain_pending_onto(&mut self, turn: &mut Turn) {
568        if turn.role != Role::Assistant {
569            return;
570        }
571        // Plaintext reasoning summaries are safe to render as thinking.
572        if !self.pending_reasoning_plaintext.is_empty() {
573            turn.thinking = Some(self.pending_reasoning_plaintext.join("\n\n"));
574            self.pending_reasoning_plaintext.clear();
575        }
576        if let Some(tu) = self.pending_token_usage.take() {
577            turn.token_usage = Some(tu);
578        }
579    }
580
581    fn last_assistant_turn_index(&self) -> Option<usize> {
582        self.turns
583            .iter()
584            .rposition(|t| t.role == Role::Assistant)
585            .or_else(|| self.turns.len().checked_sub(1))
586    }
587}
588
589// ── Patch → FileMutation conversion ─────────────────────────────────
590
591fn patch_change_to_file_mutation(path: &str, change: &PatchChange) -> FileMutation {
592    let mut fm = FileMutation {
593        path: path.to_string(),
594        ..Default::default()
595    };
596    match change {
597        PatchChange::Add { content, .. } => {
598            fm.operation = Some("add".into());
599            fm.after = Some(content.clone());
600            fm.raw_diff = Some(synth_add_diff(content));
601        }
602        PatchChange::Update {
603            unified_diff,
604            move_path,
605            ..
606        } => {
607            fm.operation = Some("update".into());
608            fm.raw_diff = Some(unified_diff.clone());
609            fm.rename_to = move_path.clone();
610        }
611        PatchChange::Delete {
612            original_content, ..
613        } => {
614            fm.operation = Some("delete".into());
615            fm.before = original_content.clone();
616            fm.raw_diff = original_content.as_deref().map(synth_delete_diff);
617        }
618        PatchChange::Unknown => {
619            fm.operation = Some("unknown".into());
620        }
621    }
622    fm
623}
624
625fn synth_add_diff(content: &str) -> String {
626    let lines: Vec<&str> = content.split('\n').collect();
627    let effective: &[&str] = if lines.last() == Some(&"") {
628        &lines[..lines.len().saturating_sub(1)]
629    } else {
630        &lines[..]
631    };
632    let mut buf = format!("@@ -0,0 +1,{} @@\n", effective.len());
633    for l in effective {
634        buf.push('+');
635        buf.push_str(l);
636        buf.push('\n');
637    }
638    buf
639}
640
641fn synth_delete_diff(original: &str) -> String {
642    let lines: Vec<&str> = original.split('\n').collect();
643    let effective: &[&str] = if lines.last() == Some(&"") {
644        &lines[..lines.len().saturating_sub(1)]
645    } else {
646        &lines[..]
647    };
648    let mut buf = format!("@@ -1,{} +0,0 @@\n", effective.len());
649    for l in effective {
650        buf.push('-');
651        buf.push_str(l);
652        buf.push('\n');
653    }
654    buf
655}
656
657fn message_to_turn(
658    msg: &Message,
659    timestamp: &str,
660    working_dir: Option<&str>,
661    model: Option<&str>,
662) -> Turn {
663    let role = match msg.role.as_str() {
664        "user" => Role::User,
665        "assistant" => Role::Assistant,
666        "developer" | "system" => Role::System,
667        other => Role::Other(other.to_string()),
668    };
669
670    let text = msg.text();
671
672    let environment = working_dir.map(|wd| EnvironmentSnapshot {
673        working_dir: Some(wd.to_string()),
674        vcs_branch: None,
675        vcs_revision: None,
676    });
677
678    Turn {
679        id: msg.id.clone().unwrap_or_default(),
680        parent_id: None,
681        role: role.clone(),
682        timestamp: timestamp.to_string(),
683        text,
684        thinking: None,
685        tool_uses: Vec::new(),
686        model: if role == Role::Assistant {
687            model.map(str::to_string)
688        } else {
689            None
690        },
691        stop_reason: None,
692        token_usage: None,
693        environment,
694        delegations: Vec::new(),
695        file_mutations: Vec::new(),
696    }
697}
698
699fn synthetic_assistant_turn(
700    timestamp: &str,
701    working_dir: Option<&str>,
702    model: Option<&str>,
703) -> Turn {
704    Turn {
705        id: format!("synth-{}", timestamp),
706        parent_id: None,
707        role: Role::Assistant,
708        timestamp: timestamp.to_string(),
709        text: String::new(),
710        thinking: None,
711        tool_uses: Vec::new(),
712        model: model.map(str::to_string),
713        stop_reason: None,
714        token_usage: None,
715        environment: working_dir.map(|wd| EnvironmentSnapshot {
716            working_dir: Some(wd.to_string()),
717            vcs_branch: None,
718            vcs_revision: None,
719        }),
720        delegations: Vec::new(),
721        file_mutations: Vec::new(),
722    }
723}
724
725fn codex_usage_to_convo(u: &CodexTokenUsage) -> TokenUsage {
726    TokenUsage {
727        input_tokens: u.input_tokens,
728        output_tokens: u.output_tokens,
729        cache_read_tokens: u.cached_input_tokens,
730        cache_write_tokens: None,
731    }
732}
733
734fn apply_token_count(total: &mut TokenUsage, info: &TokenCountInfo) {
735    if let Some(t) = info.total_token_usage.as_ref() {
736        total.input_tokens = t.input_tokens.or(total.input_tokens);
737        total.output_tokens = t.output_tokens.or(total.output_tokens);
738        total.cache_read_tokens = t.cached_input_tokens.or(total.cache_read_tokens);
739    }
740}
741
742fn event_from_raw(timestamp: &str, event_type: &str, payload: &Value) -> ConversationEvent {
743    ConversationEvent {
744        id: synthetic_event_id(timestamp, event_type),
745        timestamp: timestamp.to_string(),
746        parent_id: None,
747        event_type: event_type.to_string(),
748        data: data_from_value(payload),
749    }
750}
751
752fn synthetic_event_id(timestamp: &str, kind: &str) -> String {
753    format!("{}-{}", kind, timestamp)
754}
755
756fn data_from_value(v: &Value) -> HashMap<String, Value> {
757    match v {
758        Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
759        _ => {
760            let mut m = HashMap::new();
761            m.insert("value".into(), v.clone());
762            m
763        }
764    }
765}
766
767// ── ConversationProvider trait impl ────────────────────────────────
768
769impl ConversationProvider for CodexConvo {
770    fn list_conversations(&self, _project: &str) -> toolpath_convo::Result<Vec<String>> {
771        let metas = self
772            .list_sessions()
773            .map_err(|e| ConvoError::Provider(e.to_string()))?;
774        Ok(metas.into_iter().map(|m| m.id).collect())
775    }
776
777    fn load_conversation(
778        &self,
779        _project: &str,
780        conversation_id: &str,
781    ) -> toolpath_convo::Result<ConversationView> {
782        let session = self
783            .read_session(conversation_id)
784            .map_err(|e| ConvoError::Provider(e.to_string()))?;
785        Ok(to_view(&session))
786    }
787
788    fn load_metadata(
789        &self,
790        _project: &str,
791        conversation_id: &str,
792    ) -> toolpath_convo::Result<ConversationMeta> {
793        let path = self
794            .io
795            .resolver()
796            .find_rollout_file(conversation_id)
797            .map_err(|e| ConvoError::Provider(e.to_string()))?;
798        let meta = self
799            .io
800            .read_metadata(path)
801            .map_err(|e| ConvoError::Provider(e.to_string()))?;
802        Ok(ConversationMeta {
803            id: meta.id,
804            started_at: meta.started_at,
805            last_activity: meta.last_activity,
806            message_count: meta.line_count,
807            file_path: Some(meta.file_path),
808            predecessor: None,
809            successor: None,
810        })
811    }
812
813    fn list_metadata(&self, _project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
814        let metas = self
815            .list_sessions()
816            .map_err(|e| ConvoError::Provider(e.to_string()))?;
817        Ok(metas
818            .into_iter()
819            .map(|m| ConversationMeta {
820                id: m.id,
821                started_at: m.started_at,
822                last_activity: m.last_activity,
823                message_count: m.line_count,
824                file_path: Some(m.file_path),
825                predecessor: None,
826                successor: None,
827            })
828            .collect())
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835    use std::fs;
836    use tempfile::TempDir;
837
838    fn setup_session_fixture(body: &str) -> (TempDir, CodexConvo, String) {
839        let temp = TempDir::new().unwrap();
840        let codex = temp.path().join(".codex");
841        let day = codex.join("sessions/2026/04/20");
842        fs::create_dir_all(&day).unwrap();
843        let name = "rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d";
844        fs::write(day.join(format!("{}.jsonl", name)), body).unwrap();
845        let resolver = crate::paths::PathResolver::new().with_codex_dir(&codex);
846        (temp, CodexConvo::with_resolver(resolver), name.to_string())
847    }
848
849    fn minimal_session() -> String {
850        [
851            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"}}}"#,
852            r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"t1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#,
853            r#"{"timestamp":"2026-04-20T16:44:37.775Z","type":"event_msg","payload":{"type":"task_started","turn_id":"t1"}}"#,
854            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"}]}}"#,
855            r#"{"timestamp":"2026-04-20T16:44:38.000Z","type":"response_item","payload":{"type":"reasoning","summary":[],"content":null,"encrypted_content":"encrypted-blob-1"}}"#,
856            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"}}"#,
857            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"}}"#,
858            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"}}"#,
859            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"}}"#,
860            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"}}"#,
861            r#"{"timestamp":"2026-04-20T16:44:38.600Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_2","output":"{\"output\":\"ok\"}"}}"#,
862            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"}}}}"#,
863            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}}}}"#,
864            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}}"#,
865            r#"{"timestamp":"2026-04-20T16:44:39.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"t1","last_agent_message":"done"}}"#,
866        ]
867        .join("\n")
868    }
869
870    #[test]
871    fn build_view_basic() {
872        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
873        let session = mgr.read_session(&id).unwrap();
874        let view = to_view(&session);
875
876        assert_eq!(view.id, "019dabc6-8fef-7681-a054-b5bb75fcb97d");
877        assert_eq!(view.provider_id.as_deref(), Some("codex"));
878        assert_eq!(view.turns.len(), 3);
879        assert_eq!(view.turns[0].role, Role::User);
880        assert_eq!(view.turns[0].text, "please do a thing");
881        assert_eq!(view.turns[1].role, Role::Assistant);
882        assert_eq!(view.turns[1].text, "working on it");
883        assert_eq!(view.turns[1].model.as_deref(), Some("gpt-5.4"));
884    }
885
886    #[test]
887    fn encrypted_reasoning_does_not_land_on_thinking() {
888        // The fixture only has encrypted_content. That must NOT be rendered
889        // as `Turn.thinking` (which would be opaque ciphertext). Since
890        // Turn.extra was removed, encrypted ciphertext is simply dropped.
891        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
892        let view = to_view(&mgr.read_session(&id).unwrap());
893        let assistant = &view.turns[1];
894        assert!(
895            assistant.thinking.is_none(),
896            "encrypted ciphertext must not appear as thinking"
897        );
898    }
899
900    #[test]
901    fn plaintext_reasoning_lands_on_thinking() {
902        // Craft a session with a `content[*].text` reasoning item — this
903        // is the rare public-reasoning case.
904        let body = [
905            r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/p","originator":"x","cli_version":"1","source":"cli"}}"#,
906            r#"{"timestamp":"t","type":"response_item","payload":{"type":"reasoning","summary":[],"content":[{"type":"text","text":"I should check the file"}]}}"#,
907            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"checking"}]}}"#,
908        ]
909        .join("\n");
910        let (_t, mgr, id) = setup_session_fixture(&body);
911        let view = to_view(&mgr.read_session(&id).unwrap());
912        assert_eq!(
913            view.turns[0].thinking.as_deref(),
914            Some("I should check the file")
915        );
916    }
917
918    #[test]
919    fn function_call_pairs_with_output() {
920        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
921        let view = to_view(&mgr.read_session(&id).unwrap());
922        let assistant = &view.turns[1];
923        assert_eq!(assistant.tool_uses.len(), 2);
924        let exec = &assistant.tool_uses[0];
925        assert_eq!(exec.name, "exec_command");
926        assert_eq!(exec.category, Some(ToolCategory::Shell));
927        assert!(exec.result.is_some());
928        assert!(exec.result.as_ref().unwrap().content.contains("/tmp/proj"));
929    }
930
931    #[test]
932    fn custom_tool_call_preserves_raw_input() {
933        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
934        let view = to_view(&mgr.read_session(&id).unwrap());
935        let assistant = &view.turns[1];
936        let apply = &assistant.tool_uses[1];
937        assert_eq!(apply.name, "apply_patch");
938        assert_eq!(apply.category, Some(ToolCategory::FileWrite));
939        let input_str = apply.input.as_str().unwrap();
940        assert!(input_str.contains("*** Begin Patch"));
941    }
942
943    #[test]
944    fn patch_apply_end_aggregates_files_changed() {
945        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
946        let view = to_view(&mgr.read_session(&id).unwrap());
947        assert_eq!(view.files_changed, vec!["/tmp/proj/a.rs".to_string()]);
948    }
949
950    #[test]
951    fn files_changed_order_is_deterministic() {
952        // patch.changes is a HashMap; iteration order in Rust is
953        // randomized. Derive must still produce a stable ordering.
954        let body = [
955            r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/p","originator":"x","cli_version":"1","source":"cli"}}"#,
956            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"go"}]}}"#,
957            r#"{"timestamp":"t","type":"response_item","payload":{"type":"custom_tool_call","call_id":"c","name":"apply_patch","input":""}}"#,
958            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"}}}}"#,
959        ]
960        .join("\n");
961        let (_t, mgr, id) = setup_session_fixture(&body);
962        let view = to_view(&mgr.read_session(&id).unwrap());
963        assert_eq!(
964            view.files_changed,
965            vec![
966                "/p/a.rs".to_string(),
967                "/p/m.rs".to_string(),
968                "/p/z.rs".to_string(),
969            ]
970        );
971    }
972
973    #[test]
974    fn patch_apply_end_populates_turn_file_mutations() {
975        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
976        let view = to_view(&mgr.read_session(&id).unwrap());
977        // Find the turn that hosts the `apply_patch` file mutation. The
978        // mutation's `tool_id` should link back to the apply_patch tool.
979        let apply_patch_id = view
980            .turns
981            .iter()
982            .flat_map(|t| t.tool_uses.iter())
983            .find(|tu| tu.name == "apply_patch")
984            .map(|tu| tu.id.clone())
985            .expect("apply_patch tool invocation present");
986        let fm = view
987            .turns
988            .iter()
989            .flat_map(|t| t.file_mutations.iter())
990            .find(|fm| fm.path == "/tmp/proj/a.rs")
991            .expect("file mutation present");
992        assert_eq!(fm.tool_id.as_ref(), Some(&apply_patch_id));
993        assert_eq!(fm.operation.as_deref(), Some("add"));
994        assert!(fm.raw_diff.is_some());
995    }
996
997    #[test]
998    fn total_usage_populated() {
999        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1000        let view = to_view(&mgr.read_session(&id).unwrap());
1001        let u = view.total_usage.as_ref().unwrap();
1002        assert_eq!(u.input_tokens, Some(100));
1003        assert_eq!(u.output_tokens, Some(20));
1004        assert_eq!(u.cache_read_tokens, Some(10));
1005    }
1006
1007    #[test]
1008    fn events_preserve_non_turn_content() {
1009        let (_t, mgr, id) = setup_session_fixture(&minimal_session());
1010        let view = to_view(&mgr.read_session(&id).unwrap());
1011        let kinds: Vec<&str> = view.events.iter().map(|e| e.event_type.as_str()).collect();
1012        assert!(kinds.contains(&"session_meta"));
1013        assert!(kinds.contains(&"turn_context"));
1014        assert!(kinds.contains(&"task_started"));
1015        assert!(kinds.contains(&"task_complete"));
1016        assert!(kinds.contains(&"exec_command_end"));
1017        assert!(kinds.contains(&"patch_apply_end"));
1018        assert!(kinds.contains(&"token_count"));
1019    }
1020
1021    #[test]
1022    fn tool_category_mapping() {
1023        assert_eq!(tool_category("exec_command"), Some(ToolCategory::Shell));
1024        assert_eq!(tool_category("apply_patch"), Some(ToolCategory::FileWrite));
1025        assert_eq!(tool_category("read_file"), Some(ToolCategory::FileRead));
1026        assert_eq!(tool_category("grep_search"), Some(ToolCategory::FileSearch));
1027        assert_eq!(tool_category("web_fetch"), Some(ToolCategory::Network));
1028        assert_eq!(tool_category("spawn_agent"), Some(ToolCategory::Delegation));
1029        assert_eq!(tool_category("unknown_xyz"), None);
1030    }
1031
1032    #[test]
1033    fn provider_trait_list_load() {
1034        let (_t, mgr, _name) = setup_session_fixture(&minimal_session());
1035        let ids = ConversationProvider::list_conversations(&mgr, "").unwrap();
1036        // `list_conversations` returns inner session_meta.id, not filename stems.
1037        assert_eq!(
1038            ids,
1039            vec!["019dabc6-8fef-7681-a054-b5bb75fcb97d".to_string()]
1040        );
1041        let view = ConversationProvider::load_conversation(
1042            &mgr,
1043            "",
1044            "019dabc6-8fef-7681-a054-b5bb75fcb97d",
1045        )
1046        .unwrap();
1047        assert_eq!(view.turns.len(), 3);
1048    }
1049
1050    #[test]
1051    fn developer_role_becomes_system() {
1052        let body = [
1053            r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/","originator":"x","cli_version":"1","source":"cli"}}"#,
1054            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"system instructions"}]}}"#,
1055        ]
1056        .join("\n");
1057        let (_t, mgr, id) = setup_session_fixture(&body);
1058        let view = to_view(&mgr.read_session(&id).unwrap());
1059        assert_eq!(view.turns[0].role, Role::System);
1060    }
1061}