Skip to main content

supercode_interchange/session/
codex.rs

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