Preserve the multi-agent version when forking at a turn cutoff (#43540)

## Why

A fork cutoff can remove the only `TurnContext` recording the source thread's selected multi-agent version. Forking before the first turn must preserve that version, including when the child is resumed before its first turn.

## What changed

Recover the version from the untrimmed source history or loaded parent, independently of permission overrides, and carry it into the fork's session metadata for `last_turn_id` and `before_turn_id` cutoffs.

## Testing

Add regression coverage for forks before the first turn across legacy and paginated history modes, with loaded and unloaded parents and explicit permission overrides. Verify that the child persists `MultiAgentVersion::V2` and still uses the `collaboration` tools after restarting and resuming.

GitOrigin-RevId: d7e30cf8bfdeb4f755fefab3d1d0884964d7c977
This commit is contained in:
jif
2026-09-07 18:17:39 +00:00
committed by copyberry
parent 81f23bc186
commit cc737efd65
3 changed files with 188 additions and 7 deletions
@@ -4825,7 +4825,7 @@ impl ThreadRequestProcessor {
.name
.as_deref()
.and_then(codex_core::util::normalize_thread_name);
let prepared_fork = if paginated_source {
let mut prepared_fork = if paginated_source {
let boundary = match (last_turn_id.as_deref(), before_turn_id.as_deref()) {
(Some(turn_id), None) => {
codex_thread_store::ForkBoundary::ThroughTurn(turn_id.to_string())
@@ -4928,8 +4928,9 @@ impl ThreadRequestProcessor {
!has_permission_override(request_overrides.as_ref(), &typesafe_overrides);
let needs_latest_settings =
restore_approval_policy || restore_approvals_reviewer || restore_permission_profile;
let loaded_parent = self.thread_manager.get_thread(source_thread_id).await.ok();
let loaded_parent_settings = if paginated_source && needs_latest_settings {
if let Ok(parent) = self.thread_manager.get_thread(source_thread_id).await {
if let Some(parent) = loaded_parent.as_ref() {
let snapshot = parent.thread_settings_snapshot().await;
Some(PersistedResumeSettings {
approval_policy: snapshot.approval_policy,
@@ -4943,11 +4944,10 @@ impl ThreadRequestProcessor {
None
};
let latest_context = if paginated_source
&& needs_latest_settings
&& loaded_parent_settings.is_none()
&& loaded_parent.is_none()
&& (last_turn_id.is_some() || before_turn_id.is_some())
{
Some(
Some(Arc::new(
self.thread_store
.load_latest_model_context(StoreLoadThreadHistoryParams {
thread_id: source_thread_id,
@@ -4956,10 +4956,23 @@ impl ThreadRequestProcessor {
.await
.map_err(thread_store_resume_read_error)?
.items,
)
))
} else {
None
};
// The fork cutoff can remove the only TurnContext that records the selected version.
// Recover it from the untrimmed source or live parent, independently of permission overrides.
let source_multi_agent_version = InitialHistory::Resumed(ResumedHistory {
conversation_id: source_thread_id,
history: Arc::clone(latest_context.as_ref().unwrap_or(&source_history_items)),
rollout_path: source_thread.rollout_path.clone(),
})
.get_multi_agent_version()
.or_else(|| {
loaded_parent
.as_ref()
.and_then(|parent| parent.multi_agent_version())
});
let persisted_settings = loaded_parent_settings.or_else(|| {
latest_persisted_resume_settings(
latest_context
@@ -4992,7 +5005,7 @@ impl ThreadRequestProcessor {
let parent_trace = self.request_trace_context(&request_id).await;
let thread_source = thread_source.map(Into::into);
let history_items = if prepared_fork.is_some() {
let mut history_items = if prepared_fork.is_some() {
source_history_items
} else {
let source_history_items = Arc::unwrap_or_clone(source_history_items);
@@ -5010,6 +5023,21 @@ impl ThreadRequestProcessor {
};
Arc::new(history_items)
};
if let Some(multi_agent_version) = source_multi_agent_version
&& (last_turn_id.is_some() || before_turn_id.is_some())
{
// Update only the fork's in-memory metadata; the source rollout stays unchanged.
for item in Arc::make_mut(&mut history_items) {
if let RolloutItem::SessionMeta(meta) = item
&& meta.meta.id == source_thread_id
{
meta.meta.multi_agent_version = Some(multi_agent_version);
}
}
if let Some(prepared_fork) = prepared_fork.as_mut() {
prepared_fork.model_context = Arc::clone(&history_items);
}
}
let ephemeral_preview = if ephemeral {
if paginated_source && last_turn_id.is_none() && before_turn_id.is_none() {