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