use std::path::PathBuf;use chrono::DateTime;use chrono::Utc;use codex_protocol::ThreadId;use codex_protocol::dynamic_tools::DynamicToolSpec;use codex_protocol::models::BaseInstructions;use codex_protocol::models::PermissionProfile;use codex_protocol::openai_models::ReasoningEffort;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::GitInfo;use codex_protocol::protocol::MultiAgentVersion;use codex_protocol::protocol::RolloutItem;use codex_protocol::protocol::SessionSource;use codex_protocol::protocol::ThreadMemoryMode as MemoryMode;use codex_protocol::protocol::ThreadSource;use codex_protocol::protocol::TokenUsage;use serde::Deserialize;use serde::Deserializer;use serde::Serialize;use serde::Serializer;mod optional_option { use super::*; pub fn serialize<T, S>(value: &Option<Option<T>>, serializer: S) -> Result<S::Ok, S::Error> where T: Serialize, S: Serializer, { match value { Some(value) => value.serialize(serializer), None => serializer.serialize_none(), } } pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error> where T: Deserialize<'de>, D: Deserializer<'de>, { Option::<T>::deserialize(deserializer).map(Some) }}/// Thread-scoped metadata used when opening live persistence.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct ThreadPersistenceMetadata { /// Effective working directory for environment-backed threads. /// /// `None` means the thread has no filesystem/environment context. pub cwd: Option<PathBuf>, /// Model provider associated with the thread. pub model_provider: String, /// Memory mode associated with the live thread. pub memory_mode: MemoryMode,}/// Extra configuration fields for a thread.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct ExtraConfig {}/// Parameters required to create a persisted thread.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct CreateThreadParams { /// Thread id generated by Codex before opening persistence. pub thread_id: ThreadId, /// Optional extra configuration fields for the thread. pub extra_config: Option<ExtraConfig>, /// Source thread id when this thread is created as a fork. pub forked_from_id: Option<ThreadId>, /// The ID of the parent thread. This will only be set if this thread is a subagent. pub parent_thread_id: Option<ThreadId>, /// Runtime source for the thread. pub source: SessionSource, /// Optional analytics source classification for this thread. pub thread_source: Option<ThreadSource>, /// Base instructions persisted in session metadata. pub base_instructions: BaseInstructions, /// Dynamic tools available to the thread at startup. pub dynamic_tools: Vec<DynamicToolSpec>, /// Multi-agent runtime selected when the thread was created. pub multi_agent_version: Option<MultiAgentVersion>, /// Metadata captured for the newly created thread. pub metadata: ThreadPersistenceMetadata,}/// Parameters required to reopen persistence for an existing thread.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct ResumeThreadParams { /// Existing thread id whose future items should be appended. pub thread_id: ThreadId, /// Known local rollout path when the caller resumed from a specific file. pub rollout_path: Option<PathBuf>, /// Known replay history for the resumed thread, if already loaded by the caller. pub history: Option<Vec<RolloutItem>>, /// Whether archived threads may be reopened. pub include_archived: bool, /// Metadata for future writes appended to the resumed live thread. pub metadata: ThreadPersistenceMetadata,}/// Parameters for appending rollout items to a live thread.#[derive(Clone, Debug)]pub struct AppendThreadItemsParams { /// Thread id to append to. pub thread_id: ThreadId, /// Raw rollout items to append in order. /// /// Store implementations are responsible for applying the shared rollout persistence policy /// before writing durable replay history or any implementation-owned projections. pub items: Vec<RolloutItem>,}/// Parameters for loading persisted history for resume, fork, rollback, and memory jobs.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct LoadThreadHistoryParams { /// Thread id to load. pub thread_id: ThreadId, /// Whether archived threads are eligible. pub include_archived: bool,}/// Persisted rollout history for a thread, without any filesystem path requirement.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct StoredThreadHistory { /// Thread id represented by the history. pub thread_id: ThreadId, /// Persisted rollout items in replay order. pub items: Vec<RolloutItem>,}/// Parameters for reading a thread summary and optionally its replay history.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct ReadThreadParams { /// Thread id to read. pub thread_id: ThreadId, /// Whether archived threads are eligible. pub include_archived: bool, /// Whether persisted rollout items should be included in the response. pub include_history: bool,}/// Parameters for reading a local rollout-backed thread by path.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct ReadThreadByRolloutPathParams { /// Local rollout JSONL path to read. pub rollout_path: PathBuf, /// Whether archived threads are eligible. pub include_archived: bool, /// Whether persisted rollout items should be included in the response. pub include_history: bool,}/// The sort key to use when listing stored threads.#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]pub enum ThreadSortKey { /// Sort by the thread creation timestamp. #[default] CreatedAt, /// Sort by the thread last-update timestamp. UpdatedAt,}/// The direction to use when listing stored threads.#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]pub enum SortDirection { /// Return older threads before newer threads. Asc, /// Return newer threads before older threads. #[default] Desc,}/// Parameters for listing threads.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct ListThreadsParams { /// Maximum number of threads to return. pub page_size: usize, /// Opaque cursor returned by a previous list call. pub cursor: Option<String>, /// Sort order requested by the caller. pub sort_key: ThreadSortKey, /// Sort direction requested by the caller. pub sort_direction: SortDirection, /// Allowed session sources. Empty means implementation default. pub allowed_sources: Vec<SessionSource>, /// Optional model provider filter. `None` means implementation default, while an empty vector /// means all providers. pub model_providers: Option<Vec<String>>, /// Optional cwd filters. `None` means all working directories, while an empty vector matches no /// threads. pub cwd_filters: Option<Vec<PathBuf>>, /// Whether archived threads should be listed instead of active threads. pub archived: bool, /// Optional substring/full-text search term for thread title/preview. pub search_term: Option<String>, /// Optional direct parent thread filter. pub parent_thread_id: Option<ThreadId>, /// Return directly from the state DB without scanning JSONL rollouts to repair metadata. pub use_state_db_only: bool,}/// Parameters for searching thread content.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct SearchThreadsParams { /// Maximum number of threads to return. pub page_size: usize, /// Opaque cursor returned by a previous search call. pub cursor: Option<String>, /// Sort order requested by the caller. pub sort_key: ThreadSortKey, /// Sort direction requested by the caller. pub sort_direction: SortDirection, /// Allowed session sources. Empty means implementation default. pub allowed_sources: Vec<SessionSource>, /// Whether archived threads should be searched instead of active threads. pub archived: bool, /// Visible thread content to search for. pub search_term: String,}/// A page of stored thread records.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct ThreadPage { /// Threads returned for this page. pub items: Vec<StoredThread>, /// Opaque cursor to continue listing. pub next_cursor: Option<String>,}#[derive(Clone, Debug, Serialize, Deserialize)]pub struct StoredThreadSearchResult { pub thread: StoredThread, pub snippet: String,}/// A page of thread-search results.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct ThreadSearchPage { /// Search results returned for this page. pub items: Vec<StoredThreadSearchResult>, /// Opaque cursor to continue searching. pub next_cursor: Option<String>,}/// Requested amount of item detail for stored turns.#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]pub enum StoredTurnItemsView { /// Return turn metadata only. NotLoaded, /// Return display summary items for each turn. #[default] Summary, /// Return every persisted item available for each turn. Full,}/// Store-owned status for a persisted turn.#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]pub enum StoredTurnStatus { /// The turn completed normally. Completed, /// The turn was interrupted before normal completion. Interrupted, /// The turn failed. Failed, /// The turn is still in progress. InProgress,}/// Store-owned error details for a failed persisted turn.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct StoredTurnError { /// User-visible error message. pub message: String, /// Optional additional detail for clients that expose expanded error context. pub additional_details: Option<String>,}/// Parameters for listing turns within a stored thread.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct ListTurnsParams { /// Thread id to read. pub thread_id: ThreadId, /// Whether archived threads are eligible. pub include_archived: bool, /// Opaque cursor returned by a previous list call. pub cursor: Option<String>, /// Maximum number of turns to return. pub page_size: usize, /// Sort direction requested by the caller. pub sort_direction: SortDirection, /// Requested amount of item detail for each returned turn. pub items_view: StoredTurnItemsView,}/// Store-owned turn representation used by turn pagination APIs.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct StoredTurn { /// Turn id. pub turn_id: String, /// Persisted rollout items associated with this turn, according to `items_view`. pub items: Vec<RolloutItem>, /// Opaque serialized turn metadata supplied by a projected durable store. pub metadata_json: Option<Vec<u8>>, /// Semantic turn creation timestamp in milliseconds, when supplied by a projected durable /// store. pub turn_created_at_ms: Option<i64>, /// Amount of item detail included in `items`. pub items_view: StoredTurnItemsView, /// Store-owned status for API layer projection. pub status: StoredTurnStatus, /// Error message when the turn failed. pub error: Option<StoredTurnError>, /// Unix timestamp (seconds) when the turn started. pub started_at: Option<i64>, /// Unix timestamp (seconds) when the turn completed. pub completed_at: Option<i64>, /// Duration between turn start and completion in milliseconds, if known. pub duration_ms: Option<i64>,}/// A page of stored turns.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct TurnPage { /// Turns returned for this page. pub turns: Vec<StoredTurn>, /// Opaque cursor to continue listing. pub next_cursor: Option<String>, /// Opaque cursor for fetching in the opposite direction. pub backwards_cursor: Option<String>,}/// Parameters for listing persisted items within a single turn.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct ListItemsParams { /// Thread id to read. pub thread_id: ThreadId, /// Turn id to hydrate. pub turn_id: String, /// Whether archived threads are eligible. pub include_archived: bool, /// Opaque cursor returned by a previous list call. pub cursor: Option<String>, /// Maximum number of items to return. pub page_size: usize, /// Sort direction requested by the caller. pub sort_direction: SortDirection,}/// A projected app-server `ThreadItem` snapshot within a turn.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct StoredThreadItem { pub turn_id: Option<String>, pub item_key: String, pub item_ordinal: u64, pub item_created_at_ms: i64, pub materialized_thread_item_json: Vec<u8>,}/// A page of persisted items within a turn.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct ItemPage { /// Items returned for this page. pub items: Vec<StoredThreadItem>, /// Opaque cursor to continue listing. pub next_cursor: Option<String>, /// Opaque cursor for fetching in the opposite direction. pub backwards_cursor: Option<String>,}/// Store-owned thread metadata used by list/read/resume responses.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct StoredThread { /// Thread id. pub thread_id: ThreadId, /// Optional extra configuration fields for the thread. pub extra_config: Option<ExtraConfig>, /// Local rollout path when the backing store is filesystem-based. pub rollout_path: Option<PathBuf>, /// Source thread id when this thread was forked from another thread. pub forked_from_id: Option<ThreadId>, /// The ID of the parent thread. This will only be set if this thread is a subagent. pub parent_thread_id: Option<ThreadId>, /// Best available user-facing preview, usually the first user message. pub preview: String, /// Optional user-facing thread name/title. pub name: Option<String>, /// Model provider id associated with the thread. pub model_provider: String, /// Latest observed model, if known. pub model: Option<String>, /// Latest observed reasoning effort, if known. pub reasoning_effort: Option<ReasoningEffort>, /// Thread creation timestamp. pub created_at: DateTime<Utc>, /// Thread last-update timestamp. pub updated_at: DateTime<Utc>, /// Thread archive timestamp, if archived. pub archived_at: Option<DateTime<Utc>>, /// Working directory captured for the thread. pub cwd: PathBuf, /// CLI version captured for the thread. pub cli_version: String, /// Runtime source for the thread. pub source: SessionSource, /// Optional analytics source classification for this thread. pub thread_source: Option<ThreadSource>, /// Optional random nickname for thread-spawn sub-agents. pub agent_nickname: Option<String>, /// Optional role for thread-spawn sub-agents. pub agent_role: Option<String>, /// Optional canonical path for thread-spawn sub-agents. pub agent_path: Option<String>, /// Optional Git metadata captured for the thread. pub git_info: Option<GitInfo>, /// Approval mode captured for the thread. pub approval_mode: AskForApproval, /// Canonical runtime permissions captured for the thread. pub permission_profile: PermissionProfile, /// Last observed token usage. pub token_usage: Option<TokenUsage>, /// First user message observed for this thread, if any. pub first_user_message: Option<String>, /// Persisted history, populated only when requested. pub history: Option<StoredThreadHistory>,}/// Optional field patch where omission leaves a value unchanged and `Some(None)` clears it.pub type ClearableField<T> = Option<Option<T>>;/// Patch for thread Git metadata.#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]pub struct GitInfoPatch { /// Replacement commit SHA, clear request, or no-op. #[serde( default, skip_serializing_if = "Option::is_none", with = "optional_option" )] pub sha: ClearableField<String>, /// Replacement branch name, clear request, or no-op. #[serde( default, skip_serializing_if = "Option::is_none", with = "optional_option" )] pub branch: ClearableField<String>, /// Replacement origin URL, clear request, or no-op. #[serde( default, skip_serializing_if = "Option::is_none", with = "optional_option" )] pub origin_url: ClearableField<String>,}impl GitInfoPatch { /// Merges another patch into this one using field-presence semantics. /// /// Omitted fields in `next` leave the current patch unchanged. Present fields replace the /// current value, including clear requests like `Some(None)`. pub fn merge(&mut self, next: Self) { if next.sha.is_some() { self.sha = next.sha; } if next.branch.is_some() { self.branch = next.branch; } if next.origin_url.is_some() { self.origin_url = next.origin_url; } }}/// Patch for thread metadata.////// Every field is literal: `None` leaves that field unchanged, while `Some`/// applies the supplied value. Fields whose value may itself be cleared use an/// inner `Option`, where `Some(None)` clears the field.#[derive(Clone, Debug, Default, Serialize, Deserialize)]pub struct ThreadMetadataPatch { /// Replacement user-facing thread name. #[serde( default, skip_serializing_if = "Option::is_none", with = "optional_option" )] pub name: ClearableField<String>, /// Known local rollout path for stores that expose one. pub rollout_path: Option<PathBuf>, /// Best available preview text for discovery/listing. pub preview: Option<String>, /// Best-effort title derived from history. pub title: Option<String>, /// Model provider associated with the thread. pub model_provider: Option<String>, /// Latest observed model. pub model: Option<String>, /// Latest observed reasoning effort. pub reasoning_effort: Option<ReasoningEffort>, /// Creation timestamp when known. pub created_at: Option<DateTime<Utc>>, /// Last update timestamp for this metadata observation. pub updated_at: Option<DateTime<Utc>>, /// Session source. pub source: Option<SessionSource>, /// Optional analytics source classification. #[serde( default, skip_serializing_if = "Option::is_none", with = "optional_option" )] pub thread_source: ClearableField<ThreadSource>, /// Optional agent nickname. #[serde( default, skip_serializing_if = "Option::is_none", with = "optional_option" )] pub agent_nickname: ClearableField<String>, /// Optional agent role. #[serde( default, skip_serializing_if = "Option::is_none", with = "optional_option" )] pub agent_role: ClearableField<String>, /// Optional canonical agent path. #[serde( default, skip_serializing_if = "Option::is_none", with = "optional_option" )] pub agent_path: ClearableField<String>, /// Working directory. pub cwd: Option<PathBuf>, /// CLI version that created the thread. pub cli_version: Option<String>, /// Approval mode. pub approval_mode: Option<AskForApproval>, /// Canonical runtime permissions. pub permission_profile: Option<PermissionProfile>, /// Last observed token usage. pub token_usage: Option<TokenUsage>, /// First user message observed for this thread. pub first_user_message: Option<String>, /// Git metadata patch. pub git_info: Option<GitInfoPatch>, /// Thread memory behavior. pub memory_mode: Option<MemoryMode>,}impl ThreadMetadataPatch { /// Merges another patch into this one using field-presence semantics. /// /// Omitted fields in `next` leave the current patch unchanged. Present fields replace the /// current value, including clear requests like `Some(None)`. Nested patches use the same /// semantics. pub fn merge(&mut self, next: Self) { if next.name.is_some() { self.name = next.name; } if next.rollout_path.is_some() { self.rollout_path = next.rollout_path; } if next.preview.is_some() { self.preview = next.preview; } if next.title.is_some() { self.title = next.title; } if next.model_provider.is_some() { self.model_provider = next.model_provider; } if next.model.is_some() { self.model = next.model; } if next.reasoning_effort.is_some() { self.reasoning_effort = next.reasoning_effort; } if next.created_at.is_some() { self.created_at = next.created_at; } if next.updated_at.is_some() { self.updated_at = next.updated_at; } if next.source.is_some() { self.source = next.source; } if next.thread_source.is_some() { self.thread_source = next.thread_source; } if next.agent_nickname.is_some() { self.agent_nickname = next.agent_nickname; } if next.agent_role.is_some() { self.agent_role = next.agent_role; } if next.agent_path.is_some() { self.agent_path = next.agent_path; } if next.cwd.is_some() { self.cwd = next.cwd; } if next.cli_version.is_some() { self.cli_version = next.cli_version; } if next.approval_mode.is_some() { self.approval_mode = next.approval_mode; } if next.permission_profile.is_some() { self.permission_profile = next.permission_profile; } if next.token_usage.is_some() { self.token_usage = next.token_usage; } if next.first_user_message.is_some() { self.first_user_message = next.first_user_message; } if let Some(git_info) = next.git_info { self.git_info .get_or_insert_with(GitInfoPatch::default) .merge(git_info); } if next.memory_mode.is_some() { self.memory_mode = next.memory_mode; } } pub fn is_empty(&self) -> bool { self.name.is_none() && self.rollout_path.is_none() && self.preview.is_none() && self.title.is_none() && self.model_provider.is_none() && self.model.is_none() && self.reasoning_effort.is_none() && self.created_at.is_none() && self.updated_at.is_none() && self.source.is_none() && self.thread_source.is_none() && self.agent_nickname.is_none() && self.agent_role.is_none() && self.agent_path.is_none() && self.cwd.is_none() && self.cli_version.is_none() && self.approval_mode.is_none() && self.permission_profile.is_none() && self.token_usage.is_none() && self.first_user_message.is_none() && self.git_info.is_none() && self.memory_mode.is_none() }}/// Parameters for patching mutable thread metadata.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct UpdateThreadMetadataParams { /// Thread id to update. pub thread_id: ThreadId, /// Patch to apply. pub patch: ThreadMetadataPatch, /// Whether archived threads are eligible. pub include_archived: bool,}/// Parameters for archiving or unarchiving a thread.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct ArchiveThreadParams { /// Thread id to archive or unarchive. pub thread_id: ThreadId,}/// Parameters for deleting a thread.#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]pub struct DeleteThreadParams { /// Thread id to delete. pub thread_id: ThreadId,}#[cfg(test)]mod tests { use pretty_assertions::assert_eq; use serde_json::json; use super::*; #[test] fn thread_metadata_patch_round_trips_optional_clears() { let patch = ThreadMetadataPatch { name: Some(None), thread_source: Some(None), agent_nickname: Some(None), agent_role: Some(None), agent_path: Some(None), ..Default::default() }; let value = serde_json::to_value(&patch).expect("serialize patch"); assert_eq!(value["name"], json!(null)); assert_eq!(value["thread_source"], json!(null)); assert_eq!(value["agent_nickname"], json!(null)); assert_eq!(value["agent_role"], json!(null)); assert_eq!(value["agent_path"], json!(null)); let decoded: ThreadMetadataPatch = serde_json::from_value(value).expect("deserialize patch"); assert_eq!(decoded.name, Some(None)); assert_eq!(decoded.thread_source, Some(None)); assert_eq!(decoded.agent_nickname, Some(None)); assert_eq!(decoded.agent_role, Some(None)); assert_eq!(decoded.agent_path, Some(None)); } #[test] fn git_info_patch_round_trips_optional_clears() { let patch = ThreadMetadataPatch { git_info: Some(GitInfoPatch { sha: None, branch: Some(Some("main".to_string())), origin_url: Some(None), }), ..Default::default() }; let value = serde_json::to_value(&patch).expect("serialize patch"); assert_eq!( value["git_info"], json!({ "branch": "main", "origin_url": null, }) ); let decoded: ThreadMetadataPatch = serde_json::from_value(value).expect("deserialize patch"); assert_eq!( decoded.git_info, Some(GitInfoPatch { sha: None, branch: Some(Some("main".to_string())), origin_url: Some(None), }) ); } #[test] fn thread_metadata_patch_accepts_missing_fields() { let decoded: ThreadMetadataPatch = serde_json::from_value(json!({})).expect("deserialize legacy patch"); assert!(decoded.is_empty()); } #[test] fn thread_metadata_patch_merge_uses_presence_semantics() { let mut current = ThreadMetadataPatch { name: Some(Some("old name".to_string())), preview: Some("old preview".to_string()), git_info: Some(GitInfoPatch { sha: Some(Some("abc123".to_string())), branch: Some(Some("main".to_string())), origin_url: None, }), ..Default::default() }; current.merge(ThreadMetadataPatch { name: Some(None), preview: None, title: Some("new title".to_string()), git_info: Some(GitInfoPatch { sha: None, branch: Some(Some("feature".to_string())), origin_url: Some(None), }), ..Default::default() }); assert_eq!(current.name, Some(None)); assert_eq!(current.preview.as_deref(), Some("old preview")); assert_eq!(current.title.as_deref(), Some("new title")); assert_eq!( current.git_info, Some(GitInfoPatch { sha: Some(Some("abc123".to_string())), branch: Some(Some("feature".to_string())), origin_url: Some(None), }) ); }}