Codex Handbook
thread-store/src/types.rs 794 lines
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),            })        );    }}