mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(app-server): add history_mode to thread (#29927)
## Description This PR adds a new `historyMode = "legacy" | "paginated"` to `Thread`. This will be stored in `SessionMeta` in the JSONL rollout file and as a new column in the SQLite thread_metadata table, and exposed on `thread/start` and on the `Thread` object in app-server. ## What changed - Added canonical `ThreadHistoryMode` with `legacy` and `paginated`, defaulting old and new SessionMeta to `legacy`. - Carried `history_mode` through core session config, ThreadStore stored metadata, local/in-memory stores, rollout metadata extraction, and the existing SQLite `threads` table. - Added experimental `historyMode` to app-server v2 `Thread` and `thread/start`. - Made paginated stored threads metadata-discoverable but unsupported for legacy full-history reads, `load_history`, live resume, and create paths. - Regenerated app-server schema fixtures and added protocol/state/thread-store/app-server coverage for persistence and fail-closed behavior. ## Compatibility floor Because users may be running various versions of Codex binaries on the same machine (TUI, Codex App, etc.), we will need to establish a compatibility floor for upcoming paginated threads, which will change how thread storage reads and writes work. The overall plan here: ``` Release N: - Add historyMode to SessionMeta / Thread / SQLite metadata. - Teach binaries to understand paginated threads. - If a binary sees `historyMode="paginated"` but does not support the paginated contract, it refuses to resume/mutate the thread. - Default remains `"legacy"`. Release N+1: - First-party clients start opting into paginated threads where appropriate. - Internal dogfood / staged rollout. - Measure old-client usage and paginated-thread unsupported errors. Release N+2: - Only after Release N+ is overwhelmingly deployed, make paginated the default. - Accept that a small tail of N-1-or-older binaries may not understand paginated threads. ``` The important behavior change is fail-closed handling for a binary that encounters a persisted `paginated` thread before it knows how to fully support paginated history. In app-server, if a thread is `paginated`, we will: - allow metadata-only discovery paths like `thread/list` and `thread/read(includeTurns=false)`, so clients can still see the thread and inspect its `historyMode` - reject legacy full-history/live-thread paths like `thread/read(includeTurns=true)` and `thread/resume` with an unsupported JSON-RPC error - avoid silently treating an unknown or future `historyMode` as `legacy` Under the hood, the ThreadStore layer also rejects legacy operations that would need to load or replay the full thread history for a paginated thread. That gives us the behavior we want for Release N: future paginated threads are visible, but this binary fails closed instead of trying to operate on them as if they were legacy threads.
This commit is contained in:
@@ -678,6 +678,36 @@ pub enum ThreadMemoryMode {
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(rename_all = "lowercase")]
|
||||
pub enum ThreadHistoryMode {
|
||||
#[default]
|
||||
Legacy,
|
||||
Paginated,
|
||||
}
|
||||
|
||||
impl ThreadHistoryMode {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Legacy => "legacy",
|
||||
Self::Paginated => "paginated",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ThreadHistoryMode {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"legacy" => Ok(Self::Legacy),
|
||||
"paginated" => Ok(Self::Paginated),
|
||||
_ => Err(format!("unknown thread history mode `{value}`")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<UserInput>> for Op {
|
||||
fn from(value: Vec<UserInput>) -> Self {
|
||||
Op::UserInput {
|
||||
@@ -2607,6 +2637,18 @@ impl InitialHistory {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_history_mode(&self, default_history_mode: ThreadHistoryMode) -> ThreadHistoryMode {
|
||||
match self {
|
||||
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => {
|
||||
default_history_mode
|
||||
}
|
||||
InitialHistory::Resumed(_) => self
|
||||
.get_resumed_session_meta()
|
||||
.map(|meta| meta.history_mode)
|
||||
.unwrap_or(default_history_mode),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_latest_effective_multi_agent_mode(&self) -> Option<MultiAgentMode> {
|
||||
let items = match self {
|
||||
InitialHistory::New | InitialHistory::Cleared => return None,
|
||||
@@ -3033,6 +3075,8 @@ pub struct SessionMeta {
|
||||
pub selected_capability_roots: Vec<SelectedCapabilityRoot>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub history_mode: ThreadHistoryMode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub multi_agent_version: Option<MultiAgentVersion>,
|
||||
/// Initial context-window identity for consumers that tail rollout JSONL before compaction.
|
||||
@@ -3062,6 +3106,7 @@ impl Default for SessionMeta {
|
||||
dynamic_tools: None,
|
||||
selected_capability_roots: Vec::new(),
|
||||
memory_mode: None,
|
||||
history_mode: ThreadHistoryMode::default(),
|
||||
multi_agent_version: None,
|
||||
context_window: None,
|
||||
}
|
||||
@@ -5542,6 +5587,70 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_meta_defaults_legacy_history_mode() -> Result<()> {
|
||||
let session_meta: SessionMeta = serde_json::from_value(json!({
|
||||
"session_id": "00000000-0000-0000-0000-000000000001",
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"cwd": "/tmp",
|
||||
"originator": "codex",
|
||||
"cli_version": "0.0.0",
|
||||
"model_provider": null,
|
||||
"base_instructions": null
|
||||
}))?;
|
||||
|
||||
assert_eq!(session_meta.history_mode, ThreadHistoryMode::Legacy);
|
||||
let serialized = serde_json::to_value(&session_meta)?;
|
||||
assert_eq!(serialized["history_mode"], json!("legacy"));
|
||||
let mut unknown = serialized;
|
||||
unknown["history_mode"] = json!("future");
|
||||
assert!(serde_json::from_value::<SessionMeta>(unknown).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_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::Paginated,
|
||||
..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::Legacy),
|
||||
ThreadHistoryMode::Paginated
|
||||
);
|
||||
assert_eq!(
|
||||
InitialHistory::Forked(vec![session_meta]).get_history_mode(ThreadHistoryMode::Legacy),
|
||||
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!({
|
||||
|
||||
Reference in New Issue
Block a user