diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 915b599fa5..55724a8c17 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -946,6 +946,9 @@ impl App { (chat_widget, None) } SessionSelection::Resume(target_session) => { + if let Some(history_mode) = target_session.history_mode { + app_server.remember_thread_history_mode(target_session.thread_id, history_mode); + } let model_settings = config_persistence::resume_model_settings_for_overrides( &config, &harness_overrides, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 5b09fab403..9c27f0ee15 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -1011,6 +1011,9 @@ impl App { self.chat_widget.thread_name(), self.chat_widget.rollout_path().as_deref(), ); + if let Some(history_mode) = target_session.history_mode { + app_server.remember_thread_history_mode(target_session.thread_id, history_mode); + } match app_server .resume_thread( resume_config.clone(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2656614cb0..17e4b963aa 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5820,6 +5820,7 @@ fn session_start_error_surfaces_archived_guidance_without_rollout_path() { "/Users/me/.codex/archived_sessions/rollout.jsonl", )), thread_id, + history_mode: None, }; let expected = format!( "session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first." @@ -6123,6 +6124,7 @@ async fn remote_resume_current_cwd_rejection_snapshot() -> Result<()> { crate::resume_picker::SessionTarget { path: None, thread_id: ThreadId::new(), + history_mode: None, }, ) .await?; @@ -6165,6 +6167,7 @@ async fn remote_exec_resume_current_cwd_is_rejected() -> Result<()> { crate::resume_picker::SessionTarget { path: None, thread_id: ThreadId::new(), + history_mode: None, }, ) .await?; @@ -6202,6 +6205,7 @@ async fn in_app_resume_session_cwd_without_metadata_is_non_fatal() -> Result<()> crate::resume_picker::SessionTarget { path: None, thread_id: ThreadId::new(), + history_mode: None, }, ) .await?; @@ -6264,6 +6268,7 @@ async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> crate::resume_picker::SessionTarget { path: Some(rollout_path), thread_id: ThreadId::from_string(&thread_id)?, + history_mode: None, }, ) .await?; @@ -6382,6 +6387,7 @@ async fn in_app_resume_uses_configured_or_explicit_cwd() -> Result<()> { crate::resume_picker::SessionTarget { path: Some(rollout_path), thread_id, + history_mode: None, }, ) .await?; @@ -6494,6 +6500,7 @@ async fn remembered_current_cwd_stays_at_launch_across_in_app_resumes() -> Resul targets.push(crate::resume_picker::SessionTarget { path: Some(rollout_path), thread_id: ThreadId::from_string(&thread_id)?, + history_mode: None, }); } let state_db = diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index aaaac62a12..234275913a 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1064,6 +1064,7 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> { ) .await?; + app_server.remember_thread_history_mode(paginated_thread_id, ThreadHistoryMode::Legacy); let resumed = app_server .resume_thread( app.config.clone(), @@ -1072,6 +1073,10 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> { ) .await?; assert_eq!(resumed.session.thread_id, paginated_thread_id); + assert!(recorded_params(&requests, "thread/read").is_empty()); + let resume_requests = recorded_params(&requests, "thread/resume"); + assert_eq!(resume_requests.len(), 1); + assert_eq!(resume_requests[0]["excludeTurns"], true); let cells = crate::thread_transcript::load_session_transcript( &mut app_server, paginated_thread_id, @@ -1542,6 +1547,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { crate::resume_picker::SessionTarget { path: Some(root_rollout_path), thread_id: root_thread_id, + history_mode: None, }, ) .await?; diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 193ea5aa12..ffbdfc3b41 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -19,6 +19,7 @@ fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { crate::resume_picker::SessionTarget { path: Some(PathBuf::from("/tmp/restore")), thread_id: ThreadId::new(), + history_mode: None, } )), false @@ -28,6 +29,7 @@ fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { crate::resume_picker::SessionTarget { path: Some(PathBuf::from("/tmp/fork")), thread_id: ThreadId::new(), + history_mode: None, } )), false @@ -39,10 +41,12 @@ fn startup_paused_goal_prompt_gate_is_only_for_quiet_resume() { let resume = SessionSelection::Resume(crate::resume_picker::SessionTarget { path: Some(PathBuf::from("/tmp/restore")), thread_id: ThreadId::new(), + history_mode: None, }); let fork = SessionSelection::Fork(crate::resume_picker::SessionTarget { path: Some(PathBuf::from("/tmp/fork")), thread_id: ThreadId::new(), + history_mode: None, }); let no_images: Vec = Vec::new(); let initial_images = vec![PathBuf::from("/tmp/image.png")]; @@ -111,6 +115,7 @@ fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() { crate::resume_picker::SessionTarget { path: Some(PathBuf::from("/tmp/restore")), thread_id: ThreadId::new(), + history_mode: None, }, )); assert_eq!( @@ -124,6 +129,7 @@ fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() { crate::resume_picker::SessionTarget { path: Some(PathBuf::from("/tmp/fork")), thread_id: ThreadId::new(), + history_mode: None, }, )); assert_eq!( @@ -268,6 +274,7 @@ async fn ignore_same_thread_resume_reports_noop_for_current_thread() { let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget { path: Some(test_path_buf("/tmp/project")), thread_id, + history_mode: None, }); assert!(ignored); @@ -292,6 +299,7 @@ async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread( let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget { path: Some(test_path_buf("/tmp/project")), thread_id, + history_mode: None, }); assert!(!ignored); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4149dac6ee..515c653696 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -5,6 +5,7 @@ mod fs; mod history; +mod rollout_history; pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT; pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT; @@ -265,6 +266,7 @@ pub(crate) struct AppServerSession { history_pagination: HashMap, remote_cwd_override: Option, thread_params_mode: ThreadParamsMode, + background_rollout_migration_enabled: bool, history_support: ThreadHistorySupport, thread_settings_update_supported: bool, default_model: Option, @@ -339,6 +341,7 @@ impl AppServerSession { history_pagination: HashMap::new(), remote_cwd_override: None, thread_params_mode, + background_rollout_migration_enabled: true, history_support: ThreadHistorySupport::Paginated, thread_settings_update_supported: true, default_model: None, @@ -616,77 +619,6 @@ impl AppServerSession { started_thread_from_start_response(response, config, self.thread_params_mode()).await } - pub(crate) async fn resume_thread( - &mut self, - config: Config, - thread_id: ThreadId, - model_settings: ResumeModelSettings, - ) -> Result { - let request_id = self.next_request_id(); - let session_config = if model_settings == ResumeModelSettings::RestoreFromThread { - config.clone() - } else { - self.session_config_with_effective_service_tier(&config) - }; - let mut params = thread_resume_params_from_config( - session_config, - thread_id, - self.thread_params_mode(), - self.remote_cwd_override.as_deref(), - model_settings, - ); - params.exclude_turns = self.history_support == ThreadHistorySupport::Paginated - && self - .history_pagination - .get(&thread_id) - .is_none_or(|state| state.history_mode == ThreadHistoryMode::Paginated); - let mut response: ThreadResumeResponse = match self - .client - .request_typed(ClientRequest::ThreadResume { - request_id, - params: params.clone(), - }) - .await - { - Ok(response) => response, - Err(TypedRequestError::Server { source, .. }) - if params.exclude_turns && is_history_pagination_unsupported(&source) => - { - self.history_support = ThreadHistorySupport::LegacyOnly; - params.exclude_turns = false; - let request_id = self.next_request_id(); - self.client - .request_typed(ClientRequest::ThreadResume { request_id, params }) - .await - .map_err(|err| { - bootstrap_request_error("thread/resume failed during TUI bootstrap", err) - })? - } - Err(err) => { - return Err(bootstrap_request_error( - "thread/resume failed during TUI bootstrap", - err, - )); - } - }; - self.hydrate_initial_thread_history( - &mut response.thread, - response.turns_backwards_cursor.clone(), - response.items_backwards_cursor.clone(), - Some(&config), - HistoryHydrationScope::Initial, - ) - .await?; - let fork_parent_title = self - .fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref()) - .await; - let mut started = - started_thread_from_resume_response(response, &config, self.thread_params_mode()) - .await?; - started.session.fork_parent_title = fork_parent_title; - Ok(started) - } - pub(crate) async fn fork_thread( &mut self, config: Config, diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs new file mode 100644 index 0000000000..94d3f37ca5 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -0,0 +1,150 @@ +//! Resume history policy for local rollouts that may migrate between formats. + +use super::AppServerSession; +use super::AppServerStartedThread; +use super::HistoryHydrationScope; +use super::ResumeModelSettings; +use super::ThreadHistorySupport; +use super::bootstrap_request_error; +use super::is_history_pagination_unsupported; +use super::started_thread_from_resume_response; +use super::thread_resume_params_from_config; +use crate::legacy_core::config::Config; +use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ThreadHistoryMode; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_features::Feature; +use codex_protocol::ThreadId; +use color_eyre::eyre::Result; + +impl AppServerSession { + /// Captures the server's startup migration policy before workspace config can change. + /// + /// Sessions without a recorded startup config conservatively assume migration is enabled. + pub(crate) fn with_startup_config(mut self, config: &Config) -> Self { + self.background_rollout_migration_enabled = config + .features + .enabled(Feature::BackgroundPaginatedRolloutMigration); + self + } + + /// Retains trusted embedded-server history metadata for a later resume. + /// + /// Remote hints are ignored because another process can migrate the thread before resume. + pub(crate) fn remember_thread_history_mode( + &mut self, + thread_id: ThreadId, + history_mode: ThreadHistoryMode, + ) { + if !self.uses_embedded_app_server() { + return; + } + self.history_pagination + .entry(thread_id) + .or_default() + .history_mode = history_mode; + } + + pub(crate) async fn resume_thread( + &mut self, + config: Config, + thread_id: ThreadId, + model_settings: ResumeModelSettings, + ) -> Result { + let session_config = if model_settings == ResumeModelSettings::RestoreFromThread { + config.clone() + } else { + self.session_config_with_effective_service_tier(&config) + }; + let mut params = thread_resume_params_from_config( + session_config, + thread_id, + self.thread_params_mode(), + self.remote_cwd_override.as_deref(), + model_settings, + ); + let mut rollout_maintenance_guard = None; + params.exclude_turns = if self.history_support == ThreadHistorySupport::Paginated { + let known_legacy_history = self + .history_pagination + .get(&thread_id) + .is_some_and(|state| state.history_mode == ThreadHistoryMode::Legacy) + && (!self.uses_embedded_app_server() + || (!self.background_rollout_migration_enabled + && !config + .features + .enabled(Feature::BackgroundPaginatedRolloutMigration) + && { + rollout_maintenance_guard = + codex_rollout::try_acquire_rollout_maintenance_lock( + config.codex_home.as_path(), + ) + .ok() + .flatten(); + rollout_maintenance_guard.is_some() + } + && self + .thread_read(thread_id, /*include_turns*/ false) + .await + .is_ok_and(|thread| thread.history_mode == ThreadHistoryMode::Legacy))); + !known_legacy_history + } else { + false + }; + if params.exclude_turns { + rollout_maintenance_guard = None; + } + let request_id = self.next_request_id(); + let resume_response = self + .client + .request_typed(ClientRequest::ThreadResume { + request_id, + params: params.clone(), + }) + .await; + drop(rollout_maintenance_guard); + let mut response: ThreadResumeResponse = match resume_response { + Ok(response) => response, + Err(TypedRequestError::Server { source, .. }) + if params.exclude_turns && is_history_pagination_unsupported(&source) => + { + self.history_support = ThreadHistorySupport::LegacyOnly; + params.exclude_turns = false; + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadResume { request_id, params }) + .await + .map_err(|err| { + bootstrap_request_error("thread/resume failed during TUI bootstrap", err) + })? + } + Err(err) => { + return Err(bootstrap_request_error( + "thread/resume failed during TUI bootstrap", + err, + )); + } + }; + self.hydrate_initial_thread_history( + &mut response.thread, + response.turns_backwards_cursor.clone(), + response.items_backwards_cursor.clone(), + Some(&config), + HistoryHydrationScope::Initial, + ) + .await?; + let fork_parent_title = self + .fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref()) + .await; + let mut started = + started_thread_from_resume_response(response, &config, self.thread_params_mode()) + .await?; + started.session.fork_parent_title = fork_parent_title; + Ok(started) + } +} + +#[cfg(test)] +#[path = "rollout_history_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs new file mode 100644 index 0000000000..0fac8e9ac8 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs @@ -0,0 +1,225 @@ +use super::super::ResumeModelSettings; +use crate::legacy_core::config::Config; +use crate::legacy_core::config::ConfigBuilder; +use app_test_support::create_fake_paginated_rollout; +use app_test_support::create_fake_rollout; +use codex_app_server_protocol::ThreadHistoryMode; +use codex_features::Feature; +use codex_protocol::ThreadId; +use color_eyre::eyre::Result; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +async fn build_config(temp_dir: &TempDir) -> Config { + ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await + .expect("config should build") +} + +#[tokio::test] +async fn legacy_resume_preserves_history_mode_after_picker_server_replacement() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let config = build_config(&codex_home).await; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + let mut picker_app_server = crate::start_embedded_app_server_for_picker(&config).await?; + let history_mode = picker_app_server + .thread_read(thread_id, /*include_turns*/ false) + .await? + .history_mode; + picker_app_server.shutdown().await?; + + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + app_server.remember_thread_history_mode(thread_id, history_mode); + let next_request_id = app_server.next_request_id; + let resumed = app_server + .resume_thread(config, thread_id, ResumeModelSettings::RestoreFromThread) + .await?; + + assert_eq!(app_server.next_request_id, next_request_id + 2); + assert!(!resumed.turns.is_empty()); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn background_migration_disables_cached_legacy_resume_shortcut() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let config = build_config(&codex_home).await; + let legacy_thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved legacy user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create legacy rollout"), + )?; + let thread_id = ThreadId::from_string( + &create_fake_paginated_rollout( + codex_home.path(), + "2025-01-05T12-00-01", + "2025-01-05T12:00:01Z", + "Saved paginated user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create paginated rollout"), + )?; + let mut migration_config = config.clone(); + migration_config + .features + .enable(Feature::BackgroundPaginatedRolloutMigration)?; + + for (startup_config, resume_config) in + [(&config, &migration_config), (&migration_config, &config)] + { + // Keep the real worker disabled while exercising both startup/request mismatches. + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await? + .with_startup_config(startup_config); + app_server.remember_thread_history_mode(legacy_thread_id, ThreadHistoryMode::Legacy); + let next_request_id = app_server.next_request_id; + let legacy = app_server + .resume_thread( + resume_config.clone(), + legacy_thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(app_server.next_request_id, next_request_id + 2); + assert!(!legacy.turns.is_empty()); + + app_server.remember_thread_history_mode(thread_id, ThreadHistoryMode::Legacy); + let next_request_id = app_server.next_request_id; + + let resumed = app_server + .resume_thread( + resume_config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + + assert!(app_server.next_request_id > next_request_id + 1); + assert_eq!(resumed.session.thread_id, thread_id); + assert_eq!( + app_server + .history_pagination + .get(&thread_id) + .map(|state| state.history_mode), + Some(ThreadHistoryMode::Paginated) + ); + app_server.shutdown().await?; + } + + Ok(()) +} + +#[tokio::test] +async fn rollout_maintenance_contention_disables_cached_legacy_resume_shortcut() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let config = build_config(&codex_home).await; + let thread_id = ThreadId::from_string( + &create_fake_paginated_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved paginated user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create paginated rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + app_server.remember_thread_history_mode(thread_id, ThreadHistoryMode::Legacy); + let _maintenance_guard = + codex_rollout::try_acquire_rollout_maintenance_lock(codex_home.path())? + .expect("acquire rollout maintenance lock"); + let next_request_id = app_server.next_request_id; + + let resumed = app_server + .resume_thread(config, thread_id, ResumeModelSettings::RestoreFromThread) + .await?; + + assert_eq!(app_server.next_request_id, next_request_id + 3); + assert_eq!(resumed.session.thread_id, thread_id); + assert_eq!( + app_server + .history_pagination + .get(&thread_id) + .map(|state| state.history_mode), + Some(ThreadHistoryMode::Paginated) + ); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn stale_legacy_history_mode_is_revalidated_before_resume() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let config = build_config(&codex_home).await; + let thread_id = ThreadId::from_string( + &create_fake_paginated_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved paginated user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create paginated rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + app_server.remember_thread_history_mode(thread_id, ThreadHistoryMode::Legacy); + let next_request_id = app_server.next_request_id; + + let resumed = app_server + .resume_thread( + config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(resumed.session.thread_id, thread_id); + assert!(app_server.next_request_id >= next_request_id + 4); + assert_eq!( + app_server + .history_pagination + .get(&thread_id) + .map(|state| state.history_mode), + Some(ThreadHistoryMode::Paginated) + ); + + let missing_thread_id = ThreadId::new(); + app_server.remember_thread_history_mode(missing_thread_id, ThreadHistoryMode::Legacy); + let next_request_id = app_server.next_request_id; + assert!( + app_server + .resume_thread( + config, + missing_thread_id, + ResumeModelSettings::RestoreFromThread + ) + .await + .is_err() + ); + assert_eq!(app_server.next_request_id, next_request_id + 2); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a77d13e30f..233d3efdb1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -511,10 +511,7 @@ pub(crate) async fn start_app_server_for_picker( environment_manager, ) .await?; - Ok(AppServerSession::new( - app_server, - target.thread_params_mode(), - )) + Ok(AppServerSession::new(app_server, target.thread_params_mode()).with_startup_config(config)) } #[cfg(test)] @@ -601,6 +598,7 @@ fn session_target_from_app_server_thread( Ok(thread_id) => Some(resume_picker::SessionTarget { path: thread.path, thread_id, + history_mode: Some(thread.history_mode), }), Err(err) => { warn!( @@ -1384,7 +1382,8 @@ async fn run_ratatui_app( ) .await { - Ok(app_server) => AppServerSession::new(app_server, app_server_target.thread_params_mode()), + Ok(app_server) => AppServerSession::new(app_server, app_server_target.thread_params_mode()) + .with_startup_config(&initial_config), Err(err) => { terminal_restore_guard.restore_silently(); session_log::log_session_end(); @@ -1751,6 +1750,7 @@ async fn run_ratatui_app( { Ok(app_server) => { AppServerSession::new(app_server, app_server_target.thread_params_mode()) + .with_startup_config(&config) .with_remote_cwd_override(remote_cwd_override.clone()) } Err(err) => { @@ -2277,6 +2277,7 @@ mod tests { let target_session = resume_picker::SessionTarget { path: Some(rollout_path), thread_id, + history_mode: None, }; let session_selection = match action { CwdPromptAction::Resume => resume_picker::SessionSelection::Resume(target_session), @@ -2364,6 +2365,7 @@ mod tests { &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { path: None, thread_id: ThreadId::new(), + history_mode: None, }), /*cwd_override*/ None, /*uses_remote_workspace*/ false, @@ -2400,6 +2402,7 @@ mod tests { &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { path: None, thread_id: ThreadId::new(), + history_mode: None, }), /*cwd_override*/ None, /*uses_remote_workspace*/ false, @@ -2441,6 +2444,7 @@ mod tests { let target = crate::resume_picker::SessionTarget { path: None, thread_id, + history_mode: None, }; assert_eq!(target.display_label(), format!("thread {thread_id}")); diff --git a/codex-rs/tui/src/named_session_lookup.rs b/codex-rs/tui/src/named_session_lookup.rs index e1ab21bc8d..1fbadade78 100644 --- a/codex-rs/tui/src/named_session_lookup.rs +++ b/codex-rs/tui/src/named_session_lookup.rs @@ -131,6 +131,7 @@ async fn lookup_legacy_index_target( return Ok(Some(SessionTarget { path: Some(path), thread_id: session_meta.meta.id, + history_mode: Some(thread.history_mode), })); } Ok(None) diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 3b231cae76..c031cc3fb8 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -104,6 +104,8 @@ const PICKER_LIST_HORIZONTAL_INSET: u16 = 4; pub struct SessionTarget { pub path: Option, pub thread_id: ThreadId, + /// History mode observed during selection, if the server provided one. + pub history_mode: Option, } impl SessionTarget { @@ -150,8 +152,7 @@ impl SessionPickerAction { } } - fn selection(self, path: Option, thread_id: ThreadId) -> SessionSelection { - let target_session = SessionTarget { path, thread_id }; + fn selection(self, target_session: SessionTarget) -> SessionSelection { match self { SessionPickerAction::Resume => SessionSelection::Resume(target_session), SessionPickerAction::Fork => SessionSelection::Fork(target_session), @@ -315,6 +316,7 @@ enum PageCursor { struct PickerPage { rows: Vec, + history_modes: HashMap, next_cursor: Option, num_scanned_files: usize, reached_scan_cap: bool, @@ -724,6 +726,7 @@ fn spawn_app_server_page_loader( .map(|response| SessionTarget { path: response.thread.path, thread_id, + history_mode: Some(response.thread.history_mode), }) .map_err(std::io::Error::other); let _ = bg_tx.send(BackgroundEvent::Unarchive { thread_id, result }); @@ -774,6 +777,7 @@ struct PickerState { pagination: PaginationState, all_rows: Vec, filtered_rows: Vec, + thread_history_modes: HashMap, seen_rows: HashSet, selected: usize, scroll_top: usize, @@ -858,13 +862,20 @@ async fn load_app_server_page( .await .map_err(std::io::Error::other)?; let num_scanned_files = response.data.len(); + let (rows, history_modes): (Vec<_>, HashMap<_, _>) = response + .data + .into_iter() + .filter_map(|thread| { + let history_mode = thread.history_mode; + let row = row_from_app_server_thread(thread)?; + let thread_id = row.thread_id?; + Some((row, (thread_id, history_mode))) + }) + .unzip(); Ok(PickerPage { - rows: response - .data - .into_iter() - .filter_map(row_from_app_server_thread) - .collect(), + rows, + history_modes, next_cursor: response.next_cursor.map(PageCursor::AppServer), num_scanned_files, reached_scan_cap: false, @@ -1101,6 +1112,7 @@ impl PickerState { pagination: PaginationState::new(), all_rows: Vec::new(), filtered_rows: Vec::new(), + thread_history_modes: HashMap::new(), seen_rows: HashSet::new(), selected: 0, scroll_top: 0, @@ -1362,7 +1374,11 @@ impl PickerState { self.request_unarchive(thread_id); return Ok(None); } - return Ok(Some(self.action.selection(path, thread_id))); + return Ok(Some(self.action.selection(SessionTarget { + path, + thread_id, + history_mode: self.thread_history_modes.get(&thread_id).copied(), + }))); } self.inline_error = Some(match path { Some(path) => { @@ -1497,6 +1513,7 @@ impl PickerState { self.reset_pagination(); self.all_rows.clear(); self.filtered_rows.clear(); + self.thread_history_modes.clear(); self.seen_rows.clear(); self.selected = 0; self.pending_page_down_target = None; @@ -1629,12 +1646,14 @@ impl PickerState { fn ingest_page(&mut self, page: PickerPage) { let PickerPage { rows, + history_modes, next_cursor, num_scanned_files, reached_scan_cap, } = page; self.pagination .complete_page(next_cursor, num_scanned_files, reached_scan_cap); + self.thread_history_modes.extend(history_modes); for row in rows { if let Some(seen_key) = row.seen_key() { @@ -3573,6 +3592,7 @@ mod tests { ) -> PickerPage { PickerPage { rows, + history_modes: HashMap::new(), next_cursor: next_cursor.map(|cursor| PageCursor::AppServer(cursor.to_string())), num_scanned_files, reached_scan_cap, @@ -3843,12 +3863,12 @@ mod tests { "indexed metadata", ); row.thread_id = Some(thread_id); - deliver_page( - &mut state, - &db_request, - ok_page(vec![row], /*next_cursor*/ None), - ) - .await; + let mut listed_page = ok_page(vec![row], /*next_cursor*/ None) + .expect("indexed thread page should be available"); + listed_page + .history_modes + .insert(thread_id, ThreadHistoryMode::Legacy); + deliver_page(&mut state, &db_request, Ok(listed_page)).await; let selection = state .handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) @@ -3858,6 +3878,7 @@ mod tests { selection, Some(SessionSelection::Resume(SessionTarget { thread_id: selected_thread_id, + history_mode: Some(ThreadHistoryMode::Legacy), .. })) if selected_thread_id == thread_id )); @@ -6342,6 +6363,7 @@ session_picker_view = "dense" Some(SessionSelection::Resume(SessionTarget { path: None, thread_id: selected_thread_id, + history_mode: None, })) => assert_eq!(selected_thread_id, thread_id), other => panic!("unexpected selection: {other:?}"), } diff --git a/codex-rs/tui/src/resume_picker/archive_tests.rs b/codex-rs/tui/src/resume_picker/archive_tests.rs index 51f0d61c11..abbf48935e 100644 --- a/codex-rs/tui/src/resume_picker/archive_tests.rs +++ b/codex-rs/tui/src/resume_picker/archive_tests.rs @@ -256,6 +256,7 @@ async fn archived_session_restore_resumes_after_completion() { Ok(SessionTarget { path: None, thread_id, + history_mode: None, }), ); @@ -264,6 +265,7 @@ async fn archived_session_restore_resumes_after_completion() { Some(SessionSelection::Resume(SessionTarget { path: None, thread_id: resumed_thread_id, + history_mode: None, })) if resumed_thread_id == thread_id )); assert_eq!(state.archive_state, ArchiveState::Idle); diff --git a/codex-rs/tui/src/session_resume.rs b/codex-rs/tui/src/session_resume.rs index 4d84497e52..445c1f4cc6 100644 --- a/codex-rs/tui/src/session_resume.rs +++ b/codex-rs/tui/src/session_resume.rs @@ -502,6 +502,7 @@ mod tests { &SessionTarget { path: Some(rollout_path.clone()), thread_id, + history_mode: None, }, CwdPromptAction::Fork, ResumeCwdContext { @@ -535,6 +536,7 @@ mod tests { &SessionTarget { path: None, thread_id: ThreadId::new(), + history_mode: None, }, CwdPromptAction::Resume, ResumeCwdContext {