Bind capability roots to environment selections (#51493)

## Why

Capability discovery needs the roots captured with each environment snapshot. Threads sharing an executor can select different roots, and changing environment order must preserve executor skill aliases.

## What changed

- Attach the receiving thread's capability roots when constructing `TurnEnvironmentSelection` at startup and settings updates, including roots loaded from saved history.
- Resolve capability roots from environment snapshots while preserving their original order. Retain startup roots so deselecting and reselecting an environment restores its capabilities.
- Match inherited configuration by environment and workspace so differences in capability root metadata do not interrupt configuration propagation.

## Testing

Add regression coverage for root ordering across environment states and configuration changes, independent root selections on a shared executor, reselection with startup or saved roots, active turn updates, and pending configuration failures reaching child and grandchild threads.

GitOrigin-RevId: 4f2e4bb414d27195a6752406117a6050718a1198
This commit is contained in:
Adam Perry @ OpenAI
2026-10-06 22:39:32 +00:00
committed by copyberry
parent 28cd3e2501
commit 9479e1fdb7
34 changed files with 534 additions and 93 deletions
+36 -9
View File
@@ -31,6 +31,7 @@ use crate::thread_manager::ForkSnapshot;
use crate::thread_manager::StartThreadOptions;
use crate::tools::handlers::multi_agents_common::thread_spawn_source;
use assert_matches::assert_matches;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::Instructions;
use codex_extension_api::LoadInstructionsFuture;
use codex_extension_api::LoadedUserInstructions;
@@ -45,6 +46,8 @@ use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::AgentPath;
use codex_protocol::ResponseItemId;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
@@ -1925,6 +1928,7 @@ async fn v2_spawn_resolves_reported_effort_without_changing_child_selection(
parent.shutdown_and_wait().await.expect("shutdown parent");
}
/// Pending configuration reaches descendants even when their root metadata differs.
#[tokio::test]
async fn pending_environment_failure_reaches_child_and_grandchild() {
let (home, mut config) = test_config().await;
@@ -1940,15 +1944,31 @@ async fn pending_environment_failure_reaches_child_and_grandchild() {
let harness = AgentControlHarness::new_with_config(home, config).await;
let cwd = PathUri::from_abs_path(&harness.config.codex_home);
let pending = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd.clone(),
workspace_roots: vec![cwd],
workspace_roots: vec![cwd.clone()],
config: EnvironmentConfigState::Pending,
};
// Only the second root's environment is selected. Inherited root lists can therefore
// give this root a different position without changing which configuration to follow.
let roots = ["unselected", codex_exec_server::LOCAL_ENVIRONMENT_ID]
.into_iter()
.map(|environment_id| SelectedCapabilityRoot {
id: environment_id.to_string(),
location: CapabilityRootLocation::Environment {
environment_id: environment_id.to_string(),
path: cwd.clone(),
},
})
.collect::<Vec<_>>();
let mut thread_extension_init = ExtensionDataInit::new();
thread_extension_init.insert(roots);
let root = harness
.manager
.start_thread(StartThreadOptions {
environments: Some(vec![pending.clone().into_request()]),
thread_extension_init,
..StartThreadOptions::new(harness.config.clone())
})
.await
@@ -1969,8 +1989,16 @@ async fn pending_environment_failure_reaches_child_and_grandchild() {
.get_thread(agent.thread_id)
.await
.expect("get descendant");
assert_eq!(thread.environment_selections().await, vec![pending.clone()]);
descendants.push(Arc::clone(&thread));
let selections = thread.environment_selections().await;
assert_eq!(
selections
.clone()
.into_iter()
.map(TurnEnvironmentSelection::into_request)
.collect::<Vec<_>>(),
vec![pending.clone().into_request()],
);
descendants.push((Arc::clone(&thread), selections));
parent = thread;
}
@@ -1978,13 +2006,12 @@ async fn pending_environment_failure_reaches_child_and_grandchild() {
root.environment_failed(&pending, error.to_string())
.await
.expect("fail root environment");
let failed = TurnEnvironmentSelection {
config: EnvironmentConfigState::Failed(error.to_string()),
..pending
};
timeout(Duration::from_secs(/*secs*/ 5), async {
for thread in descendants {
while thread.environment_selections().await != [failed.clone()] {
for (thread, mut expected) in descendants {
for selection in &mut expected {
selection.config = EnvironmentConfigState::Failed(error.to_string());
}
while thread.environment_selections().await != expected {
sleep(Duration::from_millis(/*millis*/ 10)).await;
}
}
+1
View File
@@ -368,6 +368,7 @@ fn resolved_local_environments<const N: usize>(
.map(|(environment_id, cwd)| {
TurnEnvironmentState::Ready(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: environment_id.to_string(),
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: Vec::new(),
+2 -1
View File
@@ -749,7 +749,8 @@ impl CodexThread {
approval_policy,
approvals_reviewer,
},
environments: environment_requests.map(TurnEnvironmentRequests::select),
environments: environment_requests
.map(|requests| requests.select(&self.session.services.selected_capability_roots)),
runtime_workspace_roots,
profile_workspace_roots,
sandbox_policy,
@@ -426,6 +426,7 @@ fn failure_context_limits_total_detail_bytes_at_utf8_boundaries() {
.map(|index| TurnEnvironmentState::Failed {
required_skills: Vec::new(),
selection: TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: format!("remote-{index}"),
cwd: PathUri::parse("file:///workspace").unwrap(),
workspace_roots: Vec::new(),
+31 -3
View File
@@ -16,6 +16,7 @@ use codex_exec_server::ExecServerError;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::SelectedCapabilityRootsStatus;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::EnvironmentCapabilityRoots;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
@@ -418,9 +419,7 @@ impl ThreadEnvironments {
}
let previous = current.iter().find(|environment| {
let previous = &environment.selection;
previous.environment_id == selected_environment.environment_id
&& previous.cwd == selected_environment.cwd
&& previous.workspace_roots == selected_environment.workspace_roots
previous.has_same_workspace(selected_environment)
});
let (selected_environment, config_origin) =
resolve_selection_config(selected_environment.clone(), thread_defaults);
@@ -1058,6 +1057,18 @@ pub struct TurnEnvironmentSnapshot {
}
impl TurnEnvironmentSnapshot {
/// Keeps the original root order for child threads and saved history.
pub(crate) fn selected_capability_roots(&self) -> Vec<SelectedCapabilityRoot> {
EnvironmentCapabilityRoots::collect(self.environments.iter().map(|environment| {
let selection = match environment {
TurnEnvironmentState::Ready(environment) => &environment.selection,
TurnEnvironmentState::Starting(environment) => &environment.selection,
TurnEnvironmentState::Failed { selection, .. } => selection,
};
&selection.selected_capability_roots
}))
}
pub(crate) fn has_full_access(
&self,
approval_policy: AskForApproval,
@@ -1550,6 +1561,7 @@ url = "ws://127.0.0.1:8765"
/*non_blocking_snapshots*/ false,
);
turn_environments.update_selections(&[TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: Vec::new(),
@@ -1582,6 +1594,7 @@ url = "ws://127.0.0.1:8765"
let cwd_uri = PathUri::from_abs_path(&cwd);
let manager = Arc::new(EnvironmentManager::default_for_tests());
let first = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd_uri.clone(),
workspace_roots: Vec::new(),
@@ -1593,6 +1606,7 @@ url = "ws://127.0.0.1:8765"
&[
first.clone(),
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd_uri.join("other").expect("other cwd URI"),
workspace_roots: Vec::new(),
@@ -1615,6 +1629,7 @@ url = "ws://127.0.0.1:8765"
let resolved = resolve_turn_environments(
Arc::clone(&manager),
&[TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "local".to_string(),
cwd: selected_cwd_uri,
workspace_roots: Vec::new(),
@@ -1655,6 +1670,7 @@ url = "ws://127.0.0.1:8765"
let cwd_uri = PathUri::from_abs_path(&cwd);
let manager = Arc::new(EnvironmentManager::default_for_tests());
let local = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd_uri.clone(),
workspace_roots: Vec::new(),
@@ -1665,6 +1681,7 @@ url = "ws://127.0.0.1:8765"
manager,
&[
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "missing".to_string(),
cwd: cwd_uri,
workspace_roots: Vec::new(),
@@ -1708,6 +1725,7 @@ url = "ws://127.0.0.1:8765"
.await,
);
let selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")),
workspace_roots: Vec::new(),
@@ -1814,12 +1832,14 @@ url = "ws://127.0.0.1:8765"
};
let cwd = PathUri::from_abs_path(&cwd);
let remote = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: cwd.clone(),
workspace_roots: Vec::new(),
config: EnvironmentConfigState::FromThread,
};
let local = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd,
workspace_roots: Vec::new(),
@@ -1999,6 +2019,7 @@ url = "ws://127.0.0.1:8765"
.await,
);
let selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd")),
workspace_roots: Vec::new(),
@@ -2101,6 +2122,7 @@ url = "ws://127.0.0.1:8765"
.await,
);
let selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: Vec::new(),
@@ -2186,6 +2208,7 @@ url = "ws://127.0.0.1:8765"
async fn inherited_environment_reuses_parent_handle_and_uses_child_config() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: Vec::new(),
@@ -2272,6 +2295,7 @@ url = "ws://127.0.0.1:8765"
async fn owner_environment_config_is_inherited_and_reset_for_new_cwd() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: Vec::new(),
@@ -2375,6 +2399,7 @@ url = "ws://127.0.0.1:8765"
let local = resolve_turn_environments(
Arc::clone(&local_manager),
&[TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd_uri.clone(),
workspace_roots: Vec::new(),
@@ -2390,6 +2415,7 @@ url = "ws://127.0.0.1:8765"
let remote = TurnEnvironmentSnapshot {
environments: vec![TurnEnvironmentState::Ready(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: remote_cwd_uri.clone(),
workspace_roots: Vec::new(),
@@ -2404,6 +2430,7 @@ url = "ws://127.0.0.1:8765"
environments: vec![
TurnEnvironmentState::Ready(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: remote_cwd_uri,
workspace_roots: Vec::new(),
@@ -2431,6 +2458,7 @@ url = "ws://127.0.0.1:8765"
let snapshot = TurnEnvironmentSnapshot {
environments: vec![TurnEnvironmentState::Starting(StartingTurnEnvironment {
selection: TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: Vec::new(),
@@ -12,10 +12,125 @@ use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio::time::timeout;
/// Roots stay attached through config changes and retain API ordering across environment states.
#[tokio::test]
async fn capability_roots_follow_attachments_through_configuration_changes() {
let manager = Arc::new(EnvironmentManager::default_for_tests());
manager
.upsert_environment(
"other".to_string(),
"http://example.com".to_string(),
/*connect_timeout*/ None,
)
.expect("register other environment");
let cwd = PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd"));
let mut local = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd.clone(),
workspace_roots: Vec::new(),
config: EnvironmentConfigState::Pending,
};
let mut other = TurnEnvironmentSelection {
environment_id: "other".to_string(),
config: EnvironmentConfigState::Failed("offline".to_string()),
..local.clone()
};
let roots = [(&other, "first"), (&local, "second"), (&other, "third")]
.into_iter()
.map(|(selection, id)| SelectedCapabilityRoot {
id: id.to_string(),
location: CapabilityRootLocation::Environment {
environment_id: selection.environment_id.clone(),
path: cwd.clone(),
},
})
.collect::<Vec<_>>();
local.selected_capability_roots =
EnvironmentCapabilityRoots::for_environment(&local.environment_id, &roots);
other.selected_capability_roots =
EnvironmentCapabilityRoots::for_environment(&other.environment_id, &roots);
let parent = ThreadEnvironments::new(
Arc::clone(&manager),
crate::shell::default_user_shell(),
ThreadEnvironmentDefaults::new(tests::test_environment_config(), SandboxType::None),
ShellSnapshot::disabled(),
TurnEnvironmentSnapshot::default(),
/*non_blocking_snapshots*/ false,
);
parent.update_selections(&[local.clone(), other.clone()]);
let snapshot = parent.snapshot().await;
assert_eq!(snapshot.selected_capability_roots(), roots);
assert!(matches!(
&snapshot.environments[0],
TurnEnvironmentState::Starting(_)
));
assert!(matches!(
&snapshot.environments[1],
TurnEnvironmentState::Failed { .. }
));
let child = ThreadEnvironments::new(
Arc::clone(&manager),
crate::shell::default_user_shell(),
ThreadEnvironmentDefaults::new(tests::test_environment_config(), SandboxType::None),
ShellSnapshot::disabled(),
snapshot,
/*non_blocking_snapshots*/ false,
);
assert_eq!(
child.snapshot().await.selected_capability_roots(),
vec![roots[1].clone()]
);
local.config = EnvironmentConfigState::Ready(tests::test_environment_config());
parent.update_selections(&[local.clone(), other.clone()]);
let ready = parent.snapshot().await;
assert!(matches!(
&ready.environments[0],
TurnEnvironmentState::Ready(_)
));
assert_eq!(ready.selected_capability_roots(), roots);
local.cwd = cwd.join("changed-cwd").expect("changed cwd");
parent.update_selections(&[other.clone(), local.clone()]);
assert_eq!(parent.snapshot().await.selected_capability_roots(), roots);
// A second thread selects different roots on the same executor without changing the parent.
let independent_roots = vec![SelectedCapabilityRoot {
id: "independent".to_string(),
..roots[1].clone()
}];
local.selected_capability_roots =
EnvironmentCapabilityRoots::for_environment(&local.environment_id, &independent_roots);
child.update_selections(std::slice::from_ref(&local));
assert_eq!(
child.snapshot().await.selected_capability_roots(),
independent_roots
);
assert_eq!(parent.snapshot().await.selected_capability_roots(), roots);
assert!(Arc::ptr_eq(
&child
.snapshot()
.await
.primary()
.expect("child executor")
.environment,
&parent
.snapshot()
.await
.turn_environments()
.find(|environment| environment.selection.environment_id == LOCAL_ENVIRONMENT_ID)
.expect("parent executor")
.environment,
));
}
#[tokio::test]
async fn unpolled_snapshot_does_not_delay_canceling_a_removed_environment() {
let cwd = PathUri::from_abs_path(&AbsolutePathBuf::current_dir().expect("cwd"));
let selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd.clone(),
workspace_roots: vec![cwd],
@@ -68,6 +183,7 @@ async fn updating_another_environment_retries_the_executor_without_canceling_pen
.unwrap();
let cwd = PathUri::from_abs_path(&AbsolutePathBuf::current_dir().unwrap());
let mut local = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd,
workspace_roots: Vec::new(),
@@ -159,6 +275,7 @@ async fn credential_refresh_does_not_restore_a_removed_environment() {
/*non_blocking_snapshots*/ false,
));
environments.update_selections(&[TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd_uri.clone(),
workspace_roots: vec![cwd_uri],
+1
View File
@@ -1303,6 +1303,7 @@ async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Resul
.environments
.push(TurnEnvironmentState::Ready(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "remote".to_string(),
cwd: secondary_cwd.clone(),
workspace_roots: Vec::new(),
+11 -12
View File
@@ -190,11 +190,10 @@ impl Session {
let mut environments = configuration.environments.clone();
let latest_environments = &state.session_configuration.environments;
for environment in &mut environments {
if let Some(latest) = latest_environments.iter().find(|latest| {
latest.environment_id == environment.environment_id
&& latest.cwd == environment.cwd
&& latest.workspace_roots == environment.workspace_roots
}) {
if let Some(latest) = latest_environments
.iter()
.find(|latest| latest.has_same_workspace(environment))
{
environment.config = latest.config.clone();
}
}
@@ -237,10 +236,12 @@ impl Session {
inherited: &TurnEnvironmentSnapshot,
selected: &[TurnEnvironmentSelection],
) {
for starting in inherited
.starting()
.filter(|starting| selected.contains(&starting.selection))
{
for starting in inherited.starting().filter(|starting| {
selected.iter().any(|selection| {
selection.has_same_workspace(&starting.selection)
&& matches!(selection.config, EnvironmentConfigState::Pending)
})
}) {
let Some(configuration) = starting.owner_configuration() else {
continue;
};
@@ -285,9 +286,7 @@ impl Session {
let mut future = state.session_configuration.environments.clone();
let inherited = matches!(source, ConfigUpdateSource::Inherited);
let matches = |environment: &TurnEnvironmentSelection| {
environment.environment_id == selection.environment_id
&& environment.cwd == selection.cwd
&& environment.workspace_roots == selection.workspace_roots
environment.has_same_workspace(selection)
&& (!inherited || matches!(environment.config, EnvironmentConfigState::Pending))
};
let environments = (
+3 -2
View File
@@ -501,12 +501,13 @@ impl Session {
environments: &TurnEnvironmentSnapshot,
) -> Vec<ResolvedSelectedCapabilityRoot> {
let captured_environments = environments.captured_environments();
let thread_root_count = self.services.selected_capability_roots.len();
let thread_roots = environments.selected_capability_roots();
let thread_root_count = thread_roots.len();
let mut root_locations_by_id = HashMap::new();
let mut selected_capability_roots = Vec::new();
let mut ready_environment_root_count = 0;
let combined_roots = combine_selected_capability_roots(
&self.services.selected_capability_roots,
&thread_roots,
environments.turn_environments().map(|environment| {
(
environment.config_origin,
+15 -2
View File
@@ -112,6 +112,7 @@ use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
@@ -586,7 +587,7 @@ impl Session {
parent_rollout_thread_trace,
parent_trace: _,
environment_requests,
thread_extension_init,
mut thread_extension_init,
turn_extension_init,
client_mcp_extensions,
reserved_thread_id,
@@ -606,9 +607,21 @@ impl Session {
.get::<codex_extension_api::SessionIsolation>()
.map(|policy| *policy)
.unwrap_or_default();
// Manager-owned threads and inline delegates bind roots at the same startup boundary.
let selected_capability_roots =
match thread_extension_init.get::<Vec<SelectedCapabilityRoot>>() {
Some(roots) => roots.as_ref().clone(),
None => {
let roots = conversation_history.get_selected_capability_roots();
if !roots.is_empty() {
thread_extension_init.insert(roots.clone());
}
roots
}
};
let environment_selections = environment_requests
.into_iter()
.map(TurnEnvironmentSelection::new)
.map(|request| TurnEnvironmentSelection::new(request, &selected_capability_roots))
.collect::<Vec<_>>();
// Enforce snapshot-only instructions for both managed and inline isolated sessions.
let instructions = if isolation == codex_extension_api::SessionIsolation::Isolated {
+4 -11
View File
@@ -986,17 +986,10 @@ impl Session {
config.current_time_reminder.as_ref(),
external_time_provider,
)?;
let selected_capability_roots =
match thread_extension_init.get::<Vec<SelectedCapabilityRoot>>() {
Some(roots) => roots.as_ref().clone(),
None => {
let roots = initial_history.get_selected_capability_roots();
if !roots.is_empty() {
thread_extension_init.insert(roots.clone());
}
roots
}
};
let selected_capability_roots = thread_extension_init
.get::<Vec<SelectedCapabilityRoot>>()
.map(|roots| roots.as_ref().clone())
.unwrap_or_default();
thread_extension_init.insert(codex_extension_api::ThreadOriginator(
session_configuration.originator.clone(),
));
+3 -1
View File
@@ -275,7 +275,9 @@ impl Session {
let environments = environment_requests.map(|requests| {
requests
.into_iter()
.map(TurnEnvironmentSelection::new)
.map(|request| {
TurnEnvironmentSelection::new(request, &self.services.selected_capability_roots)
})
.collect::<Vec<_>>()
});
let updates_step_settings = updates_model_settings || approvals_reviewer.is_some();
+19 -4
View File
@@ -7858,6 +7858,7 @@ async fn request_permissions_tool_resolves_legacy_paths_against_selected_environ
user_home_dir: Some(PathUri::from_abs_path(&environment_home)),
..TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "remote".to_string(),
cwd: PathUri::from_abs_path(&environment_cwd),
workspace_roots: Vec::new(),
@@ -8537,6 +8538,7 @@ async fn primary_environment_uses_first_turn_environment() {
.environments
.push(TurnEnvironmentState::Ready(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "second".to_string(),
cwd: second_cwd_uri.clone(),
workspace_roots: Vec::new(),
@@ -9579,7 +9581,7 @@ async fn refresh_mcp_servers_uses_latest_state_for_existing_turns() {
let rematerialized_old = session
.mcp_runtime_for_step(
&turn_context,
/*selected_capability_roots*/ &[],
&[],
/*required_servers*/ &[],
/*required_plugins*/ &HashSet::new(),
)
@@ -10025,10 +10027,11 @@ async fn mcp_refresh_detects_shared_auth_manager_changes() {
#[tokio::test]
async fn capability_roots_require_turn_environment_selection() {
let (mut session, turn_context) = make_session_and_context().await;
let environment = turn_context
let mut environment = turn_context
.initial_environments
.primary()
.expect("ready local environment");
.expect("ready local environment")
.clone();
let root = codex_protocol::capabilities::SelectedCapabilityRoot {
id: "selected-root".to_string(),
location: codex_protocol::capabilities::CapabilityRootLocation::Environment {
@@ -10037,6 +10040,13 @@ async fn capability_roots_require_turn_environment_selection() {
},
};
session.services.selected_capability_roots = vec![root.clone()];
environment.selection = TurnEnvironmentSelection::new(
environment.selection.into_request(),
std::slice::from_ref(&root),
);
let selected_environments = TurnEnvironmentSnapshot {
environments: vec![TurnEnvironmentState::Ready(environment)],
};
let unselected_roots = session
.resolve_selected_capability_roots_for_step(&TurnEnvironmentSnapshot::default())
@@ -10044,7 +10054,7 @@ async fn capability_roots_require_turn_environment_selection() {
assert!(unselected_roots.is_empty());
let selected_roots = session
.resolve_selected_capability_roots_for_step(&turn_context.initial_environments)
.resolve_selected_capability_roots_for_step(&selected_environments)
.await;
assert_eq!(
selected_roots
@@ -10085,6 +10095,7 @@ async fn conflicting_ready_environment_root_ids_keep_first_location() {
environment_config.selected_capability_roots = vec![selected_root.clone()];
turn_environments.push(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: environment_id.clone(),
cwd: local_environment.cwd().clone(),
workspace_roots: local_environment.workspace_roots().to_vec(),
@@ -10375,6 +10386,7 @@ async fn record_context_updates_emits_environment_item_for_cwd_changes() {
current_context.initial_environments.environments[0] =
TurnEnvironmentState::Ready(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: environment.selection.environment_id,
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: Vec::new(),
@@ -10436,6 +10448,7 @@ async fn record_context_updates_use_environment_permission_profile_and_workspace
current_context.initial_environments.environments[0] =
TurnEnvironmentState::Ready(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: environment.selection.environment_id,
cwd,
workspace_roots: vec![PathUri::from_abs_path(&workspace_root)],
@@ -10513,6 +10526,7 @@ async fn record_context_updates_omits_environment_item_when_disabled() {
current_context.initial_environments.environments[0] =
TurnEnvironmentState::Ready(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: environment.selection.environment_id,
cwd: PathUri::from_abs_path(&test_path_buf("/new-repo").abs()),
workspace_roots: Vec::new(),
@@ -11080,6 +11094,7 @@ async fn turn_context_item_stores_local_cwd() {
turn_context.initial_environments.environments[0] =
TurnEnvironmentState::Ready(TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "remote".to_string(),
cwd,
workspace_roots: Vec::new(),
+4 -3
View File
@@ -7,6 +7,7 @@ use super::step_settings::StepSettingsUpdate;
use crate::WithTurnExtensionData;
use crate::config::ConstraintResult;
use codex_history::RolloutItem;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
@@ -34,7 +35,7 @@ pub(super) async fn update(
session: &Session,
overrides: impl Into<WithTurnExtensionData<ThreadSettingsOverrides>>,
) -> ConstraintResult<ThreadSettingsSnapshot> {
let updates = prepare_update(overrides);
let updates = prepare_update(overrides, &session.services.selected_capability_roots);
let commit = session.update_settings(updates).await?;
// Standalone settings changes supersede a pending automatic continuation.
session.state.lock().await.last_started_turn_id = None;
@@ -44,6 +45,7 @@ pub(super) async fn update(
/// Converts protocol overrides into the internal settings update shape.
pub(super) fn prepare_update(
overrides: impl Into<WithTurnExtensionData<ThreadSettingsOverrides>>,
roots: &[SelectedCapabilityRoot],
) -> SessionSettingsUpdate {
let WithTurnExtensionData {
request: overrides,
@@ -79,8 +81,7 @@ pub(super) fn prepare_update(
approval_policy,
approvals_reviewer,
},
environments: environment_requests
.map(codex_protocol::protocol::TurnEnvironmentRequests::select),
environments: environment_requests.map(|requests| requests.select(roots)),
runtime_workspace_roots,
profile_workspace_roots,
sandbox_policy,
+4 -1
View File
@@ -107,7 +107,10 @@ impl PreparedTurnInputSettings {
{
None
} else {
let updates = thread_settings::prepare_update(thread_settings);
let updates = thread_settings::prepare_update(
thread_settings,
&session.services.selected_capability_roots,
);
session
.preview_settings(&updates)
.await
+29 -14
View File
@@ -417,10 +417,13 @@ async fn prepared_user_updates_merge_with_settings_at_turn_start() {
},
};
session
.update_settings(thread_settings::prepare_update(ThreadSettingsOverrides {
collaboration_mode: Some(initial.clone()),
..Default::default()
}))
.update_settings(thread_settings::prepare_update(
ThreadSettingsOverrides {
collaboration_mode: Some(initial.clone()),
..Default::default()
},
&session.services.selected_capability_roots,
))
.await
.expect("set initial model and effort");
let prepared =
@@ -428,7 +431,10 @@ async fn prepared_user_updates_merge_with_settings_at_turn_start() {
.await
.expect("prepare partial protocol update");
session
.update_settings(thread_settings::prepare_update(intervening))
.update_settings(thread_settings::prepare_update(
intervening,
&session.services.selected_capability_roots,
))
.await
.expect("commit intervening settings");
@@ -466,10 +472,13 @@ async fn automatic_admission_uses_current_candidate_after_plan_preview() {
let mut plan_mode = default_mode.clone();
plan_mode.mode = ModeKind::Plan;
session
.update_settings(thread_settings::prepare_update(ThreadSettingsOverrides {
collaboration_mode: Some(plan_mode),
..Default::default()
}))
.update_settings(thread_settings::prepare_update(
ThreadSettingsOverrides {
collaboration_mode: Some(plan_mode),
..Default::default()
},
&session.services.selected_capability_roots,
))
.await
.expect("enter Plan after the initial admission check");
let prepared = PreparedTurnInputSettings::prepare(
@@ -483,10 +492,13 @@ async fn automatic_admission_uses_current_candidate_after_plan_preview() {
.await
.expect("validate the patch while the preview is Plan");
session
.update_settings(thread_settings::prepare_update(ThreadSettingsOverrides {
collaboration_mode: Some(default_mode.clone()),
..Default::default()
}))
.update_settings(thread_settings::prepare_update(
ThreadSettingsOverrides {
collaboration_mode: Some(default_mode.clone()),
..Default::default()
},
&session.services.selected_capability_roots,
))
.await
.expect("leave Plan before atomic admission");
@@ -642,7 +654,10 @@ async fn automatic_admission_rechecks_plan_mode_without_committing_sparse_settin
// The rejected candidate is valid and would have real runtime effects if
// accepted by an ordinary settings update.
session
.update_settings(thread_settings::prepare_update(overrides))
.update_settings(thread_settings::prepare_update(
overrides,
&session.services.selected_capability_roots,
))
.await
.expect("explicit settings update accepts the same patch");
assert_eq!(
@@ -322,6 +322,7 @@ async fn inactive_profiles_keep_snapshots_but_active_brokers_require_sandbox() -
/*non_blocking_snapshots*/ false,
);
environments.update_selections(&[TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&dir.path().abs()),
workspace_roots: Vec::new(),
+2 -2
View File
@@ -86,8 +86,8 @@ pub(crate) struct SessionServices {
pub(crate) thread_extension_data: ExtensionData,
/// MCP extensions fixed when this session is created.
pub(crate) client_mcp_extensions: ClientMcpExtensions,
/// Raw capability selections for this thread. Each model step resolves them against its
/// current executor environments before using them.
/// Roots supplied when the thread started or loaded from saved history.
/// Keep roots for unselected environments so selecting them again restores their capabilities.
pub(crate) selected_capability_roots: Vec<SelectedCapabilityRoot>,
pub(crate) mcp_thread_init: ExtensionDataInit,
pub(crate) agent_control: Arc<dyn AgentControl>,
@@ -1990,6 +1990,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path");
std::fs::create_dir_all(&selected_cwd).expect("create selected cwd");
let environments = vec![TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "local".to_string(),
cwd: PathUri::from_abs_path(&selected_cwd),
workspace_roots: Vec::new(),
@@ -22,6 +22,7 @@ use std::collections::HashMap;
fn test_turn_environment(environment_id: &str) -> crate::session::turn_context::TurnEnvironment {
crate::session::turn_context::TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: environment_id.to_string(),
cwd: PathUri::from_abs_path(&std::env::temp_dir().abs()),
workspace_roots: Vec::new(),
@@ -806,6 +806,7 @@ mod tests {
fn test_turn_environment(cwd: PathUri) -> TurnEnvironment {
TurnEnvironment::new(
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd,
workspace_roots: Vec::new(),
+3 -1
View File
@@ -140,7 +140,7 @@ pub fn local_request(cwd: AbsolutePathBuf) -> TurnEnvironmentRequest {
}
pub fn local(cwd: AbsolutePathBuf) -> TurnEnvironmentSelection {
TurnEnvironmentSelection::new(local_request(cwd))
TurnEnvironmentSelection::new(local_request(cwd), &[])
}
/// Builds explicit environment configuration with the test thread's permissions and shell settings.
@@ -219,6 +219,7 @@ impl TestEnv {
let cwd = local_cwd_temp_dir.abs();
let selection = match exec_server_url {
Some(_) => TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: vec![PathUri::from_abs_path(&cwd)],
@@ -301,6 +302,7 @@ pub async fn test_env() -> Result<TestEnv> {
)
.await?;
let selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(),
cwd: cwd_uri.clone(),
workspace_roots: vec![cwd_uri.clone()],
@@ -105,6 +105,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> {
vec![{
let cwd = PathUri::parse("file:///C:/codex-home")?;
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: cwd.clone(),
workspace_roots: vec![cwd],
@@ -2337,6 +2337,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re
let environments = vec![
local(shared_cwd.clone()),
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&shared_cwd),
workspace_roots: vec![PathUri::from_abs_path(&shared_cwd)],
@@ -181,12 +181,14 @@ async fn two_exec_servers_isolate_workspace_write_roots() -> Result<()> {
let second_workspace_uri = PathUri::from_host_native_path(second_workspace.path())?;
let environments = vec![
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: FIRST_ENVIRONMENT_ID.to_string(),
cwd: first_workspace_uri.clone(),
workspace_roots: vec![first_workspace_uri],
config: EnvironmentConfigState::FromThread,
},
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: SECOND_ENVIRONMENT_ID.to_string(),
cwd: second_workspace_uri.clone(),
workspace_roots: vec![second_workspace_uri],
@@ -3065,6 +3065,7 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() ->
let environments = vec![
local(local_cwd.path().abs()),
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&remote_cwd),
workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)],
+18 -6
View File
@@ -1072,6 +1072,7 @@ async fn active_environment_update_waits_for_a_configured_executor_to_connect(
.cwd
.join("remote-workspace")?;
let mut remote_selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: remote_environment_id.to_string(),
cwd: remote_cwd.clone(),
workspace_roots: vec![remote_cwd.clone()],
@@ -1228,6 +1229,7 @@ async fn deferred_executor_promotes_primary_environment_when_startup_completes()
let test = builder.build_with_remote_and_local_env(&server).await?;
let local_selection = local(test.config.cwd.clone());
let remote_selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&test.config.cwd),
workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)],
@@ -2790,12 +2792,15 @@ async fn ready_before_selection_resolves_resumed_thread_capability_root_after_wa
anyhow::Ok(())
});
let selection = TurnEnvironmentSelection::new(TurnEnvironmentRequest {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&test.config.cwd),
workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)],
config: EnvironmentConfigState::FromThread,
});
let selection = TurnEnvironmentSelection::new(
TurnEnvironmentRequest {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&test.config.cwd),
workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)],
config: EnvironmentConfigState::FromThread,
},
std::slice::from_ref(&stale_root),
);
let mut thread_extension_init = ExtensionDataInit::new();
thread_extension_init.insert(vec![stale_root]);
let resumed = test
@@ -3070,6 +3075,7 @@ async fn deferred_executor_spawn_agent_inherits_ready_step_environments(
let owner_active_profile = ActivePermissionProfile::new("owner-read-only");
let owner_profile_workspace_root = test.config.cwd.join("owner-profile-root");
let remote_selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&test.config.cwd),
workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)],
@@ -3262,6 +3268,7 @@ async fn deferred_executor_guardian_uses_newly_ready_step_environment(
let remote_denied_path = remote_cwd.join("private");
let local_denied_path = local_cwd.canonicalize()?.join("private");
let remote_selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&remote_cwd),
workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)],
@@ -3765,6 +3772,7 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> {
)
.await?;
let remote_selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&remote_cwd),
workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)],
@@ -4083,6 +4091,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result
vec![
local(local_cwd.path().abs()),
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&remote_cwd),
workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)],
@@ -4240,6 +4249,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<
Some(vec![
local(local_cwd.path().abs()),
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&remote_cwd),
workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)],
@@ -4331,6 +4341,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> {
let environments = vec![
local(local_cwd.path().abs()),
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&remote_cwd),
workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)],
@@ -4539,6 +4550,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm
Some(vec![
local(local_cwd.path().abs()),
TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: PathUri::from_abs_path(&remote_cwd),
workspace_roots: vec![PathUri::from_abs_path(&remote_cwd)],
@@ -1,11 +1,109 @@
//! Capability discovery stays within the environments captured by the current turn.
use super::*;
use codex_history::InitialHistory;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::TurnEnvironmentRequests;
use codex_protocol::protocol::TurnEnvironmentSelection;
use pretty_assertions::assert_eq;
use test_case::test_case;
/// Roots supplied at startup or loaded from history work again when their environment is reselected.
#[test_case(false; "api roots")]
#[test_case(true; "saved roots")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn selected_capability_roots_survive_environment_reselection(
from_history: bool,
) -> Result<()> {
let server = start_mock_server().await;
let observed_roots = Arc::new(Mutex::new(Vec::new()));
let mut extensions = ExtensionRegistryBuilder::new();
extensions.prompt_contributor(Arc::new(ReadyCapabilityRootsTestExtension {
observed_roots: Some(Arc::clone(&observed_roots)),
}));
let test = test_codex()
.with_extensions(Arc::new(extensions.build()))
.with_config(|config| {
config
.features
.enable(Feature::ExecutorCapabilityDiscovery)
.expect("enable discovery");
})
.build_with_auto_env(&server)
.await?;
let selection = test
.codex
.environment_selections()
.await
.into_iter()
.next()
.context("selected environment")?;
let roots = vec![SelectedCapabilityRoot {
id: "selected-root".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: selection.environment_id.clone(),
path: selection.cwd.clone(),
},
}];
let mut thread_extension_init = ExtensionDataInit::new();
let initial_history = if from_history {
InitialHistory::Forked(vec![RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
selected_capability_roots: roots.clone(),
..SessionMeta::default()
},
git: None,
})])
} else {
thread_extension_init.insert(roots.clone());
InitialHistory::New
};
let thread = test
.thread_manager
.start_thread(StartThreadOptions {
environments: Some(vec![selection.clone().into_request()]),
initial_history,
thread_extension_init,
..StartThreadOptions::new(test.config.clone())
})
.await?
.thread;
let response_mock = mount_sse_sequence(
&server,
vec![
sse(vec![ev_completed("selected")]),
sse(vec![ev_completed("removed")]),
sse(vec![ev_completed("reselected")]),
],
)
.await;
// Observe the startup selection before a settings update can rebuild it.
for environment_requests in [None, Some(Vec::new()), Some(vec![selection.into_request()])] {
thread
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "continue".to_string(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: environment_requests.map(|requests| {
TurnEnvironmentRequests::new(test.config.cwd.clone(), requests)
}),
..Default::default()
}),
)
.await?;
wait_for_event(&thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
}
let mut actual_roots = observed_roots.lock().expect("observed roots").clone();
// Prompt contributors can run more than once per turn.
actual_roots.dedup();
assert_eq!(actual_roots, vec![roots.clone(), Vec::new(), roots]);
assert_eq!(response_mock.requests().len(), 3);
Ok(())
}
#[test_case(PermissionProfile::Disabled; "full access")]
#[test_case(PermissionProfile::workspace_write(); "workspace write")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -3112,6 +3112,7 @@ async fn production_turn_keeps_rebalanced_catalogs_stable_after_compaction_and_r
/*connect_timeout*/ None,
)?;
let pending_selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "skills-executor".to_string(),
cwd: PathUri::from_abs_path(&test.config.cwd),
workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)],
@@ -3,6 +3,10 @@
use super::*;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_exec_server::CreateDirectoryOptions;
use codex_extension_api::ExtensionDataInit;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::EnvironmentCapabilityRoots;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::models::PermissionProfileSnapshot;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::TurnEnvironmentRequests;
@@ -10,9 +14,13 @@ use codex_protocol::protocol::TurnEnvironmentSelection;
use core_test_support::submit_thread_settings;
use core_test_support::test_codex::environment_config_for_selection;
use pretty_assertions::assert_eq;
use test_case::test_case;
/// Active updates restore selected roots even when the request only names the environment.
#[test_case(false; "without roots")]
#[test_case(true; "with roots")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn current_turn_environment_selections_follow_active_updates() -> Result<()> {
async fn current_turn_environment_selections_follow_active_updates(with_roots: bool) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -22,13 +30,44 @@ async fn current_turn_environment_selections_follow_active_updates() -> Result<(
)
.await;
let test = step_settings_test().build_with_auto_env(&server).await?;
let thread = &test.codex;
let selection = test.executor_environment().selection().clone();
let roots = if with_roots {
vec![SelectedCapabilityRoot {
id: "selected-root".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: selection.environment_id.clone(),
path: selection.cwd.clone(),
},
}]
} else {
Vec::new()
};
let mut thread_extension_init = ExtensionDataInit::new();
thread_extension_init.insert(roots.clone());
let thread = test
.thread_manager
.start_thread(StartThreadOptions {
environments: Some(vec![selection.into_request()]),
thread_extension_init,
..StartThreadOptions::new(test.config.clone())
})
.await?
.thread;
let thread = &thread;
let turn_id = start_paused_turn(thread).await?.turn_id;
let initial = thread
.active_turn_environment_selections()
.await
.expect("running turn");
assert_eq!(initial.len(), 1);
assert_eq!(
EnvironmentCapabilityRoots::collect(
initial
.iter()
.map(|selection| &selection.selected_capability_roots)
),
roots,
);
assert_eq!(
thread.current_turn_environment_selections(&turn_id).await,
Some(initial.clone())
@@ -41,17 +80,16 @@ async fn current_turn_environment_selections_follow_active_updates() -> Result<(
);
for environments in [vec![], initial.clone()] {
let requested = environments
.clone()
.into_iter()
.map(TurnEnvironmentSelection::into_request)
.collect();
apply_turn_settings(
thread,
&turn_id,
TurnSettingsUpdate {
environments: Some(
environments
.clone()
.into_iter()
.map(TurnEnvironmentSelection::into_request)
.collect(),
),
environments: Some(requested),
..Default::default()
},
)
+1
View File
@@ -751,6 +751,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<()
.await?;
let absolute_image_path = image_path_uri.inferred_native_path_string();
let remote_selection = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: remote_cwd_uri.clone(),
workspace_roots: vec![remote_cwd_uri],
@@ -1158,6 +1158,7 @@ async fn selected_executor_catalog_follows_step_availability_and_reuses_its_cach
let turn_store = ExtensionData::new("turn-1");
let turn_environment = TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "env-1".to_string(),
cwd: PathUri::parse("file:///workspace").expect("cwd URI"),
workspace_roots: Vec::new(),
@@ -2380,6 +2381,7 @@ async fn root_qualified_locator_selects_only_the_matching_executor_skill() -> Te
thread_id: codex_protocol::ThreadId::new(),
turn_id: "turn-1",
environments: &[TurnEnvironmentSelection {
selected_capability_roots: Default::default(),
environment_id: "env-1".to_string(),
cwd: PathUri::parse("file:///workspace").expect("cwd URI"),
workspace_roots: Vec::new(),
+38
View File
@@ -3,6 +3,7 @@ use codex_utils_path_uri::PathUri;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::sync::Arc;
use ts_rs::TS;
/// A user-selected root that can expose one or more runtime capabilities.
@@ -16,6 +17,43 @@ pub struct SelectedCapabilityRoot {
pub location: CapabilityRootLocation,
}
/// Roots selected for one environment, retaining their order in the thread's root list.
///
/// The index is each root's position in the original thread list. Executor skills assign
/// aliases (`e0`, `e1`, ...) in root order. Keeping the index lets us split roots across
/// environments and combine them again without changing those aliases.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EnvironmentCapabilityRoots(Arc<[(usize, SelectedCapabilityRoot)]>);
impl EnvironmentCapabilityRoots {
/// Selects this environment's roots without losing their original positions.
pub fn for_environment(environment_id: &str, roots: &[SelectedCapabilityRoot]) -> Self {
let mut selected = Vec::new();
for (index, root) in roots.iter().enumerate() {
let CapabilityRootLocation::Environment {
environment_id: root_environment_id,
..
} = &root.location;
if root_environment_id == environment_id {
selected.push((index, root.clone()));
}
}
Self(selected.into())
}
/// Restores root order so changing environment order does not change skill aliases.
pub fn collect<'a>(
selections: impl IntoIterator<Item = &'a Self>,
) -> Vec<SelectedCapabilityRoot> {
let mut roots = Vec::new();
for selection in selections {
roots.extend(selection.0.iter());
}
roots.sort_by_key(|(index, _)| *index);
roots.into_iter().map(|(_, root)| root.clone()).collect()
}
}
/// Location used to resolve a selected capability root.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
+34 -12
View File
@@ -1,6 +1,7 @@
//! Environment requests, thread selections, and attachment authority.
//! Input APIs accept requests; runtime code uses selections constructed by the thread.
//! Selections bind requests to the thread's capability roots before runtime use.
use crate::capabilities::EnvironmentCapabilityRoots;
use crate::capabilities::SelectedCapabilityRoot;
use crate::config_types::ShellEnvironmentPolicy;
use crate::config_types::WindowsSandboxLevel;
@@ -14,10 +15,11 @@ use codex_network_proxy::EnvironmentNetworkPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
/// An environment requested by a caller, before construction of thread-owned state.
/// An environment requested by a caller, before attaching the receiving thread's roots.
///
/// Input APIs accept requests so the receiving thread has a construction boundary for
/// attaching its state before passing a selection to runtime code.
/// Callers choose the environment and paths, but startup may still need to load roots from
/// saved history or inherited attachments. Keeping this input separate lets the receiving
/// thread construct a complete selection instead of filling in missing roots later.
#[derive(Debug, Clone, PartialEq)]
pub struct TurnEnvironmentRequest {
pub environment_id: String,
@@ -26,20 +28,30 @@ pub struct TurnEnvironmentRequest {
pub config: EnvironmentConfigState,
}
/// An environment selected by a thread for runtime use.
/// Constructed from caller input at the receiving thread's startup or settings boundary.
/// An environment selected for a thread, with its capability roots already attached.
///
/// Runtime snapshots carry this value so capability discovery uses the roots captured
/// with the environment. Construct it from a request and the receiving thread's roots.
#[derive(Debug, Clone, PartialEq)]
pub struct TurnEnvironmentSelection {
pub environment_id: String,
pub cwd: PathUri,
pub workspace_roots: Vec<PathUri>,
pub config: EnvironmentConfigState,
/// Roots selected by the client for this environment when the thread started.
pub selected_capability_roots: EnvironmentCapabilityRoots,
}
impl TurnEnvironmentSelection {
/// Constructs runtime state at the receiving thread boundary.
pub fn new(request: TurnEnvironmentRequest) -> Self {
/// Attaches this environment's roots before the selection enters runtime state.
/// The caller supplies the receiving thread's roots so reselection restores them and
/// reusing an environment from another thread does not reuse that thread's root choices.
pub fn new(request: TurnEnvironmentRequest, roots: &[SelectedCapabilityRoot]) -> Self {
Self {
selected_capability_roots: EnvironmentCapabilityRoots::for_environment(
&request.environment_id,
roots,
),
environment_id: request.environment_id,
cwd: request.cwd,
workspace_roots: request.workspace_roots,
@@ -47,6 +59,15 @@ impl TurnEnvironmentSelection {
}
}
/// Whether these selections refer to the same environment and workspace.
/// Unlike full selection equality, this ignores configuration and capability roots:
/// those can differ between threads that follow the same owner's configuration.
pub fn has_same_workspace(&self, other: &Self) -> bool {
self.environment_id == other.environment_id
&& self.cwd == other.cwd
&& self.workspace_roots == other.workspace_roots
}
/// Requests this environment again; the receiving thread supplies its own roots.
/// This explicit conversion discards thread-owned state. Test inputs should instead
/// construct requests directly so they do not depend on runtime selection fields.
@@ -92,7 +113,7 @@ impl TurnEnvironmentSelections {
}
/// Environment input supplied together with its fallback working directory.
/// The receiving thread constructs selections before capturing these environments.
/// The receiving thread attaches roots in `select` before capturing these environments.
#[derive(Debug, Clone, PartialEq)]
pub struct TurnEnvironmentRequests {
pub legacy_fallback_cwd: AbsolutePathBuf,
@@ -110,14 +131,15 @@ impl TurnEnvironmentRequests {
}
}
/// Constructs the selections captured by the receiving thread.
pub fn select(self) -> TurnEnvironmentSelections {
/// Constructs selections with the receiving thread's roots, including roots retained
/// while an environment was deselected. Snapshots can then carry complete selections.
pub fn select(self, roots: &[SelectedCapabilityRoot]) -> TurnEnvironmentSelections {
TurnEnvironmentSelections {
legacy_fallback_cwd: self.legacy_fallback_cwd,
environments: self
.environment_requests
.into_iter()
.map(TurnEnvironmentSelection::new)
.map(|request| TurnEnvironmentSelection::new(request, roots))
.collect(),
}
}