Skip to content

Commit 49fb259

Browse files
starr-openaicodex
andauthored
Add sticky environment API and thread state (openai#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. openai#18898: config.toml named environment loading 3. openai#18899: downstream tool/runtime consumers ## Validation - Not run locally; split only. --------- Co-authored-by: Codex <noreply@openai.com>
1 parent e3c8720 commit 49fb259

26 files changed

Lines changed: 988 additions & 165 deletions

File tree

codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/app-server-protocol/src/protocol/v2.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3313,6 +3313,15 @@ pub struct ThreadStartParams {
33133313
pub ephemeral: Option<bool>,
33143314
#[ts(optional = nullable)]
33153315
pub session_start_source: Option<ThreadStartSource>,
3316+
/// Optional sticky environments for this thread.
3317+
///
3318+
/// Omitted selects the default environment when environment access is
3319+
/// enabled. Empty disables environment access for turns that do not
3320+
/// provide a turn override. Non-empty selects the first environment as the
3321+
/// current turn environment.
3322+
#[experimental("thread/start.environments")]
3323+
#[ts(optional = nullable)]
3324+
pub environments: Option<Vec<TurnEnvironmentParams>>,
33163325
#[experimental("thread/start.dynamicTools")]
33173326
#[ts(optional = nullable)]
33183327
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
@@ -4993,7 +5002,11 @@ pub struct TurnStartParams {
49935002
#[experimental("turn/start.responsesapiClientMetadata")]
49945003
#[ts(optional = nullable)]
49955004
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
4996-
/// Optional turn-scoped environment selections.
5005+
/// Optional turn-scoped environments.
5006+
///
5007+
/// Omitted uses the thread sticky environments. Empty disables
5008+
/// environment access for this turn. Non-empty selects the first
5009+
/// environment as the current turn environment for this turn.
49975010
#[experimental("turn/start.environments")]
49985011
#[ts(optional = nullable)]
49995012
pub environments: Option<Vec<TurnEnvironmentParams>>,

codex-rs/app-server/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Example with notification opt-out:
142142

143143
## API Overview
144144

145-
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer `permissionProfile`; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissionProfile`.
145+
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer `permissionProfile`; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissionProfile`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
146146
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
147147
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Pass `excludeTurns: true` when the client plans to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
148148
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
@@ -541,7 +541,7 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio
541541
- `{"type":"image","url":"https://…png"}`
542542
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
543543

544-
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn.
544+
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only.
545545

546546
`approvalsReviewer` accepts:
547547

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ use codex_core::ForkSnapshot;
226226
use codex_core::NewThread;
227227
use codex_core::RolloutRecorder;
228228
use codex_core::SessionMeta;
229+
use codex_core::StartThreadWithToolsOptions;
229230
use codex_core::SteerInputError;
230231
use codex_core::ThreadConfigSnapshot;
231232
use codex_core::ThreadManager;
@@ -665,6 +666,13 @@ fn configured_thread_store(config: &Config) -> Arc<dyn ThreadStore> {
665666
}
666667
}
667668

669+
fn environment_selection_error_message(err: CodexErr) -> String {
670+
match err {
671+
CodexErr::InvalidRequest(message) => message,
672+
err => err.to_string(),
673+
}
674+
}
675+
668676
impl CodexMessageProcessor {
669677
async fn instruction_sources_from_config(config: &Config) -> Vec<AbsolutePathBuf> {
670678
codex_core::AgentsMdManager::new(config)
@@ -2431,6 +2439,7 @@ impl CodexMessageProcessor {
24312439
personality,
24322440
ephemeral,
24332441
session_start_source,
2442+
environments,
24342443
persist_extended_history,
24352444
} = params;
24362445
if sandbox.is_some() && permission_profile.is_some() {
@@ -2441,6 +2450,24 @@ impl CodexMessageProcessor {
24412450
.await;
24422451
return;
24432452
}
2453+
let environments = environments.map(|environments| {
2454+
environments
2455+
.into_iter()
2456+
.map(|environment| TurnEnvironmentSelection {
2457+
environment_id: environment.environment_id,
2458+
cwd: environment.cwd,
2459+
})
2460+
.collect::<Vec<_>>()
2461+
});
2462+
if let Some(environments) = environments.as_ref()
2463+
&& let Err(err) = self
2464+
.thread_manager
2465+
.validate_environment_selections(environments)
2466+
{
2467+
self.send_invalid_request_error(request_id, environment_selection_error_message(err))
2468+
.await;
2469+
return;
2470+
}
24442471
let mut typesafe_overrides = self.build_thread_config_overrides(
24452472
model,
24462473
model_provider,
@@ -2479,6 +2506,7 @@ impl CodexMessageProcessor {
24792506
typesafe_overrides,
24802507
dynamic_tools,
24812508
session_start_source,
2509+
environments,
24822510
persist_extended_history,
24832511
service_name,
24842512
experimental_raw_events,
@@ -2553,6 +2581,7 @@ impl CodexMessageProcessor {
25532581
typesafe_overrides: ConfigOverrides,
25542582
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
25552583
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
2584+
environments: Option<Vec<TurnEnvironmentSelection>>,
25562585
persist_extended_history: bool,
25572586
service_name: Option<String>,
25582587
experimental_raw_events: bool,
@@ -2652,6 +2681,11 @@ impl CodexMessageProcessor {
26522681
}
26532682

26542683
let instruction_sources = Self::instruction_sources_from_config(&config).await;
2684+
let environments = environments.unwrap_or_else(|| {
2685+
listener_task_context
2686+
.thread_manager
2687+
.default_environment_selections(&config.cwd)
2688+
});
26552689
let dynamic_tools = dynamic_tools.unwrap_or_default();
26562690
let core_dynamic_tools = if dynamic_tools.is_empty() {
26572691
Vec::new()
@@ -2683,19 +2717,20 @@ impl CodexMessageProcessor {
26832717

26842718
match listener_task_context
26852719
.thread_manager
2686-
.start_thread_with_tools_and_service_name(
2720+
.start_thread_with_tools_and_service_name(StartThreadWithToolsOptions {
26872721
config,
2688-
match session_start_source
2722+
initial_history: match session_start_source
26892723
.unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup)
26902724
{
26912725
codex_app_server_protocol::ThreadStartSource::Startup => InitialHistory::New,
26922726
codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared,
26932727
},
2694-
core_dynamic_tools,
2728+
dynamic_tools: core_dynamic_tools,
26952729
persist_extended_history,
2696-
service_name,
2697-
request_trace,
2698-
)
2730+
metrics_service_name: service_name,
2731+
parent_trace: request_trace,
2732+
environments,
2733+
})
26992734
.instrument(tracing::info_span!(
27002735
"app_server.thread_start.create_thread",
27012736
otel.name = "app_server.thread_start.create_thread",
@@ -2827,6 +2862,17 @@ impl CodexMessageProcessor {
28272862
))
28282863
.await;
28292864
}
2865+
Err(CodexErr::InvalidRequest(message)) => {
2866+
let error = JSONRPCErrorError {
2867+
code: INVALID_REQUEST_ERROR_CODE,
2868+
message,
2869+
data: None,
2870+
};
2871+
listener_task_context
2872+
.outgoing
2873+
.send_error(request_id, error)
2874+
.await;
2875+
}
28302876
Err(err) => {
28312877
let error = JSONRPCErrorError {
28322878
code: INTERNAL_ERROR_CODE,
@@ -6949,15 +6995,25 @@ impl CodexMessageProcessor {
69496995
let collaboration_mode = params.collaboration_mode.map(|mode| {
69506996
self.normalize_turn_start_collaboration_mode(mode, collaboration_modes_config)
69516997
});
6952-
let environments = params.environments.map(|environments| {
6953-
environments
6954-
.into_iter()
6955-
.map(|environment| TurnEnvironmentSelection {
6956-
environment_id: environment.environment_id,
6957-
cwd: environment.cwd,
6958-
})
6959-
.collect()
6960-
});
6998+
let environments: Option<Vec<TurnEnvironmentSelection>> =
6999+
params.environments.map(|environments| {
7000+
environments
7001+
.into_iter()
7002+
.map(|environment| TurnEnvironmentSelection {
7003+
environment_id: environment.environment_id,
7004+
cwd: environment.cwd,
7005+
})
7006+
.collect()
7007+
});
7008+
if let Some(environments) = environments.as_ref()
7009+
&& let Err(err) = self
7010+
.thread_manager
7011+
.validate_environment_selections(environments)
7012+
{
7013+
self.send_invalid_request_error(request_id, environment_selection_error_message(err))
7014+
.await;
7015+
return;
7016+
}
69617017

69627018
// Map v2 input items to core input items.
69637019
let mapped_items: Vec<CoreInputItem> = params

codex-rs/app-server/tests/suite/v2/skills_list.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
324324
ephemeral: None,
325325
session_start_source: None,
326326
dynamic_tools: None,
327+
environments: None,
327328
mock_experimental_field: None,
328329
experimental_raw_events: false,
329330
persist_extended_history: false,

codex-rs/app-server/tests/suite/v2/thread_start.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use codex_app_server_protocol::ThreadStartResponse;
1919
use codex_app_server_protocol::ThreadStartedNotification;
2020
use codex_app_server_protocol::ThreadStatus;
2121
use codex_app_server_protocol::ThreadStatusChangedNotification;
22+
use codex_app_server_protocol::TurnEnvironmentParams;
2223
use codex_config::types::AuthCredentialsStoreMode;
2324
use codex_core::config::set_project_trust_level;
2425
use codex_core::config_loader::project_trust_key;
@@ -48,6 +49,7 @@ use super::analytics::thread_initialized_event;
4849
use super::analytics::wait_for_analytics_payload;
4950

5051
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
52+
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
5153

5254
#[tokio::test]
5355
async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
@@ -166,6 +168,39 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
166168
Ok(())
167169
}
168170

171+
#[tokio::test]
172+
async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result<()> {
173+
let server = create_mock_responses_server_repeating_assistant("Done").await;
174+
175+
let codex_home = TempDir::new()?;
176+
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
177+
178+
let mut mcp = McpProcess::new(codex_home.path()).await?;
179+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
180+
181+
let request_id = mcp
182+
.send_thread_start_request(ThreadStartParams {
183+
environments: Some(vec![TurnEnvironmentParams {
184+
environment_id: "missing".to_string(),
185+
cwd: codex_home.path().to_path_buf().try_into()?,
186+
}]),
187+
..Default::default()
188+
})
189+
.await?;
190+
191+
let error: JSONRPCError = timeout(
192+
DEFAULT_READ_TIMEOUT,
193+
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
194+
)
195+
.await??;
196+
197+
assert_eq!(error.id, RequestId::Integer(request_id));
198+
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
199+
assert_eq!(error.error.message, "unknown turn environment id `missing`");
200+
201+
Ok(())
202+
}
203+
169204
#[tokio::test]
170205
async fn thread_start_response_includes_loaded_instruction_sources() -> Result<()> {
171206
let server = create_mock_responses_server_repeating_assistant("Done").await;

0 commit comments

Comments
 (0)