Skip to main content

supercode_interchange/session/
codex.rs

1//! Codex session codec: loaders, writers and native-record helpers.
2
3use super::*;
4
5impl Session {
6    /// Load a Codex rollout from a file.
7    pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
8        Self::from_codex_str(&read_utf8_or_diagnose(path.as_ref())?)
9    }
10
11    /// Parse a Codex rollout from an in-memory JSONL string.
12    pub fn from_codex_str(jsonl: &str) -> Result<Session> {
13        let mut meta = SessionMeta::new(SessionSource::Codex);
14        let mut messages = Vec::new();
15
16        // First pass: collect the text of every assistant message that exists as
17        // a canonical `response_item`. In normal sessions the streamed
18        // `event_msg/agent_message` events duplicate these and are safely
19        // skipped; in collab/multi-agent sessions the assistant narration lives
20        // ONLY as `agent_message` events, so we recover the ones with no
21        // response_item counterpart (deduping by exact text).
22        let assistant_texts = collect_codex_assistant_texts(jsonl);
23        // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
24        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
25        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
26        let mut pending_reasoning = String::new();
27        let mut pending_reasoning_content = String::new();
28        let mut pending_reasoning_encrypted = false;
29        // PARITY-15: see `from_claude_code_str`'s identical counter.
30        let mut parse_error_lines = 0usize;
31        let mut restored_embedded_codex_provenance = false;
32
33        for (record_index, raw_line) in raw_lines.iter().enumerate() {
34            // What the previous record created belongs to it; this record starts after.
35            if record_index > 0 {
36                stamp_message_records(&mut messages, record_index - 1);
37            }
38            let line = raw_line.trim();
39            if line.is_empty() {
40                continue;
41            }
42            let v: Value = match serde_json::from_str(line) {
43                Ok(v) => v,
44                Err(_) => {
45                    parse_error_lines += 1;
46                    continue;
47                }
48            };
49            let payload = v.get("payload").unwrap_or(&Value::Null);
50            // The envelope, the older provenance key, or a tombstone whose records were deleted
51            // (reported as `residue_loss`, PARITY-23 dev/05), as every loader reads them.
52            if !restored_embedded_codex_provenance
53                && v.get("type").and_then(Value::as_str) == Some("session_meta")
54                && restore_codex_provenance_from_top_level(payload, &mut meta)?
55            {
56                restored_embedded_codex_provenance = true;
57            }
58            if !restored_embedded_codex_provenance {
59                capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
60            }
61            // WAVE-2 item 1: every Codex record carries a real top-level
62            // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
63            // line produces via `stamp_new_codex_messages` below, at each
64            // arm that pushes messages.
65            let line_ts = v.get("timestamp").and_then(Value::as_str);
66
67            match v.get("type").and_then(Value::as_str) {
68                Some("session_meta") => {
69                    capture_codex_session_meta(payload, &mut meta);
70                    if !restored_embedded_codex_provenance {
71                        meta.codex_headers.push(v.clone());
72                    }
73                }
74                Some("turn_context") => {
75                    if meta.model.is_none() {
76                        meta.model = payload
77                            .get("model")
78                            .and_then(Value::as_str)
79                            .map(str::to_string);
80                    }
81                    if !restored_embedded_codex_provenance {
82                        meta.codex_headers.push(v.clone());
83                    }
84                }
85                Some("response_item")
86                    if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
87                {
88                    // Retain reasoning (P3): summary text if any, the raw
89                    // `content` chain-of-thought text if any (N2 — this used
90                    // to be dropped despite `Coverage::Retained` claiming the
91                    // whole item survived; see `crate::audit`'s doc comment),
92                    // plus a flag for the opaque encrypted_content a
93                    // same-model continuation can replay. Stashed onto the
94                    // next assistant message below.
95                    let summary = extract_text_content(payload.get("summary"));
96                    if !summary.trim().is_empty() {
97                        push_str_field(&mut pending_reasoning, &summary);
98                    }
99                    // N2: `content` is `null` on the vast majority of real
100                    // turns (raw reasoning text is only ever populated for
101                    // certain reasoning-transcript configurations) — guard
102                    // on non-null BEFORE calling `extract_text_content`,
103                    // since `Some(&Value::Null)` would otherwise fall into
104                    // its `Some(other) => other.to_string()` arm and
105                    // stringify to the literal text `"null"`.
106                    if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
107                        let text = extract_text_content(Some(raw_content));
108                        if !text.trim().is_empty() {
109                            push_str_field(&mut pending_reasoning_content, &text);
110                        }
111                    }
112                    // N1: `serde_json` returns `Some(&Value::Null)` for a
113                    // present-but-null `encrypted_content` key — which is
114                    // what EVERY real rollout's reasoning item carries
115                    // (upstream always serializes the field, never
116                    // `skip_serializing_if`, `codex-rs/protocol/src/
117                    // models.rs:970-983`). The old `.is_some()` check
118                    // false-flagged every single reasoning item as
119                    // "encrypted" on real data; only a genuinely non-null
120                    // value means the model actually returned an opaque
121                    // blob that a same-model continuation could replay.
122                    if payload
123                        .get("encrypted_content")
124                        .is_some_and(|v| !v.is_null())
125                    {
126                        pending_reasoning_encrypted = true;
127                    }
128                }
129                Some("response_item") => {
130                    let before = messages.len();
131                    push_codex_item(payload, &mut messages);
132                    // Attach any pending reasoning to a newly produced assistant turn.
133                    if messages.len() > before
134                        && (!pending_reasoning.is_empty()
135                            || !pending_reasoning_content.is_empty()
136                            || pending_reasoning_encrypted)
137                    {
138                        let is_assistant = messages
139                            .last()
140                            .map(|m| m.role == Role::Assistant)
141                            .unwrap_or(false);
142                        if is_assistant {
143                            let last = messages.last_mut().expect("checked above");
144                            if !pending_reasoning.is_empty() {
145                                last.metadata.insert(
146                                    "reasoning".to_string(),
147                                    std::mem::take(&mut pending_reasoning),
148                                );
149                            }
150                            if !pending_reasoning_content.is_empty() {
151                                last.metadata.insert(
152                                    "reasoning_content".to_string(),
153                                    std::mem::take(&mut pending_reasoning_content),
154                                );
155                            }
156                            if pending_reasoning_encrypted {
157                                last.metadata
158                                    .insert("reasoning_encrypted".to_string(), "true".to_string());
159                                pending_reasoning_encrypted = false;
160                            }
161                        } else {
162                            // N3: the item that just landed is NOT the
163                            // assistant turn the pending reasoning was for
164                            // (e.g. an aborted turn's reasoning directly
165                            // followed by a user message) — the old code
166                            // unconditionally cleared the pending state
167                            // here, silently discarding it. Flush it as its
168                            // own message instead, inserted just before the
169                            // interrupting item so replay order stays
170                            // chronological, keeping `Coverage::Retained`
171                            // honest for this shape too.
172                            let orphan = orphaned_reasoning_message(
173                                &mut pending_reasoning,
174                                &mut pending_reasoning_content,
175                                &mut pending_reasoning_encrypted,
176                            );
177                            messages.insert(before, orphan);
178                        }
179                    }
180                    stamp_new_codex_messages(&mut messages, before, line_ts);
181                    restore_single_grok_message(payload, &mut messages[before..]);
182                }
183                // A compaction record replaces all prior turns with its
184                // summarized `replacement_history` — exactly how Codex itself
185                // resumes a compacted session.
186                Some("compacted") => {
187                    messages.clear();
188                    if let Some(Value::Array(history)) = payload.get("replacement_history") {
189                        for item in history {
190                            push_codex_item(item, &mut messages);
191                        }
192                    }
193                    // `replacement_history` items carry no per-item
194                    // timestamp of their own (observed corpora) — the
195                    // `compacted` record's own timestamp (when it happened)
196                    // is the best-effort real source for every message it
197                    // synthesizes, so it stamps the whole rebuilt vec (index
198                    // 0, since `clear()` reset it above).
199                    stamp_new_codex_messages(&mut messages, 0, line_ts);
200                    // IX-6 fix: replaying `replacement_history` through
201                    // `push_codex_item` can leave the LAST replayed message
202                    // marked `__codex_open_turn` (if it's an assistant
203                    // `message`, per the combined-turn merge below). That
204                    // marker must not survive past the compaction boundary —
205                    // a live `function_call` arriving after this record is a
206                    // NEW turn, not a continuation of the compaction
207                    // summary's synthetic turn, so it must not merge into it.
208                    if let Some(last) = messages.last_mut() {
209                        last.metadata.remove("__codex_open_turn");
210                    }
211                }
212                Some("event_msg")
213                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
214                {
215                    let before = messages.len();
216                    let text = agent_message_text(payload);
217                    if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
218                        push_assistant(&mut messages, text, Vec::new());
219                        if let Some(last) = messages.last_mut() {
220                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
221                                last.metadata.insert("phase".to_string(), phase.to_string());
222                            }
223                        }
224                    }
225                    stamp_new_codex_messages(&mut messages, before, line_ts);
226                }
227                // The user rolled back (undid) the last N turns — replay must
228                // drop them so the reloaded conversation matches what the user
229                // actually kept.
230                Some("event_msg")
231                    if payload.get("type").and_then(Value::as_str)
232                        == Some("thread_rolled_back") =>
233                {
234                    let n = payload
235                        .get("num_turns")
236                        .and_then(Value::as_u64)
237                        .unwrap_or(1);
238                    for _ in 0..n {
239                        remove_last_turn(&mut messages);
240                    }
241                }
242                // The natural-language goal assigned to this thread (sometimes
243                // the only place the objective text is recorded).
244                Some("event_msg")
245                    if payload.get("type").and_then(Value::as_str)
246                        == Some("thread_goal_updated") =>
247                {
248                    let before = messages.len();
249                    let goal = payload.get("goal");
250                    if let Some(obj) = goal
251                        .and_then(|g| g.get("objective"))
252                        .and_then(Value::as_str)
253                    {
254                        if !obj.trim().is_empty() {
255                            messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
256                            // D4: `goal.objective` alone used to be the ONLY
257                            // captured field, but the audit labeled this
258                            // `Retained` as if the whole record survived.
259                            // `goal.status`/`goal.tokenBudget` (real
260                            // `ThreadGoal` wire fields, camelCase) are
261                            // captured too so that label is honest — see
262                            // `crate::audit::event_msg_coverage`'s doc
263                            // comment.
264                            if let Some(last) = messages.last_mut() {
265                                if let Some(status) =
266                                    goal.and_then(|g| g.get("status")).and_then(Value::as_str)
267                                {
268                                    last.metadata
269                                        .insert("goal_status".to_string(), status.to_string());
270                                }
271                                if let Some(budget) = goal
272                                    .and_then(|g| g.get("tokenBudget"))
273                                    .and_then(Value::as_i64)
274                                {
275                                    last.metadata.insert(
276                                        "goal_token_budget".to_string(),
277                                        budget.to_string(),
278                                    );
279                                }
280                            }
281                        }
282                    }
283                    stamp_new_codex_messages(&mut messages, before, line_ts);
284                }
285                // Code-review output — unique assistant-generated content with no
286                // `message` counterpart.
287                Some("event_msg")
288                    if payload.get("type").and_then(Value::as_str)
289                        == Some("exited_review_mode") =>
290                {
291                    let before = messages.len();
292                    if let Some(review) = payload.get("review_output") {
293                        let text = review
294                            .get("overall_explanation")
295                            .and_then(Value::as_str)
296                            .map(str::to_string)
297                            .unwrap_or_else(|| review.to_string());
298                        push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
299                        // D4: `overall_explanation` alone used to be the ONLY
300                        // captured field, but the audit labeled this
301                        // `Retained` as if `review_output.findings` survived
302                        // too. Capture `findings` verbatim (as JSON, onto
303                        // metadata) so that label is honest — this is the
304                        // only place review-mode findings (title/body/
305                        // confidence_score/priority/code_location) live.
306                        if let Some(findings) = review.get("findings") {
307                            if findings.as_array().is_some_and(|a| !a.is_empty()) {
308                                if let Some(last) = messages.last_mut() {
309                                    if let Ok(s) = serde_json::to_string(findings) {
310                                        last.metadata.insert("review_findings".to_string(), s);
311                                    }
312                                }
313                            }
314                        }
315                        // N4: `overall_correctness`/`overall_confidence_score`
316                        // are the review's actual verdict — distinct from the
317                        // findings list and the explanation prose already
318                        // captured above — and were neither captured nor
319                        // disclosed as residue while the audit doc stayed
320                        // silent about them. Capture both onto the same
321                        // message's metadata, same pattern as `findings`.
322                        if let Some(last) = messages.last_mut() {
323                            if let Some(correctness) =
324                                review.get("overall_correctness").and_then(Value::as_str)
325                            {
326                                last.metadata.insert(
327                                    "review_overall_correctness".to_string(),
328                                    correctness.to_string(),
329                                );
330                            }
331                            if let Some(score) = review
332                                .get("overall_confidence_score")
333                                .and_then(Value::as_f64)
334                            {
335                                last.metadata.insert(
336                                    "review_overall_confidence_score".to_string(),
337                                    score.to_string(),
338                                );
339                            }
340                        }
341                    }
342                    stamp_new_codex_messages(&mut messages, before, line_ts);
343                }
344                _ => {} // other event_msg, token_count, ... — UI events, skip
345            }
346        }
347
348        // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
349        // shape a real rollout can leave behind (the process was
350        // interrupted mid-turn, after the model reasoned but before it
351        // replied — end of file, or a rollback/compaction boundary that
352        // clears the pending state some other way) — the old code silently
353        // dropped it here (nothing ever consumed the pending buffers once
354        // the loop ended). Flush it as its own trailing message instead, so
355        // `Coverage::Retained` holds for this shape too. Superset of the
356        // independently-discovered PARITY-11 fix: also folds in
357        // `pending_reasoning_content` (the raw chain-of-thought, distinct
358        // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
359        // message` helper, which the interrupted-by-a-user-message shape
360        // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
361        // relies on — a trailing-EOF-only flush here would miss that case.
362        if !pending_reasoning.is_empty()
363            || !pending_reasoning_content.is_empty()
364            || pending_reasoning_encrypted
365        {
366            let orphan = orphaned_reasoning_message(
367                &mut pending_reasoning,
368                &mut pending_reasoning_content,
369                &mut pending_reasoning_encrypted,
370            );
371            messages.push(orphan);
372        }
373
374        stamp_message_records(&mut messages, raw_lines.len().saturating_sub(1));
375        ensure_tool_results_paired(&mut messages);
376        meta.message_records = take_message_records(&mut messages);
377        // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
378        // combined-turn merge above — strip it so it never leaks out as
379        // visible `ChatMessage` metadata.
380        for m in &mut messages {
381            m.metadata.remove("__codex_open_turn");
382            if m.metadata
383                .remove("__grok_remove_synthetic_turn_id")
384                .is_some()
385            {
386                m.metadata.remove("turn_id");
387            }
388        }
389        let imported_message_count = Some(messages.len());
390        Ok(Session {
391            meta,
392            messages,
393            subagents: Vec::new(),
394            raw,
395            raw_trailing_newline,
396            imported_message_count,
397            // Codex is line-oriented: `raw` is split directly out of the
398            // source text (strict-verbatim, IX-1).
399            raw_is_verbatim: true,
400            parse_error_lines,
401            load_residue: Vec::new(),
402        })
403    }
404
405    /// Parse a Codex rollout as bounded human-visible history rather than as
406    /// resumable model context. This deliberately ignores outer `compacted`
407    /// replacement semantics: the original `response_item` records remain in
408    /// the rollout and are the authoritative UI history.
409    pub(super) fn from_codex_display_str(jsonl: &str, message_limit: usize) -> Result<Session> {
410        let mut meta = SessionMeta::new(SessionSource::Codex);
411        let mut messages: Vec<ChatMessage> = Vec::new();
412        let mut preceding_users = Vec::new();
413        let mut parse_error_lines = 0usize;
414        let mut record_count = 0usize;
415        let mut total_message_count = 0usize;
416        let retain = message_limit.max(1).saturating_add(64);
417        let mut canonical_assistant_texts = HashSet::new();
418
419        for raw_line in non_empty_lines(jsonl) {
420            record_count += 1;
421            let value: Value = match serde_json::from_str(raw_line) {
422                Ok(value) => value,
423                Err(_) => {
424                    parse_error_lines += 1;
425                    continue;
426                }
427            };
428            let payload = value.get("payload").unwrap_or(&Value::Null);
429            let line_ts = value.get("timestamp").and_then(Value::as_str);
430            match value.get("type").and_then(Value::as_str) {
431                Some("session_meta") => capture_codex_session_meta(payload, &mut meta),
432                Some("turn_context") if meta.model.is_none() => {
433                    meta.model = payload
434                        .get("model")
435                        .and_then(Value::as_str)
436                        .map(str::to_string);
437                }
438                Some("response_item")
439                    if payload.get("type").and_then(Value::as_str) != Some("reasoning") =>
440                {
441                    let assistant_text = (payload.get("type").and_then(Value::as_str)
442                        == Some("message")
443                        && payload.get("role").and_then(Value::as_str) == Some("assistant"))
444                    .then(|| extract_text_content(payload.get("content")))
445                    .filter(|text| !text.trim().is_empty());
446                    if let Some(text) = assistant_text.as_deref() {
447                        if let Some(index) = messages.iter().rposition(|message| {
448                            message.metadata.contains_key("codex_event_message")
449                                && message.content.as_deref() == Some(text)
450                        }) {
451                            messages.remove(index);
452                            total_message_count = total_message_count.saturating_sub(1);
453                        }
454                        canonical_assistant_texts.insert(text.trim().to_string());
455                    }
456                    let before = messages.len();
457                    push_codex_item(payload, &mut messages);
458                    total_message_count += messages.len().saturating_sub(before);
459                    stamp_new_codex_messages(&mut messages, before, line_ts);
460                    restore_single_grok_message(payload, &mut messages[before..]);
461                }
462                Some("event_msg")
463                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
464                {
465                    let text = agent_message_text(payload);
466                    if !text.trim().is_empty() && !canonical_assistant_texts.contains(text.trim()) {
467                        let before = messages.len();
468                        push_assistant(&mut messages, text, Vec::new());
469                        total_message_count += 1;
470                        if let Some(last) = messages.last_mut() {
471                            last.metadata
472                                .insert("codex_event_message".to_string(), "true".to_string());
473                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
474                                last.metadata.insert("phase".to_string(), phase.to_string());
475                            }
476                        }
477                        stamp_new_codex_messages(&mut messages, before, line_ts);
478                    }
479                }
480                // `compacted` changes continuation context, not what was
481                // already visible in scrollback. Other event records are UI
482                // lifecycle noise or duplicate canonical response items.
483                _ => {}
484            }
485            if messages.len() > retain {
486                let remove = messages.len() - retain;
487                for message in messages.drain(..remove) {
488                    if message.role == Role::User {
489                        preceding_users.push(message);
490                        if preceding_users.len() > 2 {
491                            preceding_users.remove(0);
492                        }
493                    }
494                }
495            }
496        }
497
498        for message in &mut messages {
499            message.metadata.remove("__codex_open_turn");
500            message.metadata.remove("codex_event_message");
501            if message
502                .metadata
503                .remove("__grok_remove_synthetic_turn_id")
504                .is_some()
505            {
506                message.metadata.remove("turn_id");
507            }
508        }
509        truncate_messages_with_anchor(&mut messages, message_limit, preceding_users);
510        let imported_message_count = Some(total_message_count);
511        Ok(Session {
512            meta,
513            messages,
514            subagents: Vec::new(),
515            // Preserve the cheap count without retaining hundreds of
516            // megabytes of source lines in a display-only value.
517            raw: vec![String::new(); record_count],
518            raw_trailing_newline: jsonl.ends_with('\n'),
519            imported_message_count,
520            raw_is_verbatim: false,
521            parse_error_lines,
522            load_residue: vec![
523                "display history is a bounded native-record projection, not resumable model context"
524                    .to_string(),
525            ],
526        })
527    }
528}
529
530// ---- Codex ----------------------------------------------------------------
531
532pub(super) const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
533
534fn codex_provenance_kind(record: &Value) -> Option<&str> {
535    match record.get("type").and_then(Value::as_str) {
536        Some("session_meta") => Some("session_meta"),
537        Some("turn_context") => Some("turn_context"),
538        Some("compacted") => Some("compacted"),
539        Some("event_msg") => match record
540            .get("payload")
541            .and_then(|payload| payload.get("type"))
542            .and_then(Value::as_str)
543        {
544            Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
545            Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
546            Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
547            Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
548            _ => None,
549        },
550        _ => None,
551    }
552}
553
554fn capture_codex_provenance_record(
555    meta: &mut SessionMeta,
556    record_index: usize,
557    raw_line: &str,
558    record: &Value,
559) {
560    let Some(kind) = codex_provenance_kind(record) else {
561        return;
562    };
563    meta.codex_provenance.push(serde_json::json!({
564        "record_index": record_index,
565        "kind": kind,
566        "raw": raw_line,
567    }));
568}
569
570fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
571    (!meta.codex_provenance.is_empty()).then(|| {
572        serde_json::json!({
573            "version": 1,
574            "records": &meta.codex_provenance,
575        })
576    })
577}
578
579pub(super) fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
580    if extension.get("version").and_then(Value::as_u64) != Some(1) {
581        return Err(Error::InvalidSession(
582            "invalid portable Codex provenance: expected version 1".to_string(),
583        ));
584    }
585    let Some(records) = extension.get("records").and_then(Value::as_array) else {
586        return Err(Error::InvalidSession(
587            "invalid portable Codex provenance: `records` must be an array".to_string(),
588        ));
589    };
590    if records.is_empty() {
591        return Err(Error::InvalidSession(
592            "invalid portable Codex provenance: `records` must not be empty".to_string(),
593        ));
594    }
595    let mut restored = Vec::with_capacity(records.len());
596    for entry in records {
597        let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
598            return Err(Error::InvalidSession(
599                "invalid portable Codex provenance: record_index must be an integer".to_string(),
600            ));
601        };
602        let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
603            return Err(Error::InvalidSession(
604                "invalid portable Codex provenance: kind must be a string".to_string(),
605            ));
606        };
607        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
608            return Err(Error::InvalidSession(
609                "invalid portable Codex provenance: raw must be a string".to_string(),
610            ));
611        };
612        let Ok(record) = serde_json::from_str::<Value>(raw) else {
613            return Err(Error::InvalidSession(
614                "invalid portable Codex provenance: raw is not valid JSON".to_string(),
615            ));
616        };
617        if codex_provenance_kind(&record) != Some(kind) {
618            return Err(Error::InvalidSession(format!(
619                "invalid portable Codex provenance: kind `{kind}` does not match raw record"
620            )));
621        }
622        restored.push(entry.clone());
623    }
624    meta.codex_provenance = restored;
625    meta.codex_headers.clear();
626    for entry in &meta.codex_provenance {
627        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
628            continue;
629        };
630        let Ok(record) = serde_json::from_str::<Value>(raw) else {
631            continue;
632        };
633        if matches!(
634            record.get("type").and_then(Value::as_str),
635            Some("session_meta") | Some("turn_context")
636        ) {
637            meta.codex_headers.push(record);
638        }
639    }
640    Ok(true)
641}
642
643pub(super) fn restore_codex_provenance_from_top_level(
644    record: &Value,
645    meta: &mut SessionMeta,
646) -> Result<bool> {
647    if let Some(extension) = record.get(SUPERCODE_NATIVE_RESIDUE_KEY) {
648        return restore_native_residue(extension, meta);
649    }
650    if let Some(extension) = record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
651        return restore_codex_provenance(extension, meta);
652    }
653    if let Some(summary) = record.get(SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY) {
654        // Tombstone without its records: the residue was deliberately
655        // deleted. The transcript stays fully usable; the loss is REPORTED,
656        // never silent (PARITY-23 dev/05).
657        let kinds = summary
658            .get("kinds")
659            .and_then(Value::as_array)
660            .map(|kinds| {
661                kinds
662                    .iter()
663                    .filter_map(Value::as_str)
664                    .collect::<Vec<_>>()
665                    .join(", ")
666            })
667            .unwrap_or_default();
668        let count = summary.get("records").and_then(Value::as_u64).unwrap_or(0);
669        let source = summary
670            .get("source")
671            .and_then(Value::as_str)
672            .unwrap_or("unknown");
673        meta.lineage.insert(
674            "residue_loss".to_string(),
675            format!(
676                "portable {source} residue deleted: {count} record(s) of kind(s) [{kinds}] \
677                 can no longer be restored"
678            ),
679        );
680        return Ok(false);
681    }
682    Ok(false)
683}
684
685fn inject_codex_provenance(out: &mut String, extension: Value) {
686    let Some(line_end) = out.find('\n') else {
687        return;
688    };
689    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
690        return;
691    };
692    if record.get("type").and_then(Value::as_str) != Some("session_meta") {
693        return;
694    }
695    let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
696        return;
697    };
698    payload.insert(
699        SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY.to_string(),
700        native_residue_summary(&extension),
701    );
702    payload.insert(SUPERCODE_NATIVE_RESIDUE_KEY.to_string(), extension);
703    out.replace_range(..line_end, &record.to_string());
704}
705
706/// The text of a Codex `agent_message` event. `message` is usually a string but
707/// can be a structured object (e.g. review output) — fall back to its JSON.
708fn agent_message_text(payload: &Value) -> String {
709    match payload.get("message") {
710        Some(Value::String(s)) => s.clone(),
711        Some(other) => extract_text_content(Some(other)),
712        None => String::new(),
713    }
714}
715
716/// Trimmed texts of all assistant messages present as `response_item` — the
717/// dedup set for recovering collab-only `agent_message` narration.
718fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
719    let mut set = std::collections::HashSet::new();
720    for line in non_empty_lines(jsonl) {
721        let Ok(v) = serde_json::from_str::<Value>(line) else {
722            continue;
723        };
724        if v.get("type").and_then(Value::as_str) != Some("response_item") {
725            continue;
726        }
727        let payload = v.get("payload").unwrap_or(&Value::Null);
728        if payload.get("type").and_then(Value::as_str) == Some("message")
729            && payload.get("role").and_then(Value::as_str) == Some("assistant")
730        {
731            let text = extract_text_content(payload.get("content"));
732            if !text.trim().is_empty() {
733                set.insert(text.trim().to_string());
734            }
735        }
736    }
737    set
738}
739
740fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
741    if meta.session_id.is_none() {
742        if let Some(id) = payload.get("id").and_then(Value::as_str) {
743            meta.session_id = Some(id.to_string());
744        }
745    }
746    if meta.cwd.is_none() {
747        if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
748            meta.cwd = Some(PathBuf::from(cwd));
749        }
750    }
751    if meta.system_prompt.is_none() {
752        // `base_instructions` may be a string or `{ "text": "..." }`.
753        let bi = payload.get("base_instructions");
754        let text = match bi {
755            Some(Value::String(s)) => Some(s.clone()),
756            Some(Value::Object(_)) => bi
757                .and_then(|b| b.get("text"))
758                .and_then(Value::as_str)
759                .map(str::to_string),
760            _ => None,
761        };
762        meta.system_prompt = text;
763    }
764    if meta.model.is_none() {
765        if let Some(m) = payload.get("model").and_then(Value::as_str) {
766            meta.model = Some(m.to_string());
767        }
768    }
769    // Cross-file lineage keys for multi-agent / forked sessions.
770    let mut put = |key: &str, v: Option<&Value>| {
771        if let Some(s) = v.and_then(Value::as_str) {
772            meta.lineage.insert(key.to_string(), s.to_string());
773        }
774    };
775    put("parent_thread_id", payload.get("parent_thread_id"));
776    put("forked_from_id", payload.get("forked_from_id"));
777    put("thread_source", payload.get("thread_source"));
778    // How the session started, as discovery reads it, so a loaded session and its discovered row agree.
779    if meta.trigger.is_none() {
780        meta.trigger = super::codex_start_trigger(payload);
781    }
782    // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
783    // passthrough — restores a captured Claude `fork-context-ref` so a
784    // Claude -> Codex -> Claude round trip reconstructs the original record
785    // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
786    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
787        if let Some(v) = payload.get("claude_fork_context_ref") {
788            meta.lineage
789                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
790        }
791    }
792    if let Some(spawn) = payload
793        .get("source")
794        .and_then(|s| s.get("subagent"))
795        .and_then(|s| s.get("thread_spawn"))
796    {
797        // parent_thread_id can also live here (preferred when both present).
798        if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
799            meta.lineage
800                .insert("parent_thread_id".to_string(), p.to_string());
801        }
802        for k in ["agent_role", "agent_nickname"] {
803            if let Some(s) = spawn.get(k).and_then(Value::as_str) {
804                meta.lineage.insert(k.to_string(), s.to_string());
805            }
806        }
807        if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
808            meta.lineage.insert("depth".to_string(), d.to_string());
809        }
810    }
811}
812
813/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
814pub(super) fn codex_turn_id(payload: &Value) -> Option<&str> {
815    payload
816        .get("metadata")
817        .and_then(|m| m.get("turn_id"))
818        .and_then(Value::as_str)
819}
820
821/// N2 (spliced-export hardening): every Codex group id already present in
822/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
823/// replays ahead of the appended tail it synthesizes via
824/// `Session::write_codex_records`. This is the GROUND TRUTH of what
825/// physically lands in the exported `out` string for the prefix: each line
826/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
827/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
828/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
829/// export) is extracted directly — no re-derivation from `self.messages`
830/// needed (that would have to reconstruct which ids the ORIGINAL export
831/// happened to assign, which this sidesteps entirely by reading them back
832/// out of the bytes themselves). A line that fails to parse, isn't a
833/// `response_item`, or carries no `turn_id` contributes nothing — headers
834/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
835/// never carry this field to begin with.
836fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
837    let mut ids = HashSet::new();
838    for line in raw_prefix {
839        if let Ok(v) = serde_json::from_str::<Value>(line) {
840            if let Some(payload) = v.get("payload") {
841                if let Some(tid) = codex_turn_id(payload) {
842                    ids.insert(tid.to_string());
843                }
844            }
845        }
846    }
847    ids
848}
849
850/// Stamp every `ChatMessage` appended to `messages` since index `from` with
851/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
852/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
853/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
854/// message that already carries a more specific timestamp of its own is
855/// never overwritten (none currently do on the Codex side, but this keeps
856/// every loader consistent). A no-op when `ts` is `None` (a line with no
857/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
858fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
859    let Some(ts) = ts else { return };
860    let Some(slice) = messages.get_mut(from..) else {
861        return;
862    };
863    for m in slice {
864        m.metadata
865            .entry("timestamp".to_string())
866            .or_insert_with(|| ts.to_string());
867    }
868}
869
870fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
871    match payload.get("type").and_then(Value::as_str) {
872        // A `local_shell_call` is the built-in shell tool's call (`action: {type: "exec",
873        // command, timeout_ms, working_directory}`); its result arrives as a
874        // `function_call_output` with the same `call_id`. It reads as a `local_shell` call.
875        Some("local_shell_call") => {
876            let call_id = payload
877                .get("call_id")
878                .or_else(|| payload.get("id"))
879                .and_then(Value::as_str)
880                .unwrap_or_default();
881            let mut arguments = payload.get("action").cloned().unwrap_or(Value::Null);
882            if let Some(action) = arguments.as_object_mut() {
883                action.remove("type");
884            }
885            let call = serde_json::json!({
886                "type": "function_call",
887                "call_id": call_id,
888                "name": "local_shell",
889                "arguments": arguments.to_string(),
890            });
891            push_codex_item(&call, out);
892        }
893        Some("message") => {
894            let role = match payload.get("role").and_then(Value::as_str) {
895                Some("user") => Role::User,
896                Some("assistant") => Role::Assistant,
897                // "developer" and "system" both carry operator instructions.
898                _ => Role::System,
899            };
900            let content = payload.get("content");
901            let text = extract_text_content(content);
902            // IX-5: `input_image` blocks alongside/instead of text — see
903            // `codex_extract_images`. A text-only message (no image blocks)
904            // takes the historical `content: Some(text)` shape unchanged.
905            let images = codex_extract_images(content);
906            let is_empty_assistant =
907                role == Role::Assistant && text.trim().is_empty() && images.is_empty();
908            if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
909                let content_parts = if images.is_empty() {
910                    None
911                } else {
912                    let mut parts = Vec::new();
913                    if !text.trim().is_empty() {
914                        parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
915                    }
916                    parts.extend(images);
917                    Some(parts)
918                };
919                let mut msg = ChatMessage {
920                    role,
921                    content: if content_parts.is_some() || text.is_empty() {
922                        None
923                    } else {
924                        Some(text)
925                    },
926                    content_parts,
927                    tool_calls: None,
928                    tool_call_id: None,
929                    name: None,
930                    metadata: Default::default(),
931                };
932                // Preserve the assistant `phase` (commentary vs final_answer) so
933                // a reloaded transcript can distinguish narration from the answer.
934                if role == Role::Assistant {
935                    if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
936                        msg.metadata.insert("phase".to_string(), phase.to_string());
937                    }
938                    // IX-6: mark this as an open, mergeable combined-turn
939                    // candidate — a `function_call` response_item found
940                    // immediately after (still `out.last()` when reached,
941                    // i.e. no other item intervened) merges into this SAME
942                    // `ChatMessage` instead of splitting into a second one,
943                    // matching how Claude's parser keeps a text+tool_use
944                    // turn together. Stripped again before the loaded
945                    // `Session` is returned (`from_codex_str`), so it never
946                    // leaks as visible metadata.
947                    msg.metadata
948                        .insert("__codex_open_turn".to_string(), "true".to_string());
949                }
950                // The per-turn grouping key (Codex batches items by turn_id).
951                if let Some(tid) = codex_turn_id(payload) {
952                    msg.metadata.insert("turn_id".to_string(), tid.to_string());
953                }
954                // PARITY-6 dev/02: restore the original Claude
955                // `systemSubtype` for a `developer`/`system` message that
956                // was itself synthesized FROM a real Claude system record
957                // (`write_codex_records`'s `Role::System` arm stamps
958                // `claude_system_subtype`) — the exact inverse, so
959                // `write_claude_code_records`'s `Role::System` arm can
960                // re-materialize the real Claude `type: "system"` record
961                // faithfully on a Codex -> Claude Code hop instead of
962                // guessing a fallback subtype.
963                if role == Role::System {
964                    if let Some(subtype) = payload
965                        .get("metadata")
966                        .and_then(|m| m.get("claude_system_subtype"))
967                        .and_then(Value::as_str)
968                    {
969                        msg.metadata
970                            .insert("systemSubtype".to_string(), subtype.to_string());
971                    }
972                }
973                if is_empty_assistant {
974                    msg.metadata
975                        .insert("empty_assistant_record".to_string(), "true".to_string());
976                }
977                out.push(msg);
978            }
979        }
980        Some("function_call") => {
981            let id = payload
982                .get("call_id")
983                .and_then(Value::as_str)
984                .unwrap_or_default();
985            let raw_name = payload
986                .get("name")
987                .and_then(Value::as_str)
988                .unwrap_or_default();
989            // Preserve the MCP `namespace` by qualifying the tool name
990            // (`<namespace>__<name>`, matching the mcp__server__tool convention),
991            // so the tool identity isn't ambiguous on round-trip.
992            let qualified;
993            let name = match payload.get("namespace").and_then(Value::as_str) {
994                Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
995                    qualified = format!("{ns}__{raw_name}");
996                    qualified.as_str()
997                }
998                _ => raw_name,
999            };
1000            let args = payload
1001                .get("arguments")
1002                .map(value_to_arg_string)
1003                .unwrap_or_else(|| "{}".to_string());
1004            let call = function_call(id, name, args);
1005            // IX-6: a `function_call` immediately after an assistant `message`
1006            // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
1007            // by the "message" arm above, and not yet closed by anything else)
1008            // merges into that ONE `ChatMessage` — text→`content`,
1009            // call→`tool_calls` — instead of splitting into a second message.
1010            // A bare `function_call` with no such preceding turn (the marker
1011            // absent, or `out.last()` not an assistant message) is unaffected:
1012            // it still gets its own synthesized message, exactly as before.
1013            //
1014            // Belt-and-suspenders (PARITY-6/7 tightened): if this
1015            // `function_call` response_item itself carries a `turn_id` (rare
1016            // in observed real-native-Codex corpora — Codex usually only
1017            // stamps it on `message` payloads — but ALWAYS present on OUR
1018            // OWN synthesized export whenever a `ChatMessage`'s own tool
1019            // calls need merge disambiguation, see `write_codex_records`),
1020            // it must match the marked assistant message's recorded
1021            // `turn_id` EXACTLY — including "the marked message has none at
1022            // all" counting as a mismatch. That's exactly the shape of two
1023            // genuinely separate, adjacent `ChatMessage`s (an unrelated
1024            // text-only turn immediately followed by a different,
1025            // tool-call-only turn): the tool-only turn's own `function_call`s
1026            // carry a synthetic id while the unrelated preceding text
1027            // message carries none, so this correctly refuses the merge
1028            // instead of falling through to a permissive default. Only when
1029            // this `function_call` carries NO `turn_id` at all (the ordinary
1030            // real-native-Codex shape) does this fall back to the original
1031            // permissive "adjacency + open marker is enough" rule —
1032            // unchanged from before for the vast majority of real Codex
1033            // data. The truncation/clear strip above is what actually closes
1034            // the marker across rollback/compaction boundaries; this is only
1035            // an extra guard for the case where a stale-but-unstripped
1036            // marker and a turn_id mismatch coincide.
1037            let can_merge = out.last().is_some_and(|last| {
1038                last.role == Role::Assistant
1039                    && last.metadata.contains_key("__codex_open_turn")
1040                    && match codex_turn_id(payload) {
1041                        Some(fc_tid) => {
1042                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
1043                        }
1044                        None => true,
1045                    }
1046            });
1047            if can_merge {
1048                out.last_mut()
1049                    .expect("can_merge implies out.last() is Some")
1050                    .tool_calls
1051                    .get_or_insert_with(Vec::new)
1052                    .push(call);
1053            } else {
1054                push_assistant(out, String::new(), vec![call]);
1055                // PARITY-6/7: a BARE tool-call turn (no preceding `message`
1056                // in this turn, so nothing set `__codex_open_turn` above) can
1057                // still be the FIRST of several tool calls that all belong to
1058                // the SAME original `ChatMessage` (`write_codex_records`
1059                // stamps every one of a message's own tool calls with the
1060                // identical synthetic `turn_id`). Re-open THIS freshly
1061                // created message — but ONLY when a real `turn_id` is
1062                // present — so the NEXT `function_call` in the same group
1063                // merges into it instead of becoming its own message too.
1064                // Gated on `codex_turn_id(payload).is_some()` (not the bare
1065                // default `true` the belt-and-suspenders check above uses)
1066                // so real native Codex data — which almost never carries
1067                // this field on `function_call` payloads (see the comment
1068                // above) — keeps its existing "every bare tool call is its
1069                // own turn" behavior exactly as before.
1070                if let Some(tid) = codex_turn_id(payload) {
1071                    if let Some(last) = out.last_mut() {
1072                        last.metadata
1073                            .insert("__codex_open_turn".to_string(), "true".to_string());
1074                        last.metadata.insert("turn_id".to_string(), tid.to_string());
1075                    }
1076                }
1077            }
1078        }
1079        Some("function_call_output") => {
1080            let id = payload
1081                .get("call_id")
1082                .and_then(Value::as_str)
1083                .unwrap_or_default();
1084            let result = match payload.get("output") {
1085                Some(Value::String(s)) => s.clone(),
1086                Some(v) => extract_text_content(Some(v)),
1087                None => String::new(),
1088            };
1089            let mut message = tool_message(id, result);
1090            attach_codex_output_images(&mut message, payload.get("output"));
1091            // TR-13: Codex v1 exposes no structured success/error field on
1092            // this record. Free-text output is not a safe classifier, so the
1093            // reduction engine must treat the outcome as explicitly unknown
1094            // and fail closed on both success-only and error-only pruning.
1095            crate::mark_tool_outcome_unknown(&mut message);
1096            out.push(message);
1097        }
1098        // Custom / MCP tool calls are shaped like function calls but carry their
1099        // arguments under `input` (a JSON-encoded string). Normalize them the
1100        // same way so MCP-using sessions don't lose those turns.
1101        Some("custom_tool_call") => {
1102            let id = payload
1103                .get("call_id")
1104                .and_then(Value::as_str)
1105                .unwrap_or_default();
1106            let name = payload
1107                .get("name")
1108                .and_then(Value::as_str)
1109                .unwrap_or_default();
1110            // Unlike `function_call.arguments`, Codex custom tools accept a
1111            // free-form `input` string (apply_patch is the common case).
1112            // Canonical `FunctionCall::arguments` must remain valid JSON, so
1113            // retain the input's JSON type instead of treating a free-form
1114            // string as if it were already a JSON document. This lets every
1115            // target harness carry the value rather than silently replacing
1116            // it with `{}` when `parsed_arguments()` fails.
1117            let args = payload
1118                .get("input")
1119                .map(Value::to_string)
1120                .unwrap_or_else(|| "{}".to_string());
1121            push_assistant(out, String::new(), vec![function_call(id, name, args)]);
1122            if let Some(message) = out.last_mut() {
1123                message.metadata.insert(
1124                    "codex_custom_tool_call_ids".to_string(),
1125                    serde_json::json!([id]).to_string(),
1126                );
1127            }
1128        }
1129        Some("custom_tool_call_output") => {
1130            let id = payload
1131                .get("call_id")
1132                .and_then(Value::as_str)
1133                .unwrap_or_default();
1134            let result = match payload.get("output") {
1135                Some(Value::String(s)) => s.clone(),
1136                Some(v) => extract_text_content(Some(v)),
1137                None => String::new(),
1138            };
1139            let mut message = tool_message(id, result);
1140            attach_codex_output_images(&mut message, payload.get("output"));
1141            crate::mark_tool_outcome_unknown(&mut message);
1142            out.push(message);
1143        }
1144        // Tool-search is a clean call/output pair keyed by call_id.
1145        //
1146        // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
1147        // ignoring the `turn_id` merge stamps `write_codex_records` puts on
1148        // its own synthesized `tool_search_call` records (see the PARITY-6/7
1149        // comment there and on `codex_turn_id`/the `function_call` arm
1150        // above). That left the same bug-class the turn_id work fixed for
1151        // `function_call` half-done here: a single Claude assistant message
1152        // containing text + a `tool_search` block reloaded as 2 messages
1153        // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
1154        // reloaded as 3. Mirror the `function_call` arm's merge check
1155        // exactly so a `tool_search_call` immediately following an open
1156        // assistant turn (or another tool call sharing the same `turn_id`)
1157        // merges into that SAME `ChatMessage` instead of splitting.
1158        Some("tool_search_call") => {
1159            let id = payload
1160                .get("call_id")
1161                .and_then(Value::as_str)
1162                .unwrap_or_default();
1163            let args = payload
1164                .get("arguments")
1165                .map(value_to_arg_string)
1166                .unwrap_or_else(|| "{}".to_string());
1167            let call = function_call(id, "tool_search", args);
1168            let can_merge = out.last().is_some_and(|last| {
1169                last.role == Role::Assistant
1170                    && last.metadata.contains_key("__codex_open_turn")
1171                    && match codex_turn_id(payload) {
1172                        Some(fc_tid) => {
1173                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
1174                        }
1175                        None => true,
1176                    }
1177            });
1178            if can_merge {
1179                out.last_mut()
1180                    .expect("can_merge implies out.last() is Some")
1181                    .tool_calls
1182                    .get_or_insert_with(Vec::new)
1183                    .push(call);
1184            } else {
1185                push_assistant(out, String::new(), vec![call]);
1186                // Re-open the freshly created message so a FOLLOWING
1187                // `function_call`/`tool_search_call` sharing this same
1188                // `turn_id` merges into it too — matching the bare
1189                // `function_call` case's own re-open logic above.
1190                if let Some(tid) = codex_turn_id(payload) {
1191                    if let Some(last) = out.last_mut() {
1192                        last.metadata
1193                            .insert("__codex_open_turn".to_string(), "true".to_string());
1194                        last.metadata.insert("turn_id".to_string(), tid.to_string());
1195                    }
1196                }
1197            }
1198        }
1199        Some("tool_search_output") => {
1200            let id = payload
1201                .get("call_id")
1202                .and_then(Value::as_str)
1203                .unwrap_or_default();
1204            let result = payload
1205                .get("tools")
1206                .map(value_to_arg_string)
1207                .unwrap_or_default();
1208            out.push(tool_message(id, result));
1209        }
1210        // Web-search / image-generation response_items carry no paired output
1211        // here (results live in event_msg), so emit an assistant marker rather
1212        // than a dangling unanswered tool call.
1213        Some("web_search_call") => {
1214            push_assistant(out, "[web_search]".to_string(), Vec::new());
1215        }
1216        Some("image_generation_call") => {
1217            let prompt = payload
1218                .get("revised_prompt")
1219                .and_then(Value::as_str)
1220                .unwrap_or("");
1221            push_assistant(
1222                out,
1223                format!("[image_generation] {prompt}").trim().to_string(),
1224                Vec::new(),
1225            );
1226        }
1227        // "reasoning" and anything else — dropped.
1228        _ => {}
1229    }
1230}
1231
1232impl Session {
1233    /// Synthesize a Codex rollout.
1234    pub(super) fn to_codex_jsonl(&self) -> String {
1235        let mut out = String::new();
1236
1237        if self.meta.codex_headers.is_empty() {
1238            self.write_synthesized_codex_header(&mut out);
1239        } else {
1240            // Replay the exact header records the original tool wrote — Codex's
1241            // reader validates the header shape strictly — overriding only the
1242            // session id when the caller changed it.
1243            for header in &self.meta.codex_headers {
1244                let mut header = header.clone();
1245                if header.get("type").and_then(Value::as_str) == Some("session_meta") {
1246                    if let Some(id) = &self.meta.session_id {
1247                        if let Some(payload) = header.get_mut("payload") {
1248                            payload["id"] = Value::String(id.clone());
1249                        }
1250                    }
1251                }
1252                push_jsonl(&mut out, &header);
1253            }
1254        }
1255
1256        // Full synthesis: `out` at this point is only the header, so there
1257        // are no group ids yet in play to seed against (see
1258        // `write_codex_records`'s doc comment).
1259        self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
1260        if let Some(extension) = native_residue_envelope(&self.meta) {
1261            inject_codex_provenance(&mut out, extension);
1262        }
1263        out
1264    }
1265
1266    /// Synthesize Codex `response_item` records for `messages` (a full
1267    /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
1268    /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
1269    /// the record shape is defined once; `tool_search_call_ids` pairing is
1270    /// scoped to this call's `messages`, matching the header-replay
1271    /// contract that only appended records need synthesizing.
1272    ///
1273    /// `seed_used_ids` primes the N2 collision guard below with every group
1274    /// id that will ALREADY be present in `out` before this call ever runs —
1275    /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
1276    /// header) passes an empty set, since every group id in that case is
1277    /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
1278    /// splice) passes the ids already used by the verbatim RAW prefix it
1279    /// replayed into `out` just before calling this for the appended tail —
1280    /// without that seed, the tail's own `used_group_ids`/`next_group_id`
1281    /// start blind to the prefix and can fabricate/reuse a group id that
1282    /// COLLIDES with one still "open" at the end of the prefix, letting
1283    /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
1284    /// an unrelated appended message into a historical one — the same
1285    /// bug-class N2 closed for full synthesis, reopened here because the
1286    /// spliced tail's tracking set used to always start empty regardless of
1287    /// what the replayed prefix already contained.
1288    fn write_codex_records(
1289        &self,
1290        out: &mut String,
1291        messages: &[ChatMessage],
1292        seed_used_ids: &std::collections::HashSet<String>,
1293    ) {
1294        // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
1295        // the matching tool result below can be emitted as the paired
1296        // `tool_search_output` record rather than a generic
1297        // `function_call_output` — the exact inverse of the importer's
1298        // `tool_search_call`/`tool_search_output` normalization
1299        // (`push_codex_item`, above).
1300        let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
1301        // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
1302        // records (e.g. a text-only narration turn immediately followed by a
1303        // bare tool-call turn, no user turn between — a real, common Claude
1304        // Code shape) each become their own Codex `message`/`function_call`
1305        // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
1306        // opportunistically RE-MERGES an assistant `message` immediately
1307        // followed by a `function_call` back into ONE `ChatMessage`, to match
1308        // how a genuinely single Claude turn (text+tool_use in the SAME
1309        // record) round-trips — but with no distinguishing signal, it can't
1310        // tell that case apart from two originally-separate records that
1311        // just happen to be adjacent, so it wrongly recombines them too,
1312        // silently shrinking the message count on every Claude -> Codex ->
1313        // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
1314        // per ORIGINAL `ChatMessage` — onto the `message` record AND every
1315        // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
1316        // itself emits. `push_codex_item`'s merge already treats a turn_id
1317        // mismatch as "different turn, do not merge" (the pre-existing
1318        // belt-and-suspenders check); real native Codex data almost never
1319        // carries this field (per that check's own comment), so this is a
1320        // no-op there and only sharpens fidelity for OUR OWN synthesized
1321        // export.
1322        let mut next_group_id: u64 = 0;
1323        // N2 (Fable-5 review, turn_id-collision hardening): every group id
1324        // this export has already assigned — whether REUSED from a real
1325        // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
1326        // `ChatMessage` never emits one that's already in use. Two concrete
1327        // mis-merge scenarios motivate this:
1328        //
1329        // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
1330        //     own text+tool_use); reload makes A carry REAL turn_id
1331        //     `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
1332        //     its own) is then appended. Re-export: A reuses its real
1333        //     `sc-grp-0`, but B independently fabricates a FRESH id starting
1334        //     from `next_group_id == 0` again (nothing bumped it when A's id
1335        //     was reused rather than fabricated) — also `sc-grp-0`.
1336        //     Collision. If A's call has no output (interrupted session),
1337        //     reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
1338        //     adjacent with nothing to break the run and merges all three
1339        //     into ONE message (2 -> 1).
1340        // (b) Native Codex: `message(turn-7)` opens the merge marker, a
1341        //     truncation/clear event strips `__codex_open_turn` (closing the
1342        //     turn without changing the id), then `function_call(turn-7)`
1343        //     loads as a SECOND, separate `ChatMessage` that still carries
1344        //     the SAME real `turn_id` (the reopen step in `push_codex_item`
1345        //     restamps it). Full-synthesis export naively reuses `turn-7`
1346        //     verbatim for BOTH messages (they're two different loop
1347        //     iterations, each independently reusing its own `real_turn_id`)
1348        //     and emits them adjacent — reimport's merge check can't tell
1349        //     this apart from a single message's own multi-call turn and
1350        //     recombines them (2 -> 1).
1351        //
1352        // Fix: the fabricated-id counter is advanced (skipped) past any id
1353        // already in `used_group_ids`, AND a real id that's already been
1354        // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
1355        // — never letting two DIFFERENT `ChatMessage`s in this export share
1356        // one group id, since `push_codex_item`'s merge check treats a
1357        // shared id as "same turn, merge". A single `ChatMessage`'s own
1358        // message record + its own tool call records still share ONE group
1359        // id (computed once per loop iteration below, before insertion), so
1360        // the D1 tool_search merge and ordinary same-turn multi-call
1361        // grouping are unaffected — this only stops REUSE across iterations.
1362        //
1363        // Seeded from `seed_used_ids` (see this fn's doc comment) so the
1364        // spliced-export tail is likewise blind-proof against the prefix it
1365        // doesn't itself write.
1366        let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
1367
1368        for msg in messages {
1369            if is_replay_excluded(msg) {
1370                continue;
1371            }
1372            // D3 (Fable-5 review): a message loaded FROM real native Codex
1373            // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
1374            // (`push_codex_item`'s "message" arm stamps it whenever the
1375            // source record itself has one). The group-id logic below used
1376            // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
1377            // silently overwriting/discarding that real id on any
1378            // native-Codex -> load -> export-Codex hop. Reuse it verbatim
1379            // when present; only fabricate a synthetic id as a fallback for
1380            // our own merge-disambiguation need (PARITY-6/7) when the
1381            // message has no real one of its own.
1382            let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
1383            match msg.role {
1384                Role::System => {
1385                    // PARITY-6 dev/02: carry the original Claude
1386                    // `systemSubtype` (`push_claude_system`'s `.with_meta`)
1387                    // through as `metadata.claude_system_subtype`, so
1388                    // `push_codex_item`'s reverse load can restore it and
1389                    // `write_claude_code_records`'s `Role::System` arm can
1390                    // re-materialize the EXACT original subtype rather than
1391                    // guessing on a Codex -> Claude hop.
1392                    let subtype_meta = msg
1393                        .metadata
1394                        .get("systemSubtype")
1395                        .map(|s| ("claude_system_subtype", s.as_str()));
1396                    self.push_codex_message(
1397                        out,
1398                        "developer",
1399                        "input_text",
1400                        msg,
1401                        real_turn_id,
1402                        subtype_meta,
1403                    )
1404                }
1405                Role::User => {
1406                    self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
1407                }
1408                Role::Assistant => {
1409                    // Emit the message record whenever there is text OR
1410                    // content_parts (IX-6 follow-up): an image-only assistant
1411                    // message has `content: None, content_parts:
1412                    // Some([image])` (the loader's `codex_extract_images` is
1413                    // role-general, so this shape can occur on the assistant
1414                    // side too) — gating on `msg.content` alone silently
1415                    // dropped the whole message, image included. A
1416                    // text-only message (content_parts: None) keeps taking
1417                    // the historical byte-identical path via
1418                    // `codex_message_content_blocks`'s `None` arm. A real
1419                    // empty native assistant record carries the
1420                    // loader's explicit marker and must also be emitted.
1421                    // Reasoning-only cross-provider turns deliberately lack
1422                    // that marker and keep the documented Codex residue.
1423                    let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
1424                    let has_message_record = has_text
1425                        || msg.content_parts.is_some()
1426                        || msg.metadata.contains_key("empty_assistant_record");
1427                    // Only assign a synthetic group id when there's actual
1428                    // merge ambiguity to resolve (a message AND its own tool
1429                    // calls, or 2+ of this message's own tool calls) — a
1430                    // pure-text message with no tool calls, or a lone tool
1431                    // call with nothing else from the same `ChatMessage`,
1432                    // has nothing to disambiguate, so it keeps the exact
1433                    // historical byte shape (no `metadata` key at all).
1434                    let group_id: Option<String> = if let Some(real) = real_turn_id {
1435                        if used_group_ids.contains(real) {
1436                            // N2: this real turn_id was already used by an
1437                            // earlier (now-closed) `ChatMessage` in this same
1438                            // export — reusing it verbatim would let the
1439                            // reimport merge check recombine two originally
1440                            // separate messages (see the doc comment above).
1441                            let mut n = 1u64;
1442                            let mut candidate = format!("{real}~dup{n}");
1443                            while used_group_ids.contains(&candidate) {
1444                                n += 1;
1445                                candidate = format!("{real}~dup{n}");
1446                            }
1447                            Some(candidate)
1448                        } else {
1449                            Some(real.to_string())
1450                        }
1451                    } else if !msg.tool_calls().is_empty() {
1452                        // N2: skip past any id already used (e.g. a REAL
1453                        // turn_id that happens to look like `sc-grp-N`, or an
1454                        // id an earlier reused-real case landed on).
1455                        let mut candidate = format!("sc-grp-{next_group_id}");
1456                        next_group_id += 1;
1457                        while used_group_ids.contains(&candidate) {
1458                            candidate = format!("sc-grp-{next_group_id}");
1459                            next_group_id += 1;
1460                        }
1461                        Some(candidate)
1462                    } else {
1463                        None
1464                    };
1465                    if let Some(g) = &group_id {
1466                        used_group_ids.insert(g.clone());
1467                    }
1468                    if has_message_record {
1469                        self.push_codex_message(
1470                            out,
1471                            "assistant",
1472                            "output_text",
1473                            msg,
1474                            group_id.as_deref(),
1475                            None,
1476                        );
1477                    }
1478                    for tc in msg.tool_calls() {
1479                        let custom_tool_call = msg
1480                            .metadata
1481                            .get("codex_custom_tool_call_ids")
1482                            .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
1483                            .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
1484                        if custom_tool_call {
1485                            let input = tc
1486                                .function
1487                                .parsed_arguments()
1488                                .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
1489                            let mut payload = with_turn_id(
1490                                serde_json::json!({
1491                                    "type": "custom_tool_call",
1492                                    "name": tc.function.name,
1493                                    "input": input,
1494                                    "call_id": tc.id,
1495                                }),
1496                                group_id.as_deref(),
1497                            );
1498                            set_grok_message_extension(&mut payload, self.meta.source, msg);
1499                            push_jsonl(
1500                                out,
1501                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
1502                            );
1503                        } else if tc.function.name == "tool_search" {
1504                            tool_search_call_ids.insert(tc.id.clone());
1505                            let mut payload = with_turn_id(
1506                                serde_json::json!({
1507                                    "type": "tool_search_call",
1508                                    "arguments": tc.function.arguments,
1509                                    "call_id": tc.id,
1510                                }),
1511                                group_id.as_deref(),
1512                            );
1513                            set_grok_message_extension(&mut payload, self.meta.source, msg);
1514                            push_jsonl(
1515                                out,
1516                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
1517                            );
1518                        } else {
1519                            let mut payload = with_turn_id(
1520                                serde_json::json!({
1521                                    "type": "function_call",
1522                                    "name": tc.function.name,
1523                                    "arguments": tc.function.arguments,
1524                                    "call_id": tc.id,
1525                                }),
1526                                group_id.as_deref(),
1527                            );
1528                            set_grok_message_extension(&mut payload, self.meta.source, msg);
1529                            push_jsonl(
1530                                out,
1531                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
1532                            );
1533                        }
1534                    }
1535                    // PARITY-11: a genuinely reasoning-only turn (Claude
1536                    // `thinking`/`redacted_thinking` with no text, tool_use,
1537                    // or image — `push_claude_assistant`'s load-side fix for
1538                    // the ~21% of real assistant records that are exactly
1539                    // this shape) has no message record and no tool calls,
1540                    // so nothing above writes anything for it. This is
1541                    // DELIBERATE, not a residual gap: Codex's `reasoning`
1542                    // response_item is understood on import (see the
1543                    // `response_item`/`"reasoning"` arm above), but its
1544                    // real-native semantics is "the reasoning immediately
1545                    // BEFORE the next turn" — the reader attaches it to
1546                    // whatever response_item comes next, unconditionally.
1547                    // For a genuinely standalone Claude reasoning-only turn
1548                    // (no related turn follows in Codex's export at all),
1549                    // emitting one here would get silently misattributed as
1550                    // belonging to some later, unrelated turn instead —
1551                    // strictly worse than the current honest, accounted-for
1552                    // absence (thinking/redacted_thinking is provider-
1553                    // private and "not replayed across providers" by
1554                    // original design; the audit correctly classifies it
1555                    // `Coverage::Dropped`, not `Unmodeled`). See the
1556                    // PARITY-6/7 corpus test's `is_replayable` filter for
1557                    // why this doesn't count as a message-count regression.
1558                }
1559                Role::Tool
1560                    if msg
1561                        .tool_call_id
1562                        .as_deref()
1563                        .is_some_and(|id| tool_search_call_ids.contains(id)) =>
1564                {
1565                    let content = msg.content.clone().unwrap_or_default();
1566                    let tools =
1567                        serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
1568                    let mut payload = serde_json::json!({
1569                        "type": "tool_search_output",
1570                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
1571                        "tools": tools,
1572                    });
1573                    set_grok_message_extension(&mut payload, self.meta.source, msg);
1574                    push_jsonl(
1575                        out,
1576                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
1577                    );
1578                }
1579                Role::Tool => {
1580                    let mut payload = serde_json::json!({
1581                        "type": "function_call_output",
1582                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
1583                        "output": codex_tool_output_value(msg),
1584                    });
1585                    set_grok_message_extension(&mut payload, self.meta.source, msg);
1586                    push_jsonl(
1587                        out,
1588                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
1589                    );
1590                }
1591            }
1592        }
1593    }
1594
1595    /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
1596    /// line, not just the `session_meta`/`turn_context` headers
1597    /// [`Self::to_codex_jsonl`] replays — overriding only
1598    /// `session_meta.payload.id` when `session_id` is `Some` (every other
1599    /// line, including `response_item`s the stock synthesis would otherwise
1600    /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
1601    /// `response_item` records only for the appended tail, via
1602    /// [`Self::write_codex_records`].
1603    pub(super) fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
1604        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
1605
1606        let mut out = String::new();
1607        for line in &self.raw[..raw_prefix_len] {
1608            match session_id {
1609                Some(id) => {
1610                    let patched = serde_json::from_str::<Value>(line)
1611                        .ok()
1612                        .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
1613                        .map(|mut v| {
1614                            if let Some(payload) = v.get_mut("payload") {
1615                                payload["id"] = Value::String(id.to_string());
1616                            }
1617                            v.to_string()
1618                        });
1619                    out.push_str(patched.as_deref().unwrap_or(line));
1620                }
1621                None => out.push_str(line),
1622            }
1623            out.push('\n');
1624        }
1625
1626        // N2 (spliced-path hardening): seed the tail's collision guard with
1627        // every group id the just-replayed RAW prefix already carries, so
1628        // `write_codex_records` never fabricates/reuses an id for the
1629        // appended tail that collides with one still open at the end of the
1630        // prefix (see that fn's doc comment, and
1631        // `collect_codex_group_ids_from_raw`'s).
1632        let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
1633        // Belt-and-suspenders: also union in the prefix `messages`' own
1634        // recorded `turn_id` metadata. In the ordinary case this is already
1635        // a subset of what the raw-line scan above found (the loader stamps
1636        // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
1637        // field the scan reads) — but scanning `messages` too costs nothing
1638        // and means this stays correct even if some future loader path ever
1639        // derives a message's `turn_id` by some means other than a literal
1640        // `payload.metadata.turn_id` copy.
1641        for msg in &self.messages[..message_prefix_len] {
1642            if let Some(tid) = msg.metadata.get("turn_id") {
1643                seed_used_ids.insert(tid.clone());
1644            }
1645        }
1646        // A paginated rollout numbers every record (`ordinal`), and stock Codex refuses to resume
1647        // one whose final record has none: appended records continue the prefix's numbering.
1648        let last_ordinal = self.raw[..raw_prefix_len].iter().rev().find_map(|line| {
1649            serde_json::from_str::<Value>(line)
1650                .ok()?
1651                .get("ordinal")?
1652                .as_u64()
1653        });
1654        let mut tail = String::new();
1655        self.write_codex_records(
1656            &mut tail,
1657            &self.messages[message_prefix_len..],
1658            &seed_used_ids,
1659        );
1660        match last_ordinal {
1661            None => out.push_str(&tail),
1662            Some(mut ordinal) => {
1663                for line in tail.lines() {
1664                    match serde_json::from_str::<Value>(line) {
1665                        Ok(mut record) if record.is_object() => {
1666                            ordinal += 1;
1667                            record["ordinal"] = Value::from(ordinal);
1668                            out.push_str(&record.to_string());
1669                        }
1670                        _ => out.push_str(line),
1671                    }
1672                    out.push('\n');
1673                }
1674            }
1675        }
1676        out
1677    }
1678
1679    /// Build a Codex header from scratch (used when converting from another
1680    /// format, where no original Codex header exists to replay). Emits the
1681    /// fields Codex requires on `session_meta`.
1682    fn write_synthesized_codex_header(&self, out: &mut String) {
1683        let mut meta_payload = serde_json::json!({
1684            "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
1685            "timestamp": SYNTH_TS,
1686            "cwd": self.cwd_string(),
1687            "originator": "supercode",
1688            "cli_version": env!("CARGO_PKG_VERSION"),
1689            "source": "exec",
1690            "thread_source": "user",
1691            "model_provider": "openai",
1692        });
1693        if let Some(sp) = &self.meta.system_prompt {
1694            meta_payload["base_instructions"] = serde_json::json!({"text": sp});
1695        }
1696        // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
1697        // `capture_claude_meta`) through the Codex hop under a clearly
1698        // namespaced custom field — real Codex tooling ignores unknown
1699        // `session_meta.payload` keys, and `capture_codex_session_meta`
1700        // reads this same key back on import, so a Claude -> Codex -> Claude
1701        // round trip still reconstructs the original record instead of
1702        // silently losing the lineage note on the cross-format hop.
1703        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
1704            meta_payload["claude_fork_context_ref"] =
1705                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
1706        }
1707        push_jsonl(
1708            out,
1709            &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
1710        );
1711        if let Some(model) = &self.meta.model {
1712            push_jsonl(
1713                out,
1714                &serde_json::json!({
1715                    "timestamp": SYNTH_TS,
1716                    "type": "turn_context",
1717                    "payload": {"model": model, "cwd": self.cwd_string()},
1718                }),
1719            );
1720        }
1721    }
1722
1723    /// `turn_id`: see the PARITY-6/7 (and D3) comment on
1724    /// [`Self::write_codex_records`] — `Some` when the source message
1725    /// carries its own REAL `turn_id` (a native-Codex round-trip), or
1726    /// (assistant only) a synthetic disambiguation id when it owns tool
1727    /// calls needing merge disambiguation and has no real id of its own;
1728    /// `None` reproduces the exact historical shape (no `metadata` key at
1729    /// all).
1730    /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
1731    /// pair folded into `payload.metadata` alongside `turn_id` (used by the
1732    /// `Role::System` case in [`Self::write_codex_records`] to carry
1733    /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
1734    /// system record's subtype survives the Claude -> Codex -> Claude round
1735    /// trip instead of only its text; `None` for every other caller,
1736    /// preserving the exact historical shape).
1737    fn push_codex_message(
1738        &self,
1739        out: &mut String,
1740        role: &str,
1741        text_type: &str,
1742        msg: &ChatMessage,
1743        turn_id: Option<&str>,
1744        extra_metadata: Option<(&str, &str)>,
1745    ) {
1746        let mut payload = with_turn_id(
1747            serde_json::json!({
1748                "type": "message",
1749                "role": role,
1750                "content": codex_message_content_blocks(text_type, msg),
1751            }),
1752            turn_id,
1753        );
1754        if let Some((k, v)) = extra_metadata {
1755            if payload.get("metadata").is_none() {
1756                payload["metadata"] = serde_json::json!({});
1757            }
1758            payload["metadata"][k] = serde_json::json!(v);
1759        }
1760        set_grok_message_extension(&mut payload, self.meta.source, msg);
1761        push_jsonl(
1762            out,
1763            &codex_response_item(payload, msg_timestamp_or_synth(msg)),
1764        );
1765    }
1766}
1767
1768fn codex_response_item(payload: Value, ts: &str) -> Value {
1769    serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
1770}
1771
1772/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
1773/// see [`Session::write_codex_records`]); a no-op returning `payload`
1774/// untouched when `None`, so the historical byte shape is preserved for
1775/// every record that has no merge ambiguity to disambiguate.
1776fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
1777    if let Some(tid) = turn_id {
1778        payload["metadata"] = serde_json::json!({"turn_id": tid});
1779    }
1780    payload
1781}
1782
1783/// Build a Codex `message` response_item's `content` block array from a
1784/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
1785/// parse. When `content_parts` is `None` this MUST reproduce the historical
1786/// single-block shape exactly (IX-5's overriding constraint: a text-only
1787/// message's export stays byte-identical) — only a multimodal message gets
1788/// one `{text_type}` block per non-empty text part plus one native Codex
1789/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
1790/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
1791/// `output_text` blocks already follow the family of) per `image_url` part.
1792fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
1793    match &msg.content_parts {
1794        Some(parts) => {
1795            let mut blocks = Vec::new();
1796            for p in parts {
1797                match p.get("type").and_then(Value::as_str) {
1798                    Some("text") => {
1799                        if let Some(t) = p.get("text").and_then(Value::as_str) {
1800                            if !t.is_empty() {
1801                                blocks.push(serde_json::json!({"type": text_type, "text": t}));
1802                            }
1803                        }
1804                    }
1805                    Some("image_url") => {
1806                        if let Some(url) = p
1807                            .get("image_url")
1808                            .and_then(|u| u.get("url"))
1809                            .and_then(Value::as_str)
1810                        {
1811                            blocks.push(serde_json::json!({
1812                                "type": "input_image",
1813                                "image_url": url,
1814                            }));
1815                        }
1816                    }
1817                    _ => {}
1818                }
1819            }
1820            Value::Array(blocks)
1821        }
1822        None => {
1823            let text = msg.content.clone().unwrap_or_default();
1824            Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
1825        }
1826    }
1827}
1828
1829/// A Codex tool output is a bare string or an array of `input_text` / `input_image` items
1830/// (`FunctionCallOutputBody`, codex-rs `protocol/src/models.rs`; see
1831/// `docs/reference/interop/codex-fields.md`). Text alone stays a bare string; a tool result
1832/// carrying images becomes the array, so the images survive the hop.
1833fn codex_tool_output_value(msg: &ChatMessage) -> Value {
1834    let text = msg.content.clone().unwrap_or_default();
1835    let images: Vec<&str> = msg
1836        .content_parts
1837        .iter()
1838        .flatten()
1839        .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
1840        .filter_map(|p| {
1841            p.get("image_url")
1842                .and_then(|u| u.get("url"))
1843                .and_then(Value::as_str)
1844        })
1845        .collect();
1846    if images.is_empty() {
1847        return Value::String(text);
1848    }
1849    let mut items = Vec::new();
1850    if !text.is_empty() {
1851        items.push(serde_json::json!({"type": "input_text", "text": text}));
1852    }
1853    items.extend(
1854        images
1855            .into_iter()
1856            .map(|url| serde_json::json!({"type": "input_image", "image_url": url})),
1857    );
1858    Value::Array(items)
1859}
1860
1861/// The images of an array-shaped Codex tool output, as the tool message's `content_parts`
1862/// (text first, as every codec's tool messages carry it).
1863fn attach_codex_output_images(message: &mut ChatMessage, output: Option<&Value>) {
1864    let images = codex_extract_images(output);
1865    if images.is_empty() {
1866        return;
1867    }
1868    let mut parts = Vec::new();
1869    if let Some(text) = message.content.as_deref().filter(|t| !t.is_empty()) {
1870        parts.push(serde_json::json!({"type": "text", "text": text}));
1871    }
1872    parts.extend(images);
1873    message.content_parts = Some(parts);
1874}