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}