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:
Owen Lin
2026-06-26 09:12:42 -07:00
committed by GitHub
parent 2c5bc5e284
commit 5267e805fb
91 changed files with 1385 additions and 39 deletions
+109
View File
@@ -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!({