//! Conversation reduction from model-facing payload snapshots.//!//! Inference request inputs and response outputs are both part of the logical//! conversation because they are the payloads exchanged with the model. Runtime//! observations, such as local tool output, stay outside the transcript until a//! later model-facing payload carries their content.use anyhow::Context;use anyhow::Ok;use anyhow::Result;use anyhow::bail;use serde_json::Value;use self::normalize::NormalizedConversationItem;use super::TraceReducer;use crate::model::CompactionId;use crate::model::ConversationBody;use crate::model::ConversationItem;use crate::model::ConversationItemKind;use crate::model::ConversationPart;use crate::model::ConversationRole;use crate::model::InferenceCallId;use crate::model::ProducerRef;use crate::payload::RawPayloadRef;mod normalize;impl TraceReducer { /// Reduces an inference request input snapshot into model-visible conversation items. /// /// Request snapshots are reconciled by position against the previous model-visible /// snapshot for the thread so repeated history reuses ids while newly inserted /// items remain distinct. pub(super) fn reduce_inference_request( &mut self, wall_time_unix_ms: i64, inference_call_id: &InferenceCallId, thread_id: &str, codex_turn_id: &str, request_payload: &RawPayloadRef, ) -> Result<Vec<String>> { let payload = self.read_payload_json(request_payload)?; let Some(input) = payload.get("input") else { bail!( "inference request payload {} did not contain input", request_payload.raw_payload_id ); }; let Some(request_items) = input.as_array() else { bail!( "inference request payload {} had non-array input", request_payload.raw_payload_id ); }; let items = normalize::normalize_model_items(request_items, request_payload)?; let previous_response_id = payload.get("previous_response_id").and_then(Value::as_str); // After compaction, the next full request is compared against the installed replacement // history, not the pre-compaction prompt. Any repeated developer/context prefix that Codex // reinjects must therefore become a fresh post-compaction conversation item. let post_compaction_snapshot = if previous_response_id.is_none() { self.pending_compaction_replacement_item_ids .get(thread_id) .cloned() } else { None }; let request_item_ids = if let Some(previous_response_id) = previous_response_id { // Streaming follow-up requests can send only the new input plus a // `previous_response_id`. The trace model still exposes the full // model-visible input, so rebuild the omitted prefix from the // previous request and response before reducing this delta. let previous_items = self .rollout .inference_calls .values() .find(|inference| { inference.thread_id == thread_id && inference.response_id.as_deref() == Some(previous_response_id) }) .map(|inference| { let mut ids = inference.request_item_ids.clone(); ids.extend(inference.response_item_ids.clone()); ids }); let Some(mut item_ids) = previous_items else { bail!( "incremental inference request {inference_call_id} referenced unknown previous_response_id {previous_response_id}" ); }; let delta_item_ids = self.reconcile_conversation_items( items, ReconcileItems { thread_id, codex_turn_id, wall_time_unix_ms, produced_by: Vec::new(), start_index: item_ids.len(), mode: ReconcileMode::AppendOnly, snapshot_override: None, }, )?; item_ids.extend(delta_item_ids); item_ids } else { self.reconcile_conversation_items( items, ReconcileItems { thread_id, codex_turn_id, wall_time_unix_ms, produced_by: Vec::new(), start_index: 0, mode: ReconcileMode::FullSnapshot, snapshot_override: post_compaction_snapshot.as_deref(), }, )? }; self.append_thread_conversation_items(thread_id, &request_item_ids)?; if post_compaction_snapshot.is_some() { self.pending_compaction_replacement_item_ids .remove(thread_id); } self.thread_conversation_snapshots .insert(thread_id.to_string(), request_item_ids.clone()); Ok(request_item_ids) } /// Reduces an inference response payload into conversation items produced by the call. pub(super) fn reduce_inference_response( &mut self, wall_time_unix_ms: i64, inference_call_id: &InferenceCallId, response_payload: &RawPayloadRef, ) -> Result<Vec<String>> { let payload = self.read_payload_json(response_payload)?; let Some(output_items) = payload.get("output_items").and_then(Value::as_array) else { bail!( "inference response payload {} did not contain output_items", response_payload.raw_payload_id ); }; let Some((thread_id, codex_turn_id)) = self .rollout .inference_calls .get(inference_call_id) .map(|inference| (inference.thread_id.clone(), inference.codex_turn_id.clone())) else { bail!("inference response referenced unknown call {inference_call_id}"); }; let items = normalize::normalize_model_items(output_items, response_payload)?; // Response output is appended immediately: it was produced by the model, // so it is conversation even before a later request carries it forward. let append_at = self .thread_conversation_snapshots .get(&thread_id) .map_or(0, Vec::len); let response_item_ids = self.reconcile_conversation_items( items, ReconcileItems { thread_id: &thread_id, codex_turn_id: &codex_turn_id, wall_time_unix_ms, produced_by: vec![ProducerRef::Inference { inference_call_id: inference_call_id.clone(), }], start_index: append_at, mode: ReconcileMode::AppendOnly, snapshot_override: None, }, )?; self.append_thread_conversation_items(&thread_id, &response_item_ids)?; self.thread_conversation_snapshots .entry(thread_id) .or_default() .extend(response_item_ids.clone()); if let Some(usage) = payload .get("token_usage") .and_then(normalize::token_usage_from_value) && let Some(inference) = self.rollout.inference_calls.get_mut(inference_call_id) { inference.usage = Some(usage); } Ok(response_item_ids) } fn reconcile_conversation_items( &mut self, items: Vec<NormalizedConversationItem>, context: ReconcileItems<'_>, ) -> Result<Vec<String>> { let previous_snapshot = context.snapshot_override.map_or_else( || { self.thread_conversation_snapshots .get(context.thread_id) .cloned() .unwrap_or_default() }, <[_]>::to_vec, ); let mut item_ids = Vec::with_capacity(items.len()); for (offset, item) in items.into_iter().enumerate() { let index = context.start_index + offset; let tool_link_item = item.clone(); self.ensure_call_id_consistency(context.thread_id, &item)?; let item_id = if let Some(previous_item_id) = previous_snapshot.get(index) { if self.item_matches(previous_item_id, &item) { previous_item_id.clone() } else if matches!(context.mode, ReconcileMode::FullSnapshot) { self.find_matching_snapshot_item(&previous_snapshot, &item_ids, &item) .unwrap_or_else(|| { self.create_conversation_item( context.thread_id, Some(context.codex_turn_id.to_string()), context.wall_time_unix_ms, item, context.produced_by.clone(), ) }) } else { let codex_turn_id = context.codex_turn_id; let thread_id = context.thread_id; bail!( "model conversation mismatch while reducing turn {codex_turn_id} for \ thread {thread_id} at item index {index}: existing item \ {previous_item_id} does not match the current model payload item" ); } } else if matches!(context.mode, ReconcileMode::FullSnapshot) { self.find_matching_snapshot_item(&previous_snapshot, &item_ids, &item) .unwrap_or_else(|| { self.create_conversation_item( context.thread_id, Some(context.codex_turn_id.to_string()), context.wall_time_unix_ms, item, context.produced_by.clone(), ) }) } else { self.create_conversation_item( context.thread_id, Some(context.codex_turn_id.to_string()), context.wall_time_unix_ms, item, context.produced_by.clone(), ) }; self.update_conversation_item_from_sighting( &item_id, &tool_link_item, &context.produced_by, )?; self.attach_model_visible_tool_item( &item_id, tool_link_item.call_id.as_deref(), &tool_link_item.kind, )?; self.attach_model_visible_code_cell_item( &item_id, tool_link_item.call_id.as_deref(), &tool_link_item.kind, )?; self.resolve_pending_agent_edges_for_item(&item_id)?; item_ids.push(item_id); } self.flush_pending_code_cell_starts()?; Ok(item_ids) } /// Reduces a compaction checkpoint payload into installed replacement history. /// /// The returned ids let the compaction reducer record both the boundary marker /// and the snapshot that future full requests should reconcile against. pub(super) fn reduce_compaction_checkpoint( &mut self, wall_time_unix_ms: i64, thread_id: &str, codex_turn_id: &str, compaction_id: &CompactionId, checkpoint_payload: &RawPayloadRef, ) -> Result<ReducedCompactionCheckpoint> { let payload = self.read_payload_json(checkpoint_payload)?; let input_history = required_array(&payload, "input_history", checkpoint_payload)?; let replacement_history = required_array(&payload, "replacement_history", checkpoint_payload)?; let input_items = normalize::normalize_model_items(input_history, checkpoint_payload)?; let replacement_items = normalize::normalize_model_items(replacement_history, checkpoint_payload)?; let input_candidates = self .thread_conversation_snapshots .get(thread_id) .cloned() .unwrap_or_default(); let input_item_ids = self.reconcile_detached_conversation_items( input_items, DetachedReconcileItems { thread_id, codex_turn_id, wall_time_unix_ms, produced_by: Vec::new(), candidates: input_candidates, }, )?; // A compaction checkpoint has two transcript effects. First, record the structural // boundary where old live history ended. Then append the replacement items, including // the provider-visible summary item if the compact endpoint returned one. let marker_item_id = self.create_conversation_item( thread_id, Some(codex_turn_id.to_string()), wall_time_unix_ms, NormalizedConversationItem { role: ConversationRole::Assistant, channel: None, kind: ConversationItemKind::CompactionMarker, agent_message: None, // The summary is a separate model/provider-visible item. Keep the marker body // empty so transcript renderers cannot mistake the boundary for prompt content. body: ConversationBody { parts: Vec::new() }, call_id: None, }, vec![ProducerRef::Compaction { compaction_id: compaction_id.clone(), }], ); let replacement_item_ids = self.reconcile_detached_conversation_items( replacement_items, DetachedReconcileItems { thread_id, codex_turn_id, wall_time_unix_ms, produced_by: vec![ProducerRef::Compaction { compaction_id: compaction_id.clone(), }], // Replacement history is a rewrite boundary. Even if the compact endpoint emits // text that matches old history, the installed item is a new post-compaction // conversation item and should not reuse a pre-compaction ID. candidates: Vec::new(), }, )?; self.append_thread_conversation_items(thread_id, &input_item_ids)?; self.append_thread_conversation_items(thread_id, std::slice::from_ref(&marker_item_id))?; self.append_thread_conversation_items(thread_id, &replacement_item_ids)?; Ok(ReducedCompactionCheckpoint { input_item_ids, marker_item_id, replacement_item_ids, }) } fn reconcile_detached_conversation_items( &mut self, items: Vec<NormalizedConversationItem>, context: DetachedReconcileItems<'_>, ) -> Result<Vec<String>> { let mut item_ids = Vec::with_capacity(items.len()); for item in items { let tool_link_item = item.clone(); self.ensure_call_id_consistency(context.thread_id, &item)?; let item_id = self .find_matching_snapshot_item(&context.candidates, &item_ids, &item) .unwrap_or_else(|| { self.create_conversation_item( context.thread_id, Some(context.codex_turn_id.to_string()), context.wall_time_unix_ms, item, context.produced_by.clone(), ) }); self.update_conversation_item_from_sighting( &item_id, &tool_link_item, &context.produced_by, )?; self.attach_model_visible_tool_item( &item_id, tool_link_item.call_id.as_deref(), &tool_link_item.kind, )?; self.attach_model_visible_code_cell_item( &item_id, tool_link_item.call_id.as_deref(), &tool_link_item.kind, )?; self.resolve_pending_agent_edges_for_item(&item_id)?; item_ids.push(item_id); } self.flush_pending_code_cell_starts()?; Ok(item_ids) } fn create_conversation_item( &mut self, thread_id: &str, codex_turn_id: Option<String>, first_seen_at_unix_ms: i64, item: NormalizedConversationItem, produced_by: Vec<ProducerRef>, ) -> String { let item_id = self.next_conversation_item_id(); self.rollout.conversation_items.insert( item_id.clone(), ConversationItem { item_id: item_id.clone(), thread_id: thread_id.to_string(), codex_turn_id, first_seen_at_unix_ms, role: item.role, channel: item.channel, kind: item.kind, agent_message: item.agent_message, body: item.body, call_id: item.call_id, produced_by, }, ); item_id } fn update_conversation_item_from_sighting( &mut self, item_id: &str, normalized: &NormalizedConversationItem, produced_by: &[ProducerRef], ) -> Result<()> { let Some(item) = self.rollout.conversation_items.get_mut(item_id) else { bail!("conversation item {item_id} was referenced before it was created"); }; if item.kind == ConversationItemKind::Reasoning { merge_reasoning_body(&mut item.body, &normalized.body)?; } for producer in produced_by { if !item.produced_by.contains(producer) { item.produced_by.push(producer.clone()); } } Ok(()) } fn append_thread_conversation_items( &mut self, thread_id: &str, item_ids: &[String], ) -> Result<()> { let thread = self.thread_mut(thread_id)?; for item_id in item_ids { if !thread.conversation_item_ids.contains(item_id) { thread.conversation_item_ids.push(item_id.clone()); } } Ok(()) } fn find_matching_snapshot_item( &self, previous_snapshot: &[String], used_item_ids: &[String], normalized: &NormalizedConversationItem, ) -> Option<String> { previous_snapshot .iter() .find(|item_id| { !used_item_ids.contains(item_id) && self.item_matches(item_id, normalized) }) .cloned() } fn ensure_call_id_consistency( &self, thread_id: &str, normalized: &NormalizedConversationItem, ) -> Result<()> { let Some(call_id) = normalized.call_id.as_deref() else { return Ok(()); }; for item in self.rollout.conversation_items.values() { if item.thread_id == thread_id && item.call_id.as_deref() == Some(call_id) && item.kind == normalized.kind && !conversation_item_matches(item, normalized) { bail!("model-visible call id {call_id} was reused with different content"); } } Ok(()) } fn item_matches(&self, item_id: &str, normalized: &NormalizedConversationItem) -> bool { let Some(item) = self.rollout.conversation_items.get(item_id) else { return false; }; conversation_item_matches(item, normalized) } fn next_conversation_item_id(&mut self) -> String { let ordinal = self.next_conversation_item_ordinal; self.next_conversation_item_ordinal += 1; format!("conversation_item:{ordinal}") }}#[derive(Clone, Copy)]enum ReconcileMode { /// Full model requests are authoritative snapshots of the live context. The /// prompt builder can reorder already-observed items or replace history /// with synthetic summary messages, so item identity is "same content, /// reused at most once in this snapshot" rather than "same position only". FullSnapshot, /// Incremental request deltas and response outputs append to a known prefix. /// A mismatch at an occupied position means our reconstructed prefix is /// wrong and should fail replay. AppendOnly,}struct ReconcileItems<'a> { thread_id: &'a str, codex_turn_id: &'a str, wall_time_unix_ms: i64, produced_by: Vec<ProducerRef>, start_index: usize, mode: ReconcileMode, snapshot_override: Option<&'a [String]>,}struct DetachedReconcileItems<'a> { thread_id: &'a str, codex_turn_id: &'a str, wall_time_unix_ms: i64, produced_by: Vec<ProducerRef>, candidates: Vec<String>,}/// Conversation ids produced when a compaction checkpoint is installed.////// The marker item records the boundary, while replacement items are the live/// history that subsequent full requests should treat as their baseline.pub(super) struct ReducedCompactionCheckpoint { pub(super) input_item_ids: Vec<String>, pub(super) marker_item_id: String, pub(super) replacement_item_ids: Vec<String>,}fn required_array<'a>( payload: &'a Value, key: &str, raw_payload: &RawPayloadRef,) -> Result<&'a Vec<Value>> { payload.get(key).and_then(Value::as_array).with_context(|| { format!( "compaction checkpoint payload {} did not contain array {key}", raw_payload.raw_payload_id ) })}fn conversation_item_matches( item: &ConversationItem, normalized: &NormalizedConversationItem,) -> bool { let body_matches = if item.kind == ConversationItemKind::Reasoning && normalized.kind == ConversationItemKind::Reasoning { reasoning_body_matches(&item.body, &normalized.body) } else { conversation_body_matches(&item.body, &normalized.body) }; item.role == normalized.role && item.channel == normalized.channel && item.kind == normalized.kind && item.agent_message == normalized.agent_message && body_matches && item.call_id == normalized.call_id}fn conversation_body_matches(left: &ConversationBody, right: &ConversationBody) -> bool { left.parts.len() == right.parts.len() && left .parts .iter() .zip(&right.parts) .all(|(left, right)| match (left, right) { ( ConversationPart::Json { summary: left_summary, raw_payload_id: _, }, ConversationPart::Json { summary: right_summary, raw_payload_id: _, }, ) => left_summary == right_summary, _ => left == right, })}fn reasoning_body_matches(left: &ConversationBody, right: &ConversationBody) -> bool { if conversation_body_matches(left, right) { return true; } // The Responses API may return readable reasoning on completion, but later // request snapshots often replay only the encrypted blob. Treat the blob as // stable model-visible identity and merge readable text as best-effort // evidence, because request/response serialization can observe different // readable forms for the same encrypted reasoning item. let Some(left_encoded) = reasoning_encoded_part(left) else { return false; }; let Some(right_encoded) = reasoning_encoded_part(right) else { return false; }; left_encoded == right_encoded}fn merge_reasoning_body( existing: &mut ConversationBody, incoming: &ConversationBody,) -> Result<()> { if conversation_body_matches(existing, incoming) { return Ok(()); } if !reasoning_body_matches(existing, incoming) { bail!("reasoning item merge attempted with different encrypted_content identity"); } let existing_text_parts = reasoning_text_parts(existing); let existing_summary_parts = reasoning_summary_parts(existing); if !existing_text_parts.is_empty() && !existing_summary_parts.is_empty() { return Ok(()); } let incoming_text_parts = reasoning_text_parts(incoming); let incoming_summary_parts = reasoning_summary_parts(incoming); let text_parts = if !existing_text_parts.is_empty() { existing_text_parts } else { incoming_text_parts }; let summary_parts = if !existing_summary_parts.is_empty() { existing_summary_parts } else { incoming_summary_parts }; // We already know that the encoded part exist (and matches). let encoded_parts = reasoning_encoded_parts(existing); existing.parts = text_parts .into_iter() .cloned() .chain(summary_parts.into_iter().cloned()) .chain(encoded_parts.into_iter().cloned()) .collect(); Ok(())}fn reasoning_text_parts(body: &ConversationBody) -> Vec<&ConversationPart> { body.parts .iter() .filter(|part| matches!(part, ConversationPart::Text { .. })) .collect()}fn reasoning_summary_parts(body: &ConversationBody) -> Vec<&ConversationPart> { body.parts .iter() .filter(|part| matches!(part, ConversationPart::Summary { .. })) .collect()}fn reasoning_encoded_parts(body: &ConversationBody) -> Vec<&ConversationPart> { body.parts .iter() .filter(|part| matches!(part, ConversationPart::Encoded { .. })) .collect()}fn reasoning_encoded_part(body: &ConversationBody) -> Option<(&str, &str)> { body.parts.iter().find_map(|part| { if let ConversationPart::Encoded { label, value } = part { Some((label.as_str(), value.as_str())) } else { None } })}#[cfg(test)]#[path = "conversation_tests.rs"]mod tests;