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