Preserve thread settings for goal-first and forked threads (#34469)

## Why

Goal-first and forked threads could create rollouts without recording their
effective thread settings. Resuming those threads after a restart could then
use current configuration instead of the settings selected when the thread was
created.

## What changed

- Persist a `thread_settings_applied` item when a goal first materializes a
  rollout, including a follow-up snapshot if settings changed concurrently.
- Append each fork's effective settings after its copied history so they take
  precedence over inherited settings during resume.
- Centralize conversion from `ThreadConfigSnapshot` to
  `ThreadSettingsSnapshot`.

## Testing

Added coverage that restarts the app server and verifies goal-first and forked
threads retain their respective `approvals_reviewer` settings.

GitOrigin-RevId: 6cb9aaae73bbe9ca432831cff7e826f95ffd6d01
This commit is contained in:
Dylan Hurd authored and copyberry committed 2026-07-21 05:19:49 +00:00
1 parent 9970cd706f
commit 1836ae0612
7 files changed
+230 -30

No files matched your search

+18 -2
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use codex_core::ForkSnapshot;
use codex_core::NewThread;
use codex_core::ThreadConfigSnapshot;
use codex_core::parse_turn_item;
use codex_protocol::items::TurnItem;
use codex_protocol::protocol::EventMsg;
@@ -10,6 +11,7 @@ use codex_protocol::protocol::Op;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
@@ -91,7 +93,7 @@ async fn fork_thread_twice_drops_to_first_message() {
// After cutting at nth user input (n=1 → second user message), cut strictly before that input.
let cut1 = user_inputs.get(1).copied().unwrap_or(0);
let expected_after_first: Vec<RolloutItem> = base_items[..cut1].to_vec();
let mut expected_after_first: Vec<RolloutItem> = base_items[..cut1].to_vec();
// After dropping again (n=1 on fork1), compute expected relative to fork1's rollout.
@@ -111,6 +113,9 @@ async fn fork_thread_twice_drops_to_first_message() {
.expect("fork 1");
let fork1_path = codex_fork1.rollout_path().expect("rollout path");
expected_after_first.push(thread_settings_applied_item(
codex_fork1.config_snapshot().await,
));
// GetHistory on fork1 flushed; the file is ready.
let fork1_items = read_rollout_items(&fork1_path);
@@ -142,7 +147,10 @@ async fn fork_thread_twice_drops_to_first_message() {
.get(fork1_user_inputs.len().saturating_sub(1))
.copied()
.unwrap_or(0);
let expected_after_second: Vec<RolloutItem> = fork1_items[..cut_last_on_fork1].to_vec();
let mut expected_after_second: Vec<RolloutItem> = fork1_items[..cut_last_on_fork1].to_vec();
expected_after_second.push(thread_settings_applied_item(
codex_fork2.config_snapshot().await,
));
let fork2_items = read_rollout_items(&fork2_path);
pretty_assertions::assert_eq!(
serde_json::to_value(&fork2_items).unwrap(),
@@ -150,6 +158,14 @@ async fn fork_thread_twice_drops_to_first_message() {
);
}
fn thread_settings_applied_item(snapshot: ThreadConfigSnapshot) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
ThreadSettingsAppliedEvent {
thread_settings: snapshot.into_thread_settings_snapshot(),
},
))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fork_thread_from_history_does_not_require_source_rollout_path() {
skip_if_no_network!();