diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 52ea9325f1..f7d108f55f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -215,6 +215,23 @@ pub async fn run_main( .await { Ok(config) => { + let effective_toml = config.config_layer_stack.effective_config(); + match effective_toml.try_into() { + Ok(config_toml) => { + if let Err(err) = codex_core::personality_migration::maybe_migrate_personality( + &config.codex_home, + &config_toml, + ) + .await + { + warn!(error = %err, "Failed to run personality migration"); + } + } + Err(err) => { + warn!(error = %err, "Failed to deserialize config for personality migration"); + } + } + let auth_manager = AuthManager::shared( config.codex_home.clone(), false, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ba47838e1f..75b53624f4 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -48,6 +48,7 @@ mod message_history; mod model_provider_info; pub mod parse_command; pub mod path_utils; +pub mod personality_migration; pub mod powershell; mod proposed_plan_parser; pub mod sandboxing; diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs new file mode 100644 index 0000000000..cfe17c1711 --- /dev/null +++ b/codex-rs/core/src/personality_migration.rs @@ -0,0 +1,265 @@ +use crate::config::ConfigToml; +use crate::config::edit::ConfigEditsBuilder; +use crate::rollout::ARCHIVED_SESSIONS_SUBDIR; +use crate::rollout::SESSIONS_SUBDIR; +use crate::rollout::list::ThreadListConfig; +use crate::rollout::list::ThreadListLayout; +use crate::rollout::list::ThreadSortKey; +use crate::rollout::list::get_threads_in_root; +use crate::state_db; +use codex_protocol::config_types::Personality; +use codex_protocol::protocol::SessionSource; +use std::io; +use std::path::Path; +use tokio::fs::OpenOptions; +use tokio::io::AsyncWriteExt; + +pub const PERSONALITY_MIGRATION_FILENAME: &str = ".personality_migration"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersonalityMigrationStatus { + SkippedMarker, + SkippedExplicitPersonality, + SkippedNoSessions, + Applied, +} + +pub async fn maybe_migrate_personality( + codex_home: &Path, + config_toml: &ConfigToml, +) -> io::Result { + let marker_path = codex_home.join(PERSONALITY_MIGRATION_FILENAME); + if tokio::fs::try_exists(&marker_path).await? { + return Ok(PersonalityMigrationStatus::SkippedMarker); + } + + let config_profile = config_toml + .get_config_profile(None) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + if config_toml.model_personality.is_some() || config_profile.model_personality.is_some() { + create_marker(&marker_path).await?; + return Ok(PersonalityMigrationStatus::SkippedExplicitPersonality); + } + + let model_provider_id = config_profile + .model_provider + .or_else(|| config_toml.model_provider.clone()) + .unwrap_or_else(|| "openai".to_string()); + + if !has_recorded_sessions(codex_home, model_provider_id.as_str()).await? { + create_marker(&marker_path).await?; + return Ok(PersonalityMigrationStatus::SkippedNoSessions); + } + + ConfigEditsBuilder::new(codex_home) + .set_model_personality(Some(Personality::Pragmatic)) + .apply() + .await + .map_err(|err| { + io::Error::other(format!("failed to persist personality migration: {err}")) + })?; + + create_marker(&marker_path).await?; + Ok(PersonalityMigrationStatus::Applied) +} + +async fn has_recorded_sessions(codex_home: &Path, default_provider: &str) -> io::Result { + let allowed_sources: &[SessionSource] = &[]; + + if let Some(state_db_ctx) = state_db::open_if_present(codex_home, default_provider).await + && let Some(ids) = state_db::list_thread_ids_db( + Some(state_db_ctx.as_ref()), + codex_home, + 1, + None, + ThreadSortKey::CreatedAt, + allowed_sources, + None, + false, + "personality_migration", + ) + .await + && !ids.is_empty() + { + return Ok(true); + } + + let sessions = get_threads_in_root( + codex_home.join(SESSIONS_SUBDIR), + 1, + None, + ThreadSortKey::CreatedAt, + ThreadListConfig { + allowed_sources, + model_providers: None, + default_provider, + layout: ThreadListLayout::NestedByDate, + }, + ) + .await?; + if !sessions.items.is_empty() { + return Ok(true); + } + + let archived_sessions = get_threads_in_root( + codex_home.join(ARCHIVED_SESSIONS_SUBDIR), + 1, + None, + ThreadSortKey::CreatedAt, + ThreadListConfig { + allowed_sources, + model_providers: None, + default_provider, + layout: ThreadListLayout::Flat, + }, + ) + .await?; + Ok(!archived_sessions.items.is_empty()) +} + +async fn create_marker(marker_path: &Path) -> io::Result<()> { + match OpenOptions::new() + .create_new(true) + .write(true) + .open(marker_path) + .await + { + Ok(mut file) => file.write_all(b"v1\n").await, + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(()), + Err(err) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::ThreadId; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMeta; + use codex_protocol::protocol::SessionMetaLine; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::UserMessageEvent; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use tokio::io::AsyncWriteExt; + + const TEST_TIMESTAMP: &str = "2025-01-01T00-00-00"; + + async fn read_config_toml(codex_home: &Path) -> io::Result { + let contents = tokio::fs::read_to_string(codex_home.join("config.toml")).await?; + toml::from_str(&contents).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + } + + async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> { + let thread_id = ThreadId::new(); + let dir = codex_home + .join(SESSIONS_SUBDIR) + .join("2025") + .join("01") + .join("01"); + tokio::fs::create_dir_all(&dir).await?; + let file_path = dir.join(format!("rollout-{TEST_TIMESTAMP}-{thread_id}.jsonl")); + let mut file = tokio::fs::File::create(&file_path).await?; + + let session_meta = SessionMetaLine { + meta: SessionMeta { + id: thread_id, + forked_from_id: None, + timestamp: TEST_TIMESTAMP.to_string(), + cwd: std::path::PathBuf::from("."), + originator: "test_originator".to_string(), + cli_version: "test_version".to_string(), + source: SessionSource::Cli, + model_provider: None, + base_instructions: None, + dynamic_tools: None, + }, + git: None, + }; + let meta_line = RolloutLine { + timestamp: TEST_TIMESTAMP.to_string(), + item: RolloutItem::SessionMeta(session_meta), + }; + let user_event = RolloutLine { + timestamp: TEST_TIMESTAMP.to_string(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + }; + + file.write_all(format!("{}\n", serde_json::to_string(&meta_line)?).as_bytes()) + .await?; + file.write_all(format!("{}\n", serde_json::to_string(&user_event)?).as_bytes()) + .await?; + Ok(()) + } + + #[tokio::test] + async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> { + let temp = TempDir::new()?; + write_session_with_user_event(temp.path()).await?; + + let config_toml = ConfigToml::default(); + let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + + assert_eq!(status, PersonalityMigrationStatus::Applied); + assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); + + let persisted = read_config_toml(temp.path()).await?; + assert_eq!(persisted.model_personality, Some(Personality::Pragmatic)); + Ok(()) + } + + #[tokio::test] + async fn skips_when_marker_exists() -> io::Result<()> { + let temp = TempDir::new()?; + create_marker(&temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?; + + let config_toml = ConfigToml::default(); + let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + + assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); + assert!(!temp.path().join("config.toml").exists()); + Ok(()) + } + + #[tokio::test] + async fn skips_when_personality_explicit() -> io::Result<()> { + let temp = TempDir::new()?; + ConfigEditsBuilder::new(temp.path()) + .set_model_personality(Some(Personality::Friendly)) + .apply() + .await + .map_err(|err| io::Error::other(format!("failed to write config: {err}")))?; + + let config_toml = read_config_toml(temp.path()).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + + assert_eq!( + status, + PersonalityMigrationStatus::SkippedExplicitPersonality + ); + assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); + + let persisted = read_config_toml(temp.path()).await?; + assert_eq!(persisted.model_personality, Some(Personality::Friendly)); + Ok(()) + } + + #[tokio::test] + async fn skips_when_no_sessions() -> io::Result<()> { + let temp = TempDir::new()?; + let config_toml = ConfigToml::default(); + let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + + assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); + assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); + assert!(!temp.path().join("config.toml").exists()); + Ok(()) + } +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 0c3f5bf1a2..f34ca09b40 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -49,6 +49,7 @@ mod otel; mod pending_input; mod permissions_messages; mod personality; +mod personality_migration; mod prompt_caching; mod quota_exceeded; mod read_file; diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs new file mode 100644 index 0000000000..4431db2472 --- /dev/null +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -0,0 +1,154 @@ +use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_core::SESSIONS_SUBDIR; +use codex_core::config::ConfigToml; +use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; +use codex_core::personality_migration::PersonalityMigrationStatus; +use codex_core::personality_migration::maybe_migrate_personality; +use codex_protocol::ThreadId; +use codex_protocol::config_types::Personality; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::UserMessageEvent; +use pretty_assertions::assert_eq; +use std::io; +use std::path::Path; +use tempfile::TempDir; +use tokio::io::AsyncWriteExt; + +const TEST_TIMESTAMP: &str = "2025-01-01T00-00-00"; + +async fn read_config_toml(codex_home: &Path) -> io::Result { + let contents = tokio::fs::read_to_string(codex_home.join("config.toml")).await?; + toml::from_str(&contents).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) +} + +async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> { + let thread_id = ThreadId::new(); + let dir = codex_home + .join(SESSIONS_SUBDIR) + .join("2025") + .join("01") + .join("01"); + write_rollout_with_user_event(&dir, thread_id).await +} + +async fn write_archived_session_with_user_event(codex_home: &Path) -> io::Result<()> { + let thread_id = ThreadId::new(); + let dir = codex_home.join(ARCHIVED_SESSIONS_SUBDIR); + write_rollout_with_user_event(&dir, thread_id).await +} + +async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::Result<()> { + tokio::fs::create_dir_all(&dir).await?; + let file_path = dir.join(format!("rollout-{TEST_TIMESTAMP}-{thread_id}.jsonl")); + let mut file = tokio::fs::File::create(&file_path).await?; + + let session_meta = SessionMetaLine { + meta: SessionMeta { + id: thread_id, + forked_from_id: None, + timestamp: TEST_TIMESTAMP.to_string(), + cwd: std::path::PathBuf::from("."), + originator: "test_originator".to_string(), + cli_version: "test_version".to_string(), + source: SessionSource::Cli, + model_provider: None, + base_instructions: None, + dynamic_tools: None, + }, + git: None, + }; + let meta_line = RolloutLine { + timestamp: TEST_TIMESTAMP.to_string(), + item: RolloutItem::SessionMeta(session_meta), + }; + let user_event = RolloutLine { + timestamp: TEST_TIMESTAMP.to_string(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + }; + + let meta_json = serde_json::to_string(&meta_line)?; + file.write_all(format!("{meta_json}\n").as_bytes()).await?; + let user_json = serde_json::to_string(&user_event)?; + file.write_all(format!("{user_json}\n").as_bytes()).await?; + Ok(()) +} + +#[tokio::test] +async fn migration_marker_exists_no_sessions_no_change() -> io::Result<()> { + let temp = TempDir::new()?; + let marker_path = temp.path().join(PERSONALITY_MIGRATION_FILENAME); + tokio::fs::write(&marker_path, "v1\n").await?; + + let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + + assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); + assert_eq!( + tokio::fs::try_exists(temp.path().join("config.toml")).await?, + false + ); + Ok(()) +} + +#[tokio::test] +async fn no_marker_no_sessions_no_change() -> io::Result<()> { + let temp = TempDir::new()?; + + let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + + assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); + assert_eq!( + tokio::fs::try_exists(temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?, + true + ); + assert_eq!( + tokio::fs::try_exists(temp.path().join("config.toml")).await?, + false + ); + Ok(()) +} + +#[tokio::test] +async fn no_marker_sessions_sets_personality() -> io::Result<()> { + let temp = TempDir::new()?; + write_session_with_user_event(temp.path()).await?; + + let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + + assert_eq!(status, PersonalityMigrationStatus::Applied); + assert_eq!( + tokio::fs::try_exists(temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?, + true + ); + + let persisted = read_config_toml(temp.path()).await?; + assert_eq!(persisted.model_personality, Some(Personality::Pragmatic)); + Ok(()) +} + +#[tokio::test] +async fn no_marker_archived_sessions_sets_personality() -> io::Result<()> { + let temp = TempDir::new()?; + write_archived_session_with_user_event(temp.path()).await?; + + let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + + assert_eq!(status, PersonalityMigrationStatus::Applied); + assert_eq!( + tokio::fs::try_exists(temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?, + true + ); + + let persisted = read_config_toml(temp.path()).await?; + assert_eq!(persisted.model_personality, Some(Personality::Pragmatic)); + Ok(()) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 82a06a7032..dc29030434 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -209,6 +209,13 @@ pub async fn run_main( } }; + if let Err(err) = + codex_core::personality_migration::maybe_migrate_personality(&codex_home, &config_toml) + .await + { + tracing::warn!(error = %err, "failed to run personality migration"); + } + let cloud_auth_manager = AuthManager::shared( codex_home.to_path_buf(), false,