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