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