mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
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:
committed by
copyberry
parent
a1c88e865d
commit
63002bdb26
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user