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
@@ -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,
};