Skip to main content

toolpath_codex/
project.rs

1//! [`CodexProjector`] — maps a [`ConversationView`] back to a Codex
2//! [`crate::Session`].
3//!
4//! This is the inverse of [`crate::provider::to_view`]: where `to_view`
5//! reads a Codex rollout JSONL into a provider-agnostic view,
6//! `CodexProjector` serializes that view back into the on-disk shape
7//! (a sequence of [`RolloutLine`]s starting with a `session_meta`
8//! line, followed by a `turn_context`, then per-turn `response_item`
9//! and `event_msg` lines).
10//!
11//! The projector consumes provider-specific data the forward path
12//! stashed under `Turn.extra["codex"]`:
13//!
14//! - `reasoning_encrypted`: opaque ciphertext blobs that round-trip
15//!   verbatim as `Reasoning::encrypted_content`
16//! - `tool_extras`: per-`call_id` extras (exec exit_codes, custom-call
17//!   statuses, patch-change manifests, etc.) preserved on the matching
18//!   `function_call_output` / `custom_tool_call_output` line
19//!
20//! For non-Codex sources, the projector synthesizes sensible defaults
21//! (originator: `"codex-toolpath"`, source: `"cli"`, model_provider:
22//! `"openai"`).
23//!
24//! Foreign-namespace extras (`Turn.extra["claude"]`,
25//! `Turn.extra["gemini"]`, …) are dropped — they have no meaning in
26//! Codex's protocol and would pollute the JSONL.
27
28use std::collections::HashMap;
29use std::path::PathBuf;
30
31use serde_json::{Map, Value, json};
32use toolpath_convo::{
33    ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn,
34};
35
36use crate::types::{
37    ContentPart, CustomToolCall, CustomToolCallOutput, FunctionCall, FunctionCallOutput, Message,
38    Reasoning, RolloutLine, SessionMeta, TurnContext,
39};
40
41// ── CodexProjector ───────────────────────────────────────────────────
42
43/// Project a [`ConversationView`] into a Codex [`crate::types::Session`].
44///
45/// Config fields are optional. Defaults match what Codex itself would
46/// write for a fresh CLI invocation.
47///
48/// # Example
49///
50/// ```rust
51/// use toolpath_codex::project::CodexProjector;
52/// use toolpath_convo::{ConversationProjector, ConversationView};
53///
54/// let view = ConversationView {
55///     id: "session-uuid".into(),
56///     provider_id: Some("codex".into()),
57///     ..Default::default()
58/// };
59///
60/// let session = CodexProjector::default().project(&view).unwrap();
61/// assert_eq!(session.id, "session-uuid");
62/// // Always at least the session_meta line.
63/// assert!(!session.lines.is_empty());
64/// ```
65#[derive(Debug, Clone, Default)]
66pub struct CodexProjector {
67    /// Session `cwd`. Falls back to the first turn's environment, then `/`.
68    pub cwd: Option<String>,
69    /// Default model for the `turn_context` line. Falls back to the
70    /// first assistant turn's `Turn.model`, then `"unknown"`.
71    pub model: Option<String>,
72    /// `originator` field on the session_meta line. Defaults to
73    /// `"codex-toolpath"` so Codex-side tooling can tell projected
74    /// sessions from real ones at a glance.
75    pub originator: Option<String>,
76    /// CLI version string for `session_meta.cli_version`.
77    pub cli_version: Option<String>,
78}
79
80impl CodexProjector {
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
86        self.cwd = Some(cwd.into());
87        self
88    }
89
90    pub fn with_model(mut self, model: impl Into<String>) -> Self {
91        self.model = Some(model.into());
92        self
93    }
94
95    pub fn with_originator(mut self, originator: impl Into<String>) -> Self {
96        self.originator = Some(originator.into());
97        self
98    }
99}
100
101impl ConversationProjector for CodexProjector {
102    type Output = crate::types::Session;
103
104    fn project(&self, view: &ConversationView) -> Result<crate::types::Session> {
105        project_view(self, view).map_err(ConvoError::Provider)
106    }
107}
108
109// ── Projection logic ─────────────────────────────────────────────────
110
111fn project_view(
112    cfg: &CodexProjector,
113    view: &ConversationView,
114) -> std::result::Result<crate::types::Session, String> {
115    let cwd = cfg
116        .cwd
117        .clone()
118        .or_else(|| {
119            view.turns
120                .iter()
121                .find_map(|t| t.environment.as_ref()?.working_dir.clone())
122        })
123        .unwrap_or_else(|| "/".to_string());
124
125    let model = cfg
126        .model
127        .clone()
128        .or_else(|| view.turns.iter().find_map(|t| t.model.clone()))
129        .unwrap_or_else(|| "unknown".to_string());
130
131    let session_timestamp = view
132        .started_at
133        .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
134        .or_else(|| view.turns.first().map(|t| t.timestamp.clone()))
135        .unwrap_or_else(|| "1970-01-01T00:00:00.000Z".to_string());
136
137    let mut lines: Vec<RolloutLine> = Vec::new();
138
139    // Line 1: session_meta. Codex always writes this first.
140    lines.push(make_session_meta_line(cfg, view, &session_timestamp, &cwd));
141
142    // Find the last assistant turn so we can mark it `phase: "final"`.
143    // Codex annotates every other assistant turn with `phase: "commentary"`,
144    // matching what real rollouts look like.
145    let last_assistant_idx = view
146        .turns
147        .iter()
148        .rposition(|t| matches!(t.role, Role::Assistant));
149
150    // A turn's group ID is its `group_id`; an assistant turn without one
151    // is its own group (a unique synthesized ID) so its own total survives.
152    let group_of = |idx: usize, turn: &Turn| -> String {
153        turn.group_id
154            .clone()
155            .unwrap_or_else(|| format!("{}-t{}", view.id, idx))
156    };
157
158    // Line 2: an opening turn_context (real Codex writes it right after
159    // session_meta, before the first user turn). Its turn_id is the first
160    // group's, so leading user turns and the first assistant share it; later
161    // group boundaries emit their own. This is what makes the source's
162    // grouping survive the round-trip — the reader keys `Turn.group_id`
163    // off the turn_context `turn_id`.
164    let first_group = view
165        .turns
166        .iter()
167        .enumerate()
168        .find(|(_, t)| matches!(t.role, Role::Assistant))
169        .map(|(i, t)| group_of(i, t))
170        .unwrap_or_else(|| view.id.clone());
171    lines.push(make_turn_context_line(&first_group, &session_timestamp, &cwd, &model));
172    let mut current_group = Some(first_group);
173
174    // Running session-cumulative usage. Codex's `total_token_usage` is
175    // cumulative; we advance it by each turn's per-step contribution and
176    // emit it after the turn, so a re-read differences it back to the same
177    // per-step spend.
178    let mut running = toolpath_convo::TokenUsage::default();
179    for (idx, turn) in view.turns.iter().enumerate() {
180        if matches!(turn.role, Role::Assistant) {
181            let group = group_of(idx, turn);
182            if current_group.as_deref() != Some(&group) {
183                lines.push(make_turn_context_line(&group, &turn.timestamp, &cwd, &model));
184                current_group = Some(group);
185            }
186        }
187        let codex = codex_extras(turn).cloned().unwrap_or_default();
188        let is_final_assistant = Some(idx) == last_assistant_idx;
189        emit_turn_lines(turn, &codex, is_final_assistant, &cwd, &mut lines, &mut running);
190    }
191
192    Ok(crate::types::Session {
193        id: view.id.clone(),
194        file_path: PathBuf::new(),
195        lines,
196    })
197}
198
199fn make_session_meta_line(
200    cfg: &CodexProjector,
201    view: &ConversationView,
202    timestamp: &str,
203    cwd: &str,
204) -> RolloutLine {
205    let meta = SessionMeta {
206        id: view.id.clone(),
207        timestamp: timestamp.to_string(),
208        cwd: PathBuf::from(cwd),
209        originator: cfg
210            .originator
211            .clone()
212            .unwrap_or_else(|| "codex-toolpath".to_string()),
213        cli_version: cfg
214            .cli_version
215            .clone()
216            .unwrap_or_else(|| "0.0.0-projected".to_string()),
217        source: "cli".to_string(),
218        forked_from_id: None,
219        agent_nickname: None,
220        agent_role: None,
221        agent_path: None,
222        model_provider: Some("openai".to_string()),
223        base_instructions: None,
224        dynamic_tools: None,
225        memory_mode: None,
226        git: None,
227        extra: HashMap::new(),
228    };
229    RolloutLine {
230        timestamp: timestamp.to_string(),
231        kind: "session_meta".to_string(),
232        payload: serde_json::to_value(&meta).unwrap_or(Value::Null),
233        extra: HashMap::new(),
234    }
235}
236
237fn make_turn_context_line(
238    turn_id: &str,
239    timestamp: &str,
240    cwd: &str,
241    model: &str,
242) -> RolloutLine {
243    let tc = TurnContext {
244        turn_id: turn_id.to_string(),
245        cwd: PathBuf::from(cwd),
246        current_date: None,
247        timezone: None,
248        approval_policy: None,
249        sandbox_policy: None,
250        model: Some(model.to_string()),
251        personality: None,
252        collaboration_mode: None,
253        extra: HashMap::new(),
254    };
255    RolloutLine {
256        timestamp: timestamp.to_string(),
257        kind: "turn_context".to_string(),
258        payload: serde_json::to_value(&tc).unwrap_or(Value::Null),
259        extra: HashMap::new(),
260    }
261}
262
263/// Used to return `Turn.extra["codex"]`; the IR no longer carries
264/// provider-namespaced extras. Always `None`. Callers fall back to
265/// reconstructing source-format details from typed IR fields and
266/// reasonable defaults.
267fn codex_extras(_turn: &Turn) -> Option<&'static Map<String, Value>> {
268    None
269}
270
271fn emit_turn_lines(
272    turn: &Turn,
273    codex: &Map<String, Value>,
274    is_final_assistant: bool,
275    session_cwd: &str,
276    lines: &mut Vec<RolloutLine>,
277    running: &mut toolpath_convo::TokenUsage,
278) {
279    match &turn.role {
280        Role::User => emit_user_message(turn, lines),
281        Role::Assistant => {
282            emit_assistant(turn, codex, is_final_assistant, session_cwd, lines, running)
283        }
284        Role::System => emit_developer_message(turn, lines),
285        Role::Other(_) => {
286            // Unknown roles don't have a clean Codex analog; emit them
287            // as `developer`-role messages so the text survives in the
288            // log even if the role label is foreign.
289            emit_developer_message(turn, lines);
290        }
291    }
292}
293
294fn emit_user_message(turn: &Turn, lines: &mut Vec<RolloutLine>) {
295    let msg = Message {
296        role: "user".to_string(),
297        content: vec![ContentPart::InputText {
298            text: turn.text.clone(),
299            extra: HashMap::new(),
300        }],
301        id: None,
302        end_turn: None,
303        phase: None,
304        extra: HashMap::new(),
305    };
306    lines.push(response_item_line(
307        &turn.timestamp,
308        "message",
309        serde_json::to_value(&msg).unwrap_or(Value::Null),
310    ));
311    if !turn.text.is_empty() && !is_system_caveat(&turn.text) {
312        lines.push(event_msg_line(
313            &turn.timestamp,
314            json!({
315                "type": "user_message",
316                "message": turn.text,
317                "images": [],
318                "local_images": [],
319                "text_elements": [],
320            }),
321        ));
322    }
323}
324
325fn is_system_caveat(text: &str) -> bool {
326    let trimmed = text.trim_start();
327    trimmed.starts_with('<') && trimmed.contains('>')
328}
329
330fn emit_developer_message(turn: &Turn, lines: &mut Vec<RolloutLine>) {
331    let msg = Message {
332        role: "developer".to_string(),
333        content: vec![ContentPart::InputText {
334            text: turn.text.clone(),
335            extra: HashMap::new(),
336        }],
337        id: None,
338        end_turn: None,
339        phase: None,
340        extra: HashMap::new(),
341    };
342    lines.push(response_item_line(
343        &turn.timestamp,
344        "message",
345        serde_json::to_value(&msg).unwrap_or(Value::Null),
346    ));
347}
348
349fn emit_assistant(
350    turn: &Turn,
351    codex: &Map<String, Value>,
352    is_final_assistant: bool,
353    session_cwd: &str,
354    lines: &mut Vec<RolloutLine>,
355    running: &mut toolpath_convo::TokenUsage,
356) {
357    // Order matches what Codex itself emits per turn:
358    //   reasoning? → message → (function_call → function_call_output)*
359    //
360    // For Codex→View→Codex round-trips, encrypted ciphertext lives on
361    // `Turn.extra["codex"]["reasoning_encrypted"]` and round-trips
362    // verbatim. For cross-harness sources, `Turn.thinking` is the
363    // public reasoning summary; we put it in `summary` instead of
364    // re-encrypting it.
365    let encrypted_blobs = codex
366        .get("reasoning_encrypted")
367        .and_then(Value::as_array)
368        .cloned()
369        .unwrap_or_default();
370    if !encrypted_blobs.is_empty() {
371        for blob in encrypted_blobs {
372            let enc = blob.as_str().map(str::to_string);
373            let r = Reasoning {
374                id: None,
375                summary: vec![],
376                content: None,
377                encrypted_content: enc,
378                extra: HashMap::new(),
379            };
380            lines.push(response_item_line(
381                &turn.timestamp,
382                "reasoning",
383                serde_json::to_value(&r).unwrap_or(Value::Null),
384            ));
385        }
386    } else if let Some(thinking) = &turn.thinking
387        && !thinking.is_empty()
388    {
389        // Foreign-source: render thinking as a single summary entry.
390        // This is how Codex serializes plain reasoning summaries when
391        // it has them.
392        let r = Reasoning {
393            id: None,
394            summary: vec![json!({"type": "summary_text", "text": thinking})],
395            content: None,
396            encrypted_content: None,
397            extra: HashMap::new(),
398        };
399        lines.push(response_item_line(
400            &turn.timestamp,
401            "reasoning",
402            serde_json::to_value(&r).unwrap_or(Value::Null),
403        ));
404    }
405
406    // The TUI gates scrollback rendering on `final_answer` exactly —
407    // `final` would silently drop the closing message from view.
408    let phase = Some(if is_final_assistant {
409        "final_answer".to_string()
410    } else {
411        "commentary".to_string()
412    });
413    // The forward path uses `response_item.message` as the turn anchor:
414    // tool calls without a preceding message attach to whatever assistant
415    // turn was last seen, even across user messages. Emit a message line
416    // for every non-empty assistant turn so consecutive cross-harness
417    // assistants don't merge. `thinking: Some("")` is treated as absent —
418    // codex's forward path drops empty thinking, so emitting on the
419    // first pass and not the second would be non-idempotent.
420    let has_thinking = turn.thinking.as_ref().is_some_and(|s| !s.is_empty());
421    if is_final_assistant || !turn.text.is_empty() || !turn.tool_uses.is_empty() || has_thinking {
422        let msg = Message {
423            role: "assistant".to_string(),
424            content: vec![ContentPart::OutputText {
425                text: turn.text.clone(),
426                extra: HashMap::new(),
427            }],
428            id: None,
429            end_turn: None,
430            phase: phase.clone(),
431            extra: HashMap::new(),
432        };
433        lines.push(response_item_line(
434            &turn.timestamp,
435            "message",
436            serde_json::to_value(&msg).unwrap_or(Value::Null),
437        ));
438        if !turn.text.is_empty() {
439            lines.push(event_msg_line(
440                &turn.timestamp,
441                json!({
442                    "type": "agent_message",
443                    "message": turn.text,
444                    "phase": phase,
445                    "memory_citation": Value::Null,
446                }),
447            ));
448        }
449    }
450
451    let tool_extras = codex
452        .get("tool_extras")
453        .and_then(Value::as_object)
454        .cloned()
455        .unwrap_or_default();
456    for tu in &turn.tool_uses {
457        let name = tool_native_name(tu);
458        emit_tool_call(turn, tu, &name, &tool_extras, session_cwd, lines);
459    }
460
461    // Advance the session-cumulative counter by this step's contribution
462    // (its attributed per-step spend, or its group total when no per-step
463    // split exists), then emit `token_count` AFTER the turn — the reader
464    // differences the cumulative and attributes the delta to the step it
465    // follows. Mirrors how real Codex streams cumulative counts per step.
466    if let Some(contribution) = turn
467        .attributed_token_usage
468        .as_ref()
469        .or(turn.token_usage.as_ref())
470    {
471        add_codex_usage(running, contribution);
472        lines.push(event_msg_line(
473            &turn.timestamp,
474            json!({
475                "type": "token_count",
476                "info": {
477                    "total_token_usage": convo_usage_to_codex_json(running),
478                },
479                "rate_limits": Value::Null,
480            }),
481        ));
482    }
483}
484
485/// Component-wise `acc += delta` on the convo usage type (None as 0).
486fn add_codex_usage(acc: &mut toolpath_convo::TokenUsage, delta: &toolpath_convo::TokenUsage) {
487    let add = |a: &mut Option<u32>, b: Option<u32>| {
488        if let Some(b) = b {
489            *a = Some(a.unwrap_or(0) + b);
490        }
491    };
492    add(&mut acc.input_tokens, delta.input_tokens);
493    add(&mut acc.output_tokens, delta.output_tokens);
494    add(&mut acc.cache_read_tokens, delta.cache_read_tokens);
495    add(&mut acc.cache_write_tokens, delta.cache_write_tokens);
496}
497
498fn emit_tool_call(
499    turn: &Turn,
500    tu: &ToolInvocation,
501    name: &str,
502    tool_extras: &Map<String, Value>,
503    session_cwd: &str,
504    lines: &mut Vec<RolloutLine>,
505) {
506    let extras_for_call = tool_extras
507        .get(&tu.id)
508        .and_then(Value::as_object)
509        .cloned()
510        .unwrap_or_default();
511
512    if name == "apply_patch" {
513        let input_str = match &tu.input {
514            Value::String(s) => s.clone(),
515            other => serde_json::to_string(other).unwrap_or_default(),
516        };
517        let status = extras_for_call
518            .get("status")
519            .and_then(Value::as_str)
520            .map(str::to_string);
521        let call = CustomToolCall {
522            name: name.to_string(),
523            input: input_str,
524            call_id: tu.id.clone(),
525            status,
526            id: None,
527            extra: HashMap::new(),
528        };
529        lines.push(response_item_line(
530            &turn.timestamp,
531            "custom_tool_call",
532            serde_json::to_value(&call).unwrap_or(Value::Null),
533        ));
534        if let Some(result) = &tu.result {
535            let mut out_extra = HashMap::new();
536            if result.is_error {
537                out_extra.insert("is_error".to_string(), Value::Bool(true));
538            }
539            let out = CustomToolCallOutput {
540                call_id: tu.id.clone(),
541                output: result.content.clone(),
542                extra: out_extra,
543            };
544            lines.push(response_item_line(
545                &turn.timestamp,
546                "custom_tool_call_output",
547                serde_json::to_value(&out).unwrap_or(Value::Null),
548            ));
549            lines.push(event_msg_line(
550                &turn.timestamp,
551                json!({
552                    "type": "patch_apply_end",
553                    "call_id": tu.id,
554                    "stdout": result.content,
555                    "stderr": "",
556                    "success": !result.is_error,
557                    "changes": {},
558                }),
559            ));
560        }
561    } else {
562        // Codex's `arguments` field is a JSON-encoded string, not a parsed value.
563        let arguments = serde_json::to_string(&tu.input).unwrap_or_else(|_| "{}".into());
564        let call = FunctionCall {
565            name: name.to_string(),
566            arguments,
567            call_id: tu.id.clone(),
568            id: None,
569            namespace: None,
570            extra: HashMap::new(),
571        };
572        lines.push(response_item_line(
573            &turn.timestamp,
574            "function_call",
575            serde_json::to_value(&call).unwrap_or(Value::Null),
576        ));
577        if let Some(result) = &tu.result {
578            // Codex's wire format has no first-class error flag for
579            // non-shell tools (only `exec_command_end.exit_code`). Stash
580            // it on the function_call_output's extras so the forward
581            // path can recover it.
582            let mut out_extra = HashMap::new();
583            if result.is_error {
584                out_extra.insert("is_error".to_string(), Value::Bool(true));
585            }
586            let out = FunctionCallOutput {
587                call_id: tu.id.clone(),
588                output: result.content.clone(),
589                extra: out_extra,
590            };
591            lines.push(response_item_line(
592                &turn.timestamp,
593                "function_call_output",
594                serde_json::to_value(&out).unwrap_or(Value::Null),
595            ));
596            // The TUI requires `status: "completed"` and a populated
597            // `parsed_cmd` to render the entry; synthetic values are fine.
598            if name == "exec_command" || name == "shell" {
599                let cmd_str = tu
600                    .input
601                    .get("cmd")
602                    .or_else(|| tu.input.get("command"))
603                    .and_then(Value::as_str)
604                    .unwrap_or("")
605                    .to_string();
606                let command = if cmd_str.is_empty() {
607                    Vec::<String>::new()
608                } else {
609                    vec!["bash".to_string(), "-lc".to_string(), cmd_str.clone()]
610                };
611                let exit_code = if result.is_error { 1 } else { 0 };
612                lines.push(event_msg_line(
613                    &turn.timestamp,
614                    json!({
615                        "type": "exec_command_end",
616                        "call_id": tu.id,
617                        "turn_id": turn.id,
618                        "command": command,
619                        "cwd": session_cwd,
620                        "parsed_cmd": [{"type": "unknown", "cmd": cmd_str}],
621                        "source": "unified_exec_startup",
622                        "stdout": "",
623                        "stderr": "",
624                        "aggregated_output": result.content,
625                        "exit_code": exit_code,
626                        "duration": {"secs": 0, "nanos": 0},
627                        "formatted_output": "",
628                        "status": "completed",
629                    }),
630                ));
631            }
632        }
633    }
634}
635
636/// Pick Codex's native tool name. Same logic as the other projectors:
637/// keep the source name when it's already in Codex's vocabulary;
638/// otherwise route through the source's `category` plus
639/// `native_name(args)` to land on a Codex-canonical name; otherwise
640/// pass the source name through verbatim (Codex's protocol accepts
641/// any string here, so MCP tools and unknown future tools survive).
642fn tool_native_name(tu: &ToolInvocation) -> String {
643    if crate::provider::tool_category(&tu.name).is_some() {
644        return tu.name.clone();
645    }
646    if let Some(cat) = tu.category
647        && let Some(remap) = crate::provider::native_name(cat, &tu.input)
648    {
649        return remap.to_string();
650    }
651    tu.name.clone()
652}
653
654fn response_item_line(timestamp: &str, inner_type: &str, mut payload: Value) -> RolloutLine {
655    // Ensure the inner discriminant is set; serde sometimes drops it
656    // when the value is reconstructed from a struct without an
657    // explicit `type` field.
658    if let Value::Object(m) = &mut payload {
659        m.entry("type".to_string())
660            .or_insert_with(|| Value::String(inner_type.to_string()));
661    }
662    RolloutLine {
663        timestamp: timestamp.to_string(),
664        kind: "response_item".to_string(),
665        payload,
666        extra: HashMap::new(),
667    }
668}
669
670fn event_msg_line(timestamp: &str, payload: Value) -> RolloutLine {
671    RolloutLine {
672        timestamp: timestamp.to_string(),
673        kind: "event_msg".to_string(),
674        payload,
675        extra: HashMap::new(),
676    }
677}
678
679fn convo_usage_to_codex_json(u: &toolpath_convo::TokenUsage) -> Value {
680    let mut m = Map::new();
681    if let Some(v) = u.input_tokens {
682        m.insert("input_tokens".to_string(), Value::from(v));
683    }
684    if let Some(v) = u.cache_read_tokens {
685        m.insert("cached_input_tokens".to_string(), Value::from(v));
686    }
687    if let Some(v) = u.output_tokens {
688        m.insert("output_tokens".to_string(), Value::from(v));
689    }
690    Value::Object(m)
691}
692
693// ── Tests ─────────────────────────────────────────────────────────────
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use toolpath_convo::{TokenUsage, ToolCategory, ToolInvocation, ToolResult};
699
700    fn user_turn(id: &str, text: &str) -> Turn {
701        Turn {
702            id: id.into(),
703            parent_id: None,
704            group_id: None,
705            role: Role::User,
706            timestamp: "2026-04-20T16:00:00.000Z".into(),
707            text: text.into(),
708            thinking: None,
709            tool_uses: vec![],
710            model: None,
711            stop_reason: None,
712            token_usage: None,
713            attributed_token_usage: None,
714            environment: None,
715            delegations: vec![],
716            file_mutations: Vec::new(),
717        }
718    }
719
720    fn assistant_turn(id: &str, text: &str) -> Turn {
721        Turn {
722            id: id.into(),
723            parent_id: None,
724            group_id: None,
725            role: Role::Assistant,
726            timestamp: "2026-04-20T16:00:01.000Z".into(),
727            text: text.into(),
728            thinking: None,
729            tool_uses: vec![],
730            model: Some("gpt-5.4".into()),
731            stop_reason: Some("stop".into()),
732            token_usage: Some(TokenUsage {
733                input_tokens: Some(100),
734                output_tokens: Some(50),
735                cache_read_tokens: None,
736                cache_write_tokens: None,
737                ..Default::default()
738            }),
739            attributed_token_usage: None,
740            environment: None,
741            delegations: vec![],
742            file_mutations: Vec::new(),
743        }
744    }
745
746    fn view_with(turns: Vec<Turn>) -> ConversationView {
747        ConversationView {
748            id: "session-uuid".into(),
749            started_at: None,
750            last_activity: None,
751            turns,
752            total_usage: None,
753            provider_id: Some("codex".into()),
754            files_changed: vec![],
755            session_ids: vec![],
756            events: vec![],
757            ..Default::default()
758        }
759    }
760
761    fn line_kinds(s: &crate::types::Session) -> Vec<String> {
762        s.lines.iter().map(|l| l.kind.clone()).collect()
763    }
764
765    fn inner_types(s: &crate::types::Session) -> Vec<String> {
766        s.lines
767            .iter()
768            .map(|l| {
769                l.payload
770                    .get("type")
771                    .and_then(Value::as_str)
772                    .unwrap_or("")
773                    .to_string()
774            })
775            .collect()
776    }
777
778    #[test]
779    fn empty_view_yields_session_meta_plus_turn_context() {
780        let s = CodexProjector::default()
781            .project(&view_with(vec![]))
782            .unwrap();
783        assert_eq!(s.id, "session-uuid");
784        assert_eq!(line_kinds(&s), vec!["session_meta", "turn_context"]);
785    }
786
787    #[test]
788    fn user_turn_becomes_user_role_message() {
789        let s = CodexProjector::default()
790            .project(&view_with(vec![user_turn("u1", "hi")]))
791            .unwrap();
792        let kinds = line_kinds(&s);
793        // response_item carries the model-facing message; event_msg is
794        // the TUI scrollback counterpart that `codex resume` renders.
795        assert_eq!(
796            kinds,
797            vec!["session_meta", "turn_context", "response_item", "event_msg"]
798        );
799        let payload = &s.lines[2].payload;
800        assert_eq!(payload["type"], "message");
801        assert_eq!(payload["role"], "user");
802        assert_eq!(payload["content"][0]["type"], "input_text");
803        assert_eq!(payload["content"][0]["text"], "hi");
804        let event = &s.lines[3].payload;
805        assert_eq!(event["type"], "user_message");
806        assert_eq!(event["message"], "hi");
807    }
808
809    #[test]
810    fn user_turn_with_system_caveat_skips_event_msg() {
811        // System-injected `<…>` envelopes (caveats, environment_context)
812        // never appear in the TUI scrollback. Skip the event_msg.
813        let s = CodexProjector::default()
814            .project(&view_with(vec![user_turn(
815                "u1",
816                "<local-command-caveat>do not respond</local-command-caveat>",
817            )]))
818            .unwrap();
819        let kinds = line_kinds(&s);
820        assert_eq!(kinds, vec!["session_meta", "turn_context", "response_item"]);
821    }
822
823    #[test]
824    fn assistant_turn_with_function_call_and_output() {
825        let mut t = assistant_turn("a1", "Let me check.");
826        t.tool_uses = vec![ToolInvocation {
827            id: "call_001".into(),
828            name: "exec_command".into(),
829            input: json!({"cmd": "pwd"}),
830            result: Some(ToolResult {
831                content: "/tmp\n".into(),
832                is_error: false,
833            }),
834            category: Some(ToolCategory::Shell),
835        }];
836        let s = CodexProjector::default()
837            .project(&view_with(vec![t]))
838            .unwrap();
839        let inner = inner_types(&s);
840        // session_meta + turn_context + message + agent_message
841        // + function_call + function_call_output + exec_command_end
842        // + token_count. The token_count follows the turn (the reader
843        // differences the cumulative and attributes the delta to the step
844        // it follows); the per-step spend is the turn's contribution.
845        assert_eq!(
846            inner,
847            vec![
848                "",
849                "",
850                "message",
851                "agent_message",
852                "function_call",
853                "function_call_output",
854                "exec_command_end",
855                "token_count"
856            ]
857        );
858
859        // FunctionCall.arguments is a JSON STRING, not a parsed value.
860        let fc_payload = &s.lines[4].payload;
861        assert_eq!(fc_payload["type"], "function_call");
862        assert_eq!(fc_payload["call_id"], "call_001");
863        assert_eq!(fc_payload["name"], "exec_command");
864        let args = fc_payload["arguments"].as_str().unwrap();
865        let parsed: Value = serde_json::from_str(args).unwrap();
866        assert_eq!(parsed["cmd"], "pwd");
867
868        let fco_payload = &s.lines[5].payload;
869        assert_eq!(fco_payload["type"], "function_call_output");
870        assert_eq!(fco_payload["call_id"], "call_001");
871        assert_eq!(fco_payload["output"], "/tmp\n");
872
873        // exec_command_end: TUI counterpart with the aggregated output.
874        let exec = &s.lines[6].payload;
875        assert_eq!(exec["type"], "exec_command_end");
876        assert_eq!(exec["call_id"], "call_001");
877        assert_eq!(exec["aggregated_output"], "/tmp\n");
878        assert_eq!(exec["exit_code"], 0);
879    }
880
881    #[test]
882    fn foreign_tool_name_remaps_to_codex_via_category() {
883        // Claude's `Bash` should land as Codex's `exec_command` because
884        // category routes it through `native_name(Shell, _)`.
885        let mut t = assistant_turn("a1", "");
886        t.tool_uses = vec![ToolInvocation {
887            id: "call_x".into(),
888            name: "Bash".into(),
889            input: json!({"command": "ls"}),
890            result: None,
891            category: Some(ToolCategory::Shell),
892        }];
893        let s = CodexProjector::default()
894            .project(&view_with(vec![t]))
895            .unwrap();
896        let fc = &s
897            .lines
898            .iter()
899            .find(|l| l.payload.get("type").and_then(Value::as_str) == Some("function_call"))
900            .expect("function_call line")
901            .payload;
902        assert_eq!(fc["name"], "exec_command");
903    }
904
905    #[test]
906    fn apply_patch_preserves_free_form_input_as_custom_tool_call() {
907        // Codex apply_patch uses CustomToolCall + free-form input
908        // (V4A patch text), not JSON args. Source name passes through
909        // verbatim because Codex classifies `apply_patch` as FileWrite.
910        let patch_body =
911            "*** Begin Patch\n*** Add File: hello.rs\n+fn main(){}\n*** End Patch".to_string();
912        let mut t = assistant_turn("a1", "");
913        t.tool_uses = vec![ToolInvocation {
914            id: "call_p".into(),
915            name: "apply_patch".into(),
916            input: Value::String(patch_body.clone()),
917            result: Some(ToolResult {
918                content: "ok".into(),
919                is_error: false,
920            }),
921            category: Some(ToolCategory::FileWrite),
922        }];
923        let s = CodexProjector::default()
924            .project(&view_with(vec![t]))
925            .unwrap();
926        let inner = inner_types(&s);
927        assert!(inner.contains(&"custom_tool_call".to_string()));
928        assert!(inner.contains(&"custom_tool_call_output".to_string()));
929        let ctc = s
930            .lines
931            .iter()
932            .find(|l| l.payload.get("type").and_then(Value::as_str) == Some("custom_tool_call"))
933            .unwrap();
934        assert_eq!(ctc.payload["input"], patch_body);
935    }
936
937    #[test]
938    fn assistant_thinking_emits_reasoning_summary() {
939        let mut t = assistant_turn("a1", "Done.");
940        t.thinking = Some("hmm let me consider".into());
941        let s = CodexProjector::default()
942            .project(&view_with(vec![t]))
943            .unwrap();
944        let reasoning_line = s
945            .lines
946            .iter()
947            .find(|l| l.payload.get("type").and_then(Value::as_str) == Some("reasoning"));
948        assert!(reasoning_line.is_some(), "expected a reasoning line");
949        let summary = &reasoning_line.unwrap().payload["summary"];
950        assert!(summary.is_array());
951        assert_eq!(summary[0]["type"], "summary_text");
952        assert_eq!(summary[0]["text"], "hmm let me consider");
953    }
954
955    #[test]
956    fn session_meta_carries_default_originator() {
957        let s = CodexProjector::default()
958            .project(&view_with(vec![]))
959            .unwrap();
960        let meta = &s.lines[0].payload;
961        assert_eq!(meta["originator"], "codex-toolpath");
962        assert_eq!(meta["source"], "cli");
963    }
964
965    #[test]
966    fn last_assistant_gets_phase_final_others_commentary() {
967        // The closing assistant turn in the view — regardless of
968        // stop_reason — gets `phase: "final"`. Every other assistant
969        // turn gets `phase: "commentary"`. `end_turn` is never set
970        // (matches real Codex rollouts: `phase` alone discriminates).
971        let mut t1 = assistant_turn("a1", "first");
972        t1.stop_reason = Some("tool_use".into());
973        let mut t2 = assistant_turn("a2", "second");
974        t2.stop_reason = Some("tool_use".into());
975        let mut t3 = assistant_turn("a3", "All done.");
976        t3.stop_reason = Some("end_turn".into());
977
978        let s = CodexProjector::default()
979            .project(&view_with(vec![t1, t2, t3]))
980            .unwrap();
981        let messages: Vec<&RolloutLine> = s
982            .lines
983            .iter()
984            .filter(|l| {
985                l.payload.get("type").and_then(Value::as_str) == Some("message")
986                    && l.payload.get("role").and_then(Value::as_str) == Some("assistant")
987            })
988            .collect();
989        assert_eq!(messages.len(), 3);
990        assert_eq!(messages[0].payload["phase"], "commentary");
991        assert_eq!(messages[1].payload["phase"], "commentary");
992        assert_eq!(messages[2].payload["phase"], "final_answer");
993        // No message should carry `end_turn` — real rollouts omit it.
994        for m in &messages {
995            assert!(
996                m.payload.get("end_turn").is_none(),
997                "end_turn should be absent: {}",
998                m.payload
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn jsonl_serializes_one_line_per_entry() {
1005        let s = CodexProjector::default()
1006            .project(&view_with(vec![user_turn("u1", "hi")]))
1007            .unwrap();
1008        for line in &s.lines {
1009            let serialized = serde_json::to_string(line).unwrap();
1010            assert!(
1011                !serialized.contains('\n'),
1012                "line serialized with newline: {}",
1013                serialized
1014            );
1015        }
1016    }
1017}