//! Normalization from Responses-shaped JSON items into conversation item data.use anyhow::Context;use anyhow::Result;use anyhow::bail;use codex_protocol::models::AgentMessageInputContent;use codex_protocol::models::ResponseItem;use serde_json::Value;use crate::model::AgentMessageMetadata;use crate::model::ConversationBody;use crate::model::ConversationChannel;use crate::model::ConversationItemKind;use crate::model::ConversationPart;use crate::model::ConversationRole;use crate::model::TokenUsage;use crate::payload::RawPayloadRef;/// Conversation fields parsed from one Responses item before trace identity.////// IDs and provenance are assigned after positional reconciliation. Keeping the/// normalized data separate from `ConversationItem` makes reuse vs insertion a/// single reducer decision instead of something the parser has to know about.#[derive(Clone)]pub(super) struct NormalizedConversationItem { pub(super) role: ConversationRole, pub(super) channel: Option<ConversationChannel>, pub(super) kind: ConversationItemKind, pub(super) agent_message: Option<AgentMessageMetadata>, pub(super) body: ConversationBody, pub(super) call_id: Option<String>,}pub(super) fn normalize_model_items( items: &[Value], raw_payload: &RawPayloadRef,) -> Result<Vec<NormalizedConversationItem>> { let mut normalized_items = Vec::new(); for item in items { normalized_items.push(normalize_model_item(item, raw_payload)?); } Ok(normalized_items)}pub(super) fn token_usage_from_value(value: &Value) -> Option<TokenUsage> { Some(TokenUsage { input_tokens: u64_field(value, "input_tokens")?, cached_input_tokens: u64_field(value, "cached_input_tokens")?, output_tokens: u64_field(value, "output_tokens")?, reasoning_output_tokens: u64_field(value, "reasoning_output_tokens")?, })}fn normalize_model_item( item: &Value, raw_payload: &RawPayloadRef,) -> Result<NormalizedConversationItem> { let Some(item_type) = item.get("type").and_then(Value::as_str) else { bail!( "model item in payload {} did not contain a string type", raw_payload.raw_payload_id ); }; match item_type { "message" => normalize_message_item(item, raw_payload), "agent_message" => normalize_agent_message_item(item, raw_payload), "reasoning" => normalize_reasoning_item(item, raw_payload), "function_call" => Ok(NormalizedConversationItem { role: ConversationRole::Assistant, channel: Some(ConversationChannel::Commentary), kind: ConversationItemKind::FunctionCall, agent_message: None, body: raw_text_or_json_body(item.get("arguments"), raw_payload), call_id: item .get("call_id") .and_then(Value::as_str) .map(ToString::to_string), }), "function_call_output" => Ok(NormalizedConversationItem { role: ConversationRole::Tool, channel: Some(ConversationChannel::Commentary), kind: ConversationItemKind::FunctionCallOutput, agent_message: None, body: tool_output_body(item.get("output"), raw_payload), call_id: item .get("call_id") .and_then(Value::as_str) .map(ToString::to_string), }), "custom_tool_call" => Ok(NormalizedConversationItem { role: ConversationRole::Assistant, channel: Some(ConversationChannel::Commentary), kind: ConversationItemKind::CustomToolCall, agent_message: None, body: custom_tool_call_body(item, raw_payload), call_id: item .get("call_id") .and_then(Value::as_str) .map(ToString::to_string), }), "custom_tool_call_output" => Ok(NormalizedConversationItem { role: ConversationRole::Tool, channel: Some(ConversationChannel::Commentary), kind: ConversationItemKind::CustomToolCallOutput, agent_message: None, body: tool_output_body(item.get("output"), raw_payload), call_id: item .get("call_id") .and_then(Value::as_str) .map(ToString::to_string), }), "tool_search_call" | "web_search_call" | "image_generation_call" | "local_shell_call" => { Ok(NormalizedConversationItem { role: ConversationRole::Assistant, channel: Some(ConversationChannel::Commentary), kind: ConversationItemKind::FunctionCall, agent_message: None, body: json_body(item, raw_payload), call_id: item .get("call_id") .and_then(Value::as_str) .map(ToString::to_string), }) } "tool_search_output" | "mcp_tool_call_output" => Ok(NormalizedConversationItem { role: ConversationRole::Tool, channel: Some(ConversationChannel::Commentary), kind: ConversationItemKind::FunctionCallOutput, agent_message: None, body: json_body(item, raw_payload), call_id: item .get("call_id") .and_then(Value::as_str) .map(ToString::to_string), }), "compaction" | "compaction_summary" | "context_compaction" => { Ok(NormalizedConversationItem { role: ConversationRole::Assistant, channel: Some(ConversationChannel::Summary), kind: ConversationItemKind::Message, agent_message: None, body: compaction_body(item, raw_payload)?, call_id: None, }) } _ => bail!( "unsupported model item type {item_type} in payload {}", raw_payload.raw_payload_id ), }}fn normalize_message_item( item: &Value, raw_payload: &RawPayloadRef,) -> Result<NormalizedConversationItem> { let Some(role) = item.get("role").and_then(Value::as_str) else { bail!( "message item in payload {} did not contain a string role", raw_payload.raw_payload_id ); }; let Some(role) = role_from_str(role) else { bail!( "unsupported message role {role} in payload {}", raw_payload.raw_payload_id ); }; Ok(NormalizedConversationItem { role, channel: item .get("phase") .and_then(Value::as_str) .and_then(channel_from_phase), kind: ConversationItemKind::Message, agent_message: None, body: ConversationBody { parts: content_parts(item.get("content"), raw_payload), }, call_id: None, })}fn normalize_agent_message_item( item: &Value, raw_payload: &RawPayloadRef,) -> Result<NormalizedConversationItem> { let raw_payload_id = &raw_payload.raw_payload_id; let response_item = serde_json::from_value::<ResponseItem>(item.clone()).with_context(|| { format!("failed to parse agent_message item in payload {raw_payload_id}") })?; let ResponseItem::AgentMessage { author, recipient, content, .. } = response_item else { bail!("item in payload {raw_payload_id} was not an agent_message"); }; let parts = content .into_iter() .map(|content| match content { AgentMessageInputContent::InputText { text } => ConversationPart::Text { text }, AgentMessageInputContent::EncryptedContent { encrypted_content } => { ConversationPart::Encoded { label: "encrypted_content".to_string(), value: encrypted_content, } } }) .collect::<Vec<_>>(); if parts.is_empty() { bail!("agent_message item in payload {raw_payload_id} contained no content"); } Ok(NormalizedConversationItem { role: ConversationRole::Assistant, channel: Some(ConversationChannel::Analysis), kind: ConversationItemKind::Message, agent_message: Some(AgentMessageMetadata { author, recipient }), body: ConversationBody { parts }, call_id: None, })}fn normalize_reasoning_item( item: &Value, raw_payload: &RawPayloadRef,) -> Result<NormalizedConversationItem> { let mut parts = Vec::new(); append_reasoning_parts( item, "content", ReasoningPartKind::Content, raw_payload, &mut parts, )?; append_reasoning_parts( item, "summary", ReasoningPartKind::Summary, raw_payload, &mut parts, )?; if let Some(encrypted_content) = item.get("encrypted_content") { let encrypted_content = match encrypted_content { Value::Null => None, Value::String(encrypted_content) => Some(encrypted_content), _ => { bail!( "reasoning item in payload {} had non-string encrypted_content", raw_payload.raw_payload_id ); } }; if let Some(encrypted_content) = encrypted_content { parts.push(ConversationPart::Encoded { label: "encrypted_content".to_string(), value: encrypted_content.to_string(), }); } } if parts.is_empty() { bail!( "reasoning item in payload {} contained no content, summary, or encrypted_content", raw_payload.raw_payload_id ); } Ok(NormalizedConversationItem { role: ConversationRole::Assistant, channel: Some(ConversationChannel::Analysis), kind: ConversationItemKind::Reasoning, agent_message: None, body: ConversationBody { parts }, call_id: None, })}#[derive(Clone, Copy)]enum ReasoningPartKind { Content, Summary,}fn append_reasoning_parts( item: &Value, key: &str, kind: ReasoningPartKind, raw_payload: &RawPayloadRef, parts: &mut Vec<ConversationPart>,) -> Result<()> { let Some(items) = item.get(key) else { return Ok(()); }; if matches!((kind, items), (ReasoningPartKind::Content, Value::Null)) { return Ok(()); } let Some(items) = items.as_array() else { bail!( "reasoning item in payload {} had non-array {key}", raw_payload.raw_payload_id ); }; for content_item in items { let Some(item_type) = content_item.get("type").and_then(Value::as_str) else { bail!( "reasoning item in payload {} had {key} entry without string type", raw_payload.raw_payload_id ); }; let expected_type = match kind { ReasoningPartKind::Content => { if !matches!(item_type, "reasoning_text" | "text") { bail!( "reasoning item in payload {} had unsupported content type {item_type}", raw_payload.raw_payload_id ); } "content" } ReasoningPartKind::Summary => { if item_type != "summary_text" { bail!( "reasoning item in payload {} had unsupported summary type {item_type}", raw_payload.raw_payload_id ); } "summary" } }; let Some(text) = content_item.get("text").and_then(Value::as_str) else { bail!( "reasoning item in payload {} had {expected_type} entry without string text", raw_payload.raw_payload_id ); }; match kind { ReasoningPartKind::Content => parts.push(ConversationPart::Text { text: text.to_string(), }), ReasoningPartKind::Summary => parts.push(ConversationPart::Summary { text: text.to_string(), }), } } Ok(())}fn role_from_str(role: &str) -> Option<ConversationRole> { match role { "system" => Some(ConversationRole::System), "developer" => Some(ConversationRole::Developer), "user" => Some(ConversationRole::User), "assistant" => Some(ConversationRole::Assistant), "tool" => Some(ConversationRole::Tool), _ => None, }}fn channel_from_phase(phase: &str) -> Option<ConversationChannel> { match phase { "commentary" => Some(ConversationChannel::Commentary), "final_answer" => Some(ConversationChannel::Final), "summary" => Some(ConversationChannel::Summary), _ => None, }}fn content_parts(content: Option<&Value>, raw_payload: &RawPayloadRef) -> Vec<ConversationPart> { let Some(content) = content.and_then(Value::as_array) else { return vec![payload_ref_part("content", raw_payload)]; }; let mut parts = Vec::new(); for part in content { match part.get("type").and_then(Value::as_str) { Some("input_text" | "output_text" | "text") => { if let Some(text) = part.get("text").and_then(Value::as_str) { parts.push(ConversationPart::Text { text: text.to_string(), }); } } Some("input_image") => parts.push(payload_ref_part("input_image", raw_payload)), Some(other) => parts.push(payload_ref_part(other, raw_payload)), None => parts.push(payload_ref_part("content", raw_payload)), } } if parts.is_empty() { parts.push(payload_ref_part("empty_content", raw_payload)); } parts}fn custom_tool_call_body(item: &Value, raw_payload: &RawPayloadRef) -> ConversationBody { let Some(input) = item.get("input").and_then(Value::as_str) else { return json_body(item, raw_payload); }; if item.get("name").and_then(Value::as_str) == Some("exec") { ConversationBody { parts: vec![ConversationPart::Code { language: "javascript".to_string(), source: input.to_string(), }], } } else { ConversationBody { parts: vec![ConversationPart::Text { text: input.to_string(), }], } }}fn raw_text_or_json_body(value: Option<&Value>, raw_payload: &RawPayloadRef) -> ConversationBody { match value { Some(Value::String(text)) => { if let Ok(json) = serde_json::from_str::<Value>(text) { json_body(&json, raw_payload) } else { ConversationBody { parts: vec![ConversationPart::Text { text: text.clone() }], } } } Some(value) => json_body(value, raw_payload), None => ConversationBody { parts: vec![payload_ref_part("payload", raw_payload)], }, }}fn tool_output_body(output: Option<&Value>, raw_payload: &RawPayloadRef) -> ConversationBody { match output { Some(Value::String(text)) => ConversationBody { parts: vec![ConversationPart::Text { text: text.clone() }], }, Some(Value::Array(_)) => ConversationBody { parts: content_parts(output, raw_payload), }, Some(value) => json_body(value, raw_payload), None => ConversationBody { parts: vec![payload_ref_part("tool_output", raw_payload)], }, }}fn compaction_body(item: &Value, raw_payload: &RawPayloadRef) -> Result<ConversationBody> { let Some(encrypted_content) = item.get("encrypted_content").and_then(Value::as_str) else { bail!( "compaction item in payload {} did not contain string encrypted_content", raw_payload.raw_payload_id ); }; // `type: "compaction"` is the remote-compaction summary that later re-enters model requests. // The structural "history was cut here" marker is inserted separately when the checkpoint is // installed; payload refs are observation-local, so the encoded summary itself is identity. Ok(ConversationBody { parts: vec![ConversationPart::Encoded { label: "encrypted_content".to_string(), value: encrypted_content.to_string(), }], })}fn json_body(value: &Value, raw_payload: &RawPayloadRef) -> ConversationBody { ConversationBody { parts: vec![ConversationPart::Json { summary: summarize_json(value), raw_payload_id: raw_payload.raw_payload_id.clone(), }], }}fn payload_ref_part(label: &str, raw_payload: &RawPayloadRef) -> ConversationPart { ConversationPart::PayloadRef { label: label.to_string(), raw_payload_id: raw_payload.raw_payload_id.clone(), }}fn summarize_json(value: &Value) -> String { const MAX_JSON_SUMMARY_LEN: usize = 240; let mut summary = serde_json::to_string(value).unwrap_or_else(|_| "<unserializable json>".to_string()); if summary.len() > MAX_JSON_SUMMARY_LEN { summary.truncate(MAX_JSON_SUMMARY_LEN); summary.push_str("..."); } summary}fn u64_field(value: &Value, field: &str) -> Option<u64> { value .get(field) .and_then(Value::as_i64) .map(|value| value.max(0) as u64)}