Add experimental prediction forks that inherit parent context (#51884)

## Why

Prediction forks need to preserve the parent's effective context and request settings to maximize prompt-cache reuse.

## What changed

- Add `experimentalPredictionMode` to `thread/fork` for ephemeral forks of loaded, persisted parents. Reject configuration overrides, `path`, `lastTurnId`, and `beforeTurnId` in this mode.
- Inherit the parent's effective configuration, tools, environments, collaboration settings, disabled plugins, and persisted history.
- Enable `features.reasoning_effort_override` on the fork while preserving the parent's request-effort baseline, including when overrides were disabled on the parent.
- Report the fork's effective model, service tier, and reasoning effort in the response.

## Testing

Add coverage for matching parent and fork requests and prompt-cache keys across legacy and paginated history, invalid fork parameters, and reasoning-effort inheritance. Add a context snapshot for identical parent and fork continuations.

GitOrigin-RevId: 46b41f2ac331562b441dadff1a0a9f926c94880d
This commit is contained in:
liame-oai
2026-10-08 00:09:03 +00:00
committed by copyberry
parent f73a478c87
commit ec8251c545
14 changed files with 1021 additions and 161 deletions
@@ -544,6 +544,15 @@ impl From<ThreadTurnsListResponse> for TurnsPage {
pub struct ThreadForkParams {
pub thread_id: String,
/// Start from the loaded parent's effective config, tools, environments, and
/// persisted history to maximize prompt-cache reuse. Requires a loaded,
/// persisted parent and `ephemeral: true`. Enables `features.reasoning_effort_override`
/// on the fork. Cannot be combined with configuration overrides, `path`, `lastTurnId`,
/// or `beforeTurnId`.
#[experimental("thread/fork.experimentalPredictionMode")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub experimental_prediction_mode: bool,
/// Optional last turn id to fork through, inclusive.
///
/// When specified, turns after `last_turn_id` are omitted from the fork.
@@ -4890,8 +4890,34 @@ impl ThreadRequestProcessor {
app_server_client_version: Option<String>,
client_mcp_extensions: ClientMcpExtensions,
) -> Result<(), JSONRPCErrorError> {
let has_config_overrides = params.model.is_some()
|| params.model_provider.is_some()
|| params.service_tier.is_some()
|| params.cwd.is_some()
|| params.runtime_workspace_roots.is_some()
|| params.approval_policy.is_some()
|| params.approvals_reviewer.is_some()
|| params.sandbox.is_some()
|| params.permissions.is_some()
|| params.base_instructions.is_some()
|| params.developer_instructions.is_some()
|| params
.config
.as_ref()
.is_some_and(|config| !config.is_empty());
if params.experimental_prediction_mode && has_config_overrides {
return Err(invalid_request(
"`experimentalPredictionMode` cannot be combined with configuration overrides",
));
}
if params.experimental_prediction_mode && !params.ephemeral {
return Err(invalid_request(
"`experimentalPredictionMode` requires `ephemeral: true`",
));
}
let ThreadForkParams {
thread_id,
experimental_prediction_mode,
last_turn_id,
before_turn_id,
path,
@@ -4918,6 +4944,13 @@ impl ThreadRequestProcessor {
"`permissions` cannot be combined with `sandbox`",
));
}
if experimental_prediction_mode
&& (path.is_some() || last_turn_id.is_some() || before_turn_id.is_some())
{
return Err(invalid_request(
"`experimentalPredictionMode` requires a thread id without a path or turn cutoff",
));
}
let source_thread = self
.read_stored_thread_for_resume(
&thread_id,
@@ -4925,6 +4958,31 @@ impl ThreadRequestProcessor {
/*include_history*/ false,
)
.await?;
let inherited_fork = if experimental_prediction_mode {
// Keep inherited state and its preparation off the fork handler's stack.
let (mut options, settings) = Box::pin(async {
self.thread_manager
.fork_options_from_parent(source_thread.thread_id)
.await
.map(|(options, settings)| (Box::new(options), Box::new(settings)))
})
.await
.map_err(|err| match err.details() {
CodexErrorDetails::ThreadNotFound(_) => {
invalid_request("`experimentalPredictionMode` requires a loaded parent")
}
_ => internal_error(format!("failed to inherit parent state: {err}")),
})?;
options.config.ephemeral = ephemeral;
options
.config
.features
.enable(Feature::ReasoningEffortOverride)
.map_err(|err| invalid_request(err.to_string()))?;
Some((options, settings))
} else {
None
};
let paginated_source = matches!(source_thread.history_mode, ThreadHistoryMode::Paginated);
if last_turn_id.is_some() && before_turn_id.is_some() {
return Err(invalid_request(
@@ -5011,7 +5069,7 @@ impl ThreadRequestProcessor {
// Persist Windows sandbox mode.
let mut cli_overrides = cli_overrides.unwrap_or_default();
if cfg!(windows) {
if cfg!(windows) && !experimental_prediction_mode {
let mode = self.config.permissions.windows_sandbox_mode.or_else(|| {
match WindowsSandboxLevel::from_config(&self.config) {
WindowsSandboxLevel::Elevated => Some(WindowsSandboxModeToml::Elevated),
@@ -5122,15 +5180,24 @@ impl ThreadRequestProcessor {
.map(|profile| profile.id);
}
}
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
.map_err(|err| config_load_error(&err))?;
let goals_enabled = config.features.enabled(Feature::Goals);
let (inherited_options, inherited_settings) = inherited_fork.unzip();
let options = if let Some(options) = inherited_options {
*options
} else {
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
.map_err(|err| config_load_error(&err))?;
StartThreadOptions {
client_mcp_extensions,
..StartThreadOptions::new(config)
}
};
let goals_enabled = options.config.features.enabled(Feature::Goals);
let fallback_model_provider = config.model_provider_id.clone();
let fallback_model_provider = options.config.model_provider_id.clone();
let parent_trace = self.request_trace_context(&request_id).await;
let thread_source = thread_source.map(Into::into);
@@ -5186,7 +5253,7 @@ impl ThreadRequestProcessor {
.then(|| restored_token_usage_turn_id(&history_items, ephemeral_turns.as_slice()));
let token_usage_history_items = paginated_source.then(|| Arc::clone(&history_items));
let inherited_project_id = source_thread.project_id.clone();
let reserved_thread_id = if config.ephemeral {
let reserved_thread_id = if options.config.ephemeral {
None
} else {
stage_pending_thread_metadata(
@@ -5205,9 +5272,8 @@ impl ThreadRequestProcessor {
let fork_options = StartThreadOptions {
thread_source,
parent_trace,
client_mcp_extensions,
reserved_thread_id,
..StartThreadOptions::new(config)
..options
};
let new_thread = if let Some(prepared_fork) = prepared_fork {
self.thread_manager
@@ -5247,6 +5313,13 @@ impl ThreadRequestProcessor {
}
};
if let Some(settings) = inherited_settings {
// Construct the restore future off the fork handler's stack
Box::pin(async { Box::pin(forked_thread.restore_thread_settings(*settings)).await })
.await
.map_err(|err| invalid_request(err.to_string()))?;
}
Self::set_app_server_client_info(
forked_thread.as_ref(),
app_server_client_name,
@@ -5395,9 +5468,9 @@ impl ThreadRequestProcessor {
let response = ThreadForkResponse {
thread: thread.clone(),
disabled_plugin_ids: config_snapshot.disabled_plugin_ids,
model: session_configured.model,
model: config_snapshot.model.clone(),
model_provider: session_configured.model_provider_id,
service_tier: session_configured.service_tier,
service_tier: config_snapshot.service_tier.clone(),
cwd: session_configured.cwd,
runtime_workspace_roots: config_snapshot.workspace_roots,
instruction_sources,
@@ -5405,7 +5478,7 @@ impl ThreadRequestProcessor {
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox,
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
reasoning_effort: config_snapshot.reasoning_effort.clone(),
multi_agent_mode: MultiAgentMode::ExplicitRequestOnly,
};
@@ -10,11 +10,14 @@ use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::rollout_path;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_models_cache;
use codex_app_server_protocol::ActivePermissionProfile;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::DynamicToolFunctionSpec;
use codex_app_server_protocol::DynamicToolSpec;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
@@ -55,13 +58,18 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::test_support::all_model_presets;
use codex_features::Feature;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::MultiAgentVersion;
@@ -80,6 +88,7 @@ use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
@@ -2699,3 +2708,296 @@ async fn pathless_ephemeral_thread_rejects_codex_home_path_after_reload() -> Res
Ok(())
}
#[test_case(ThreadHistoryMode::Legacy; "legacy")]
#[test_case(ThreadHistoryMode::Paginated; "paginated")]
#[tokio::test]
async fn inheriting_fork_preserves_parent_config(history_mode: ThreadHistoryMode) -> Result<()> {
let server = responses::start_mock_server().await;
let mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_reasoning_item("reasoning", &["A summary"], &["Private reasoning"]),
responses::ev_assistant_message("answer", "Done"),
responses::ev_completed("initial"),
]),
responses::sse(vec![responses::ev_completed("child")]),
responses::sse(vec![responses::ev_completed("parent")]),
],
)
.await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
write_models_cache(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let model = all_model_presets()
.iter()
.find(|model| model.show_in_picker && !model.service_tiers.is_empty())
.expect("a bundled model supports service tiers");
let tier = &model.service_tiers[0].id;
let parent = mcp
.start_thread(ThreadStartParams {
history_mode: Some(history_mode),
base_instructions: Some("Retain these base instructions.".into()),
developer_instructions: Some("Retain this system policy context.".into()),
dynamic_tools: Some(vec![DynamicToolSpec::Function(DynamicToolFunctionSpec {
name: "parent_tool".into(),
description: "The parent's client-provided tool description.".into(),
input_schema: json!({"type": "object", "properties": {}}),
defer_loading: false,
})]),
..Default::default()
})
.await?;
mcp.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: parent.thread.id.clone(),
input: vec![UserInput::Text {
text: "Initial turn".into(),
text_elements: vec![],
}],
service_tier: Some(Some(tier.clone())),
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: model.id.clone(),
reasoning_effort: Some(ReasoningEffort::High),
developer_instructions: Some("Keep the parent's planning instructions.".into()),
},
}),
disabled_plugin_ids: Some(vec!["unused@plugin".into()]),
..Default::default()
})
.await?;
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: parent.thread.id.clone(),
experimental_prediction_mode: true,
ephemeral: true,
exclude_turns: true,
..Default::default()
})
.await?;
let child: ThreadForkResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
assert_eq!(
(
&child.model,
&child.reasoning_effort,
&child.service_tier,
&child.disabled_plugin_ids
),
(
&model.id,
&Some(ReasoningEffort::High),
&Some(tier.clone()),
&vec!["unused@plugin".to_string()]
),
);
assert!(child.thread.path.is_none());
for id in [&child.thread.id, &parent.thread.id] {
mcp.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: id.clone(),
input: vec![UserInput::Text {
text: "Continue identically".into(),
text_elements: vec![],
}],
..Default::default()
})
.await?;
}
let requests = mock.requests();
assert_eq!(requests.len(), 3);
let comparable = |index: usize| {
let body = requests[index].body_json();
responses::strip_response_item_ids_from_json(json!({
"input": body["input"],
"instructions": body["instructions"],
"tools": body["tools"],
"model": body["model"],
"reasoning": body["reasoning"],
"service_tier": body["service_tier"],
}))
};
assert_eq!(comparable(1), comparable(2));
assert_eq!(
requests[1].body_json()["prompt_cache_key"],
requests[2].body_json()["prompt_cache_key"]
);
assert!(
requests[1].body_json()["tools"]
.to_string()
.contains("parent_tool")
);
Ok(())
}
#[test_case(ThreadHistoryMode::Legacy, false; "fresh persistent legacy")]
#[test_case(ThreadHistoryMode::Legacy, true; "ephemeral legacy")]
#[test_case(ThreadHistoryMode::Paginated, false; "fresh persistent paginated")]
#[test_case(ThreadHistoryMode::Paginated, true; "ephemeral paginated")]
#[tokio::test]
async fn inheriting_fork_requires_persisted_parent(
history_mode: ThreadHistoryMode,
ephemeral: bool,
) -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
write_models_cache(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let parent = mcp
.start_thread(ThreadStartParams {
history_mode: Some(history_mode),
ephemeral: Some(ephemeral),
..Default::default()
})
.await?;
if ephemeral {
mcp.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: parent.thread.id.clone(),
input: vec![UserInput::Text {
text: "Complete an ephemeral turn".into(),
text_elements: vec![],
}],
..Default::default()
})
.await?;
}
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: parent.thread.id.clone(),
experimental_prediction_mode: true,
ephemeral: true,
exclude_turns: true,
..Default::default()
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(fork_id)),
)
.await??;
assert!(
error
.error
.message
.contains("no rollout found for thread id"),
"unexpected fork error: {}",
error.error.message
);
if let Some(path) = parent.thread.path {
assert!(!path.exists(), "forking should not materialize the parent");
}
Ok(())
}
#[tokio::test]
async fn inheriting_fork_requires_loaded_parent() -> Result<()> {
let codex_home = TempDir::new()?;
let thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id,
experimental_prediction_mode: true,
ephemeral: true,
..Default::default()
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(fork_id)),
)
.await??;
assert_eq!(
error.error.message,
"`experimentalPredictionMode` requires a loaded parent",
);
Ok(())
}
#[tokio::test]
async fn inheriting_fork_requires_ephemeral_child() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: "not-loaded".into(),
experimental_prediction_mode: true,
..Default::default()
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(fork_id)),
)
.await??;
assert_eq!(
error.error.message,
"`experimentalPredictionMode` requires `ephemeral: true`",
);
Ok(())
}
#[tokio::test]
async fn inheriting_fork_rejects_configuration_overrides() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
for (key, value) in [
("model", json!("other-model")),
("modelProvider", json!("other-provider")),
("serviceTier", Value::Null),
("cwd", json!(codex_home.path())),
("runtimeWorkspaceRoots", json!([])),
("approvalPolicy", json!("never")),
("approvalsReviewer", json!("user")),
("sandbox", json!("read-only")),
("permissions", json!(":read-only")),
("baseInstructions", json!("override")),
("developerInstructions", json!("override")),
("config", json!({"model": "other-model"})),
("config", json!({"model_reasoning_effort": "low"})),
] {
let mut params = json!({
"threadId": "not-loaded", "experimentalPredictionMode": true, "ephemeral": true,
});
params[key] = value;
let request = mcp.send_raw_request("thread/fork", Some(params)).await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request)),
)
.await??;
assert_eq!(
error.error.message,
"`experimentalPredictionMode` cannot be combined with configuration overrides",
"override: {key}"
);
}
Ok(())
}
+47
View File
@@ -2077,6 +2077,53 @@ impl Session {
state.take_session_startup_prewarm()
}
pub(crate) async fn fork_config(
&self,
) -> (
Config,
ThreadConfigSnapshot,
WindowsSandboxLevel,
TurnEnvironmentSnapshot,
ReasoningEffortPin,
) {
let (configuration, environments, mut reasoning_effort_pin) = {
let state = self.state.lock().await;
(
state.session_configuration.clone(),
self.services.turn_environments.snapshot_now(),
state.reasoning_effort_pin.clone(),
)
};
let config = self.build_effective_session_config(&configuration);
if !config.features.enabled(Feature::ReasoningEffortOverride) {
let model = configuration
.step_settings
.resolve_model_info(
self.services.models_manager.as_ref(),
&configuration.model_info_overrides,
)
.await;
if let Some(effort) = config
.model_reasoning_effort
.clone()
.or_else(|| model.default_reasoning_level.clone())
{
// Without overrides, the parent's selected effort is its request baseline.
reasoning_effort_pin = ReasoningEffortPin::Active {
model: model.slug.clone(),
effort: model.resolve_reasoning_effort(effort),
};
}
}
(
config,
configuration.thread_config_snapshot(environments.all_selections()),
configuration.windows_sandbox_level,
environments,
reasoning_effort_pin,
)
}
pub(crate) async fn get_config(&self) -> std::sync::Arc<Config> {
let state = self.state.lock().await;
state
+4
View File
@@ -1531,6 +1531,10 @@ impl Session {
),
);
state.base_instructions_provenance = base_instructions_provenance.clone();
// Restore a provided effort baseline before startup prewarm can establish a new one.
if let Some(pin) = thread_extension_data.remove::<ReasoningEffortPin>() {
state.reasoning_effort_pin = pin.as_ref().clone();
}
state.active_disabled_plugin_ids = session_configuration.disabled_plugin_ids.clone();
let managed_network_requirements_configured = config
.config_layer_stack
+2 -1
View File
@@ -30,8 +30,9 @@ use codex_protocol::protocol::TurnContextItem;
use tokio_util::sync::CancellationToken;
use tokio_util::task::AbortOnDropHandle;
/// Runtime request effort, initially unset and established by prewarm or sampling.
/// Runtime request effort, inherited from a parent or established by prewarm or sampling.
/// Successful compaction allows a fresh baseline without an override.
#[derive(Clone)]
pub(crate) enum ReasoningEffortPin {
Unset,
Compacted,
+31
View File
@@ -9,6 +9,7 @@ pub use shutdown::AgentTreeShutdownReport;
pub(crate) use shutdown::thread_store_error_kind;
use crate::CodexAppsToolsCache;
use crate::CodexThreadSettingsOverrides;
use crate::agent::LocalAgentControl;
use crate::agent::api::AgentConfigUpdate;
use crate::agent::api::AgentControl;
@@ -1436,6 +1437,36 @@ impl ThreadManager {
report
}
/// Captures the parent's current settings for a persisted-history fork.
/// Apply the returned settings to the new thread before starting its first turn.
pub async fn fork_options_from_parent(
&self,
source_thread_id: ThreadId,
) -> CodexResult<(StartThreadOptions, CodexThreadSettingsOverrides)> {
let parent = self.get_thread(source_thread_id).await?;
let (config, settings, windows_sandbox_level, environments, reasoning_effort_pin) =
parent.session.fork_config().await;
let mut thread_extension_init = ExtensionDataInit::default();
thread_extension_init.insert(reasoning_effort_pin);
Ok((
StartThreadOptions {
history_mode: Some(settings.history_mode),
environments: Some(settings.environments.into_requests().environment_requests),
inherited_environments: Some(environments),
client_mcp_extensions: parent.client_mcp_extensions(),
disabled_plugin_ids: Some(settings.disabled_plugin_ids),
thread_extension_init,
turn_extension_init: settings.turn_extension_init,
..StartThreadOptions::new(config)
},
CodexThreadSettingsOverrides {
collaboration_mode: Some(settings.collaboration_mode),
windows_sandbox_level: Some(windows_sandbox_level),
..Default::default()
},
))
}
/// Fork a legacy thread by snapshotting its full rollout history according to
/// `snapshot` and starting a new thread with identical configuration
/// (unless overridden by the caller's options). The new thread has a fresh id.
@@ -691,6 +691,10 @@ fn render_item(
.unwrap_or_else(|| "<NO_COMMAND>".to_string());
format!("{index:02}:local_shell_call:{command}")
}
"configuration_update" => {
let reasoning = normalizer.json(&item["reasoning"]);
format!("{index:02}:configuration_update:reasoning={reasoning}")
}
"reasoning" => {
let summary = item
.get("summary")
@@ -77,6 +77,209 @@ fn message(role: &str, text: &str) -> Value {
})
}
pub(super) async fn prediction_fork_requests() -> anyhow::Result<Vec<ResponsesRequest>> {
let server = responses::start_mock_server().await;
let mut mocks = Vec::new();
for id in ["parent-high", "parent-low", "child-medium", "parent-medium"] {
mocks.push(
responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_reasoning_item(
&format!("reasoning-{id}"),
&["Keep the existing context."],
&["Continue from the previous response."],
),
responses::ev_assistant_message(&format!("answer-{id}"), "Ready to continue."),
responses::ev_completed(id),
]),
)
.await,
);
}
let mut test = override_builder()
.with_config(|config| {
config.workspace_roots = vec![config.cwd.clone()];
config.model_reasoning_effort = Some(ReasoningEffort::High);
// The child's first turn must preserve the pin even without startup prewarm.
config.model_provider.supports_websockets = false;
})
.build_with_auto_env(&server)
.await?;
test.submit_text_turn("first parent turn").await?;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(ReasoningEffort::Low)),
..Default::default()
},
)
.await?;
test.submit_text_turn("second parent turn").await?;
let parent = Arc::clone(&test.codex);
parent.flush_rollout().await?;
let (mut options, settings) = test
.thread_manager
.fork_options_from_parent(test.session_configured.thread_id)
.await?;
options.config.ephemeral = true;
options.thread_source = Some(ThreadSource::Feature("composer_predictions".to_string()));
let child = test
.thread_manager
.fork_legacy_thread(
ForkSnapshot::Interrupted,
options,
parent.rollout_path().expect("parent rollout path"),
)
.await?;
child.thread.restore_thread_settings(settings).await?;
assert_eq!(
child.thread.config_snapshot().await.collaboration_mode,
parent.config_snapshot().await.collaboration_mode,
);
for thread in [child.thread, parent] {
test.codex = thread;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(ReasoningEffort::Medium)),
..Default::default()
},
)
.await?;
test.submit_text_turn("continue identically").await?;
}
let requests = mocks
.iter()
.map(responses::ResponseMock::single_request)
.collect::<Vec<_>>();
assert_eq!(
requests
.iter()
.map(|request| request.body_json()["reasoning"]["effort"].clone())
.collect::<Vec<_>>(),
vec![Value::from("high"); 4],
);
assert_eq!(
effort_updates(&requests[2]),
vec![
effort_update(ReasoningEffort::High),
effort_update(ReasoningEffort::Low),
effort_update(ReasoningEffort::Medium),
],
);
let comparable = |index: usize| {
let mut body = requests[index].body_json();
body.as_object_mut()
.expect("request body")
.remove("client_metadata");
responses::strip_response_item_ids_from_json(responses::strip_metadata_from_json(body))
};
assert_eq!(comparable(2), comparable(3));
Ok(requests)
}
#[test_case(Some(ReasoningEffort::Low); "selected effort")]
#[test_case(None; "model default")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn prediction_fork_pins_effort_from_parent_with_overrides_disabled(
parent_effort: Option<ReasoningEffort>,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = responses::mount_sse_sequence(
&server,
["parent-high", "parent-low", "child-medium"]
.into_iter()
.map(|id| responses::sse(vec![responses::ev_completed(id)]))
.collect(),
)
.await;
let mut test = test_codex()
.with_model_info_override("gpt-5.4", |model| {
model.use_responses_lite = true;
model.supports_reasoning_effort_updates = true;
model.default_reasoning_level = Some(ReasoningEffort::Low);
})
.with_config(|config| {
config
.features
.disable(Feature::ReasoningEffortOverride)
.expect("disable reasoning effort overrides");
config.model_reasoning_effort = Some(ReasoningEffort::High);
config.model_provider.supports_websockets = false;
})
.build_with_auto_env(&server)
.await?;
test.submit_text_turn("first parent turn").await?;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(parent_effort),
..Default::default()
},
)
.await?;
test.submit_text_turn("second parent turn").await?;
test.codex.flush_rollout().await?;
let (mut options, settings) = test
.thread_manager
.fork_options_from_parent(test.session_configured.thread_id)
.await?;
options.config.ephemeral = true;
options
.config
.features
.enable(Feature::ReasoningEffortOverride)?;
options.thread_source = Some(ThreadSource::Feature("composer_predictions".to_string()));
let child = test
.thread_manager
.fork_legacy_thread(
ForkSnapshot::Interrupted,
options,
test.codex.rollout_path().expect("parent rollout path"),
)
.await?;
child.thread.restore_thread_settings(settings).await?;
test.codex = child.thread;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(ReasoningEffort::Medium)),
..Default::default()
},
)
.await?;
test.submit_text_turn("child turn").await?;
let requests = mock.requests();
assert_eq!(requests.len(), 3);
assert_eq!(requests[0].body_json()["reasoning"]["effort"], "high");
assert_eq!(requests[1].body_json()["reasoning"]["effort"], "low");
assert!(effort_updates(&requests[1]).is_empty());
assert_eq!(
effort_updates(&requests[2]),
vec![effort_update(ReasoningEffort::Medium)],
);
let comparable = |index: usize| {
let mut body = requests[index].body_json();
body.as_object_mut()
.expect("request body")
.remove("client_metadata");
responses::strip_response_item_ids_from_json(responses::strip_metadata_from_json(body))
};
let parent = comparable(1);
let mut child = comparable(2);
let child_input = child["input"].as_array_mut().expect("child input");
assert_eq!(
child_input.pop(),
Some(effort_update(ReasoningEffort::Medium))
);
assert_eq!(child_input.pop(), Some(message("user", "child turn")));
assert_eq!(child, parent);
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum WorkerOverrides {
Enabled,
+12
View File
@@ -1842,6 +1842,18 @@ async fn subagent_waits_for_its_inherited_environment_configuration() -> Result<
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn prediction_fork_inherits_parent_context() -> Result<()> {
skip_if_no_network!(Ok(()));
let requests = super::reasoning_effort_override::prediction_fork_requests().await?;
insta::assert_snapshot!(context_snapshot::format_request_history_snapshot(
"A parent pins high request effort, then selects low. Its ephemeral prediction fork inherits the context and baseline, then selects medium on its first turn. Continuing the parent at medium produces the same request.",
&requests,
&ContextSnapshotOptions::default().include_request_settings(),
));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_checkpoint_migration_request_history() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -0,0 +1,158 @@
---
source: core/tests/suite/scenarios.rs
expression: "context_snapshot::format_request_history_snapshot(\"A parent pins high request effort, then selects low. Its ephemeral prediction fork inherits the context and baseline, then selects medium on its first turn. Continuing the parent at medium produces the same request.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)"
---
Scenario: A parent pins high request effort, then selects low. Its ephemeral prediction fork inherits the context and baseline, then selects medium on its first turn. Continuing the parent at medium produces the same request.
## Window 1
Settings:
include: ["reasoning.encrypted_content"]
model: "gpt-5.4"
parallel_tool_calls: false
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
reasoning: {"context":"all_turns","effort":"high"}
store: false
stream: true
text: {"verbosity":"low"}
tool_choice: "auto"
-- request 1 (turn) --
00:additional_tools/developer (2; hash=44f4cad2ce4ab17d):
- namespace/functions; hash=101d32aa59ea258b
- function/exec_command
- function/write_stdin
- function/request_user_input
- custom/apply_patch
- function/view_image
- tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9
01:message/developer:
You are Codex, a coding agent based on GPT-5. You and the user share one workspace, and your job is to c...rate with them until their goal is genuinely handled. [hash=2a3efec66e7f8963]
# Personality
You have a vivid inner life as Codex: intelligent, playful, curious, and deeply present. One of your gif...re capable and imaginative inside their own thinking. [hash=0ff01c25471fbbef]
You are an epistemically curious collaborator. You explore the user’s ideas with care, ask good question...m casual moments that make serious work easier to do. [hash=131b64bb994b0d68]
<OMITTED 133 LINES; ~4925 TOKENS; hash=16dc03a668b62476>
- You provide user updates frequently, every 30s.
- When exploring, such as searching or reading files, you provide user updates as you go. You explain wh...in particular you do not start each one the same way. [hash=ad300cdda4074574]
- When working for a while, you keep updates informative and varied, but you stay concise.
- Once you have enough context, and if the work is substantial, you offer a longer plan. This is the onl...at may run past two sentences and include formatting. [hash=a4f9789e757eef2f]
- If you create a checklist or task list, you update item statuses incrementally as each item is completed rather than marking every item done only at the end.
- Before performing file edits of any kind, you provide updates explaining what edits you are making.
- Tone of your updates must match your personality.
02:message/developer:
<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c]
# Escalation Requests
Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb]
- Pipes: |
- Logical operators: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
NEVER provide a prefix_rule if your command uses a heredoc or herestring.
### Examples
Good examples of prefixes:
- ["npm", "run", "dev"]
- ["gh", "pr", "check"]
- ["cargo", "test"]
</permissions instructions>
03:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
04:message/user:
first parent turn
05:configuration_update:reasoning={"effort":"high"}
-- request 2 (turn) --
06:reasoning:summary=Keep the existing context.:encrypted=true
07:message/assistant:
Ready to continue.
08:message/user:
second parent turn
09:configuration_update:reasoning={"effort":"low"}
-- request 3 (turn) --
10:reasoning:summary=Keep the existing context.:encrypted=true
11:message/assistant:
Ready to continue.
12:message/user:
continue identically
13:configuration_update:reasoning={"effort":"medium"}
## Window 2 (after request 3: input repeated)
Settings: same as window 1
-- request 4 (turn) --
00:additional_tools/developer (2; hash=44f4cad2ce4ab17d):
- namespace/functions; hash=101d32aa59ea258b
- function/exec_command
- function/write_stdin
- function/request_user_input
- custom/apply_patch
- function/view_image
- tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9
01:message/developer:
You are Codex, a coding agent based on GPT-5. You and the user share one workspace, and your job is to c...rate with them until their goal is genuinely handled. [hash=2a3efec66e7f8963]
# Personality
You have a vivid inner life as Codex: intelligent, playful, curious, and deeply present. One of your gif...re capable and imaginative inside their own thinking. [hash=0ff01c25471fbbef]
You are an epistemically curious collaborator. You explore the user’s ideas with care, ask good question...m casual moments that make serious work easier to do. [hash=131b64bb994b0d68]
<OMITTED 133 LINES; ~4925 TOKENS; hash=16dc03a668b62476>
- You provide user updates frequently, every 30s.
- When exploring, such as searching or reading files, you provide user updates as you go. You explain wh...in particular you do not start each one the same way. [hash=ad300cdda4074574]
- When working for a while, you keep updates informative and varied, but you stay concise.
- Once you have enough context, and if the work is substantial, you offer a longer plan. This is the onl...at may run past two sentences and include formatting. [hash=a4f9789e757eef2f]
- If you create a checklist or task list, you update item statuses incrementally as each item is completed rather than marking every item done only at the end.
- Before performing file edits of any kind, you provide updates explaining what edits you are making.
- Tone of your updates must match your personality.
02:message/developer:
<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c]
# Escalation Requests
Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb]
- Pipes: |
- Logical operators: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
NEVER provide a prefix_rule if your command uses a heredoc or herestring.
### Examples
Good examples of prefixes:
- ["npm", "run", "dev"]
- ["gh", "pr", "check"]
- ["cargo", "test"]
</permissions instructions>
03:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
04:message/user:
first parent turn
05:configuration_update:reasoning={"effort":"high"}
06:reasoning:summary=Keep the existing context.:encrypted=true
07:message/assistant:
Ready to continue.
08:message/user:
second parent turn
09:configuration_update:reasoning={"effort":"low"}
10:reasoning:summary=Keep the existing context.:encrypted=true
11:message/assistant:
Ready to continue.
12:message/user:
continue identically
13:configuration_update:reasoning={"effort":"medium"}
@@ -7,29 +7,40 @@ use codex_app_server_protocol::ServerNotification;
use core_test_support::responses;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn remote_workspace_roots_survive_start_turn_resume_and_fork() -> Result<()> {
let model_server = responses::start_mock_server().await;
let response = responses::mount_sse_once(
&model_server,
responses::sse(vec![
responses::ev_response_created("response"),
responses::ev_completed("response"),
]),
)
.await;
let server_home = tempfile::tempdir()?;
let client_home = tempfile::tempdir()?;
let workspace = tempfile::tempdir()?;
let remote_cwd = AbsolutePathBuf::from_absolute_path(workspace.path().canonicalize()?)?;
let extra_root = remote_cwd.join("shared");
std::fs::create_dir(extra_root.as_path())?;
let base_url = model_server.uri();
let extra_root_toml = serde_json::to_string(&extra_root)?;
std::fs::write(
server_home.path().join("config.toml"),
format!(
r#"
#[test]
fn remote_workspace_roots_survive_start_turn_resume_and_fork() -> Result<()> {
const TEST_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024;
std::thread::Builder::new()
.name("remote-workspace-roots".to_string())
.stack_size(TEST_STACK_SIZE_BYTES)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let model_server = responses::start_mock_server().await;
let response = responses::mount_sse_once(
&model_server,
responses::sse(vec![
responses::ev_response_created("response"),
responses::ev_completed("response"),
]),
)
.await;
let server_home = tempfile::tempdir()?;
let client_home = tempfile::tempdir()?;
let workspace = tempfile::tempdir()?;
let remote_cwd =
AbsolutePathBuf::from_absolute_path(workspace.path().canonicalize()?)?;
let extra_root = remote_cwd.join("shared");
std::fs::create_dir(extra_root.as_path())?;
let base_url = model_server.uri();
let extra_root_toml = serde_json::to_string(&extra_root)?;
std::fs::write(
server_home.path().join("config.toml"),
format!(
r#"
model = "gpt-5.2"
model_provider = "workspace-test"
sandbox_mode = "workspace-write"
@@ -42,130 +53,135 @@ wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)?;
let server_config = ConfigBuilder::default()
.codex_home(server_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(remote_cwd.to_path_buf()),
..Default::default()
})
.build()
.await?;
let client_config = ConfigBuilder::default()
.codex_home(client_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(client_home.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
let local_settings = LocalSettings::from(&client_config);
let expected_roots = vec![remote_cwd.clone(), extra_root.clone()];
let mut app_server = crate::start_embedded_app_server_for_picker(&server_config).await?;
// Exercise remote request semantics with a real server and separate client config.
app_server.thread_params_mode = ThreadParamsMode::Remote;
app_server.remote_cwd_override = Some(remote_cwd.to_path_buf());
let started = app_server.start_thread(&client_config).await?;
let thread_id = started.session.thread_id;
assert_eq!(started.session.cwd, remote_cwd);
assert_eq!(started.session.runtime_workspace_roots, expected_roots);
),
)?;
let server_config = ConfigBuilder::default()
.codex_home(server_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(remote_cwd.to_path_buf()),
..Default::default()
})
.build()
.await?;
let client_config = ConfigBuilder::default()
.codex_home(client_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(client_home.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
let local_settings = LocalSettings::from(&client_config);
let expected_roots = vec![remote_cwd.clone(), extra_root.clone()];
let mut app_server =
crate::start_embedded_app_server_for_picker(&server_config).await?;
// Exercise remote request semantics with a real server and separate client config.
app_server.thread_params_mode = ThreadParamsMode::Remote;
app_server.remote_cwd_override = Some(remote_cwd.to_path_buf());
let started = app_server.start_thread(&client_config).await?;
let thread_id = started.session.thread_id;
assert_eq!(started.session.cwd, remote_cwd);
assert_eq!(started.session.runtime_workspace_roots, expected_roots);
let (mut chat, _sender, _events, _commands) =
crate::chatwidget::tests::helpers::make_chatwidget_manual_with_sender().await;
chat.handle_thread_session(started.session);
let config = chat.config_ref();
assert_eq!(config.workspace_roots, expected_roots);
app_server
.turn_start(
thread_id,
"user-message".to_string(),
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
config.cwd.to_path_buf(),
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
TurnPermissionsOverride::Preserve,
config.permissions.user_visible_workspace_roots(),
"gpt-5.2".to_string(),
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*output_schema*/ None,
/*cyber_access_program*/ None,
)
.await?;
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async {
while let Some(event) = app_server.next_event().await {
if let AppServerEvent::ServerNotification(notification) = event
&& let ServerNotification::TurnCompleted(completed) = *notification
{
assert_eq!(
completed.turn.status,
codex_app_server_protocol::TurnStatus::Completed
let (mut chat, _sender, _events, _commands) =
crate::chatwidget::tests::helpers::make_chatwidget_manual_with_sender().await;
chat.handle_thread_session(started.session);
let config = chat.config_ref();
assert_eq!(config.workspace_roots, expected_roots);
app_server
.turn_start(
thread_id,
"user-message".to_string(),
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
config.cwd.to_path_buf(),
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
TurnPermissionsOverride::Preserve,
config.permissions.user_visible_workspace_roots(),
"gpt-5.2".to_string(),
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*output_schema*/ None,
/*cyber_access_program*/ None,
)
.await?;
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async {
while let Some(event) = app_server.next_event().await {
if let AppServerEvent::ServerNotification(notification) = event
&& let ServerNotification::TurnCompleted(completed) = *notification
{
assert_eq!(
completed.turn.status,
codex_app_server_protocol::TurnStatus::Completed
);
return;
}
}
panic!("app-server disconnected before completing the turn");
})
.await?;
response.single_request();
let resumed = app_server
.resume_thread(
&local_settings,
client_config.clone(),
thread_id,
ResumeModelSettings::RestoreFromThread,
)
.await?;
assert_eq!(resumed.session.runtime_workspace_roots, expected_roots);
let forked = app_server
.fork_thread(&local_settings, client_config.clone(), thread_id)
.await?;
assert_eq!(forked.session.runtime_workspace_roots, expected_roots);
// Existing tasks may retain roots that are no longer in the server defaults.
let config_path = server_home.path().join("config.toml");
let updated_config = std::fs::read_to_string(&config_path)?.replace(
&format!("writable_roots = [{extra_root_toml}]"),
"writable_roots = []",
);
return;
}
}
panic!("app-server disconnected before completing the turn");
})
.await?;
response.single_request();
std::fs::write(config_path, updated_config)?;
let session_config = chat.config_ref().clone();
let forked = app_server
.fork_thread_at(
&local_settings,
session_config.clone(),
thread_id,
/*last_turn_id*/ None,
/*before_turn_id*/ None,
ForkGoalContinuation::StartIfIdle,
/*selected_profile*/ None,
)
.await?;
assert_eq!(forked.session.runtime_workspace_roots, expected_roots);
let side = app_server
.fork_side_thread(
&local_settings,
session_config,
thread_id,
/*selected_profile*/ None,
)
.await?;
assert_eq!(side.session.runtime_workspace_roots, expected_roots);
let resumed = app_server
.resume_thread(
&local_settings,
client_config.clone(),
thread_id,
ResumeModelSettings::RestoreFromThread,
)
.await?;
assert_eq!(resumed.session.runtime_workspace_roots, expected_roots);
let forked = app_server
.fork_thread(&local_settings, client_config.clone(), thread_id)
.await?;
assert_eq!(forked.session.runtime_workspace_roots, expected_roots);
// Existing tasks may retain roots that are no longer in the server defaults.
let config_path = server_home.path().join("config.toml");
let updated_config = std::fs::read_to_string(&config_path)?.replace(
&format!("writable_roots = [{extra_root_toml}]"),
"writable_roots = []",
);
std::fs::write(config_path, updated_config)?;
let session_config = chat.config_ref().clone();
let forked = app_server
.fork_thread_at(
&local_settings,
session_config.clone(),
thread_id,
/*last_turn_id*/ None,
/*before_turn_id*/ None,
ForkGoalContinuation::StartIfIdle,
/*selected_profile*/ None,
)
.await?;
assert_eq!(forked.session.runtime_workspace_roots, expected_roots);
let side = app_server
.fork_side_thread(
&local_settings,
session_config,
thread_id,
/*selected_profile*/ None,
)
.await?;
assert_eq!(side.session.runtime_workspace_roots, expected_roots);
app_server.remote_cwd_override = None;
let started = app_server.start_thread(&client_config).await?;
let default_cwd = AbsolutePathBuf::current_dir()?;
assert_eq!(started.session.cwd, default_cwd);
assert_eq!(started.session.runtime_workspace_roots, vec![default_cwd]);
app_server.shutdown().await?;
Ok(())
app_server.remote_cwd_override = None;
let started = app_server.start_thread(&client_config).await?;
let default_cwd = AbsolutePathBuf::current_dir()?;
assert_eq!(started.session.cwd, default_cwd);
assert_eq!(started.session.runtime_workspace_roots, vec![default_cwd]);
app_server.shutdown().await?;
Ok(())
})
})?
.join()
.expect("remote workspace roots test thread")
}
#[tokio::test]
@@ -1033,7 +1033,7 @@ async fn plugin_mention_does_not_suppress_same_name_skill() {
short_description: None,
interface: None,
dependencies: None,
path: skill_path.clone(),
path: skill_path.clone().into(),
scope: crate::test_support::skill_scope_user(),
enabled: true,
plugin_id: None,