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