diff --git a/codex-rs/app-server/src/request_processors/memory_status.rs b/codex-rs/app-server/src/request_processors/memory_status.rs index b7847015dc..b7eb34a8f6 100644 --- a/codex-rs/app-server/src/request_processors/memory_status.rs +++ b/codex-rs/app-server/src/request_processors/memory_status.rs @@ -35,10 +35,19 @@ impl ThreadRequestProcessor { let summary = tokio::fs::read_to_string(root.join("memory_summary.md")) .await .ok(); - let ready = count >= minimum - && summary - .as_deref() - .is_some_and(codex_memories_write::workspace::is_valid_v2_summary); + let has_v1_workspace = tokio::fs::try_exists( + self.config + .codex_home + .join(MemoryVersion::V1.directory_name()), + ) + .await + .map_err(|error| internal_error(format!("failed to check v1 memory workspace: {error}")))?; + // Fresh profiles can start on V2 without consolidating any V1 history first. + let ready = !has_v1_workspace + || count >= minimum + && summary + .as_deref() + .is_some_and(codex_memories_write::workspace::is_valid_v2_summary); Ok(Some( MemoryStatusResponse { v2_consolidated_threads: count, diff --git a/codex-rs/app-server/tests/suite/v2/memory_read.rs b/codex-rs/app-server/tests/suite/v2/memory_read.rs index cb675f95f5..77dde57b4b 100644 --- a/codex-rs/app-server/tests/suite/v2/memory_read.rs +++ b/codex-rs/app-server/tests/suite/v2/memory_read.rs @@ -22,9 +22,11 @@ use tempfile::TempDir; #[tokio::test] async fn memory_read_version_selects_only_its_own_context() -> Result<()> { - for (version, expected, excluded) in [ - ("v1", "legacy-only-marker", "v2-only-marker"), - ("v2", "v2-only-marker", "legacy-only-marker"), + for (version, has_v1_workspace, expected, excluded) in [ + (Some("v1"), true, "legacy-only-marker", "v2-only-marker"), + (Some("v2"), true, "v2-only-marker", "legacy-only-marker"), + (None, true, "legacy-only-marker", "v2-only-marker"), + (None, false, "v2-only-marker", "legacy-only-marker"), ] { let server = start_mock_server().await; let home = TempDir::new()?; @@ -35,6 +37,9 @@ async fn memory_read_version_selects_only_its_own_context() -> Result<()> { ("memories", "legacy-only-marker"), ("memories_v2", "v2-only-marker"), ] { + if directory == "memories" && !has_v1_workspace { + continue; + } tokio::fs::create_dir_all(home.path().join(directory)).await?; tokio::fs::write( home.path().join(directory).join("memory_summary.md"), @@ -55,17 +60,18 @@ async fn memory_read_version_selects_only_its_own_context() -> Result<()> { .with_codex_home(home.path()) .build_initialized() .await?; + let mut config = std::collections::HashMap::from([( + "memories.generate_memories".to_string(), + json!(false), + )]); + if let Some(version) = version { + config.insert("memories.version".to_string(), json!(version)); + } let start = app .send_thread_start_request_with_auto_env(ThreadStartParams { // Skip background consolidation so it cannot consume the read test's response. ephemeral: Some(true), - config: Some( - [ - ("memories.version".to_string(), json!(version)), - ("memories.generate_memories".to_string(), json!(false)), - ] - .into(), - ), + config: Some(config), ..Default::default() }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/memory_reset.rs b/codex-rs/app-server/tests/suite/v2/memory_reset.rs index 1effa23de5..683cab296d 100644 --- a/codex-rs/app-server/tests/suite/v2/memory_reset.rs +++ b/codex-rs/app-server/tests/suite/v2/memory_reset.rs @@ -155,15 +155,34 @@ async fn memory_status_requires_successful_v2_consolidation_and_resets() -> Resu .enable_feature(Feature::Sqlite) .write(home.path())?; let db = init_state_db(home.path()).await?; - seed_stage1_output(&db, home.path(), MemoryVersion::V1).await?; - let source = seed_stage1_output(&db, home.path(), MemoryVersion::V2).await?; - seed_stage1_output(&db, home.path(), MemoryVersion::V2).await?; let mut server = TestAppServer::builder() .with_codex_home(home.path()) .without_auto_env() .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) .await?; let params = serde_json::json!({"minConsolidatedThreads": 2}); + // Resetting a fresh profile must not create an unused V1 workspace. + let request = server + .send_raw_request("memory/reset", /*params*/ None) + .await?; + let _: MemoryResetResponse = server.read_response(request).await?; + assert!(!home.path().join("memories").exists()); + let request = server + .send_raw_request("memory/status", Some(params.clone())) + .await?; + let status: MemoryStatusResponse = server.read_response(request).await?; + assert_eq!( + status, + MemoryStatusResponse { + v2_consolidated_threads: 0, + v2_ready: true + } + ); + // Even an empty V1 workspace retains the existing readiness requirements. + tokio::fs::create_dir(home.path().join("memories")).await?; + seed_stage1_output(&db, home.path(), MemoryVersion::V1).await?; + let source = seed_stage1_output(&db, home.path(), MemoryVersion::V2).await?; + seed_stage1_output(&db, home.path(), MemoryVersion::V2).await?; let request = server .send_raw_request("memory/status", Some(params.clone())) .await?; diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index a42d709777..449b920f7f 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -320,7 +320,8 @@ pub use codex_protocol::MemoryVersion; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct MemoriesToml { - /// Selects the memory pipeline; v1 remains the default. + /// Selects the memory pipeline. Fresh profiles default to v2 when memories are enabled, + /// unless dual-write is explicitly disabled; existing v1 profiles default to v1. pub version: Option, /// Generate both versions while v1 is selected; v2 runs alone after cutover. pub dual_write: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index cc0aa777c3..7c59145359 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2752,7 +2752,7 @@ "$ref": "#/definitions/MemoryVersion" } ], - "description": "Selects the memory pipeline; v1 remains the default." + "description": "Selects the memory pipeline. Fresh profiles default to v2 when memories are enabled, unless dual-write is explicitly disabled; existing v1 profiles default to v1." } }, "type": "object" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c45fb2b0c3..fc82e704e1 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -397,6 +397,34 @@ consolidation_model = "gpt-5.2" ); } +#[tokio::test] +async fn fresh_memory_profiles_preserve_explicit_version_and_transition_opt_out() { + for (settings, expected) in [ + ("", codex_protocol::MemoryVersion::V2), + ("version = 'v1'", codex_protocol::MemoryVersion::V1), + ("dual_write = false", codex_protocol::MemoryVersion::V1), + ( + "version = 'v2'\ndual_write = false", + codex_protocol::MemoryVersion::V2, + ), + ] { + let home = tempdir().expect("tempdir"); + let cfg = toml::from_str::(&format!( + "[features]\nmemories = true\n[memories]\n{settings}" + )) + .expect("memory config"); + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + home.abs(), + ) + .await + .expect("load config"); + assert_eq!(config.memories.version, expected, "{settings}"); + assert!(!home.path().join("memories").exists()); + } +} + #[tokio::test] async fn goal_max_token_budget_requires_positive_integer() { let config_toml = toml::from_str::("[goals]\nmax_goal_token_budget = 25000\n") @@ -6301,7 +6329,7 @@ async fn memory_tool_makes_memories_root_readable_without_creating_or_widening_w -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - let memories_root = codex_home.path().join("memories"); + let memories_root = codex_home.path().join("memories_v2"); let memories_root_abs = memories_root.abs(); let config = Config::load_from_base_config_with_overrides( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3f6c1cc9e1..d93932417d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -97,6 +97,7 @@ use codex_model_provider_info::built_in_model_providers; use codex_model_provider_info::merge_configured_model_providers; use codex_models_manager::ModelsManagerConfig; use codex_prompts::ResolvedModelMessages; +use codex_protocol::MemoryVersion; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; @@ -3646,7 +3647,20 @@ impl Config { )); } - let memories_config: MemoriesConfig = cfg.memories.clone().unwrap_or_default().into(); + let mut memories = cfg.memories.clone().unwrap_or_default(); + // Fresh profiles have no V1 context to preserve or warm up before using V2. + // Keep explicit selections and transition opt-outs, and fail closed on IO errors. + if features.enabled(Feature::MemoryTool) + && memories.version.is_none() + && memories.dual_write != Some(false) + && matches!( + tokio::fs::try_exists(codex_home.join(MemoryVersion::V1.directory_name())).await, + Ok(false) + ) + { + memories.version = Some(MemoryVersion::V2); + } + let memories_config: MemoriesConfig = memories.into(); let memories_root = codex_home.join(memories_config.version.directory_name()); let profiles_are_active = effective_permission_selection.profiles_are_active( diff --git a/codex-rs/memories/write/src/control.rs b/codex-rs/memories/write/src/control.rs index 9d0da1a0b7..b4ed62d092 100644 --- a/codex-rs/memories/write/src/control.rs +++ b/codex-rs/memories/write/src/control.rs @@ -24,7 +24,7 @@ pub(crate) async fn clear_memory_root_contents(memory_root: &Path) -> std::io::R )); } Ok(_) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(err) => return Err(err), } diff --git a/codex-rs/memories/write/src/startup_dual_write_tests.rs b/codex-rs/memories/write/src/startup_dual_write_tests.rs index 9b62c3bc64..ffb3582aa3 100644 --- a/codex-rs/memories/write/src/startup_dual_write_tests.rs +++ b/codex-rs/memories/write/src/startup_dual_write_tests.rs @@ -10,14 +10,34 @@ use wiremock::matchers::path_regex; #[tokio::test] async fn dual_write_runs_both_versions_until_v2_is_selected() -> anyhow::Result<()> { - for (selected_version, expected_pipelines) in [(MemoryVersion::V1, 2), (MemoryVersion::V2, 1)] { + for (version, expected_pipelines) in [(Some("v1"), 2), (Some("v2"), 1), (None, 1)] { let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); - let mut memories = startup_test_memories_config(); - memories.dual_write = true; - memories.version = selected_version; - let test = - build_test_codex_with_memories_config(&server, Arc::clone(&home), memories).await?; + let version_config = version + .map(|version| format!("version = {version:?}")) + .unwrap_or_default(); + tokio::fs::write( + home.path().join("config.toml"), + format!( + "[features]\nmemories = true\n\ + [memories]\ndual_write = true\nmax_raw_memories_for_consolidation = 1\n\ + min_rollout_idle_hours = 0\n{version_config}\n" + ), + ) + .await?; + let test = test_codex() + .with_home(Arc::clone(&home)) + .build(&server) + .await?; + let selected_version = test.config.memories.version; + assert_eq!( + selected_version, + if version == Some("v1") { + MemoryVersion::V1 + } else { + MemoryVersion::V2 + } + ); let db = test.codex.state_db().expect("memory state"); let source = seed_stage1_candidate( &db, @@ -28,7 +48,9 @@ async fn dual_write_runs_both_versions_until_v2_is_selected() -> anyhow::Result< .await?; let v1_root = home.path().join("memories"); let v2_root = home.path().join("memories_v2"); - seed_required_memory_artifacts(&v1_root).await?; + if version.is_some() { + seed_required_memory_artifacts(&v1_root).await?; + } tokio::fs::create_dir_all(&v2_root).await?; tokio::fs::write( v2_root.join("memory_summary.md"), @@ -119,6 +141,9 @@ async fn dual_write_runs_both_versions_until_v2_is_selected() -> anyhow::Result< ); assert!(!v2_root.join("MEMORY.md").exists()); assert!(!v2_root.join("raw_memories.md").exists()); + if version.is_none() { + assert!(!v1_root.exists()); + } shutdown_test_codex(&test).await?; } Ok(())