diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 405e6ca3d..c3573a99a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -97,6 +97,7 @@ use crate::protocol::Submission; use crate::protocol::TokenCountEvent; use crate::protocol::TokenUsage; use crate::protocol::TurnDiffEvent; +use crate::protocol::WarningEvent; use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::shell; @@ -674,6 +675,34 @@ impl Session { let rollout_items = conversation_history.get_rollout_items(); let persist = matches!(conversation_history, InitialHistory::Forked(_)); + // If resuming, warn when the last recorded model differs from the current one. + if let InitialHistory::Resumed(_) = conversation_history + && let Some(prev) = rollout_items.iter().rev().find_map(|it| { + if let RolloutItem::TurnContext(ctx) = it { + Some(ctx.model.as_str()) + } else { + None + } + }) + { + let curr = turn_context.client.get_model(); + if prev != curr { + warn!( + "resuming session with different model: previous={prev}, current={curr}" + ); + self.send_event( + &turn_context, + EventMsg::Warning(WarningEvent { + message: format!( + "This session was recorded with model `{prev}` but is resuming with `{curr}`. \ + Consider switching back to `{prev}` as it may affect Codex performance." + ), + }), + ) + .await; + } + } + // Always add response items to conversation history let reconstructed_history = self.reconstruct_history_from_rollout(&turn_context, &rollout_items); diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs new file mode 100644 index 000000000..c8376e410 --- /dev/null +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -0,0 +1,70 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use codex_core::AuthManager; +use codex_core::CodexAuth; +use codex_core::ConversationManager; +use codex_core::NewConversation; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InitialHistory; +use codex_core::protocol::ResumedHistory; +use codex_core::protocol::RolloutItem; +use codex_core::protocol::TurnContextItem; +use codex_core::protocol::WarningEvent; +use codex_protocol::ConversationId; +use core::time::Duration; +use core_test_support::load_default_config_for_test; +use core_test_support::wait_for_event; +use tempfile::TempDir; + +fn resume_history(config: &codex_core::config::Config, previous_model: &str, rollout_path: &std::path::Path) -> InitialHistory { + let turn_ctx = TurnContextItem { + cwd: config.cwd.clone(), + approval_policy: config.approval_policy, + sandbox_policy: config.sandbox_policy.clone(), + model: previous_model.to_string(), + effort: config.model_reasoning_effort, + summary: config.model_reasoning_summary, + }; + + InitialHistory::Resumed(ResumedHistory { + conversation_id: ConversationId::default(), + history: vec![RolloutItem::TurnContext(turn_ctx)], + rollout_path: rollout_path.to_path_buf(), + }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_warning_when_resumed_model_differs() { + // Arrange a config with a current model and a prior rollout recorded under a different model. + let home = TempDir::new().expect("tempdir"); + let mut config = load_default_config_for_test(&home); + config.model = "current-model".to_string(); + // Ensure cwd is absolute (the helper sets it to the temp dir already). + assert!(config.cwd.is_absolute()); + + let rollout_path = home.path().join("rollout.jsonl"); + std::fs::write(&rollout_path, "").expect("create rollout placeholder"); + + let initial_history = resume_history(&config, "previous-model", &rollout_path); + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + + // Act: resume the conversation. + let NewConversation { conversation, .. } = conversation_manager + .resume_conversation_with_history(config, initial_history, auth_manager) + .await + .expect("resume conversation"); + + // Assert: a Warning event is emitted describing the model mismatch. + let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await; + let EventMsg::Warning(WarningEvent { message }) = warning else { + panic!("expected warning event"); + }; + assert!(message.contains("previous-model")); + assert!(message.contains("current-model")); + + // Drain the TaskComplete/Shutdown window to avoid leaking tasks between tests. + // The warning is emitted during initialization, so a short sleep is sufficient. + tokio::time::sleep(Duration::from_millis(50)).await; +}