diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 08ebc8e755..e6b15206e8 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server/src/external_agent_migration/session_importer.rs b/codex-rs/app-server/src/external_agent_migration/session_importer.rs index 5f08aa502a..d691ed2e1f 100644 --- a/codex-rs/app-server/src/external_agent_migration/session_importer.rs +++ b/codex-rs/app-server/src/external_agent_migration/session_importer.rs @@ -482,6 +482,7 @@ impl ExternalAgentSessionImporter { }, dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + runtime_workspace_roots: None, multi_agent_version: Some(MultiAgentVersion::V1), history_mode: ThreadHistoryMode::Legacy, history_base: None, diff --git a/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs b/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs index 792fc344a1..9cce9977c3 100644 --- a/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs +++ b/codex-rs/app-server/src/request_processors/persisted_resume_settings_tests.rs @@ -38,6 +38,7 @@ fn settings_item( permission_profile: PermissionProfile::read_only(), active_permission_profile, cwd: cwd(), + runtime_workspace_roots: None, reasoning_effort: None, reasoning_summary: None, personality: None, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 842ee1bb2a..df4ffe73ab 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -84,6 +84,7 @@ struct ThreadListFilters { #[derive(PartialEq)] struct ResumeConfigState { history_cwd: Option, + workspace_roots: Option>, persisted_metadata: Option, persisted_settings: Option, } @@ -2269,6 +2270,15 @@ impl ThreadRequestProcessor { "failed to restore thread settings after revert: {err}" )) })?; + // Replace the resume-time checkpoint written from the original config. + codex_thread + .checkpoint_thread_settings() + .await + .map_err(|err| { + internal_error(format!( + "failed to persist restored thread settings after revert: {err}" + )) + })?; Self::set_app_server_client_info( codex_thread.as_ref(), app_server_client_name, @@ -3799,21 +3809,72 @@ impl ThreadRequestProcessor { } // Copied or referenced history can contain another thread's settings. Only snapshots - // explicitly owned by this thread can override its startup cwd. - let history_cwd = if let InitialHistory::Resumed(resumed) = &thread_history { + // explicitly owned by this thread can override its startup cwd and workspace folders. + let history_settings = if let InitialHistory::Resumed(resumed) = &thread_history { resumed.history.iter().rev().find_map(|item| match item { RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) if event.thread_id == Some(resumed.conversation_id) => { - Some(event.thread_settings.cwd.to_path_buf()) + Some(&event.thread_settings) } _ => None, }) } else { None }; - let history_cwd = history_cwd.or_else(|| thread_history.session_cwd()); - let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); + let history_cwd = history_settings + .map(|settings| settings.cwd.to_path_buf()) + .or_else(|| thread_history.session_cwd()); + let mut runtime_workspace_roots = + runtime_workspace_roots.map(resolve_runtime_workspace_roots); + if runtime_workspace_roots.is_none() { + // A retained owned snapshot is authoritative. Only use startup metadata when + // no such snapshot exists; missing roots do not resurrect an older selection. + let saved_workspaces: Option<(&Path, Vec<&Path>)> = match history_settings { + Some(settings) => settings.runtime_workspace_roots.as_ref().map(|roots| { + ( + settings.cwd.as_path(), + roots.iter().map(AbsolutePathBuf::as_path).collect(), + ) + }), + None => { + if let InitialHistory::Resumed(resumed) = &thread_history + && let Some(RolloutItem::SessionMeta(meta)) = resumed.history.first() + && meta.meta.id == resumed.conversation_id + && let Some(roots) = &meta.meta.runtime_workspace_roots + { + Some(( + meta.meta.cwd.as_path(), + roots.iter().map(PathBuf::as_path).collect(), + )) + } else { + None + } + } + }; + if let Some((saved_cwd, saved_roots)) = saved_workspaces { + let resume_cwd = resolve_request_cwd(cwd.as_ref().map(PathBuf::from))?; + // Retarget before validation so an explicit cwd can replace a + // startup cwd recorded on another operating system. + let restored_roots = path_utils::replace_path_and_deduplicate( + saved_roots, + saved_cwd, + resume_cwd.as_ref().map_or(saved_cwd, AbsolutePathBuf::as_path), + ) + .into_iter() + .map(|root| { + AbsolutePathBuf::from_absolute_path_checked(root).map_err(|err| { + invalid_params(format!( + "cannot restore workspace root `{}` on this host: {err}. Pass `runtimeWorkspaceRoots` with valid local paths or an empty list.", + root.display() + )) + }) + }) + .collect::, _>>()?; + // Validation can normalize distinct saved paths to the same root. + runtime_workspace_roots = Some(resolve_runtime_workspace_roots(restored_roots)); + } + } let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -3865,6 +3926,7 @@ impl ThreadRequestProcessor { .is_some_and(|metadata| metadata.reasoning_effort.is_none()); let config_state = ResumeConfigState { history_cwd: history_cwd.clone(), + workspace_roots: typesafe_overrides.workspace_roots.clone(), persisted_metadata, persisted_settings: match &thread_history { InitialHistory::Resumed(resumed) => { diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index aec4870d84..2beaa87033 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -113,9 +113,16 @@ fn map_additional_context( .collect() } +#[derive(Default)] +struct ThreadEnvironmentOverride { + environments: Option, + // Only default-environment updates replace the task's separately persisted root selection. + runtime_workspace_roots: Option>, +} + struct ThreadSettingsBuildParams { method: &'static str, - environments: Option, + environment_override: ThreadEnvironmentOverride, approval_policy: Option, approvals_reviewer: Option, sandbox_policy: Option, @@ -607,7 +614,7 @@ impl TurnRequestProcessor { } }; let cwd = resolve_request_cwd(params.cwd)?; - let environments = self + let environment_override = self .build_environment_override( thread.as_ref(), cwd, @@ -620,7 +627,7 @@ impl TurnRequestProcessor { thread.as_ref(), ThreadSettingsBuildParams { method: "turn/start", - environments, + environment_override, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, @@ -704,9 +711,9 @@ impl TurnRequestProcessor { cwd: Option, workspace_roots: Option>, environment_selections: Option>, - ) -> Option { + ) -> ThreadEnvironmentOverride { if cwd.is_none() && workspace_roots.is_none() && environment_selections.is_none() { - return None; + return ThreadEnvironmentOverride::default(); } // Explicit environment selections own their roots and pass through unchanged. Top-level @@ -723,42 +730,37 @@ impl TurnRequestProcessor { None => thread.config_snapshot().await.cwd().clone(), }, }; - return Some(TurnEnvironmentSelections::new( - legacy_fallback_cwd, - environment_selections, - )); + return ThreadEnvironmentOverride { + environments: Some(TurnEnvironmentSelections::new( + legacy_fallback_cwd, + environment_selections, + )), + ..Default::default() + }; } - let snapshot = thread.config_snapshot().await; - let current_cwd = snapshot.cwd().clone(); + // Default-environment updates retain the task's fallback roots, not its active roots. + let snapshot = thread.thread_settings_snapshot().await; + let current_cwd = snapshot.cwd; let legacy_fallback_cwd = cwd.unwrap_or_else(|| current_cwd.clone()); let workspace_roots = match workspace_roots { Some(workspace_roots) => workspace_roots, - None => { - // Match the pre-environment partial-update behavior: a cwd-only update retargets - // the old cwd root while preserving any additional roots. Deduplicate because the - // new cwd may already be present as an additional root. - let mut retargeted_workspace_roots = Vec::new(); - for root in snapshot.workspace_roots { - let root = if root == current_cwd { - legacy_fallback_cwd.clone() - } else { - root - }; - if !retargeted_workspace_roots.contains(&root) { - retargeted_workspace_roots.push(root); - } - } - retargeted_workspace_roots - } + None => path_utils::replace_path_and_deduplicate( + snapshot.runtime_workspace_roots.unwrap_or_default(), + current_cwd.as_path(), + legacy_fallback_cwd.clone(), + ), }; let environment_selections = self .thread_manager .default_environment_selections(&legacy_fallback_cwd, &workspace_roots); - Some(TurnEnvironmentSelections::new( - legacy_fallback_cwd, - environment_selections, - )) + ThreadEnvironmentOverride { + environments: Some(TurnEnvironmentSelections::new( + legacy_fallback_cwd, + environment_selections, + )), + runtime_workspace_roots: Some(workspace_roots), + } } async fn build_thread_settings_overrides( @@ -768,7 +770,11 @@ impl TurnRequestProcessor { ) -> Result { let ThreadSettingsBuildParams { method, - environments, + environment_override: + ThreadEnvironmentOverride { + environments, + runtime_workspace_roots, + }, approval_policy, approvals_reviewer, sandbox_policy, @@ -865,6 +871,7 @@ impl TurnRequestProcessor { thread .preview_thread_settings_overrides(CodexThreadSettingsOverrides { environments: environments.clone(), + runtime_workspace_roots: runtime_workspace_roots.clone(), approval_policy, approvals_reviewer, sandbox_policy: sandbox_policy.clone(), @@ -887,6 +894,7 @@ impl TurnRequestProcessor { Ok(codex_protocol::protocol::ThreadSettingsOverrides { environments, + runtime_workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -912,7 +920,7 @@ impl TurnRequestProcessor { self.ensure_direct_input_allowed(request_id, thread.as_ref()) .await?; let cwd = resolve_request_cwd(params.cwd)?; - let environments = self + let environment_override = self .build_environment_override( thread.as_ref(), cwd, @@ -925,7 +933,7 @@ impl TurnRequestProcessor { thread.as_ref(), ThreadSettingsBuildParams { method: "thread/settings/update", - environments, + environment_override, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 1c36d944aa..d55fd86c13 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -260,6 +260,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id( parent_thread_id, timestamp: meta_rfc3339.to_string(), cwd: test_path_buf("/"), + runtime_workspace_roots: None, originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source, @@ -353,6 +354,7 @@ pub fn create_fake_rollout_with_text_elements( parent_thread_id: None, timestamp: meta_rfc3339.to_string(), cwd: test_path_buf("/"), + runtime_workspace_roots: None, originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 5e64c8871f..02e65ab155 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -138,6 +138,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> history_base: None, subagent_history_start_ordinal: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 498c19755b..8264bd68c6 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -33,6 +33,7 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_features::Feature; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -269,6 +270,8 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - let codex_home = TempDir::new()?; let initial_cwd = TempDir::new()?; let updated_cwd = TempDir::new()?; + let extra_root = TempDir::new()?; + let updated_roots = vec![updated_cwd.path().abs(), extra_root.path().abs()]; compaction_config(&server.uri(), /*auto_compact_limit*/ 1_000_000).write(codex_home.path())?; // Top-level cwd restoration uses host-native paths, not a foreign executor's paths. @@ -295,6 +298,7 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - mcp.start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread_id.clone(), cwd: Some(updated_cwd.path().to_path_buf()), + runtime_workspace_roots: Some(updated_roots.clone()), input: vec![V2UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -354,7 +358,8 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - ); // A completed turn after compaction permits bounded replay. Neither this turn nor - // resume resends settings, so restoring the updated cwd depends on the checkpoint. + // resume resends settings, so restoring the updated cwd and roots depends on the + // checkpoint; startup metadata does not contain the extra root. send_turn_and_wait(&mut mcp, &thread_id, "continue").await?; timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??; @@ -370,9 +375,15 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - ..Default::default() }) .await?; - let ThreadResumeResponse { cwd, .. } = - timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; - assert_eq!(cwd.as_path(), updated_cwd.path()); + let ThreadResumeResponse { + cwd, + runtime_workspace_roots, + .. + } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; + assert_eq!( + (cwd.as_path(), runtime_workspace_roots), + (updated_cwd.path(), updated_roots) + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index dc77bc4a9e..60d4a65585 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -70,9 +70,16 @@ use codex_core::config::ConfigBuilder; use codex_exec_server::EnvironmentManager; use codex_features::Feature; use codex_feedback::CodexFeedback; +use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSettingsAppliedEvent; +use codex_rollout::RolloutItem; +use codex_rollout::RolloutRecorder; +use codex_rollout::append_rollout_item_to_path; use codex_state::PINNED_THREAD_SECTION_ID; use codex_state::PINNED_THREAD_SECTION_NAME; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1258,6 +1265,104 @@ async fn resume_revalidates_persisted_thread_after_config_load( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_reloads_config_when_saved_workspace_roots_change() -> Result<()> { + let responses_server = create_mock_responses_server_repeating_assistant("Ready").await; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&responses_server.uri()).write(codex_home.path())?; + let filename_timestamp = "2025-01-05T12-00-00"; + let thread_id = create_fake_rollout( + codex_home.path(), + filename_timestamp, + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + let path = rollout_path(codex_home.path(), filename_timestamp, &thread_id); + let resume_cwd = TempDir::new()?; + let saved_workspace = TempDir::new()?; + let cwd = AbsolutePathBuf::from_absolute_path(resume_cwd.path())?; + let saved_root = AbsolutePathBuf::from_absolute_path(saved_workspace.path())?; + let settings: ThreadSettingsAppliedEvent = serde_json::from_value(json!({ + "thread_id": thread_id, + "thread_settings": { + "model": "gpt-5.4", + "model_provider_id": "mock_provider", + "cwd": cwd, + "runtime_workspace_roots": [saved_root], + "approval_policy": "never", + "approvals_reviewer": "user", + "permission_profile": PermissionProfile::read_only(), + "collaboration_mode": { "mode": "default", "settings": { "model": "gpt-5.4" } }, + }, + }))?; + append_rollout_item_to_path( + &path, + &RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(settings.clone())), + ) + .await?; + + let blocked_resume = Arc::new(BlockedResumeConfig { + cwd: cwd.clone(), + matching_loads: AtomicUsize::new(0), + entered: Notify::new(), + release: Notify::new(), + }); + let client = + start_resource_in_process_client(codex_home.path(), blocked_resume.clone()).await?; + let sender = client.sender(); + let mut resume = pin!(sender.request(ClientRequest::ThreadResume { + request_id: RequestId::Integer(1), + params: ThreadResumeParams { + thread_id: thread_id.clone(), + cwd: Some(cwd.to_string_lossy().into_owned()), + ..Default::default() + }, + })); + assert!(poll!(&mut resume).is_pending()); + let update_result = async { + timeout(DEFAULT_READ_TIMEOUT, blocked_resume.entered.notified()) + .await + .context("resume did not enter config loading")?; + let mut cleared = settings; + cleared.thread_settings.runtime_workspace_roots = Some(Vec::new()); + append_rollout_item_to_path( + &path, + &RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(cleared)), + ) + .await?; + anyhow::Ok(()) + } + .await; + blocked_resume.release.notify_one(); + update_result?; + let resumed: ThreadResumeResponse = serde_json::from_value( + timeout(DEFAULT_READ_TIMEOUT, &mut resume) + .await?? + .expect("resume updated thread"), + )?; + let (items, _, _) = RolloutRecorder::load_rollout_items(&path).await?; + client.shutdown().await?; + + assert_eq!(resumed.runtime_workspace_roots, Vec::new()); + assert!( + blocked_resume.matching_loads.load(Ordering::Relaxed) >= 2, + "roots-only updates must reload config" + ); + let expected_thread_id = ThreadId::from_string(&thread_id)?; + let latest_roots = items.into_iter().rev().find_map(|item| match item { + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) + if event.thread_id == Some(expected_thread_id) => + { + Some(event.thread_settings.runtime_workspace_roots) + } + _ => None, + }); + assert_eq!(latest_roots, Some(Some(Vec::new()))); + Ok(()) +} + async fn complete_in_process_turn( client: &mut in_process::InProcessClientHandle, request_id: RequestId, diff --git a/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs b/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs index 76f263532a..e5553bffdf 100644 --- a/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs +++ b/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs @@ -863,7 +863,7 @@ features.shell_tool = false assert!(loaded.data.contains(&thread_id)); assert!(!loaded.data.contains(&child_thread_id)); - let mut expected = baseline; + let expected = baseline; if history_mode == ThreadHistoryMode::Paginated { let state_db = StateRuntime::init( codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()), @@ -881,11 +881,11 @@ features.shell_tool = false else { anyhow::bail!("expected worker thread-spawn source"); }; - // Stale display metadata must not choose which parent controls the resume. + // Stale display metadata must not choose which parent controls the resume; + // the resume checkpoint restores the canonical source. *parent_thread_id = ThreadId::new(); metadata.source = serde_json::to_string(&source)?; state_db.upsert_thread(&metadata).await?; - expected.thread.source = source.into(); } let is_child_usage = |notification: &JSONRPCNotification| { diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index b59b2453ba..0ebf3024cd 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -335,6 +335,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist history_base: None, subagent_history_start_ordinal: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(codex_home.path().to_path_buf()), model_provider: "mock_provider".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 06aaa9b5f4..48633ce600 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -529,6 +529,7 @@ async fn thread_search_occurrences_reads_paginated_projection() -> Result<()> { history_base: None, subagent_history_start_ordinal: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(codex_home.path().to_path_buf()), model_provider: "mock_provider".to_string(), @@ -1588,6 +1589,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( history_base: None, subagent_history_start_ordinal: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(codex_home.path().to_path_buf()), model_provider: "mock_provider".to_string(), @@ -2314,6 +2316,7 @@ async fn seed_pathless_store_thread( history_base: None, subagent_history_start_ordinal: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index c70eda9e66..01a6d34051 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -71,6 +71,7 @@ use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadTurnsListResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -78,6 +79,7 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_config::types::AuthCredentialsStoreMode; use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_features::Feature; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::ThreadId; @@ -173,7 +175,6 @@ async fn thread_resume_paginated_model_context_preserves_original_metadata() -> /*git_info*/ None, )?; let path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &conversation_id); - let startup_cwd = read_session_meta_line(&path).await?.meta.cwd; let settings: ThreadSettingsAppliedEvent = serde_json::from_value(json!({ "thread_id": conversation_id, "thread_settings": { @@ -265,9 +266,9 @@ async fn thread_resume_paginated_model_context_preserves_original_metadata() -> cwd, .. } = timeout(DEFAULT_READ_TIMEOUT, secondary.read_response(resume_id)).await??; - // The completed turn now permits a bounded replay ending at the compaction, - // so the earlier settings snapshot is outside the normal resume window. - assert_eq!(cwd.as_path(), startup_cwd); + // The resume checkpoint keeps the effective cwd inside the bounded replay, + // even after the original settings snapshot falls outside its window. + assert_eq!(cwd.as_path(), saved_cwd); assert_eq!(resumed.preview, "Saved user message"); assert!(resumed.turns.is_empty()); @@ -619,58 +620,71 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul } #[tokio::test] -async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { +async fn thread_resume_restores_startup_and_updated_runtime_workspace_roots() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; mock_responses_config(&server.uri()).write(codex_home.path())?; - let extra_root_tmp = TempDir::new()?; - let extra_root = extra_root_tmp.path().join("extra-root"); - std::fs::create_dir_all(&extra_root)?; + let initial_workspace = TempDir::new()?; + let initial_cwd = normalized_existing_path(initial_workspace.path())?.abs(); + let extra_workspace = TempDir::new()?; + let extra_root = extra_workspace.path(); + let initial_roots = vec![initial_cwd.clone(), extra_root.abs()]; + let explicit_workspace = TempDir::new()?; + let explicit_cwd = normalized_existing_path(explicit_workspace.path())?.abs(); + let explicit_environments = vec![TurnEnvironmentParams { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: explicit_cwd.clone().into(), + runtime_workspace_roots: Some(vec![explicit_cwd.clone().into()]), + }]; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) + // These compatibility parameters carry host-native paths, not foreign environment paths. + .without_auto_env() .build_initialized() .await?; let start_id = mcp - .send_thread_start_request_with_auto_env(ThreadStartParams { + .send_thread_start_request(ThreadStartParams { model: Some("gpt-5.4".to_string()), + cwd: Some(initial_cwd.to_string_lossy().into_owned()), + runtime_workspace_roots: Some(initial_roots.clone()), + environments: Some(explicit_environments.clone()), ..Default::default() }) .await?; let ThreadStartResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??; - let turn_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - client_user_message_id: None, - input: vec![UserInput::Text { - text: "Hello".to_string(), - text_elements: Vec::new(), - }], - runtime_workspace_roots: Some(vec![ - AbsolutePathBuf::from_absolute_path(&extra_root)?, - AbsolutePathBuf::from_absolute_path(extra_root.join("."))?, - ]), - ..Default::default() - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), - ) - .await??; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - + let updated_roots = vec![initial_cwd.clone(), explicit_cwd.clone(), extra_root.abs()]; + let mut roots_with_duplicate = updated_roots.clone(); + roots_with_duplicate.push(extra_root.abs()); + for (environments, roots) in [ + // Materialize the rollout before settings updates begin emitting persisted snapshots. + (None, None), + (None, Some(roots_with_duplicate)), + // Explicit environments own their live roots; the top-level list is ignored. + (Some(explicit_environments), Some(initial_roots.clone())), + ] { + timeout( + DEFAULT_READ_TIMEOUT, + mcp.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "update workspace selection".to_string(), + text_elements: Vec::new(), + }], + environments, + runtime_workspace_roots: roots, + ..Default::default() + }), + ) + .await??; + } let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { - thread_id: thread.id, + thread_id: thread.id.clone(), exclude_turns: true, ..Default::default() }) @@ -679,12 +693,278 @@ async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Resul runtime_workspace_roots, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; + assert_eq!(runtime_workspace_roots, vec![explicit_cwd.clone()]); + // A cwd-only update must retain the fallback's additional folder, not the live roots. + mcp.clear_message_buffer(); + let update_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + cwd: Some(initial_cwd.to_path_buf()), + ..Default::default() + }) + .await?; + let _: ThreadSettingsUpdateResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(update_id)).await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/settings/updated"), + ) + .await??; + timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??; + + // The header preserves the startup fallback selection, not the explicit environment's roots. + let startup = read_session_meta_line(thread.path.as_ref().expect("rollout path")).await?; assert_eq!( - runtime_workspace_roots, - vec![AbsolutePathBuf::from_absolute_path(extra_root)?] + startup.meta.runtime_workspace_roots, + Some( + initial_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect() + ) + ); + // Check both input deduplication and cwd retargeting before resume can normalize roots. + let expected_roots = vec![initial_cwd.clone(), extra_root.abs()]; + let (items, _, _) = + RolloutRecorder::load_rollout_items(thread.path.as_ref().expect("rollout path")).await?; + let snapshot_roots: Vec<_> = items + .into_iter() + .rev() + .filter_map(|item| match item { + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) => { + Some(event.thread_settings.runtime_workspace_roots) + } + _ => None, + }) + .take(/*n*/ 3) + .collect(); + assert_eq!( + snapshot_roots, + vec![ + Some(expected_roots.clone()), + Some(vec![explicit_cwd, extra_root.abs()]), + Some(updated_roots), + ] ); + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized() + .await?; + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let ThreadResumeResponse { + cwd, + runtime_workspace_roots, + .. + } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; + assert_eq!( + (cwd, runtime_workspace_roots), + (initial_cwd.clone(), expected_roots) + ); + + Ok(()) +} + +#[tokio::test] +async fn cold_resume_workspace_roots_honor_owned_snapshot_and_caller_overrides() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + mock_responses_config(&server.uri()).write(codex_home.path())?; + let saved_cwd = normalized_existing_path(codex_home.path())?.abs(); + let new_workspace = TempDir::new()?; + let new_cwd = normalized_existing_path(new_workspace.path())?.abs(); + let extra_workspace = TempDir::new()?; + let extra_root = extra_workspace.path().abs(); + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved workspace selection", + Some("mock_provider"), + /*git_info*/ None, + )?; + let path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &thread_id); + let owned: ThreadSettingsAppliedEvent = serde_json::from_value(json!({ + "thread_id": thread_id, + "thread_settings": { + "model": "gpt-5.4", + "model_provider_id": "mock_provider", + "cwd": saved_cwd, + "runtime_workspace_roots": [saved_cwd, new_cwd, extra_root], + "approval_policy": "never", + "approvals_reviewer": "user", + "permission_profile": PermissionProfile::read_only(), + "collaboration_mode": { "mode": "default", "settings": { "model": "gpt-5.4" } }, + }, + }))?; + let mut foreign = owned.clone(); + foreign.thread_id = Some(ThreadId::new()); + foreign.thread_settings.runtime_workspace_roots = Some(vec![extra_root.clone()]); + for settings in [owned, foreign] { + append_rollout_item_to_path( + &path, + &RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(settings)), + ) + .await?; + } + + // Every iteration is a cold resume; the final one must retain the preceding explicit clear. + for (cwd_override, roots_override, expected_cwd, expected_roots) in [ + ( + Some(&new_cwd), + None, + &new_cwd, + vec![new_cwd.clone(), extra_root.clone()], + ), + ( + Some(&saved_cwd), + Some(vec![new_cwd.clone()]), + &saved_cwd, + vec![new_cwd.clone()], + ), + (None, Some(Vec::new()), &saved_cwd, Vec::new()), + (None, None, &saved_cwd, Vec::new()), + ] { + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized() + .await?; + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + cwd: cwd_override.map(|cwd| cwd.to_string_lossy().into_owned()), + runtime_workspace_roots: roots_override, + ..Default::default() + }) + .await?; + let resumed: ThreadResumeResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; + assert_eq!( + (resumed.cwd, resumed.runtime_workspace_roots), + (expected_cwd.clone(), expected_roots.clone()) + ); + + // Inspect persistence before shutdown or another resume can hide a missing checkpoint. + let expected_thread_id = ThreadId::from_string(&thread_id)?; + let (items, _, _) = RolloutRecorder::load_rollout_items(&path).await?; + let persisted = items.into_iter().rev().find_map(|item| match item { + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) + if event.thread_id == Some(expected_thread_id) => + { + Some(( + event.thread_settings.cwd, + event.thread_settings.runtime_workspace_roots, + )) + } + _ => None, + }); + assert_eq!( + persisted, + Some((expected_cwd.clone(), Some(expected_roots))) + ); + timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??; + } + Ok(()) +} + +#[tokio::test] +async fn cold_resume_workspace_roots_validate_foreign_startup_paths() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + mock_responses_config(&server.uri()).write(codex_home.path())?; + let local_cwd = normalized_existing_path(codex_home.path())?.abs(); + let extra_workspace = TempDir::new()?; + let extra_root = normalized_existing_path(extra_workspace.path())?.abs(); + let extra_root_string = extra_root.to_string_lossy(); + let unnormalized_extra_root = extra_root + .as_path() + .join("child") + .join("..") + .to_string_lossy() + .into_owned(); + let (foreign_cwd, foreign_extra) = if cfg!(windows) { + ("/foreign/project", "/foreign/extra") + } else { + (r"C:\foreign\project", r"C:\foreign\extra") + }; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + // Startup metadata uses the app-server host's paths, not executor paths. + .without_auto_env() + .build_initialized() + .await?; + + for (saved_roots, roots_override, expected_roots) in [ + ( + vec![ + foreign_cwd, + extra_root_string.as_ref(), + unnormalized_extra_root.as_str(), + ], + None, + Some(vec![local_cwd.clone(), extra_root.clone()]), + ), + (vec![foreign_cwd, foreign_extra], None, None), + ( + vec![foreign_cwd, foreign_extra], + Some(Vec::new()), + Some(Vec::new()), + ), + ] { + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Foreign startup paths", + Some("mock_provider"), + /*git_info*/ None, + )?; + let path = rollout_path(codex_home.path(), "2025-01-05T12-00-00", &thread_id); + let contents = std::fs::read_to_string(&path)?; + let (meta, history) = contents.split_once('\n').expect("rollout metadata line"); + let mut meta: serde_json::Value = serde_json::from_str(meta)?; + meta["payload"]["cwd"] = json!(foreign_cwd); + meta["payload"]["runtime_workspace_roots"] = json!(saved_roots); + std::fs::write(&path, format!("{meta}\n{history}"))?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + cwd: Some(local_cwd.to_string_lossy().into_owned()), + runtime_workspace_roots: roots_override, + ..Default::default() + }) + .await?; + if let Some(expected_roots) = expected_roots { + let ThreadResumeResponse { + thread, + cwd, + runtime_workspace_roots, + .. + } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; + assert_eq!( + (thread.id, cwd, runtime_workspace_roots), + (thread_id, local_cwd.clone(), expected_roots) + ); + } else { + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + assert_eq!(error.error.code, -32602); + assert!(error.error.message.contains(foreign_extra)); + assert!(error.error.message.contains("runtimeWorkspaceRoots")); + } + } Ok(()) } @@ -1160,7 +1440,7 @@ async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewe assert_eq!(model, "gpt-5.2-codex"); assert_eq!(reasoning_effort, Some(ReasoningEffort::Ultra)); assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview); - assert_eq!(thread.cwd.as_path(), persisted_cwd); + assert_eq!(thread.cwd.as_path(), live_cwd); assert_eq!(cwd.as_path(), live_cwd); let update_id = mcp @@ -1222,7 +1502,15 @@ async fn cold_resume_reresolves_persisted_active_permission_profile() -> Result< for history_mode in [ThreadHistoryMode::Legacy, ThreadHistoryMode::Paginated] { let codex_home = TempDir::new()?; let previous_workspace_root = TempDir::new()?; + let profile_workspace = TempDir::new()?; write_dev_permission_config(&server.uri(), codex_home.path(), ":workspace")?; + let profile_root = serde_json::to_string(profile_workspace.path())?; + writeln!( + std::fs::OpenOptions::new() + .append(true) + .open(codex_home.path().join("config.toml"))?, + "\n[permissions.dev.workspace_roots]\n{profile_root} = true" + )?; let thread_id = { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -1275,10 +1563,9 @@ async fn cold_resume_reresolves_persisted_active_permission_profile() -> Result< extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) ); - assert!( - !runtime_workspace_roots.contains(&AbsolutePathBuf::from_absolute_path( - previous_workspace_root.path(), - )?) + assert_eq!( + runtime_workspace_roots, + vec![previous_workspace_root.path().abs()] ); } Ok(()) @@ -2165,7 +2452,7 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.path.as_ref().expect("thread path").is_absolute()); - assert_eq!(thread.cwd.as_path(), saved_cwd); + assert_eq!(thread.cwd.as_path(), cwd.as_path()); assert_eq!(cwd, test_absolute_path("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); @@ -4090,6 +4377,7 @@ async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Res parent_thread_id: None, timestamp: "2025-01-05T12:00:00Z".to_string(), cwd: repo_path.clone(), + runtime_workspace_roots: None, originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: RolloutSessionSource::Cli, @@ -4304,7 +4592,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is } #[tokio::test] -async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { +async fn thread_resume_checkpoints_settings_without_advancing_recency() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?; @@ -4335,12 +4623,16 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let ThreadResumeResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; - assert_eq!(thread.updated_at, before_resume.updated_at); assert_eq!(thread.recency_at, before_resume.recency_at); assert_eq!(thread.status, ThreadStatus::Idle); - let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; - assert_eq!(after_modified, rollout.before_modified); + let expected_thread_id = ThreadId::from_string(&thread_id)?; + let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout.rollout_file_path).await?; + assert!(items.iter().any(|item| matches!( + item, + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) + if event.thread_id == Some(expected_thread_id) + ))); let unsubscribe_id = mcp .send_thread_unsubscribe_request(ThreadUnsubscribeParams { @@ -4404,9 +4696,6 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { ) .await??; - let after_turn_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; - assert!(after_turn_modified > rollout.before_modified); - Ok(()) } @@ -5363,7 +5652,7 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< } #[tokio::test] -async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Result<()> { +async fn thread_resume_with_overrides_preserves_recency_and_checkpoints_model() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; mock_responses_config(&server.uri()).write(codex_home.path())?; @@ -5372,11 +5661,8 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re mut mcp, thread_id, rollout_file_path, - updated_at, + recency_at, } = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?; - let expected_updated_at_rfc3339 = "2025-01-07T00:00:00Z"; - set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?; - let before_modified = std::fs::metadata(&rollout_file_path)?.modified()?; let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { @@ -5390,11 +5676,19 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; - assert_eq!(resumed_thread.updated_at, updated_at); + assert_eq!(resumed_thread.recency_at, recency_at); assert_eq!(resumed_thread.status, ThreadStatus::Idle); - - let after_resume_modified = std::fs::metadata(&rollout_file_path)?.modified()?; - assert_eq!(after_resume_modified, before_modified); + let expected_thread_id = ThreadId::from_string(&resumed_thread.id)?; + let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_file_path).await?; + let persisted_model = items.iter().rev().find_map(|item| match item { + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) + if event.thread_id == Some(expected_thread_id) => + { + Some(event.thread_settings.model.as_str()) + } + _ => None, + }); + assert_eq!(persisted_model, Some("mock-model")); let turn_id = mcp .send_turn_start_request(TurnStartParams { @@ -5418,9 +5712,6 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re ) .await??; - let after_turn_modified = std::fs::metadata(&rollout_file_path)?.modified()?; - assert!(after_turn_modified > before_modified); - Ok(()) } @@ -5679,7 +5970,7 @@ struct RestartedThreadFixture { mcp: TestAppServer, thread_id: String, rollout_file_path: PathBuf, - updated_at: i64, + recency_at: Option, } async fn start_materialized_thread_and_restart( @@ -5735,7 +6026,7 @@ async fn start_materialized_thread_and_restart( let rollout_file_path = thread .path .ok_or_else(|| anyhow::anyhow!("thread path missing from thread/start response"))?; - let updated_at = thread.updated_at; + let recency_at = thread.recency_at; drop(first_mcp); @@ -5748,7 +6039,7 @@ async fn start_materialized_thread_and_restart( mcp: second_mcp, thread_id, rollout_file_path: rollout_file_path.to_path_buf(), - updated_at, + recency_at, }) } @@ -5889,7 +6180,6 @@ fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> { struct RolloutFixture { conversation_id: String, rollout_file_path: PathBuf, - before_modified: std::time::SystemTime, } async fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result { @@ -5914,10 +6204,8 @@ async fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result Result<()> { let updated_workspace = TempDir::new()?; let saved_cwd = AbsolutePathBuf::from_absolute_path(updated_workspace.path().canonicalize()?)? .into_path_buf(); + let extra_workspace = TempDir::new()?; + let saved_roots = vec![ + AbsolutePathBuf::from_absolute_path(&saved_cwd)?, + AbsolutePathBuf::from_absolute_path(extra_workspace.path().canonicalize()?)?, + ]; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; - // This fixture checks host-native cwd restoration across fork and revert. + // This fixture checks host-native cwd and workspace restoration across fork and revert. let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() @@ -221,11 +226,15 @@ async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> { }) .expect("inherited turn start ordinal"); let mut child_turns = Vec::new(); - for text in ["child first", "child second"] { + for (text, runtime_workspace_roots) in [ + ("child first", None), + ("child second", Some(saved_roots.clone())), + ] { let completed = mcp .start_turn_and_wait_for_completion(TurnStartParams { thread_id: child.id.clone(), cwd: Some(saved_cwd.clone()), + runtime_workspace_roots, input: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -272,7 +281,11 @@ async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> { .build() .await?; initialize_experimental(&mut mcp).await?; - let ThreadResumeResponse { cwd, .. } = mcp + let ThreadResumeResponse { + cwd, + runtime_workspace_roots, + .. + } = mcp .request(|request_id| ClientRequest::ThreadResume { request_id, params: ThreadResumeParams { @@ -281,12 +294,10 @@ async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> { }, }) .await?; - if expected_cutoff == fork_cutoff { - assert_eq!(cwd.as_path(), saved_cwd); - } else { - // Only parent-owned snapshots remain after reverting into inherited history. - assert_eq!(cwd.as_path(), child_meta.cwd); - } + assert_eq!( + (cwd.as_path(), runtime_workspace_roots), + (saved_cwd.as_path(), saved_roots.clone()) + ); mcp.start_turn_and_wait_for_completion(TurnStartParams { thread_id: child.id.clone(), input: vec![UserInput::Text { diff --git a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs index 6c799a4dbb..6cc196667b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs @@ -75,6 +75,7 @@ async fn timeline_pages_mix_items_and_resolve_the_opening_realtime_session() -> history_base: None, subagent_history_start_ordinal: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(codex_home.path().to_path_buf()), model_provider: "mock_provider".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index ad690591d6..3af590c312 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -262,6 +262,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { history_base: None, subagent_history_start_ordinal: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 61ebf719ba..dc5d668994 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1211,6 +1211,7 @@ async fn spawn_agent_fork_from_paginated_parent_uses_model_context_prefix() { permission_profile: PermissionProfile::workspace_write(), active_permission_profile: None, cwd: harness.config.cwd.clone(), + runtime_workspace_roots: None, reasoning_effort: None, reasoning_summary: None, personality: None, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 47b5ed05a7..5be8adadc0 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -138,6 +138,7 @@ impl ThreadConfigSnapshot { #[derive(Clone, Default)] pub struct CodexThreadSettingsOverrides { pub environments: Option, + pub runtime_workspace_roots: Option>, pub profile_workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, @@ -535,9 +536,17 @@ impl CodexThread { self.session.update_settings(updates).await.map(|_| ()) } + /// Persists current settings without emitting a live settings event. + /// + /// Serializes snapshot capture and persistence with accepted settings updates. + pub async fn checkpoint_thread_settings(&self) -> ThreadStoreResult<()> { + self.session.checkpoint_thread_settings().await + } + fn thread_settings_update(overrides: CodexThreadSettingsOverrides) -> SessionSettingsUpdate { let CodexThreadSettingsOverrides { environments, + runtime_workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -564,6 +573,7 @@ impl CodexThread { approvals_reviewer, }, environments, + runtime_workspace_roots, profile_workspace_roots, sandbox_policy, permission_profile, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 816f84ae0b..bce3d7a512 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -766,6 +766,7 @@ impl Session { windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), + runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -1472,8 +1473,14 @@ impl Session { self.state.lock().await.latest_token_usage_record = Self::last_token_usage_record_from_rollout(&rollout_items); + // Checkpoint effective settings even when no turn follows the resume. + self.persist_rollout_items(&[RolloutItem::EventMsg( + thread_settings::applied_event(self).await, + )]) + .await; + // Defer seeding the session's initial context until the first turn starts so - // turn/start overrides can be merged before we write to the rollout. + // turn/start overrides can be merged before we write model-visible context. if !is_subagent { let _ = self.flush_rollout().await; } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c43e21de2e..bb50e2f558 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -34,6 +34,7 @@ use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelections; use codex_skills::SkillError; use codex_utils_git_discovery::GitRootDiscovery; +use codex_utils_path::replace_path_and_deduplicate; use std::sync::OnceLock; use tokio::sync::Semaphore; @@ -108,6 +109,8 @@ pub(crate) struct SessionConfiguration { /// Legacy thread cwd used when a turn does not select an environment. pub(super) legacy_fallback_cwd: AbsolutePathBuf, + /// Top-level runtime workspace roots, independent of explicit environment selections. + pub(super) runtime_workspace_roots: Vec, /// Directory containing all Codex state for this session. pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. @@ -293,6 +296,7 @@ impl SessionConfiguration { permission_profile: self.materialized_permission_profile(environment_selections), active_permission_profile: self.active_permission_profile(), cwd: self.legacy_fallback_cwd.clone(), + runtime_workspace_roots: Some(self.runtime_workspace_roots.clone()), reasoning_effort: self.step_settings.collaboration_mode.reasoning_effort(), reasoning_summary: self.step_settings.reasoning_summary, personality: self.step_settings.personality, @@ -310,6 +314,7 @@ impl SessionConfiguration { self.legacy_fallback_cwd.clone(), environment_selections, )), + runtime_workspace_roots: Some(self.runtime_workspace_roots.clone()), profile_workspace_roots: Some( self.permission_profile_state .profile_workspace_roots() @@ -388,6 +393,15 @@ impl SessionConfiguration { next_configuration.legacy_fallback_cwd = environments.legacy_fallback_cwd.clone(); } let cwd_changed = next_configuration.legacy_fallback_cwd != current_cwd; + if let Some(runtime_workspace_roots) = &updates.runtime_workspace_roots { + next_configuration.runtime_workspace_roots = runtime_workspace_roots.clone(); + } else if cwd_changed { + next_configuration.runtime_workspace_roots = replace_path_and_deduplicate( + next_configuration.runtime_workspace_roots, + current_cwd.as_path(), + next_configuration.legacy_fallback_cwd.clone(), + ); + } if let Some(permission_profile) = updates.permission_profile.clone() { let active_permission_profile = @@ -549,6 +563,7 @@ pub(crate) struct SessionSettingsCommit { pub(crate) struct SessionSettingsUpdate { pub(crate) step_settings: StepSettingsUpdate, pub(crate) environments: Option, + pub(crate) runtime_workspace_roots: Option>, pub(crate) profile_workspace_roots: Option>, pub(crate) sandbox_policy: Option, pub(crate) permission_profile: Option, @@ -882,6 +897,7 @@ impl Session { initial_window_id: initial_auto_compact_window_ids .window_id .to_string(), + runtime_workspace_roots: Some(config.workspace_roots.clone()), metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 18cd2f04b9..24ab858613 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4486,6 +4486,7 @@ async fn set_rate_limits_retains_previous_credits() { windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), + runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -4605,6 +4606,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), + runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -4956,6 +4958,7 @@ async fn open_thread_persistence(session: &mut Session) -> PathBuf { subagent_history_start_ordinal: None, history_base: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), @@ -5249,6 +5252,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), + runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -5907,8 +5911,8 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up } #[tokio::test] -async fn compaction_checkpoint_waits_for_accepted_settings_persistence() { - let (mut session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( +async fn settings_checkpoint_waits_for_accepted_settings_persistence() { + let (mut session, _turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), |config| { @@ -5957,13 +5961,17 @@ async fn compaction_checkpoint_waits_for_accepted_settings_persistence() { }, ), )); + let mut settings_checkpoint = Box::pin(tokio::task::unconstrained( + session.checkpoint_thread_settings(), + )); assert!(futures::poll!(checkpoint.as_mut()).is_pending()); + assert!(futures::poll!(settings_checkpoint.as_mut()).is_pending()); assert_eq!( session.clone_history().await.annotated_items(), history_before.annotated_items() ); - // Direct runtime restoration may overlap postcommit work. The checkpoint must wait + // Direct runtime restoration may overlap postcommit work. Both checkpoints must wait // for the accepted event, then capture current settings rather than its older commit. let restored = session .update_settings(SessionSettingsUpdate { @@ -5980,8 +5988,18 @@ async fn compaction_checkpoint_waits_for_accepted_settings_persistence() { drop(refresh_guard); update.await.expect("accepted settings update"); checkpoint.await; + settings_checkpoint + .await + .expect("checkpoint current settings"); + + let live_snapshots = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event.msg { + EventMsg::ThreadSettingsApplied(event) => Some(event.thread_settings), + _ => None, + }) + .collect::>(); + assert_eq!(live_snapshots, vec![committed.clone()]); - session.flush_rollout().await.expect("flush checkpoint"); let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path) .await .expect("read persisted settings"); @@ -5998,6 +6016,7 @@ async fn compaction_checkpoint_waits_for_accepted_settings_persistence() { snapshots, vec![ (Some(session.thread_id), committed), + (Some(session.thread_id), restored.clone()), (Some(session.thread_id), restored), ] ); @@ -6304,6 +6323,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), + runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -6458,6 +6478,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), + runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -6760,6 +6781,7 @@ async fn make_session_with_config_and_rx( windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), + runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -6887,6 +6909,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), + runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -8222,6 +8245,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { subagent_history_start_ordinal: None, history_base: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), @@ -8333,6 +8357,7 @@ async fn submission_loop_channel_close_runs_full_thread_teardown() { subagent_history_start_ordinal: None, history_base: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), @@ -8761,6 +8786,7 @@ where windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, use_legacy_landlock: config.features.use_legacy_landlock(), legacy_fallback_cwd: config.cwd.clone(), + runtime_workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, original_config_do_not_use: Arc::clone(&config), @@ -10999,6 +11025,7 @@ async fn attach_in_memory_thread_store( subagent_history_start_ordinal: None, history_base: None, initial_window_id: Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), diff --git a/codex-rs/core/src/session/thread_settings.rs b/codex-rs/core/src/session/thread_settings.rs index 44b9d344b4..5194ab7c91 100644 --- a/codex-rs/core/src/session/thread_settings.rs +++ b/codex-rs/core/src/session/thread_settings.rs @@ -1,10 +1,11 @@ //! Handles persistent thread-settings updates and serializes their persistence -//! with compaction checkpoints. +//! with checkpoints written directly to storage. use super::session::Session; use super::session::SessionSettingsUpdate; use super::step_settings::StepSettingsUpdate; use crate::config::ConstraintResult; +use codex_history::RolloutItem; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -12,9 +13,24 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadSettingsAppliedEvent; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::ThreadSettingsSnapshot; +use codex_thread_store::ThreadStoreResult; use std::sync::Arc; use tokio::sync::SemaphorePermit; +impl Session { + /// Captures and flushes current settings under the shared persistence permit. + pub(crate) async fn checkpoint_thread_settings(&self) -> ThreadStoreResult<()> { + let _settings_guard = acquire_persistence_lock(self).await; + if let Some(live_thread) = self.live_thread() { + live_thread + .append_items(&[RolloutItem::EventMsg(applied_event(self).await)]) + .await?; + live_thread.flush().await?; + } + Ok(()) + } +} + /// Applies standalone thread settings and reports invalid overrides through the /// normal event stream. pub(super) async fn update( @@ -41,6 +57,7 @@ pub(super) async fn update( pub(super) fn prepare_update(overrides: ThreadSettingsOverrides) -> SessionSettingsUpdate { let ThreadSettingsOverrides { environments, + runtime_workspace_roots, profile_workspace_roots, approval_policy, approvals_reviewer, @@ -67,6 +84,7 @@ pub(super) fn prepare_update(overrides: ThreadSettingsOverrides) -> SessionSetti approvals_reviewer, }, environments, + runtime_workspace_roots, profile_workspace_roots, sandbox_policy, permission_profile, @@ -115,7 +133,7 @@ pub(super) async fn emit_applied( .await; } -/// Builds a current thread-owned snapshot for fork and compaction persistence. +/// Builds a current thread-owned snapshot for storage checkpoints. pub(super) async fn applied_event(session: &Session) -> EventMsg { EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent { thread_id: Some(session.thread_id()), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index f28dd835d7..e86c6b73dc 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -421,6 +421,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { parent_thread_id: None, timestamp: "2026-01-27T12:00:00Z".to_string(), cwd: codex_home.to_path_buf(), + runtime_workspace_roots: None, originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::default(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 66e431551d..3dbc19a944 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -522,6 +522,10 @@ pub struct ThreadSettingsOverrides { /// Updated fallback `cwd` and environments supplied together as a complete pair. pub environments: Option, + /// Updated top-level runtime workspace roots for default environments. + /// Explicit environment selections own their roots separately. + pub runtime_workspace_roots: Option>, + /// Updated profile-defined workspace roots for status summaries and /// per-turn config reconstruction. pub profile_workspace_roots: Option>, @@ -2203,6 +2207,12 @@ pub struct ThreadSettingsSnapshot { #[ts(optional)] pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + /// Top-level runtime workspace roots for default environments, excluding roots + /// supplied by explicit environment selections or permission profiles. + /// An absent value means unknown; an empty list means no roots. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub runtime_workspace_roots: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -3051,6 +3061,13 @@ pub struct SessionMeta { pub parent_thread_id: Option, pub timestamp: String, pub cwd: PathBuf, + /// Top-level runtime workspace roots at creation for default environments, + /// excluding roots supplied by explicit environment selections or permission profiles. + /// An absent value means unknown; an empty list means no roots. + /// Keep native paths parseable across hosts; validate them when restoring settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub runtime_workspace_roots: Option>, pub originator: String, pub cli_version: String, #[serde(default)] @@ -3112,6 +3129,7 @@ impl Default for SessionMeta { parent_thread_id: None, timestamp: String::new(), cwd: PathBuf::new(), + runtime_workspace_roots: None, originator: String::new(), cli_version: String::new(), source: SessionSource::default(), diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index d2ac7450a6..a46b2adb8e 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -681,6 +681,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) -> parent_thread_id: None, timestamp: "2025-01-03T12:00:00Z".to_string(), cwd: parent.to_path_buf(), + runtime_workspace_roots: None, originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index cefbe322a0..3e0ec6d494 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -124,6 +124,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { parent_thread_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: dir.path().to_path_buf(), + runtime_workspace_roots: None, originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), @@ -223,6 +224,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { parent_thread_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: dir.path().to_path_buf(), + runtime_workspace_roots: None, originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), @@ -568,6 +570,7 @@ fn write_rollout_in_sessions_with_cwd( parent_thread_id: None, timestamp: event_ts.to_string(), cwd, + runtime_workspace_roots: None, originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 0e41fc48da..8388541c64 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -111,6 +111,7 @@ pub enum RolloutRecorderParams { base_instructions: BaseInstructions, dynamic_tools: Vec, selected_capability_roots: Vec, + runtime_workspace_roots: Option>, multi_agent_version: Option, history_mode: ThreadHistoryMode, history_base: Option, @@ -208,6 +209,7 @@ impl RolloutRecorderParams { base_instructions, dynamic_tools, selected_capability_roots: Vec::new(), + runtime_workspace_roots: None, multi_agent_version: None, history_mode: Default::default(), history_base: None, @@ -251,6 +253,20 @@ impl RolloutRecorderParams { self } + pub fn with_runtime_workspace_roots( + mut self, + runtime_workspace_roots: Option>, + ) -> Self { + if let Self::Create { + runtime_workspace_roots: roots, + .. + } = &mut self + { + *roots = runtime_workspace_roots; + } + self + } + pub fn with_multi_agent_version( mut self, multi_agent_version: Option, @@ -852,6 +868,7 @@ impl RolloutRecorder { base_instructions, dynamic_tools, selected_capability_roots, + runtime_workspace_roots, multi_agent_version, history_mode, history_base, @@ -880,6 +897,7 @@ impl RolloutRecorder { parent_thread_id, timestamp, cwd: cwd.clone(), + runtime_workspace_roots, originator, cli_version: env!("CARGO_PKG_VERSION").to_string(), agent_nickname: source.get_nickname(), diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 2b4d847ecf..c9d4b1f0f4 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -196,6 +196,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { parent_thread_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: home.path().to_path_buf(), + runtime_workspace_roots: None, originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 47c0eaff37..5280d7a013 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -42,6 +42,7 @@ fn write_rollout_with_source_and_provider( parent_thread_id: None, timestamp, cwd: ".".into(), + runtime_workspace_roots: None, originator: "test_originator".into(), cli_version: "test_version".into(), source, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index ad38541c29..41e1c48a44 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -332,6 +332,7 @@ fn write_rollout_with_user_message( parent_thread_id: None, timestamp: "2026-06-01T14:26:25Z".to_string(), cwd: home.to_path_buf(), + runtime_workspace_roots: None, originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 4855915a25..2a70d4c235 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1494,6 +1494,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { parent_thread_id: None, timestamp: ts.to_string(), cwd: ".".into(), + runtime_workspace_roots: None, originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 5f20565531..1b04099352 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -404,6 +404,7 @@ mod tests { parent_thread_id: None, timestamp: "2026-02-26T00:00:00.000Z".to_string(), cwd: PathBuf::from("/child/worktree"), + runtime_workspace_roots: None, originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, @@ -620,6 +621,7 @@ mod tests { permission_profile: permission_profile.clone(), active_permission_profile: None, cwd: cwd.clone().try_into().expect("absolute settings cwd"), + runtime_workspace_roots: None, reasoning_effort: Some(ReasoningEffort::Ultra), reasoning_summary: Some(ReasoningSummary::Auto), personality: None, @@ -667,6 +669,7 @@ mod tests { parent_thread_id: None, timestamp: "2026-02-26T00:00:00.000Z".to_string(), cwd: PathBuf::from("/workspace"), + runtime_workspace_roots: None, originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index d1886bfee2..aacc960b04 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -2602,6 +2602,7 @@ mod tests { parent_thread_id: None, timestamp: metadata.created_at.to_rfc3339(), cwd: PathBuf::new(), + runtime_workspace_roots: None, originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, @@ -2673,6 +2674,7 @@ mod tests { parent_thread_id: None, timestamp: created_at, cwd: PathBuf::new(), + runtime_workspace_roots: None, originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index ffc571043c..924a0fe788 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -20,6 +20,7 @@ use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadMemoryMode; use codex_rollout::RolloutItem; use codex_rollout::persisted_rollout_items; +use codex_utils_absolute_path::AbsolutePathBuf; use crate::AppendThreadItemsParams; use crate::ArchiveThreadParams; @@ -144,6 +145,7 @@ mod tests { history_base: None, subagent_history_start_ordinal: None, initial_window_id: uuid::Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), @@ -437,6 +439,7 @@ mod tests { history_base: None, subagent_history_start_ordinal: None, initial_window_id: uuid::Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: thread_metadata(), } } @@ -549,6 +552,10 @@ impl InMemoryThreadStore { forked_from_id: params.forked_from_id, parent_thread_id: params.parent_thread_id, cwd: params.metadata.cwd.clone().unwrap_or_default(), + runtime_workspace_roots: params + .runtime_workspace_roots + .as_ref() + .map(|roots| roots.iter().map(AbsolutePathBuf::to_path_buf).collect()), agent_nickname: params.source.get_nickname(), agent_role: params.source.get_agent_role(), agent_path: params.source.get_agent_path().map(Into::into), diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 3be4c808bb..a8cd5b636e 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -6,6 +6,7 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_rollout::RolloutConfig; use codex_rollout::RolloutRecorder; use codex_rollout::RolloutRecorderParams; +use codex_utils_absolute_path::AbsolutePathBuf; pub(super) async fn create_thread( store: &LocalThreadStore, @@ -39,6 +40,12 @@ pub(super) async fn create_thread( ) .with_session_id(params.session_id) .with_selected_capability_roots(params.selected_capability_roots) + .with_runtime_workspace_roots(params.runtime_workspace_roots.map(|roots| { + roots + .into_iter() + .map(AbsolutePathBuf::into_path_buf) + .collect() + })) .with_multi_agent_version(params.multi_agent_version) .with_history_mode(params.history_mode) .with_history_base(params.history_base) diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 7d1e65edd3..a83134e443 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1963,6 +1963,7 @@ mod tests { history_base: None, subagent_history_start_ordinal: None, initial_window_id: uuid::Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: thread_metadata(), } } diff --git a/codex-rs/thread-store/src/local/pending_thread_metadata_tests.rs b/codex-rs/thread-store/src/local/pending_thread_metadata_tests.rs index 6811a0b3af..3e31298016 100644 --- a/codex-rs/thread-store/src/local/pending_thread_metadata_tests.rs +++ b/codex-rs/thread-store/src/local/pending_thread_metadata_tests.rs @@ -277,6 +277,7 @@ fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams { history_base: None, subagent_history_start_ordinal: None, initial_window_id: uuid::Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(std::env::current_dir().expect("cwd")), model_provider: "test-provider".to_string(), diff --git a/codex-rs/thread-store/src/local/revert_thread.rs b/codex-rs/thread-store/src/local/revert_thread.rs index c96cac26b3..6012462c44 100644 --- a/codex-rs/thread-store/src/local/revert_thread.rs +++ b/codex-rs/thread-store/src/local/revert_thread.rs @@ -170,6 +170,7 @@ async fn create_replacement_recorder( .with_session_id(source_meta.session_id) .with_rollout_id(rollout_id) .with_selected_capability_roots(source_meta.selected_capability_roots) + .with_runtime_workspace_roots(source_meta.runtime_workspace_roots) .with_multi_agent_version(source_meta.multi_agent_version) .with_history_mode(ThreadHistoryMode::Paginated) .with_history_base(history_base) diff --git a/codex-rs/thread-store/src/local/revert_thread_tests.rs b/codex-rs/thread-store/src/local/revert_thread_tests.rs index ebfc64d2db..137dbaeeee 100644 --- a/codex-rs/thread-store/src/local/revert_thread_tests.rs +++ b/codex-rs/thread-store/src/local/revert_thread_tests.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_rollout::RolloutItem; +use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -94,6 +95,10 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() { .meta; assert_eq!(replacement_meta.id, thread_id); assert_eq!(replacement_meta.memory_mode, None); + assert_eq!( + replacement_meta.runtime_workspace_roots, + Some(vec![home.path().join("workspace")]) + ); assert_eq!(turn_ids(&store, thread_id).await, vec!["turn-1"]); store @@ -242,6 +247,7 @@ async fn create_paginated_thread(store: &LocalThreadStore, thread_id: ThreadId) history_base: None, subagent_history_start_ordinal: None, initial_window_id: "window-1".to_string(), + runtime_workspace_roots: Some(vec![store.config.codex_home.join("workspace").abs()]), metadata: ThreadPersistenceMetadata { cwd: Some(std::env::current_dir().expect("cwd")), model_provider: "test-provider".to_string(), diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index 8b8ded8e67..7e8401aa03 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -1,10 +1,13 @@ use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; use codex_install_context::InstallContext; use codex_rollout::RolloutConfig; use codex_rollout::first_rollout_content_match_snippet; use codex_rollout::parse_cursor; use codex_rollout::search_rollout_matches; +use codex_utils_absolute_path::normalize_windows_device_path; use super::LocalThreadStore; use super::helpers::resolve_thread_names; @@ -106,7 +109,10 @@ pub(super) async fn search_threads( relation_filter: None, use_state_db_only: state_db.is_some(), }; - let mut remaining_rollouts = matching_rollouts; + let mut remaining_rollouts = matching_rollouts + .into_iter() + .map(|(path, snippet)| (rollout_search_path(&path), snippet)) + .collect::>(); loop { let page = list_rollout_threads( @@ -120,7 +126,7 @@ pub(super) async fn search_threads( ) .await?; for item in page.items { - let logical_path = codex_rollout::plain_rollout_path(item.path.as_path()); + let logical_path = rollout_search_path(item.path.as_path()); let Some(snippet) = (match remaining_rollouts.remove(logical_path.as_path()) { Some(Some(snippet)) => Some(snippet), Some(None) => first_rollout_content_match_snippet(item.path.as_path(), search_term) @@ -195,6 +201,19 @@ pub(super) async fn search_threads( Ok(ThreadSearchPage { items, next_cursor }) } +fn rollout_search_path(path: &Path) -> PathBuf { + let path = codex_rollout::plain_rollout_path(path); + // Resume can persist a Windows namespace prefix while filesystem search returns the + // ordinary spelling. Normalize both join keys without requiring the uncompressed file + // to exist, and retain the filename identifying the selected rollout after a revert. + if cfg!(windows) + && let Some(normalized) = path.to_str().and_then(normalize_windows_device_path) + { + return PathBuf::from(normalized); + } + path +} + fn cursor_from_thread_search_item( item: &ThreadSearchItem, sort_key: ThreadSortKey, diff --git a/codex-rs/thread-store/src/local/search_threads_tests.rs b/codex-rs/thread-store/src/local/search_threads_tests.rs index bfcf768276..959882e243 100644 --- a/codex-rs/thread-store/src/local/search_threads_tests.rs +++ b/codex-rs/thread-store/src/local/search_threads_tests.rs @@ -1,10 +1,21 @@ +use chrono::Utc; use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadHistoryMode; use codex_rollout::ThreadItem; use pretty_assertions::assert_eq; +use tempfile::TempDir; +use uuid::Uuid; +use super::super::LocalThreadStore; +use super::super::test_support::test_config; +use super::super::test_support::write_session_file_with; use super::ThreadSearchItem; use super::cursor_from_thread_search_item; +use crate::SearchThreadsParams; +use crate::SortDirection; use crate::ThreadSortKey; +use crate::ThreadStore; #[test] fn recency_cursor_includes_thread_id_tie_breaker() { @@ -27,3 +38,126 @@ fn recency_cursor_includes_thread_id_tie_breaker() { format!("\"2026-01-27T12:34:56Z|{thread_id}\"") ); } + +#[tokio::test] +async fn search_matches_selected_rollout_across_path_spellings_and_compression() { + for compressed in [false, true] { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let state_db = codex_state::StateRuntime::init( + config.sqlite.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("initialize state database"); + let uuid = Uuid::new_v4(); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("thread ID should parse"); + let selected_path = write_session_file_with( + home.path(), + home.path().join("sessions/2025/01/03"), + "2025-01-03T12-00-00", + uuid, + "needle in selected rollout", + Some("test-provider"), + ThreadHistoryMode::Legacy, + ) + .expect("write selected rollout"); + // Reverting a thread leaves its previous rollout on disk with the same thread ID. + write_session_file_with( + home.path(), + home.path().join("sessions/2025/01/03"), + "2025-01-03T11-00-00", + uuid, + "obsolete needle in previous rollout", + Some("test-provider"), + ThreadHistoryMode::Legacy, + ) + .expect("write unselected rollout"); + + let home_paths = [home.path().to_path_buf()]; + #[cfg(windows)] + let home_paths = { + let verbatim = std::fs::canonicalize(&home_paths[0]).expect("canonicalize home"); + let ordinary = + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&verbatim) + .expect("normalize home") + .into_path_buf(); + [ordinary, verbatim] + }; + let relative_path = selected_path + .strip_prefix(home.path()) + .expect("relative rollout path"); + let selected_paths = home_paths.each_ref().map(|home| home.join(relative_path)); + #[cfg(windows)] + assert_ne!(selected_paths[0], selected_paths[1]); + + if compressed { + let contents = std::fs::read(&selected_path).expect("read selected rollout"); + let contents = zstd::stream::encode_all(contents.as_slice(), /*level*/ 3) + .expect("compress selected rollout"); + std::fs::write(selected_path.with_extension("jsonl.zst"), contents) + .expect("write compressed rollout"); + std::fs::remove_file(&selected_path).expect("remove plain rollout"); + } + let selected_paths = selected_paths + .into_iter() + .flat_map(|path| { + let compressed_path = compressed.then(|| path.with_extension("jsonl.zst")); + std::iter::once(path).chain(compressed_path) + }) + .collect::>(); + + for codex_home in home_paths { + let store = LocalThreadStore::new( + super::super::LocalThreadStoreConfig { + codex_home, + ..config.clone() + }, + Some(state_db.clone()), + ); + for selected_path in &selected_paths { + let mut metadata = codex_state::ThreadMetadataBuilder::new( + thread_id, + selected_path.clone(), + Utc::now(), + SessionSource::Cli, + ) + .build("test-provider"); + metadata.first_user_message = Some("needle in selected rollout".to_string()); + state_db + .upsert_thread(&metadata) + .await + .expect("select rollout in database"); + + for (search_term, expected) in [ + ("needle", vec![(thread_id, "needle in selected rollout")]), + ("obsolete", Vec::new()), + ] { + let page = store + .search_threads(SearchThreadsParams { + page_size: 10, + cursor: None, + sort_key: ThreadSortKey::CreatedAt, + sort_direction: SortDirection::Desc, + allowed_sources: vec![SessionSource::Cli], + archived: false, + search_term: search_term.to_string(), + }) + .await + .expect("search selected rollout"); + assert_eq!( + ( + page.items + .iter() + .map(|item| (item.thread.thread_id, item.snippet.as_str())) + .collect::>(), + page.next_cursor, + ), + (expected, None), + "compressed={compressed}, selected_path={selected_path:?}", + ); + } + } + } + } +} diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index f0b71bbbb0..2ea0ac86ef 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -599,6 +599,7 @@ async fn paginated_realtime_items_materialize_separately_in_rollout_order() { history_base: None, subagent_history_start_ordinal: None, initial_window_id: "window-1".to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(home.path().to_path_buf()), model_provider: "test-provider".to_string(), @@ -2586,6 +2587,7 @@ async fn create_paginated_subagent_thread( history_base, subagent_history_start_ordinal, initial_window_id: "window-1".to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(std::env::current_dir().expect("cwd")), model_provider: "test-provider".to_string(), diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 0ea33257c0..22ee5533ff 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -460,6 +460,7 @@ mod tests { history_base: None, subagent_history_start_ordinal: None, initial_window_id: uuid::Uuid::now_v7().to_string(), + runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), @@ -663,6 +664,7 @@ mod tests { permission_profile: permission_profile.clone(), active_permission_profile: None, cwd: cwd.clone().try_into().expect("absolute settings cwd"), + runtime_workspace_roots: None, reasoning_effort: Some(ReasoningEffort::Ultra), reasoning_summary: Some(ReasoningSummary::Auto), personality: None, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 877533a6dd..67105ae595 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use codex_rollout::RolloutItem; +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; @@ -104,6 +105,9 @@ pub struct CreateThreadParams { pub subagent_history_start_ordinal: Option, /// Initial context-window identity captured when the thread was created. pub initial_window_id: String, + /// Runtime workspace roots at creation, excluding permission-profile roots. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_workspace_roots: Option>, /// Metadata captured for the newly created thread. pub metadata: ThreadPersistenceMetadata, } diff --git a/codex-rs/utils/path-utils/src/lib.rs b/codex-rs/utils/path-utils/src/lib.rs index 067c75e90c..0bdfca9248 100644 --- a/codex-rs/utils/path-utils/src/lib.rs +++ b/codex-rs/utils/path-utils/src/lib.rs @@ -1,4 +1,4 @@ -//! Path normalization, symlink resolution, and atomic writes shared across Codex crates. +//! Path normalization, replacement, symlink resolution, and atomic writes. pub(crate) mod env; pub use env::is_wsl; @@ -8,6 +8,7 @@ pub use system_commands::system_path; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashSet; +use std::hash::Hash; use std::io; use std::io::Write; use std::path::Path; @@ -32,6 +33,24 @@ pub fn paths_match_after_normalization(left: impl AsRef, right: impl AsRef left.as_ref() == right.as_ref() } +/// Replace paths equal to `old_path` and remove duplicates, preserving their order. +/// +/// Other paths, including descendants of `old_path`, remain unchanged. This uses +/// path equality without filesystem access or normalization; callers validate paths. +pub fn replace_path_and_deduplicate

(mut paths: Vec

, old_path: &Path, new_path: P) -> Vec

+where + P: AsRef + Clone + Eq + Hash, +{ + for path in &mut paths { + if path.as_ref() == old_path { + *path = new_path.clone(); + } + } + let mut seen = HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); + paths +} + pub fn normalize_for_native_workdir(path: impl AsRef) -> PathBuf { normalize_for_native_workdir_with_flag(path.as_ref().to_path_buf(), cfg!(windows)) } diff --git a/codex-rs/utils/path-utils/src/path_utils_tests.rs b/codex-rs/utils/path-utils/src/path_utils_tests.rs index 94c914fec2..9a79210b3c 100644 --- a/codex-rs/utils/path-utils/src/path_utils_tests.rs +++ b/codex-rs/utils/path-utils/src/path_utils_tests.rs @@ -1,3 +1,24 @@ +use super::replace_path_and_deduplicate; +use pretty_assertions::assert_eq; +use std::path::Path; + +#[test] +fn replace_path_and_deduplicate_preserves_other_paths_and_order() { + let old_path = Path::new("old"); + let new_path = Path::new("new"); + let extra = Path::new("extra"); + let descendant = Path::new("old/child"); + + assert_eq!( + replace_path_and_deduplicate( + vec![old_path, extra, descendant, new_path, extra], + old_path, + new_path, + ), + vec![new_path, extra, descendant] + ); +} + #[cfg(unix)] mod symlinks { use super::super::resolve_symlink_write_paths;