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