diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index f944e3e38..48e308f62 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -56,7 +56,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi } metadata.id = meta_line.meta.id; metadata.source = enum_to_string(&meta_line.meta.source); - metadata.history_mode = meta_line.meta.history_mode; + // Later SessionMeta lines do not redefine the canonical history_mode. metadata.thread_source = meta_line.meta.thread_source.clone(); metadata.agent_nickname = meta_line.meta.agent_nickname.clone(); metadata.agent_role = meta_line.meta.agent_role.clone(); @@ -178,6 +178,7 @@ mod tests { use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; + use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::UserMessageEvent; @@ -516,6 +517,7 @@ mod tests { #[test] fn session_meta_does_not_set_model_or_reasoning_effort() { let mut metadata = metadata_for_test(); + metadata.history_mode = ThreadHistoryMode::Paginated; let thread_id = metadata.id; apply_rollout_item( @@ -540,7 +542,7 @@ mod tests { dynamic_tools: None, selected_capability_roots: Vec::new(), memory_mode: None, - history_mode: Default::default(), + history_mode: ThreadHistoryMode::Legacy, multi_agent_version: None, context_window: None, }, @@ -551,6 +553,7 @@ mod tests { assert_eq!(metadata.model, None); assert_eq!(metadata.reasoning_effort, None); + assert_eq!(metadata.history_mode, ThreadHistoryMode::Paginated); } fn metadata_for_test() -> ThreadMetadata { diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 38598830f..cb3f198ff 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -218,17 +218,22 @@ mod tests { }) .await .expect("register rollout path"); - store - .update_thread_metadata(UpdateThreadMetadataParams { - thread_id, - patch: ThreadMetadataPatch { - history_mode: Some(ThreadHistoryMode::Paginated), - ..Default::default() - }, - include_archived: false, - }) - .await - .expect("seed paginated metadata"); + { + let mut state = store.state.lock().await; + state + .created_threads + .get_mut(&thread_id) + .expect("created thread") + .history_mode = ThreadHistoryMode::Paginated; + let Some(RolloutItem::SessionMeta(meta_line)) = state + .histories + .get_mut(&thread_id) + .and_then(|history| history.first_mut()) + else { + panic!("canonical session meta"); + }; + meta_line.meta.history_mode = ThreadHistoryMode::Paginated; + } let thread = store .read_thread(ReadThreadParams { @@ -784,9 +789,7 @@ fn stored_thread_from_state( source: metadata .and_then(|metadata| metadata.source.clone()) .unwrap_or_else(|| created.source.clone()), - history_mode: metadata - .and_then(|metadata| metadata.history_mode) - .unwrap_or(created.history_mode), + history_mode: created.history_mode, thread_source: metadata .and_then(|metadata| metadata.thread_source.clone()) .unwrap_or_else(|| created.thread_source.clone()), @@ -811,15 +814,9 @@ fn history_mode_from_state( thread_id: ThreadId, ) -> ThreadHistoryMode { state - .metadata_updates + .created_threads .get(&thread_id) - .and_then(|metadata| metadata.history_mode) - .or_else(|| { - state - .created_threads - .get(&thread_id) - .map(|thread| thread.history_mode) - }) + .map(|thread| thread.history_mode) .unwrap_or_default() } diff --git a/codex-rs/thread-store/src/local/live_writer.rs b/codex-rs/thread-store/src/local/live_writer.rs index 85ef80d88..74e707bb5 100644 --- a/codex-rs/thread-store/src/local/live_writer.rs +++ b/codex-rs/thread-store/src/local/live_writer.rs @@ -26,9 +26,12 @@ pub(super) async fn create_thread( params: CreateThreadParams, ) -> ThreadStoreResult<()> { let thread_id = params.thread_id; + let history_mode = params.history_mode; store.ensure_live_recorder_absent(thread_id).await?; let recorder = create_thread::create_thread(store, params).await?; - store.insert_live_recorder(thread_id, recorder).await + store + .insert_live_recorder(thread_id, recorder, history_mode) + .await } pub(super) async fn resume_thread( @@ -98,7 +101,9 @@ pub(super) async fn resume_thread( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to resume local thread recorder: {err}"), })?; - store.insert_live_recorder(params.thread_id, recorder).await + store + .insert_live_recorder(params.thread_id, recorder, history_mode) + .await } #[tracing::instrument( @@ -191,6 +196,7 @@ pub(super) async fn rollout_path( .await .get(&thread_id) .ok_or(ThreadStoreError::ThreadNotFound { thread_id })? + .recorder .rollout_path() .to_path_buf()) } diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index bc34c52f3..5939ea606 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -13,6 +13,7 @@ mod update_thread_metadata; mod test_support; use codex_protocol::ThreadId; +use codex_protocol::protocol::ThreadHistoryMode; use codex_rollout::RolloutRecorder; use codex_rollout::StateDbHandle; use std::collections::HashMap; @@ -57,10 +58,18 @@ use crate::UpdateThreadMetadataParams; #[derive(Clone)] pub struct LocalThreadStore { pub(super) config: LocalThreadStoreConfig, - live_recorders: Arc>>, + live_recorders: Arc>>, state_db: Option, } +struct LiveRecorderEntry { + recorder: RolloutRecorder, + // Local rollout files are materialized lazily, but metadata updates can arrive before the + // canonical SessionMeta is durable. Retain the mode captured when live persistence was opened + // so missing SQLite rows can still be seeded. + history_mode: ThreadHistoryMode, +} + /// Process-scoped configuration for local thread storage. /// /// This describes where local storage lives. New-thread rollout metadata such @@ -135,7 +144,7 @@ impl LocalThreadStore { .lock() .await .get(&thread_id) - .cloned() + .map(|entry| entry.recorder.clone()) .ok_or(ThreadStoreError::ThreadNotFound { thread_id }) } @@ -155,13 +164,17 @@ impl LocalThreadStore { &self, thread_id: ThreadId, recorder: RolloutRecorder, + history_mode: ThreadHistoryMode, ) -> ThreadStoreResult<()> { match self.live_recorders.lock().await.entry(thread_id) { Entry::Occupied(entry) => Err(ThreadStoreError::InvalidRequest { message: format!("thread {} already has a live local writer", entry.key()), }), Entry::Vacant(entry) => { - entry.insert(recorder); + entry.insert(LiveRecorderEntry { + recorder, + history_mode, + }); Ok(()) } } @@ -580,6 +593,46 @@ mod tests { ); } + #[tokio::test] + async fn live_thread_memory_mode_update_before_rollout_materializes_keeps_history_mode() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id)) + .await + .expect("create live thread"); + + live_thread + .update_memory_mode(ThreadMemoryMode::Disabled, /*include_archived*/ false) + .await + .expect("update memory mode"); + + assert_eq!( + runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata") + .history_mode, + ThreadHistoryMode::Legacy + ); + assert_eq!( + runtime + .get_thread_memory_mode(thread_id) + .await + .expect("thread memory mode should be readable") + .as_deref(), + Some("disabled") + ); + } + #[tokio::test] async fn live_thread_shutdown_with_buffered_items_materializes_before_metadata_read() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 38e919b87..580df1dc6 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -6,6 +6,7 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadMemoryMode; use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; use codex_rollout::append_rollout_item_to_path; @@ -225,31 +226,27 @@ async fn apply_metadata_update( rollout_path_archived = resolved.archived; rollout_path = Some(resolved.path); } - let mut metadata = existing.clone().unwrap_or_else(|| { - let created_at = patch - .created_at - .or(patch.updated_at) - .unwrap_or_else(Utc::now); - let mut builder = ThreadMetadataBuilder::new( - thread_id, - rollout_path.clone().unwrap_or_default(), - created_at, - patch.source.clone().unwrap_or(SessionSource::Unknown), - ); - builder.model_provider = patch.model_provider.clone(); - builder.history_mode = patch.history_mode.unwrap_or_default(); - builder.thread_source = patch.thread_source.clone().flatten(); - builder.agent_nickname = patch.agent_nickname.clone().flatten(); - builder.agent_role = patch.agent_role.clone().flatten(); - builder.agent_path = patch.agent_path.clone().flatten(); - builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default(); - builder.cli_version = patch.cli_version.clone(); - let mut metadata = builder.build(store.config.default_model_provider_id.as_str()); - if rollout_path_archived { - metadata.archived_at = Some(metadata.updated_at); + let mut metadata = match existing.clone() { + Some(metadata) => metadata, + None => { + let rollout_path = + rollout_path + .as_deref() + .ok_or_else(|| ThreadStoreError::Internal { + message: format!( + "thread metadata missing rollout path for {thread_id}" + ), + })?; + metadata_for_missing_sqlite_row( + store, + thread_id, + rollout_path, + rollout_path_archived, + &patch, + ) + .await? } - metadata - }); + }; if let Some(rollout_path) = rollout_path { metadata.rollout_path = rollout_path; } @@ -285,9 +282,6 @@ async fn apply_metadata_update( if let Some(source) = patch.source { metadata.source = enum_to_string(&source); } - if let Some(history_mode) = patch.history_mode { - metadata.history_mode = history_mode; - } if let Some(thread_source) = patch.thread_source { metadata.thread_source = thread_source; } @@ -389,6 +383,78 @@ async fn apply_metadata_update( .await } +async fn metadata_for_missing_sqlite_row( + store: &LocalThreadStore, + thread_id: ThreadId, + rollout_path: &Path, + rollout_path_archived: bool, + patch: &ThreadMetadataPatch, +) -> ThreadStoreResult { + let created_at = patch + .created_at + .or(patch.updated_at) + .unwrap_or_else(Utc::now); + let mut builder = ThreadMetadataBuilder::new( + thread_id, + rollout_path.to_path_buf(), + created_at, + patch.source.clone().unwrap_or(SessionSource::Unknown), + ); + builder.model_provider = patch.model_provider.clone(); + builder.history_mode = canonical_history_mode(store, thread_id, rollout_path).await?; + builder.thread_source = patch.thread_source.clone().flatten(); + builder.agent_nickname = patch.agent_nickname.clone().flatten(); + builder.agent_role = patch.agent_role.clone().flatten(); + builder.agent_path = patch.agent_path.clone().flatten(); + builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default(); + builder.cli_version = patch.cli_version.clone(); + let mut metadata = builder.build(store.config.default_model_provider_id.as_str()); + if rollout_path_archived { + metadata.archived_at = Some(metadata.updated_at); + } + Ok(metadata) +} + +async fn canonical_history_mode( + store: &LocalThreadStore, + thread_id: ThreadId, + rollout_path: &Path, +) -> ThreadStoreResult { + let session_meta = match read_session_meta_line(rollout_path).await { + Ok(session_meta) => session_meta, + Err(err) => { + if codex_rollout::existing_rollout_path(rollout_path) + .await + .is_none() + && let Some(history_mode) = store + .live_recorders + .lock() + .await + .get(&thread_id) + .map(|entry| entry.history_mode) + { + // The live writer retains the canonical mode selected before its deferred + // SessionMeta reaches JSONL. + return Ok(history_mode); + } + return Err(ThreadStoreError::Internal { + message: format!( + "failed to read canonical session metadata for {thread_id}: {err}" + ), + }); + } + }; + if session_meta.meta.id != thread_id { + return Err(ThreadStoreError::Internal { + message: format!( + "failed to rebuild thread metadata: rollout session metadata id mismatch: expected {thread_id}, found {}", + session_meta.meta.id + ), + }); + } + Ok(session_meta.meta.history_mode) +} + fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool { if patch.name.is_some() { return true; @@ -431,7 +497,6 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { || patch.permission_profile.is_some() || patch.token_usage.is_some() || patch.first_user_message.is_some() - || patch.history_mode.is_some() } fn enum_to_string(value: &T) -> String { @@ -1475,6 +1540,51 @@ mod tests { assert!(metadata.is_none()); } + #[tokio::test] + async fn observed_metadata_rebuilds_history_mode_from_canonical_session_meta() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let uuid = Uuid::from_u128(317); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + write_session_file_with_history_mode( + home.path(), + "2025-01-03T19-15-00", + uuid, + ThreadHistoryMode::Paginated, + ) + .expect("session file"); + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime.clone())); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + preview: Some("Paginated preview".to_string()), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("update paginated thread without sqlite row"); + + assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated); + assert_eq!( + runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata") + .history_mode, + ThreadHistoryMode::Paginated + ); + } + #[tokio::test] async fn update_thread_metadata_recreates_missing_archived_sqlite_row_as_archived() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index a7b52ee0c..1057715a4 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -76,7 +76,6 @@ impl ThreadMetadataSync { cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), git_info: git_info.map(git_info_patch_from_observation), memory_mode: Some(params.metadata.memory_mode), - history_mode: Some(params.history_mode), ..Default::default() }; Self { @@ -238,7 +237,6 @@ impl ThreadMetadataSync { { update.memory_mode = Some(memory_mode); } - update.history_mode = Some(meta_line.meta.history_mode); } RolloutItem::TurnContext(turn_ctx) => { if !self.cwd_seen { @@ -377,7 +375,6 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.first_user_message.is_some() || update.git_info.is_some() || update.memory_mode.is_some() - || update.history_mode.is_some() } fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch { diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 3b86840ca..4e688eca6 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -596,8 +596,6 @@ pub struct ThreadMetadataPatch { pub git_info: Option, /// Thread memory behavior. pub memory_mode: Option, - /// Persisted thread history contract. - pub history_mode: Option, } impl ThreadMetadataPatch { @@ -678,9 +676,6 @@ impl ThreadMetadataPatch { if next.memory_mode.is_some() { self.memory_mode = next.memory_mode; } - if next.history_mode.is_some() { - self.history_mode = next.history_mode; - } } pub fn is_empty(&self) -> bool { @@ -707,7 +702,6 @@ impl ThreadMetadataPatch { && self.first_user_message.is_none() && self.git_info.is_none() && self.memory_mode.is_none() - && self.history_mode.is_none() } }