mirror of
https://github.com/openai/codex.git
synced 2026-11-08 08:37:30 +00:00
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:
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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
-23
@@ -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
|
||||
-26
@@ -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
|
||||
Reference in new issue
Block a user