feat(core,tui,app-server) personality migration (#10307)

## Summary
Keep existing users on Pragmatic, to preserve behavior while new users
default to Friendly

## Testing
- [x] Tested locally
- [x] add integration tests
This commit is contained in:
Dylan Hurd
2026-01-31 17:25:14 -07:00
committed by GitHub
parent 8a461765f3
commit 0f9858394b
6 changed files with 445 additions and 0 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<PersonalityMigrationStatus> {
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<bool> {
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<ConfigToml> {
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(())
}
}

View File

@@ -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;

View File

@@ -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<ConfigToml> {
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(())
}

View File

@@ -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,