Remove the deprecated thread/rollback API (#44915)

## What changed

Remove `thread/rollback`, its request and response types, generated bindings, and the core `Op::ThreadRollback` operation. Requests now follow the generic unknown-method rejection path. Document `thread/revert` as the alternative for paginated threads.

Keep historical `ThreadRolledBack` markers and legacy error deserialization so existing rollouts remain compatible with replay and migration.

## Testing

Adapt retained-context and Guardian history tests to append legacy rollback markers and resume threads, preserving coverage of surviving instructions, answers, and review history.

GitOrigin-RevId: b3da1becdf86b1869275aacb0ffc2817cee5af2e
This commit is contained in:
Owen Lin authored and copyberry committed 2026-09-11 21:32:04 +00:00
1 parent c62d191c4c
commit 3052bbcf8c
65 files changed
+124 -5472

No files matched your search

@@ -19,30 +19,21 @@ use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_history::CodexHarnessMetadata;
use codex_history::RolloutItem;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use core_test_support::ThreadIdle;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
@@ -54,7 +45,6 @@ use tempfile::TempDir;
use wiremock::MockServer;
const AFTER_SECOND_RESUME: &str = "AFTER_SECOND_RESUME";
const AFTER_ROLLBACK: &str = "AFTER_ROLLBACK";
const CHECKPOINT_METADATA_KEY: &str = "replacement_history_metadata";
fn network_disabled() -> bool {
@@ -506,233 +496,6 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
/// Scenario: rolling back behind a pre-turn compaction should replay
/// append-only history from the rollout file and keep earlier compacted
/// history visible.
async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Result<()> {
if network_disabled() {
println!("Skipping test because network is disabled in this sandbox");
return Ok(());
}
const EDITED_AFTER_COMPACT: &str = "EDITED_AFTER_COMPACT";
const SECOND_REPLY: &str = "SECOND_REPLY";
let server = MockServer::start().await;
let sse1 = sse(vec![
ev_assistant_message("m1", FIRST_REPLY),
ev_completed("r1"),
]);
let sse2 = sse(vec![
ev_assistant_message("m2", SUMMARY_TEXT),
ev_completed("r2"),
]);
let sse3 = sse(vec![
ev_assistant_message("m3", SECOND_REPLY),
ev_completed("r3"),
]);
let sse4 = sse(vec![ev_completed("r4")]);
let request_log = mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await;
let (_home, _config, _manager, base) = start_test_conversation(&server, /*model*/ None).await;
user_turn(&base, "hello world").await;
compact_conversation(&base).await;
user_turn(&base, EDITED_AFTER_COMPACT).await;
base.submit(Op::ThreadRollback { num_turns: 1 })
.await
.expect("submit thread rollback");
let rollback_event = wait_for_event(&base, |ev| {
if let EventMsg::Error(error) = ev {
panic!("rollback failed: {error:?}");
}
matches!(ev, EventMsg::ThreadRolledBack(_))
})
.await;
let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else {
panic!("expected thread rolled back event");
};
assert_eq!(rollback_event.num_turns, 1);
user_turn(&base, AFTER_ROLLBACK).await;
let requests = request_log.requests();
assert_eq!(requests.len(), 4);
assert!(requests[1].body_contains_text(SUMMARIZATION_PROMPT));
assert!(requests[2].body_contains_text("hello world"));
assert!(requests[2].body_contains_text(SUMMARY_TEXT));
assert!(requests[2].body_contains_text(EDITED_AFTER_COMPACT));
let after_rollback_user_texts = requests[3].message_input_texts("user");
let after_rollback_last = after_rollback_user_texts
.last()
.expect("post-rollback request missing user messages");
assert_eq!(after_rollback_last, AFTER_ROLLBACK);
assert!(
requests[3].body_contains_text("hello world"),
"the first turn should remain visible after rollback behind compaction",
);
assert!(
!requests[3].body_contains_text(EDITED_AFTER_COMPACT),
"the edited post-compaction turn should be removed by rollback",
);
assert!(
requests[3].body_contains_text(SUMMARY_TEXT),
"compaction summary should remain for the preserved first turn",
);
insta::assert_snapshot!(
"rollback_past_compaction_shapes",
context_snapshot::format_labeled_requests_snapshot(
"rollback past compaction replay after rollback",
&[
("compaction request", &requests[1]),
("before rollback", &requests[2]),
("after rollback", &requests[3]),
],
&ContextSnapshotOptions::default()
.strip_capability_instructions()
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }),
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
/// Scenario: rolling back a turn that introduced persistent pre-thread settings
/// diffs should trim those context updates so the next request includes them
/// only once.
async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> {
if network_disabled() {
println!("Skipping test because network is disabled in this sandbox");
return Ok(());
}
const MODEL: &str = "gpt-5.4";
const TURN_ONE_USER: &str = "turn 1 user";
const TURN_TWO_USER: &str = "turn 2 user";
const FOLLOWUP_USER: &str = "follow-up user";
const ROLLED_BACK_DEV_INSTRUCTIONS: &str = "ROLLED_BACK_DEV_INSTRUCTIONS";
const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD";
let server = MockServer::start().await;
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_assistant_message("m1", "turn 1 assistant"),
ev_completed("r1"),
]),
sse(vec![
ev_assistant_message("m2", "turn 2 assistant"),
ev_completed("r2"),
]),
sse(vec![ev_response_created("r3"), ev_completed("r3")]),
],
)
.await;
let (_home, config, _manager, conversation) =
start_test_conversation(&server, Some(MODEL)).await;
user_turn(&conversation, TURN_ONE_USER).await;
let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD);
std::fs::create_dir_all(&override_cwd)?;
core_test_support::submit_thread_settings(
&conversation,
ThreadSettingsOverrides {
environments: Some(local_selections(override_cwd.clone())),
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: MODEL.to_string(),
reasoning_effort: None,
developer_instructions: Some(ROLLED_BACK_DEV_INSTRUCTIONS.to_string()),
},
}),
..Default::default()
},
)
.await?;
user_turn(&conversation, TURN_TWO_USER).await;
conversation
.submit(Op::ThreadRollback { num_turns: 1 })
.await?;
let rollback_event = wait_for_event(&conversation, |ev| {
if let EventMsg::Error(error) = ev {
panic!("rollback failed: {error:?}");
}
matches!(ev, EventMsg::ThreadRolledBack(_))
})
.await;
let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else {
panic!("expected thread rolled back event");
};
assert_eq!(rollback_event.num_turns, 1);
user_turn(&conversation, FOLLOWUP_USER).await;
let requests = request_log.requests();
assert_eq!(requests.len(), 3);
let before_rollback_developer_count = requests[1]
.message_input_texts("developer")
.iter()
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
.count();
assert_eq!(before_rollback_developer_count, 1);
assert_eq!(
requests[1]
.message_input_texts("user")
.iter()
.filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))
.count(),
1
);
let after_rollback_developer_count = requests[2]
.message_input_texts("developer")
.iter()
.filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS))
.count();
assert_eq!(after_rollback_developer_count, 1);
let after_rollback_user_texts = requests[2].message_input_texts("user");
assert_eq!(
after_rollback_user_texts
.iter()
.filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD))
.count(),
1
);
assert_eq!(
after_rollback_user_texts.last().map(String::as_str),
Some(FOLLOWUP_USER)
);
insta::assert_snapshot!(
"rollback_followup_turn_trims_context_updates",
context_snapshot::format_labeled_requests_snapshot(
"rollback trims pre-turn override context updates before the follow-up request",
&[
("rolled-back turn request", &requests[1]),
("follow-up request after rollback", &requests[2]),
],
&ContextSnapshotOptions::default()
.strip_capability_instructions()
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }),
)
);
Ok(())
}
fn normalize_line_endings(value: &mut Value) {
match value {
Value::String(text) if text.contains('\r') => {
@@ -217,8 +217,7 @@ async fn guardian_revalidates_owning_session_before_allow(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_authorization_revision_survives_compaction_not_user_input_or_rollback()
-> Result<()> {
async fn guardian_authorization_revision_survives_compaction_not_user_input() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -285,14 +284,5 @@ async fn guardian_authorization_revision_survives_compaction_not_user_input_or_r
.await;
assert_eq!(test.codex.guardian_authorization_version().await, expected);
test.codex.ensure_rollout_materialized().await;
test.codex
.submit(Op::ThreadRollback { num_turns: 1 })
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ThreadRolledBack(_))
})
.await;
assert_ne!(test.codex.guardian_authorization_version().await, expected);
Ok(())
}
+33 -8
View File
@@ -1,4 +1,4 @@
//! Exercises retained review history through compaction, resume, fork, eviction, and rollback.
//! Exercises retained review history through compaction, resume, fork, eviction, and legacy rollback replay.
use anyhow::Result;
use base64::Engine;
@@ -17,6 +17,7 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
@@ -310,14 +311,15 @@ async fn guardian_history_uses_deltas_between_eviction_batches() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_history_survives_compaction_and_eviction_but_not_rollback() -> Result<()> {
async fn guardian_history_survives_compaction_and_eviction_but_not_legacy_rollback_replay()
-> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_wine_exec!(
Ok(()),
"Guardian approval actions require host-native paths"
);
let server = start_mock_server().await;
let test = test_codex()
let mut test = test_codex()
.with_config(|config| {
config.features.enable(Feature::TokenBudget).unwrap();
config
@@ -499,12 +501,35 @@ async fn guardian_history_survives_compaction_and_eviction_but_not_rollback() ->
);
test.codex.ensure_rollout_materialized().await;
test.codex
.submit(Op::ThreadRollback { num_turns: 2 })
.append_rollout_items(&[RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
ThreadRolledBackEvent { num_turns: 2 },
))])
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ThreadRolledBack(_))
})
.await;
test.codex.shutdown_and_wait().await?;
let thread_id = test.session_configured.thread_id;
test.thread_manager.remove_thread(&thread_id).await;
let model_context = test
.thread_store
.load_latest_model_context(LoadThreadHistoryParams {
thread_id,
include_archived: false,
})
.await?;
test.codex = test
.thread_manager
.resume_thread_with_history(
test.config.clone(),
InitialHistory::Resumed(ResumedHistory {
conversation_id: thread_id,
history: Arc::new(model_context.items),
rollout_path: None,
}),
test.thread_manager.auth_manager(),
/*parent_trace*/ None,
ClientMcpExtensions::default(),
)
.await?
.thread;
} else {
assert!(!transcript.contains(">>> TRUSTED USER ANSWERS START"));
assert!(!transcript.contains("Do not publish anything."));
@@ -1,5 +1,5 @@
//! Retained-instruction and answer lifecycles through real sessions and durable checkpoints.
//! Real user input covers steering, compaction and rollback; legacy replay fixtures also use
//! Real user input covers steering and compaction; legacy rollback replay fixtures use
//! the public rollout append API. Resume and child forks use production paths.
use std::collections::HashMap;
@@ -36,6 +36,7 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
@@ -63,7 +64,7 @@ use wiremock::MockServer;
use wiremock::matchers::header;
// Keep child completion out of the parent's history and wait for parent turn cleanup
// before checking rollback boundaries.
// before checking inherited authorization.
#[derive(Default)]
struct ForkTestLifecycle {
entered: Notify,
@@ -104,7 +105,7 @@ async fn wait_for_thread_idle(thread: &CodexThread) {
.get_or_init(ThreadIdle::default);
tokio::time::timeout(Duration::from_secs(10), idle.0.notified())
.await
.expect("thread should become idle before rollback");
.expect("thread should become idle");
}
async fn record_answer(
@@ -220,7 +221,7 @@ async fn compact_and_assert_answers(
expected: &[VerifiedAnswer],
) -> Result<RetainedContext> {
// Inspect live state by persisting a real compaction checkpoint, not a private getter.
// Repeating this after resume/rollback also catches checkpoint resurrection.
// Repeating this after legacy rollback replay also catches checkpoint resurrection.
thread.submit(Op::Compact).await?;
wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
thread.flush_rollout().await?;
@@ -250,11 +251,11 @@ enum InstructionSize {
Oversized,
}
#[test_case(ThreadHistoryMode::Legacy, true, InstructionSize::Normal; "enabled legacy rollback")]
#[test_case(ThreadHistoryMode::Legacy, true, InstructionSize::Normal; "enabled legacy rollback replay")]
#[test_case(ThreadHistoryMode::Paginated, true, InstructionSize::Normal; "enabled paginated resume")]
#[test_case(ThreadHistoryMode::Legacy, false, InstructionSize::Normal; "disabled legacy rollback")]
#[test_case(ThreadHistoryMode::Legacy, false, InstructionSize::Normal; "disabled legacy rollback replay")]
#[test_case(ThreadHistoryMode::Paginated, false, InstructionSize::Normal; "disabled paginated resume")]
#[test_case(ThreadHistoryMode::Legacy, true, InstructionSize::Oversized; "oversized instruction rollback")]
#[test_case(ThreadHistoryMode::Legacy, true, InstructionSize::Oversized; "oversized instruction rollback replay")]
#[test_case(ThreadHistoryMode::Paginated, true, InstructionSize::Oversized; "oversized instruction resume")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn retained_instructions_keep_identity_across_compaction_and_resume(
@@ -272,8 +273,7 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume(
"Project detail. ".repeat(2_000)
),
};
// Paginated history supports checkpoint resume, but not the full-history read
// used by ThreadRollback. Exercise rollback on its supported legacy path.
// Legacy rollouts can contain rollback markers; paginated histories use checkpoint resume.
let rollback_counts: &[usize] = match history_mode {
ThreadHistoryMode::Legacy => &[1, 0],
ThreadHistoryMode::Paginated => &[],
@@ -496,11 +496,12 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume(
serde_json::Value::Null
}
);
thread.submit(Op::ThreadRollback { num_turns: 1 }).await?;
wait_for_event(&thread, |event| {
matches!(event, EventMsg::ThreadRolledBack(_))
})
.await;
thread
.append_rollout_items(&[RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
ThreadRolledBackEvent { num_turns: 1 },
))])
.await?;
thread = resume(&test, &thread).await?;
expected["user_messages"]
.as_array_mut()
.expect("expected retained user messages")
@@ -772,7 +773,7 @@ async fn disabled_capture_stays_incomplete_after_compaction_and_enabled_resume()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn retained_answers_rollback_only_the_steered_instruction() -> Result<()> {
async fn legacy_rollback_replay_retains_only_surviving_steered_answers() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let test = test_codex()
@@ -907,11 +908,12 @@ async fn retained_answers_rollback_only_the_steered_instruction() -> Result<()>
let mut thread = resume(&test, &test.codex).await?;
compact_and_assert_answers(&test, &thread, &answers).await?;
for expected in [&answers[..1], &[]] {
thread.submit(Op::ThreadRollback { num_turns: 1 }).await?;
wait_for_event(&thread, |event| {
matches!(event, EventMsg::ThreadRolledBack(_))
})
.await;
thread
.append_rollout_items(&[RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
ThreadRolledBackEvent { num_turns: 1 },
))])
.await?;
thread = resume(&test, &thread).await?;
compact_and_assert_answers(&test, &thread, expected).await?;
thread = resume(&test, &thread).await?;
compact_and_assert_answers(&test, &thread, expected).await?;
@@ -922,7 +924,7 @@ async fn retained_answers_rollback_only_the_steered_instruction() -> Result<()>
#[derive(Clone, Copy)]
enum LifecycleBoundary {
Rollback,
LegacyRollbackReplay,
ChildFork,
}
@@ -1221,8 +1223,8 @@ async fn forked_parent_instructions_do_not_become_local_authorization(
sse(vec![ev_completed("parent-fork-completion")]),
)
.await;
// Hold the child at a real tool boundary until rollback is checked. Its completion
// notification must not add a parent message or consume a parent mock response.
// Pause the child while checking inherited authorization. Its completion notification
// must not add a parent message or consume a parent mock response.
let child_requests = mount_sse_once_match(
&server,
move |request: &wiremock::Request| {
@@ -1274,36 +1276,9 @@ async fn forked_parent_instructions_do_not_become_local_authorization(
thread_context_enabled.then_some((0, true)),
"inherited conversation must not populate child-local authorization",
);
let root_before = child.guardian_root_snapshot().await.context("live root")?;
let root_snapshot = child.guardian_root_snapshot().await.context("live root")?;
assert!(
root_before
.messages
.contains(&codex_core::GuardianRootMessage::User(
PARENT_GRANT.to_owned()
))
);
wait_for_thread_idle(&test.codex).await;
test.codex
.submit(Op::ThreadRollback { num_turns: 1 })
.await?;
wait_for_event(&test.codex, |event| {
if let EventMsg::Error(error) = event {
panic!("rollback failed: {error:?}");
}
matches!(event, EventMsg::ThreadRolledBack(_))
})
.await;
let root_after = child
.guardian_root_snapshot()
.await
.context("live root after rollback")?;
assert_ne!(
root_before.authorization_version,
root_after.authorization_version
);
assert!(
!root_after
root_snapshot
.messages
.contains(&codex_core::GuardianRootMessage::User(
PARENT_GRANT.to_owned()
@@ -1368,7 +1343,7 @@ async fn forked_parent_instructions_do_not_become_local_authorization(
Ok(())
}
#[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::Rollback; "legacy rollback")]
#[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::LegacyRollbackReplay; "legacy rollback replay")]
#[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::ChildFork; "legacy child fork")]
#[test_case(ThreadHistoryMode::Paginated, LifecycleBoundary::ChildFork; "paginated child fork")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1455,15 +1430,16 @@ async fn retained_answers_cross_real_session_boundaries(
let expected = [before.clone(), after];
match boundary {
LifecycleBoundary::Rollback => {
LifecycleBoundary::LegacyRollbackReplay => {
// First remove the suffix answer, then the source retained only in the checkpoint.
let mut thread = thread;
for expected in [std::slice::from_ref(&before), &[]] {
thread.submit(Op::ThreadRollback { num_turns: 1 }).await?;
wait_for_event(&thread, |event| {
matches!(event, EventMsg::ThreadRolledBack(_))
})
.await;
thread
.append_rollout_items(&[RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
ThreadRolledBackEvent { num_turns: 1 },
))])
.await?;
thread = resume(&test, &thread).await?;
compact_and_assert_answers(&test, &thread, expected).await?;
thread = resume(&test, &thread).await?;
compact_and_assert_answers(&test, &thread, expected).await?;
@@ -3,12 +3,7 @@ use codex_config::types::Personality;
use codex_core::CodexThread;
use codex_core::ForkSnapshot;
use codex_core::TurnInputRequest;
use codex_core::config::Config;
use codex_core::config::Constrained;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadIdleInput;
use codex_extension_api::ThreadLifecycleContributor;
use codex_features::Feature;
use codex_history::RolloutItem;
use codex_login::CodexAuth;
@@ -64,9 +59,7 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use test_case::test_case;
use tokio::sync::Notify;
use wiremock::MockServer;
fn read_only_user_turn(test: &TestCodex, items: Vec<UserInput>, model: String) -> TurnInputRequest {
@@ -303,131 +296,6 @@ async fn first_turn_after_empty_prefix_fork_preserves_inherited_base_instruction
Ok(())
}
#[derive(Clone, Copy)]
enum RollbackFollowup {
StartupModel,
SwitchedModel,
ColdResume,
}
#[derive(Default)]
struct RollbackReady {
idle: Notify,
}
impl ThreadLifecycleContributor<Config> for RollbackReady {
fn on_thread_idle<'a>(&'a self, _input: ThreadIdleInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
self.idle.notify_one();
})
}
}
impl RollbackReady {
async fn wait(&self) {
// TurnComplete is delivered before the active turn is cleared. Rollback requires
// the later thread-idle callback so it cannot race with turn cleanup.
tokio::time::timeout(Duration::from_secs(10), self.idle.notified())
.await
.expect("thread should become idle before rollback");
}
}
#[test_case(RollbackFollowup::StartupModel; "return to startup model")]
#[test_case(RollbackFollowup::SwitchedModel; "retry switched model")]
#[test_case(RollbackFollowup::ColdResume; "retry switched model after cold resume")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rollback_first_turn_model_change_removes_its_instructions(
followup: RollbackFollowup,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let response_mock = mount_sse_sequence(
&server,
vec![sse_completed("resp-first"), sse_completed("resp-followup")],
)
.await;
let initial_model = "gpt-5.6-terra";
let switched_model = "gpt-5.5";
let rollback_ready = Arc::new(RollbackReady::default());
let mut extensions = ExtensionRegistryBuilder::new();
extensions.thread_lifecycle_contributor(rollback_ready.clone());
let mut builder = test_codex()
.with_model_info_override(initial_model, configure_model_switching_fixture)
.with_extensions(Arc::new(extensions.build()));
let test = builder.build_with_auto_env(&server).await?;
submit_model_turn(
&test.codex,
switched_model,
ThreadSettingsOverrides::default(),
)
.await?;
rollback_ready.wait().await;
test.codex
.submit(Op::ThreadRollback { num_turns: 1 })
.await?;
wait_for_event(&test.codex, |ev| {
if let EventMsg::Error(error) = ev {
panic!("rollback failed: {error:?}");
}
matches!(ev, EventMsg::ThreadRolledBack(_))
})
.await;
let test = match followup {
RollbackFollowup::ColdResume => {
let mut resume_builder = test_codex()
.with_model_info_override(initial_model, configure_model_switching_fixture)
.with_model(switched_model);
resume_builder.restart(&server, &test).await?
}
RollbackFollowup::StartupModel | RollbackFollowup::SwitchedModel => test,
};
let followup_model = match followup {
RollbackFollowup::StartupModel => initial_model,
RollbackFollowup::SwitchedModel | RollbackFollowup::ColdResume => switched_model,
};
submit_model_turn(
&test.codex,
followup_model,
ThreadSettingsOverrides::default(),
)
.await?;
let request = &response_mock.requests()[1];
assert_eq!(request.body_json()["model"], followup_model);
let misaligned_messages = request
.inputs_of_type("message")
.into_iter()
.filter(|message| {
message["internal_chat_message_metadata_passthrough"]["content_item_kinds"]
.as_array()
.is_some_and(|kinds| {
message["content"]
.as_array()
.is_none_or(|content| content.len() != kinds.len())
})
})
.collect::<Vec<_>>();
assert_eq!(misaligned_messages, Vec::<serde_json::Value>::new());
let model_switch_count = request
.message_input_texts("developer")
.iter()
.filter(|text| text.contains("<model_switch>"))
.count();
assert_eq!(
model_switch_count,
usize::from(followup_model == switched_model),
"rolled-back model instructions must not survive or be duplicated"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_change_appends_model_instructions_developer_message() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1359,113 +1227,6 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let image_model_slug = "test-image-model";
let image_model = test_model_info(
image_model_slug,
"Test Image Model",
"supports image input",
default_input_modalities(),
);
mount_models_once(
&server,
ModelsResponse {
models: vec![image_model],
},
)
.await;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_image_generation_call("ig_rollback", "completed", "lobster", "Zm9v"),
ev_completed_with_tokens("resp-1", /*total_tokens*/ 10),
]),
sse_completed("resp-2"),
],
)
.await;
let rollback_ready = Arc::new(RollbackReady::default());
let mut extensions = ExtensionRegistryBuilder::new();
extensions.thread_lifecycle_contributor(rollback_ready.clone());
let mut builder = test_codex()
.with_extensions(Arc::new(extensions.build()))
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.model = Some(image_model_slug.to_string());
});
let test = builder.build(&server).await?;
let models_manager = test.thread_manager.get_models_manager();
let _ = models_manager
.list_models(
RefreshStrategy::OnlineIfUncached,
codex_core::test_support::default_http_client_factory(),
)
.await;
test.codex
.start_or_steer_turn(read_only_user_turn(
&test,
vec![UserInput::Text {
text: "generate a lobster".to_string(),
text_elements: Vec::new(),
}],
image_model_slug.to_string(),
))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
rollback_ready.wait().await;
test.codex
.submit(Op::ThreadRollback { num_turns: 1 })
.await?;
wait_for_event(&test.codex, |ev| {
if let EventMsg::Error(error) = ev {
panic!("rollback failed: {error:?}");
}
matches!(ev, EventMsg::ThreadRolledBack(_))
})
.await;
test.codex
.start_or_steer_turn(read_only_user_turn(
&test,
vec![UserInput::Text {
text: "after rollback".to_string(),
text_elements: Vec::new(),
}],
image_model_slug.to_string(),
))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let requests = responses.requests();
assert_eq!(requests.len(), 2, "expected two model requests");
let second_request = requests.last().expect("expected second request");
assert!(
!second_request
.message_input_texts("user")
.iter()
.any(|text| text == "generate a lobster"),
"rollback should remove the rolled-back image-generation user turn"
);
assert!(
second_request
.inputs_of_type("image_generation_call")
.is_empty(),
"rollback should remove the generated image call with the rolled-back turn"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -7,10 +7,6 @@ use codex_core::SuspendTurnOutcome;
use codex_core::TurnInputRequest;
use codex_core::TurnInputSubmission;
use codex_core::config::Config;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadIdleInput;
use codex_extension_api::ThreadLifecycleContributor;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::models::ResponseItem;
@@ -32,8 +28,6 @@ use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
use test_case::test_case;
use tokio::sync::Notify;
use tokio::time::timeout;
use wiremock::ResponseTemplate;
fn override_builder() -> TestCodexBuilder {
@@ -378,8 +372,6 @@ enum PrewarmStartup {
New,
Resume,
Fork,
ResumeThenRollback,
ForkThenRollback,
}
#[test_case(true, PrewarmStartup::New; "new thread feature enabled")]
@@ -388,8 +380,6 @@ enum PrewarmStartup {
#[test_case(false, PrewarmStartup::Resume; "resumed thread feature disabled")]
#[test_case(true, PrewarmStartup::Fork; "forked thread feature enabled")]
#[test_case(false, PrewarmStartup::Fork; "forked thread feature disabled")]
#[test_case(true, PrewarmStartup::ResumeThenRollback; "resumed thread rollback before first turn")]
#[test_case(true, PrewarmStartup::ForkThenRollback; "forked thread rollback before first turn")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_websocket_prewarm_preserves_baseline(
feature_enabled: bool,
@@ -443,10 +433,7 @@ async fn reasoning_effort_override_websocket_prewarm_preserves_baseline(
.map(String::as_str),
Some("previous turn"),
);
if matches!(
startup,
PrewarmStartup::Fork | PrewarmStartup::ForkThenRollback
) {
if matches!(startup, PrewarmStartup::Fork) {
previous.codex.shutdown_and_wait().await?;
let mut config = previous.config.clone();
configure_prewarm(&mut config);
@@ -475,23 +462,6 @@ async fn reasoning_effort_override_websocket_prewarm_preserves_baseline(
.await?;
assert_eq!(warmup.body_json()["generate"], false);
assert_eq!(warmup.body_json()["reasoning"]["effort"], "medium");
if matches!(
startup,
PrewarmStartup::ResumeThenRollback | PrewarmStartup::ForkThenRollback
) {
// Observing the warmup request guarantees that Medium is pinned before rollback.
test.codex
.submit(Op::ThreadRollback { num_turns: 1 })
.await?;
let rollback = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ThreadRolledBack(_) | EventMsg::Error(_))
})
.await;
assert!(
matches!(rollback, EventMsg::ThreadRolledBack(_)),
"rollback failed: {rollback:?}",
);
}
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
@@ -765,124 +735,6 @@ async fn reasoning_effort_override_disabled_on_resume_retires_update_at_compacti
Ok(())
}
#[test_case(1, ReasoningEffort::High; "later turn keep changed selection")]
#[test_case(1, ReasoningEffort::Medium; "later turn match restored settings")]
#[test_case(2, ReasoningEffort::High; "through first turn")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reasoning_effort_override_rollback_reestablishes_selected_effort(
num_turns: u32,
effort: ReasoningEffort,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
#[derive(Default)]
struct ThreadIdle {
ready: Notify,
}
impl ThreadLifecycleContributor<Config> for ThreadIdle {
fn on_thread_idle<'a>(&'a self, _input: ThreadIdleInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
self.ready.notify_one();
})
}
}
let idle = Arc::new(ThreadIdle::default());
let mut extensions = ExtensionRegistryBuilder::new();
extensions.thread_lifecycle_contributor(idle.clone());
let server = responses::start_mock_server().await;
let first = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("first")]),
)
.await;
let removed = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("removed")]),
)
.await;
let replacement = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("replacement")]),
)
.await;
let test = override_builder()
.with_extensions(Arc::new(extensions.build()))
.build_with_auto_env(&server)
.await?;
test.submit_text_turn("first turn").await?;
timeout(Duration::from_secs(/*secs*/ 10), idle.ready.notified()).await?;
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(ReasoningEffort::High)),
..Default::default()
},
)
.await?;
test.submit_text_turn("removed turn").await?;
// Completion can be delivered before the active slot is cleared. Wait for
// actual idle so this test exercises effort replay, not rollback admission.
timeout(Duration::from_secs(/*secs*/ 10), idle.ready.notified()).await?;
test.codex.submit(Op::ThreadRollback { num_turns }).await?;
let rollback = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ThreadRolledBack(_) | EventMsg::Error(_))
})
.await;
let EventMsg::ThreadRolledBack(rollback) = rollback else {
panic!("rollback failed: {rollback:?}");
};
assert_eq!(rollback.num_turns, num_turns);
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
effort: Some(Some(effort.clone())),
..Default::default()
},
)
.await?;
test.submit_text_turn("replacement turn").await?;
timeout(Duration::from_secs(/*secs*/ 10), idle.ready.notified()).await?;
assert_eq!(
effort_updates(&first.single_request()),
[effort_update(ReasoningEffort::Medium)]
);
assert_eq!(
effort_updates(&removed.single_request()),
[
effort_update(ReasoningEffort::Medium),
effort_update(ReasoningEffort::High)
]
);
let request = replacement.single_request();
assert_eq!(request.body_json()["reasoning"]["effort"], effort.as_str());
let mut expected = if num_turns == 1 {
vec![effort_update(ReasoningEffort::Medium)]
} else {
Vec::new()
};
expected.push(effort_update(effort.clone()));
assert_eq!(effort_updates(&request), expected);
let input = request.input();
assert!(
!input
.iter()
.any(|item| item["content"][0]["text"] == "removed turn")
);
let tail = responses::strip_response_item_ids_from_json(responses::strip_metadata_from_json(
Value::Array(input[input.len() - 2..].to_vec()),
));
assert_eq!(
tail,
serde_json::json!([
message("user", "replacement turn"),
effort_update(effort.clone()),
])
);
Ok(())
}
#[test_case(ReasoningEffort::Medium; "same selection")]
#[test_case(ReasoningEffort::High; "changed selection")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1,14 +1,12 @@
use anyhow::Result;
use codex_core::TurnInputRequest;
use codex_core::config::RolloutBudgetConfig;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_features::Feature;
use codex_model_provider_info::built_in_model_providers;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::ThreadIdle;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -24,7 +22,6 @@ use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use test_case::test_case;
use tokio::time::timeout;
@@ -468,56 +465,3 @@ async fn restates_the_current_remainder_after_compaction() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restates_the_current_remainder_after_rollback() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_completed_with_tokens("resp-1", /*total_tokens*/ 30),
]),
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
],
)
.await;
let mut extensions = ExtensionRegistryBuilder::new();
extensions.thread_lifecycle_contributor(Arc::new(ThreadIdle));
let test = test_codex()
.with_extensions(Arc::new(extensions.build()))
.with_config(|config| {
config.rollout_budget = Some(RolloutBudgetConfig {
reminder_at_remaining_tokens: vec![50],
..rollout_budget()
});
})
.build(&server)
.await?;
test.submit_turn("rolled-back turn").await?;
ThreadIdle::wait(&test.codex).await;
test.codex
.submit(Op::ThreadRollback { num_turns: 1 })
.await?;
wait_for_event(&test.codex, |event| {
if let EventMsg::Error(error) = event {
panic!("rollback failed: {error:?}");
}
matches!(event, EventMsg::ThreadRolledBack(_))
})
.await;
test.submit_turn("turn after rollback").await?;
let requests = responses.requests();
assert_eq!(
rollout_budget_texts(&requests[1]),
vec![rollout_budget_message(/*remaining_tokens*/ 70)],
"rollback should rearm the current budget reminder without refunding usage"
);
Ok(())
}
@@ -1,23 +0,0 @@
---
source: core/tests/suite/compact_resume_fork.rs
expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback trims pre-turn override context updates before the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)"
---
Scenario: rollback trims pre-turn override context updates before the follow-up request
## rolled-back turn request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:turn 1 user
03:message/assistant:turn 1 assistant
04:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
06:message/user:turn 2 user
## follow-up request after rollback
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:turn 1 user
03:message/assistant:turn 1 assistant
04:message/developer:<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
06:message/user:follow-up user
@@ -1,26 +0,0 @@
---
source: core/tests/suite/compact_resume_fork.rs
expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback past compaction replay after rollback\",\n&[(\"compaction request\", &requests[1]), (\"before rollback\", &requests[2]),\n(\"after rollback\", &requests[3]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 64 }),)"
---
Scenario: rollback past compaction replay after rollback
## compaction request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:hello world
03:message/assistant:FIRST_REPLY
04:message/user:<SUMMARIZATION_PROMPT>
## before rollback
00:message/user:hello world
01:message/user:<COMPACTION_SUMMARY>\nSUMMARY_ONLY_CONTEXT
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
04:message/user:EDITED_AFTER_COMPACT
## after rollback
00:message/user:hello world
01:message/user:<COMPACTION_SUMMARY>\nSUMMARY_ONLY_CONTEXT
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
04:message/user:AFTER_ROLLBACK