Add sticky environment API and thread state (#18897)

## Summary
- add sticky environment selections to app-server v2 thread/start and
turn/start request flow
- carry thread-level selections through core session/thread state
- add app-server coverage for sticky selections and turn overrides

## Stack
1. This PR: API and thread persistence
2. #18898: config.toml named environment loading
3. #18899: downstream tool/runtime consumers

## Validation
- Not run locally; split only.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
starr-openai
2026-04-23 18:57:13 -07:00
committed by GitHub
Unverified
parent e3c8720a99
commit 49fb25997f
26 changed files with 988 additions and 165 deletions
@@ -226,6 +226,7 @@ use codex_core::ForkSnapshot;
use codex_core::NewThread;
use codex_core::RolloutRecorder;
use codex_core::SessionMeta;
use codex_core::StartThreadWithToolsOptions;
use codex_core::SteerInputError;
use codex_core::ThreadConfigSnapshot;
use codex_core::ThreadManager;
@@ -665,6 +666,13 @@ fn configured_thread_store(config: &Config) -> Arc<dyn ThreadStore> {
}
}
fn environment_selection_error_message(err: CodexErr) -> String {
match err {
CodexErr::InvalidRequest(message) => message,
err => err.to_string(),
}
}
impl CodexMessageProcessor {
async fn instruction_sources_from_config(config: &Config) -> Vec<AbsolutePathBuf> {
codex_core::AgentsMdManager::new(config)
@@ -2431,6 +2439,7 @@ impl CodexMessageProcessor {
personality,
ephemeral,
session_start_source,
environments,
persist_extended_history,
} = params;
if sandbox.is_some() && permission_profile.is_some() {
@@ -2441,6 +2450,24 @@ impl CodexMessageProcessor {
.await;
return;
}
let environments = environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: environment.cwd,
})
.collect::<Vec<_>>()
});
if let Some(environments) = environments.as_ref()
&& let Err(err) = self
.thread_manager
.validate_environment_selections(environments)
{
self.send_invalid_request_error(request_id, environment_selection_error_message(err))
.await;
return;
}
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
@@ -2479,6 +2506,7 @@ impl CodexMessageProcessor {
typesafe_overrides,
dynamic_tools,
session_start_source,
environments,
persist_extended_history,
service_name,
experimental_raw_events,
@@ -2553,6 +2581,7 @@ impl CodexMessageProcessor {
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
environments: Option<Vec<TurnEnvironmentSelection>>,
persist_extended_history: bool,
service_name: Option<String>,
experimental_raw_events: bool,
@@ -2652,6 +2681,11 @@ impl CodexMessageProcessor {
}
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let environments = environments.unwrap_or_else(|| {
listener_task_context
.thread_manager
.default_environment_selections(&config.cwd)
});
let dynamic_tools = dynamic_tools.unwrap_or_default();
let core_dynamic_tools = if dynamic_tools.is_empty() {
Vec::new()
@@ -2683,19 +2717,20 @@ impl CodexMessageProcessor {
match listener_task_context
.thread_manager
.start_thread_with_tools_and_service_name(
.start_thread_with_tools_and_service_name(StartThreadWithToolsOptions {
config,
match session_start_source
initial_history: match session_start_source
.unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup)
{
codex_app_server_protocol::ThreadStartSource::Startup => InitialHistory::New,
codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared,
},
core_dynamic_tools,
dynamic_tools: core_dynamic_tools,
persist_extended_history,
service_name,
request_trace,
)
metrics_service_name: service_name,
parent_trace: request_trace,
environments,
})
.instrument(tracing::info_span!(
"app_server.thread_start.create_thread",
otel.name = "app_server.thread_start.create_thread",
@@ -2827,6 +2862,17 @@ impl CodexMessageProcessor {
))
.await;
}
Err(CodexErr::InvalidRequest(message)) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
};
listener_task_context
.outgoing
.send_error(request_id, error)
.await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
@@ -6949,15 +6995,25 @@ impl CodexMessageProcessor {
let collaboration_mode = params.collaboration_mode.map(|mode| {
self.normalize_turn_start_collaboration_mode(mode, collaboration_modes_config)
});
let environments = params.environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: environment.cwd,
})
.collect()
});
let environments: Option<Vec<TurnEnvironmentSelection>> =
params.environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: environment.cwd,
})
.collect()
});
if let Some(environments) = environments.as_ref()
&& let Err(err) = self
.thread_manager
.validate_environment_selections(environments)
{
self.send_invalid_request_error(request_id, environment_selection_error_message(err))
.await;
return;
}
// Map v2 input items to core input items.
let mapped_items: Vec<CoreInputItem> = params