Extract persisted history types into a dedicated crate (#37871)

## What changed

- Add `codex-history` for model-history and persisted-rollout domain types, including `RolloutItem`, `RolloutLine`, `CompactedItem`, and initial/resumed history state.
- Re-export the persisted types from `codex-rollout` and update consumers to use the new crate boundary instead of `codex-protocol`.
- Preserve existing rollout serialization, including legacy numeric compacted-window IDs.

## Testing

- Add `codex-history` tests for rollout JSON round trips, compacted-history compatibility, persisted history modes, and multi-agent version selection.

GitOrigin-RevId: 944daa9297ddd231d3aebbdcb05fff4adf8b4e1b
This commit is contained in:
Adam Perry @ OpenAI
2026-08-10 19:26:52 +00:00
committed by copyberry
parent a1c88e865d
commit 63002bdb26
127 changed files with 791 additions and 673 deletions
-106
View File
@@ -1,106 +0,0 @@
use crate::models::ResponseItem;
use crate::protocol::CompactedItem;
use serde::Deserialize;
// Before `window_number` was introduced, the numeric window number was serialized as
// `window_id`. Accept that shape so existing rollouts remain resumable.
impl<'de> Deserialize<'de> for CompactedItem {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let serialized = SerializedCompactedItem::deserialize(deserializer)?;
let mut window_number = serialized.window_number;
let window_id = match serialized.window_id {
Some(SerializedWindowId::Id(window_id)) => Some(window_id),
Some(SerializedWindowId::LegacyWindowNumber(legacy_window_number)) => {
window_number.get_or_insert(legacy_window_number);
None
}
None => None,
};
Ok(Self {
message: serialized.message,
replacement_history: serialized.replacement_history,
window_number,
first_window_id: serialized.first_window_id,
previous_window_id: serialized.previous_window_id,
window_id,
})
}
}
#[derive(Deserialize)]
struct SerializedCompactedItem {
message: String,
#[serde(default)]
replacement_history: Option<Vec<ResponseItem>>,
#[serde(default)]
window_number: Option<u64>,
#[serde(default)]
first_window_id: Option<String>,
#[serde(default)]
previous_window_id: Option<String>,
#[serde(default)]
window_id: Option<SerializedWindowId>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum SerializedWindowId {
Id(String),
LegacyWindowNumber(u64),
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn serializes_window_number_and_id() -> Result<()> {
let item = CompactedItem {
message: "summary".to_string(),
replacement_history: None,
window_number: Some(3),
first_window_id: Some("019b3f6e-0000-7000-8000-000000000001".to_string()),
previous_window_id: Some("019b3f6e-0000-7000-8000-000000000002".to_string()),
window_id: Some("019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001".to_string()),
};
assert_eq!(
serde_json::to_value(item)?,
json!({
"message": "summary",
"window_number": 3,
"first_window_id": "019b3f6e-0000-7000-8000-000000000001",
"previous_window_id": "019b3f6e-0000-7000-8000-000000000002",
"window_id": "019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001",
})
);
Ok(())
}
#[test]
fn migrates_legacy_numeric_window_id() -> Result<()> {
let item = serde_json::from_value::<CompactedItem>(json!({
"message": "summary",
"window_id": 3,
}))?;
assert_eq!(
item,
CompactedItem {
message: "summary".to_string(),
replacement_history: None,
window_number: Some(3),
first_window_id: None,
previous_window_id: None,
window_id: None,
}
);
Ok(())
}
}
-1
View File
@@ -13,7 +13,6 @@ pub use tool_name::DEFAULT_FUNCTION_NAMESPACE;
pub use tool_name::ToolName;
pub mod approvals;
pub mod capabilities;
mod compacted_item;
pub mod config_types;
pub mod dynamic_tools;
pub mod error;
-366
View File
@@ -10,7 +10,6 @@ use std::ops::Mul;
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use strum_macros::EnumIter;
@@ -2564,207 +2563,6 @@ pub struct ConversationPathResponseEvent {
pub path: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ResumedHistory {
pub conversation_id: ThreadId,
pub history: Arc<Vec<RolloutItem>>,
pub rollout_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub enum InitialHistory {
New,
Cleared,
Resumed(ResumedHistory),
Forked(Vec<RolloutItem>),
}
impl InitialHistory {
pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool {
match self {
InitialHistory::New | InitialHistory::Cleared => false,
InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate),
InitialHistory::Forked(items) => items.iter().any(predicate),
}
}
pub fn forked_from_id(&self) -> Option<ThreadId> {
match self {
InitialHistory::New | InitialHistory::Cleared => None,
InitialHistory::Resumed(resumed) => {
resumed.history.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => meta_line.meta.forked_from_id,
_ => None,
})
}
InitialHistory::Forked(items) => items.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id),
_ => None,
}),
}
}
pub fn session_cwd(&self) -> Option<PathBuf> {
match self {
InitialHistory::New | InitialHistory::Cleared => None,
InitialHistory::Resumed(resumed) => session_cwd_from_items(&resumed.history),
InitialHistory::Forked(items) => session_cwd_from_items(items),
}
}
pub fn get_rollout_items(&self) -> &[RolloutItem] {
match self {
InitialHistory::New | InitialHistory::Cleared => &[],
InitialHistory::Resumed(resumed) => &resumed.history,
InitialHistory::Forked(items) => items,
}
}
pub fn get_event_msgs(&self) -> Option<Vec<EventMsg>> {
match self {
InitialHistory::New | InitialHistory::Cleared => None,
InitialHistory::Resumed(resumed) => Some(
resumed
.history
.iter()
.filter_map(|ri| match ri {
RolloutItem::EventMsg(ev) => Some(ev.clone()),
_ => None,
})
.collect(),
),
InitialHistory::Forked(items) => Some(
items
.iter()
.filter_map(|ri| match ri {
RolloutItem::EventMsg(ev) => Some(ev.clone()),
_ => None,
})
.collect(),
),
}
}
pub fn get_base_instructions(&self) -> Option<BaseInstructions> {
// TODO: SessionMeta should (in theory) always be first in the history, so we can probably only check the first item?
match self {
InitialHistory::New | InitialHistory::Cleared => None,
InitialHistory::Resumed(resumed) => {
resumed.history.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(),
_ => None,
})
}
InitialHistory::Forked(items) => items.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(),
_ => None,
}),
}
}
pub fn get_dynamic_tools(&self) -> Option<Vec<DynamicToolSpec>> {
match self {
InitialHistory::New | InitialHistory::Cleared => None,
InitialHistory::Resumed(resumed) => {
resumed.history.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(),
_ => None,
})
}
InitialHistory::Forked(items) => items.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(),
_ => None,
}),
}
}
pub fn get_selected_capability_roots(&self) -> Vec<SelectedCapabilityRoot> {
self.get_session_meta()
.map(|meta| meta.selected_capability_roots.clone())
.unwrap_or_default()
}
pub fn get_multi_agent_version(&self) -> Option<MultiAgentVersion> {
match self {
InitialHistory::New | InitialHistory::Cleared => None,
InitialHistory::Resumed(resumed) => {
multi_agent_version_from_items(&resumed.history, Some(resumed.conversation_id))
}
InitialHistory::Forked(items) => {
multi_agent_version_from_items(items, /*thread_id*/ None)
}
}
}
pub fn get_history_mode(&self, default_history_mode: ThreadHistoryMode) -> ThreadHistoryMode {
match self {
InitialHistory::New | InitialHistory::Cleared => default_history_mode,
// Forks copy their source rollout items as-is, so they must keep
// the source format instead of converting to the destination default.
InitialHistory::Resumed(_) | InitialHistory::Forked(_) => self
.get_session_meta()
.map(|meta| meta.history_mode)
.unwrap_or(default_history_mode),
}
}
pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option<ThreadSource>)> {
let meta = self.get_resumed_session_meta()?;
Some((meta.source.clone(), meta.thread_source.clone()))
}
pub fn get_resumed_thread_source(&self) -> Option<ThreadSource> {
self.get_resumed_session_meta()
.and_then(|meta| meta.thread_source.clone())
}
pub fn get_session_originator(&self) -> Option<String> {
self.get_session_meta()
.map(|meta| meta.originator.clone())
.filter(|originator| !originator.is_empty())
}
pub fn get_resumed_parent_thread_id(&self) -> Option<ThreadId> {
self.get_resumed_session_meta()
.and_then(|meta| meta.parent_thread_id)
}
fn get_session_meta(&self) -> Option<&SessionMeta> {
match self {
InitialHistory::New | InitialHistory::Cleared => None,
InitialHistory::Resumed(resumed) => {
resumed.history.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
_ => None,
})
}
InitialHistory::Forked(items) => items.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
_ => None,
}),
}
}
fn get_resumed_session_meta(&self) -> Option<&SessionMeta> {
match self {
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
InitialHistory::Resumed(resumed) => {
resumed.history.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
_ => None,
})
}
}
}
}
fn session_cwd_from_items(items: &[RolloutItem]) -> Option<PathBuf> {
items.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.cwd.clone()),
_ => None,
})
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
@@ -3017,33 +2815,6 @@ impl fmt::Display for InternalSessionSource {
}
}
fn multi_agent_version_from_items(
items: &[RolloutItem],
thread_id: Option<ThreadId>,
) -> Option<MultiAgentVersion> {
let session_meta_version = items.iter().rev().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line)
if thread_id.is_none_or(|thread_id| meta_line.meta.id == thread_id) =>
{
meta_line.meta.multi_agent_version
}
_ => None,
});
session_meta_version.or_else(|| {
items.iter().rev().find_map(|item| match item {
RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_version,
RolloutItem::SessionMeta(_)
| RolloutItem::ResponseItem(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::Compacted(_)
| RolloutItem::WorldState(_)
| RolloutItem::EventMsg(_) => None,
})
})
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -3208,23 +2979,6 @@ impl<'de> Deserialize<'de> for SessionMetaLine {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum RolloutItem {
SessionMeta(SessionMetaLine),
ResponseItem(ResponseItem),
/// Legacy delivery item reconstructed as a model-visible `agent_message`.
InterAgentCommunication(InterAgentCommunication),
/// Local delivery metadata that is not part of the Responses API item.
InterAgentCommunicationMetadata {
trigger_turn: bool,
},
Compacted(CompactedItem),
TurnContext(TurnContextItem),
WorldState(WorldStateItem),
EventMsg(EventMsg),
}
/// Persisted comparison state used to resume model-visible world-state diffing.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
pub struct WorldStateItem {
@@ -3243,39 +2997,6 @@ impl WorldStateItem {
}
}
#[derive(Serialize, Clone, Debug, PartialEq, JsonSchema, TS)]
pub struct CompactedItem {
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replacement_history: Option<Vec<ResponseItem>>,
/// Monotonic position of this context window within the thread.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window_number: Option<u64>,
/// UUIDv7 identity of the first context window in this thread's window chain.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_window_id: Option<String>,
/// UUIDv7 identity of the context window immediately before this one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_window_id: Option<String>,
/// UUIDv7 identity of this context window.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window_id: Option<String>,
}
impl From<CompactedItem> for ResponseItem {
fn from(value: CompactedItem) -> Self {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: value.message,
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
pub struct TurnContextNetworkItem {
pub allowed_domains: Vec<String>,
@@ -3403,15 +3124,6 @@ impl Mul<f64> for TruncationPolicy {
}
}
#[derive(Serialize, Deserialize, Clone, JsonSchema)]
pub struct RolloutLine {
pub timestamp: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ordinal: Option<u64>,
#[serde(flatten)]
pub item: RolloutItem,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
pub struct GitInfo {
/// Current commit hash (SHA)
@@ -6006,49 +5718,6 @@ mod tests {
Ok(())
}
#[test]
fn copied_history_uses_persisted_history_mode() -> Result<()> {
let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?;
let session_meta = RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
session_id: thread_id.into(),
id: thread_id,
history_mode: ThreadHistoryMode::Legacy,
..SessionMeta::default()
},
git: None,
});
let history = InitialHistory::Resumed(ResumedHistory {
conversation_id: thread_id,
history: Arc::new(vec![session_meta.clone()]),
rollout_path: None,
});
assert_eq!(
history.get_history_mode(ThreadHistoryMode::Paginated),
ThreadHistoryMode::Legacy
);
assert_eq!(
InitialHistory::Forked(vec![session_meta])
.get_history_mode(ThreadHistoryMode::Paginated),
ThreadHistoryMode::Legacy
);
assert_eq!(
InitialHistory::New.get_history_mode(ThreadHistoryMode::Paginated),
ThreadHistoryMode::Paginated
);
assert_eq!(
InitialHistory::Resumed(ResumedHistory {
conversation_id: thread_id,
history: Arc::new(Vec::new()),
rollout_path: None,
})
.get_history_mode(ThreadHistoryMode::Paginated),
ThreadHistoryMode::Paginated
);
Ok(())
}
#[test]
fn turn_context_item_deserializes_without_network() -> Result<()> {
let item: TurnContextItem = serde_json::from_value(json!({
@@ -6079,41 +5748,6 @@ mod tests {
Ok(())
}
#[test]
fn multi_agent_version_uses_newest_present_session_meta_value() -> Result<()> {
let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
let older_meta = SessionMetaLine {
meta: SessionMeta {
session_id: thread_id.into(),
id: thread_id,
multi_agent_version: Some(MultiAgentVersion::V2),
..Default::default()
},
git: None,
};
let newer_meta_without_version = SessionMetaLine {
meta: SessionMeta {
session_id: thread_id.into(),
id: thread_id,
multi_agent_version: None,
..Default::default()
},
git: None,
};
assert_eq!(
multi_agent_version_from_items(
&[
RolloutItem::SessionMeta(older_meta),
RolloutItem::SessionMeta(newer_meta_without_version),
],
Some(thread_id),
),
Some(MultiAgentVersion::V2)
);
Ok(())
}
#[test]
fn turn_context_item_serializes_network_when_present() -> Result<()> {
let item = TurnContextItem {