mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
BIN
Binary file not shown.
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user