mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
5267e805fb
## 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.
276 lines
9.8 KiB
Rust
276 lines
9.8 KiB
Rust
use super::CodexErrorInfo;
|
|
use super::ThreadItem;
|
|
use super::ThreadStatus;
|
|
use super::TurnStatus;
|
|
use codex_experimental_api_macros::ExperimentalApi;
|
|
use codex_protocol::protocol::SessionSource as CoreSessionSource;
|
|
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
|
|
use codex_protocol::protocol::ThreadHistoryMode as CoreThreadHistoryMode;
|
|
use codex_protocol::protocol::ThreadSource as CoreThreadSource;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use schemars::JsonSchema;
|
|
use schemars::r#gen::SchemaGenerator;
|
|
use schemars::schema::Schema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use std::path::PathBuf;
|
|
use thiserror::Error;
|
|
use ts_rs::TS;
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase", export_to = "v2/")]
|
|
#[derive(Default)]
|
|
pub enum SessionSource {
|
|
Cli,
|
|
#[serde(rename = "vscode")]
|
|
#[ts(rename = "vscode")]
|
|
#[default]
|
|
VsCode,
|
|
Exec,
|
|
AppServer,
|
|
Custom(String),
|
|
SubAgent(CoreSubAgentSource),
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
impl From<CoreSessionSource> for SessionSource {
|
|
fn from(value: CoreSessionSource) -> Self {
|
|
match value {
|
|
CoreSessionSource::Cli => SessionSource::Cli,
|
|
CoreSessionSource::VSCode => SessionSource::VsCode,
|
|
CoreSessionSource::Exec => SessionSource::Exec,
|
|
CoreSessionSource::Mcp => SessionSource::AppServer,
|
|
CoreSessionSource::Custom(source) => SessionSource::Custom(source),
|
|
// We do not want to render those at the app-server level.
|
|
CoreSessionSource::Internal(_) => SessionSource::Unknown,
|
|
CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub),
|
|
CoreSessionSource::Unknown => SessionSource::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SessionSource> for CoreSessionSource {
|
|
fn from(value: SessionSource) -> Self {
|
|
match value {
|
|
SessionSource::Cli => CoreSessionSource::Cli,
|
|
SessionSource::VsCode => CoreSessionSource::VSCode,
|
|
SessionSource::Exec => CoreSessionSource::Exec,
|
|
SessionSource::AppServer => CoreSessionSource::Mcp,
|
|
SessionSource::Custom(source) => CoreSessionSource::Custom(source),
|
|
SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub),
|
|
SessionSource::Unknown => CoreSessionSource::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
|
#[serde(rename_all = "lowercase")]
|
|
#[ts(rename_all = "lowercase", export_to = "v2/")]
|
|
pub enum ThreadHistoryMode {
|
|
#[default]
|
|
Legacy,
|
|
Paginated,
|
|
}
|
|
|
|
impl From<CoreThreadHistoryMode> for ThreadHistoryMode {
|
|
fn from(value: CoreThreadHistoryMode) -> Self {
|
|
match value {
|
|
CoreThreadHistoryMode::Legacy => Self::Legacy,
|
|
CoreThreadHistoryMode::Paginated => Self::Paginated,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ThreadHistoryMode> for CoreThreadHistoryMode {
|
|
fn from(value: ThreadHistoryMode) -> Self {
|
|
match value {
|
|
ThreadHistoryMode::Legacy => Self::Legacy,
|
|
ThreadHistoryMode::Paginated => Self::Paginated,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS)]
|
|
#[serde(try_from = "String", into = "String")]
|
|
#[ts(type = "string")]
|
|
#[ts(export_to = "v2/")]
|
|
pub enum ThreadSource {
|
|
User,
|
|
Subagent,
|
|
Feature(String),
|
|
MemoryConsolidation,
|
|
}
|
|
|
|
impl JsonSchema for ThreadSource {
|
|
fn schema_name() -> String {
|
|
"ThreadSource".to_string()
|
|
}
|
|
|
|
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
|
String::json_schema(generator)
|
|
}
|
|
}
|
|
|
|
impl TryFrom<String> for ThreadSource {
|
|
type Error = String;
|
|
|
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
|
value.parse::<CoreThreadSource>().map(Into::into)
|
|
}
|
|
}
|
|
|
|
impl From<ThreadSource> for String {
|
|
fn from(value: ThreadSource) -> Self {
|
|
CoreThreadSource::from(value).into()
|
|
}
|
|
}
|
|
|
|
impl From<CoreThreadSource> for ThreadSource {
|
|
fn from(value: CoreThreadSource) -> Self {
|
|
match value {
|
|
CoreThreadSource::User => ThreadSource::User,
|
|
CoreThreadSource::Subagent => ThreadSource::Subagent,
|
|
CoreThreadSource::Feature(feature) => ThreadSource::Feature(feature),
|
|
CoreThreadSource::MemoryConsolidation => ThreadSource::MemoryConsolidation,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ThreadSource> for CoreThreadSource {
|
|
fn from(value: ThreadSource) -> Self {
|
|
match value {
|
|
ThreadSource::User => CoreThreadSource::User,
|
|
ThreadSource::Subagent => CoreThreadSource::Subagent,
|
|
ThreadSource::Feature(feature) => CoreThreadSource::Feature(feature),
|
|
ThreadSource::MemoryConsolidation => CoreThreadSource::MemoryConsolidation,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extra app-server data for a thread.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase", export_to = "v2/")]
|
|
pub struct ThreadExtra {}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(export_to = "v2/")]
|
|
pub struct GitInfo {
|
|
pub sha: Option<String>,
|
|
pub branch: Option<String>,
|
|
pub origin_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(export_to = "v2/")]
|
|
pub struct Thread {
|
|
/// Identifier for this thread. Codex-generated thread IDs are UUIDv7.
|
|
pub id: String,
|
|
/// Optional implementation-specific thread data.
|
|
#[experimental("thread.extra")]
|
|
pub extra: Option<ThreadExtra>,
|
|
/// Session id shared by threads that belong to the same session tree.
|
|
pub session_id: String,
|
|
/// Source thread id when this thread was created by forking another thread.
|
|
pub forked_from_id: Option<String>,
|
|
/// The ID of the parent thread. This will only be set if this thread is a subagent.
|
|
pub parent_thread_id: Option<String>,
|
|
/// Usually the first user message in the thread, if available.
|
|
pub preview: String,
|
|
/// Whether the thread is ephemeral and should not be materialized on disk.
|
|
pub ephemeral: bool,
|
|
/// Persisted thread history contract selected when this thread was created.
|
|
#[experimental("thread.historyMode")]
|
|
#[serde(default)]
|
|
pub history_mode: ThreadHistoryMode,
|
|
/// Model provider used for this thread (for example, 'openai').
|
|
pub model_provider: String,
|
|
/// Unix timestamp (in seconds) when the thread was created.
|
|
#[ts(type = "number")]
|
|
pub created_at: i64,
|
|
/// Unix timestamp (in seconds) when the thread was last updated.
|
|
#[ts(type = "number")]
|
|
pub updated_at: i64,
|
|
/// Unix timestamp (in seconds) used for thread recency ordering.
|
|
#[ts(type = "number | null")]
|
|
pub recency_at: Option<i64>,
|
|
/// Current runtime status for the thread.
|
|
pub status: ThreadStatus,
|
|
/// [UNSTABLE] Path to the thread on disk.
|
|
pub path: Option<PathBuf>,
|
|
/// Working directory captured for the thread.
|
|
pub cwd: AbsolutePathBuf,
|
|
/// Version of the CLI that created the thread.
|
|
pub cli_version: String,
|
|
/// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
|
|
pub source: SessionSource,
|
|
/// Optional analytics source classification for this thread.
|
|
pub thread_source: Option<ThreadSource>,
|
|
/// Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
|
|
pub agent_nickname: Option<String>,
|
|
/// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
|
|
pub agent_role: Option<String>,
|
|
/// Optional Git metadata captured when the thread was created.
|
|
pub git_info: Option<GitInfo>,
|
|
/// Optional user-facing thread title.
|
|
pub name: Option<String>,
|
|
/// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read`
|
|
/// (when `includeTurns` is true) responses.
|
|
/// For all other responses and notifications returning a Thread,
|
|
/// the turns field will be an empty list.
|
|
pub turns: Vec<Turn>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(export_to = "v2/")]
|
|
pub struct Turn {
|
|
/// Identifier for this turn. Codex-generated turn IDs are UUIDv7.
|
|
pub id: String,
|
|
/// Thread items currently included in this turn payload.
|
|
pub items: Vec<ThreadItem>,
|
|
/// Describes how much of `items` has been loaded for this turn.
|
|
#[serde(default)]
|
|
pub items_view: TurnItemsView,
|
|
pub status: TurnStatus,
|
|
/// Only populated when the Turn's status is failed.
|
|
pub error: Option<TurnError>,
|
|
/// Unix timestamp (in seconds) when the turn started.
|
|
#[ts(type = "number | null")]
|
|
pub started_at: Option<i64>,
|
|
/// Unix timestamp (in seconds) when the turn completed.
|
|
#[ts(type = "number | null")]
|
|
pub completed_at: Option<i64>,
|
|
/// Duration between turn start and completion in milliseconds, if known.
|
|
#[ts(type = "number | null")]
|
|
pub duration_ms: Option<i64>,
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(export_to = "v2/")]
|
|
pub enum TurnItemsView {
|
|
/// `items` was not loaded for this turn. The field is intentionally empty.
|
|
NotLoaded,
|
|
/// `items` contains only a display summary for this turn.
|
|
Summary,
|
|
/// `items` contains every ThreadItem available from persisted app-server history for this turn.
|
|
#[default]
|
|
Full,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(export_to = "v2/")]
|
|
#[error("{message}")]
|
|
pub struct TurnError {
|
|
pub message: String,
|
|
pub codex_error_info: Option<CodexErrorInfo>,
|
|
#[serde(default)]
|
|
pub additional_details: Option<String>,
|
|
}
|