Preserve runtime workspace roots across thread resume (#43848)

## Why

Resuming a thread should retain its selected workspace folders, including additional roots and explicit empty selections. Resume overrides also need to survive a subsequent resume when no turn has run.

## What changed

- Persist `runtime_workspace_roots` in startup metadata and thread settings snapshots, separately from explicit environment selections and permission-profile roots.
- Restore roots from the latest snapshot owned by the resumed thread, falling back to owned startup metadata only when no snapshot exists. Honor explicit `runtimeWorkspaceRoots` overrides, retarget the old `cwd` root when `cwd` changes, deduplicate roots, and validate restored paths for the current host.
- Checkpoint effective settings on resume and restored settings after revert. Reload resume configuration if saved workspace roots change during loading.
- Normalize Windows rollout path spellings when matching thread search results, preserving selection of the correct rollout after revert, including compressed rollouts.

## Testing

Add regression coverage for workspace restoration, empty and explicit overrides, foreign paths, compaction and revert, resume checkpoints without recency changes, concurrent settings persistence, and rollout search path matching.

GitOrigin-RevId: d98d9d34dd63934d441120916c61c12b69e7f062
This commit is contained in:
rhan-oai
2026-09-08 16:24:38 +00:00
committed by copyberry
parent dd9512c000
commit 6515a72db7
46 changed files with 984 additions and 135 deletions
@@ -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,
@@ -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,
@@ -84,6 +84,7 @@ struct ThreadListFilters {
#[derive(PartialEq)]
struct ResumeConfigState {
history_cwd: Option<PathBuf>,
workspace_roots: Option<Vec<AbsolutePathBuf>>,
persisted_metadata: Option<ThreadMetadata>,
persisted_settings: Option<PersistedResumeSettings>,
}
@@ -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::<Result<Vec<_>, _>>()?;
// 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) => {
@@ -113,9 +113,16 @@ fn map_additional_context(
.collect()
}
#[derive(Default)]
struct ThreadEnvironmentOverride {
environments: Option<TurnEnvironmentSelections>,
// Only default-environment updates replace the task's separately persisted root selection.
runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
}
struct ThreadSettingsBuildParams {
method: &'static str,
environments: Option<TurnEnvironmentSelections>,
environment_override: ThreadEnvironmentOverride,
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
sandbox_policy: Option<codex_app_server_protocol::SandboxPolicy>,
@@ -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<AbsolutePathBuf>,
workspace_roots: Option<Vec<AbsolutePathBuf>>,
environment_selections: Option<Vec<TurnEnvironmentSelection>>,
) -> Option<TurnEnvironmentSelections> {
) -> 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<codex_protocol::protocol::ThreadSettingsOverrides, JSONRPCErrorError> {
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,
@@ -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,
@@ -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(),
@@ -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(())
}
@@ -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,
@@ -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| {
@@ -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(),
@@ -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(),
@@ -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<i64>,
}
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<RolloutFixture> {
@@ -5914,10 +6204,8 @@ async fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result<Ro
append_rollout_item_to_path(&rollout_file_path, &RolloutItem::SessionMeta(session_meta))
.await?;
set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?;
let before_modified = std::fs::metadata(&rollout_file_path)?.modified()?;
Ok(RolloutFixture {
conversation_id,
rollout_file_path,
before_modified,
})
}
@@ -155,8 +155,13 @@ async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> 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 {
@@ -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(),
@@ -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(),
+1
View File
@@ -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,
+10
View File
@@ -138,6 +138,7 @@ impl ThreadConfigSnapshot {
#[derive(Clone, Default)]
pub struct CodexThreadSettingsOverrides {
pub environments: Option<TurnEnvironmentSelections>,
pub runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
pub approval_policy: Option<AskForApproval>,
pub approvals_reviewer: Option<ApprovalsReviewer>,
@@ -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,
+8 -1
View File
@@ -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;
}
+16
View File
@@ -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<AbsolutePathBuf>,
/// 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<TurnEnvironmentSelections>,
pub(crate) runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
pub(crate) profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
pub(crate) sandbox_policy: Option<SandboxPolicy>,
pub(crate) permission_profile: Option<PermissionProfile>,
@@ -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(),
+31 -4
View File
@@ -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::<Vec<_>>();
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(),
+20 -2
View File
@@ -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()),
@@ -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(),
+18
View File
@@ -522,6 +522,10 @@ pub struct ThreadSettingsOverrides {
/// Updated fallback `cwd` and environments supplied together as a complete pair.
pub environments: Option<TurnEnvironmentSelections>,
/// Updated top-level runtime workspace roots for default environments.
/// Explicit environment selections own their roots separately.
pub runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
/// Updated profile-defined workspace roots for status summaries and
/// per-turn config reconstruction.
pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
@@ -2203,6 +2207,12 @@ pub struct ThreadSettingsSnapshot {
#[ts(optional)]
pub active_permission_profile: Option<ActivePermissionProfile>,
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<Vec<AbsolutePathBuf>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffortConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -3051,6 +3061,13 @@ pub struct SessionMeta {
pub parent_thread_id: Option<ThreadId>,
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<Vec<PathBuf>>,
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(),
@@ -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,
+3
View File
@@ -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(),
+18
View File
@@ -111,6 +111,7 @@ pub enum RolloutRecorderParams {
base_instructions: BaseInstructions,
dynamic_tools: Vec<DynamicToolSpec>,
selected_capability_roots: Vec<SelectedCapabilityRoot>,
runtime_workspace_roots: Option<Vec<PathBuf>>,
multi_agent_version: Option<MultiAgentVersion>,
history_mode: ThreadHistoryMode,
history_base: Option<HistoryPosition>,
@@ -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<Vec<PathBuf>>,
) -> 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<MultiAgentVersion>,
@@ -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(),
+1
View File
@@ -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,
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
+3
View File
@@ -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,
+2
View File
@@ -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,
+7
View File
@@ -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),
@@ -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)
+1
View File
@@ -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(),
}
}
@@ -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(),
@@ -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)
@@ -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(),
@@ -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::<HashMap<_, _>>();
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,
@@ -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::<Vec<_>>();
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::<Vec<_>>(),
page.next_cursor,
),
(expected, None),
"compressed={compressed}, selected_path={selected_path:?}",
);
}
}
}
}
}
@@ -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(),
@@ -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,
+4
View File
@@ -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<u64>,
/// 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<Vec<AbsolutePathBuf>>,
/// Metadata captured for the newly created thread.
pub metadata: ThreadPersistenceMetadata,
}
+20 -1
View File
@@ -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<Path>, 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<P>(mut paths: Vec<P>, old_path: &Path, new_path: P) -> Vec<P>
where
P: AsRef<Path> + 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<Path>) -> PathBuf {
normalize_for_native_workdir_with_flag(path.as_ref().to_path_buf(), cfg!(windows))
}
@@ -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;