Compare commits

...

6 Commits

Author SHA1 Message Date
Charles Cunningham
bf57a558ca Update rollback test expectations
Co-authored-by: Codex <noreply@openai.com>
2026-03-25 12:42:11 -07:00
Charles Cunningham
ece3b40fb3 Revert prefix-preserving rollback trim
Co-authored-by: Codex <noreply@openai.com>
2026-03-25 12:26:07 -07:00
Charles Cunningham
a5b8849d79 Use TEST_TMPDIR for fake bubblewrap tests
Co-authored-by: Codex <noreply@openai.com>
2026-03-25 11:52:35 -07:00
Charles Cunningham
5e70ae9de0 Preserve contextual session prefix on rollback
Co-authored-by: Codex <noreply@openai.com>
2026-03-25 11:40:08 -07:00
Charles Cunningham
287f030978 Restore missing rollback history test attrs
Co-authored-by: Codex <noreply@openai.com>
2026-03-25 11:29:23 -07:00
Charles Cunningham
120251eb5a Trim first-turn rollback context scaffolding
Co-authored-by: Codex <noreply@openai.com>
2026-03-25 10:47:32 -07:00
4 changed files with 63 additions and 39 deletions

View File

@@ -1422,7 +1422,8 @@ async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns()
assert_eq!(rollback_event.num_turns, 99);
let history = sess.clone_history().await;
assert_eq!(initial_context, history.raw_items());
assert!(history.raw_items().is_empty());
assert!(sess.reference_context_item().await.is_none());
}
#[tokio::test]

View File

@@ -5659,14 +5659,20 @@ fn system_bwrap_warning_is_disabled_off_linux() {
fn write_fake_bwrap(contents: &str) -> tempfile::TempPath {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use tempfile::NamedTempFile;
// Bazel can mount the OS temp directory `noexec`, so prefer the current
// working directory for fake executables and fall back to the default temp
// dir outside that environment.
let temp_file = std::env::current_dir()
.ok()
// Bazel can mount the default temp directory `noexec`, so prefer Bazel's
// per-test temp dir when it is available. Fall back to the working
// directory, then the platform temp dir outside that environment.
let bazel_temp_dir = std::env::var_os("TEST_TMPDIR").map(PathBuf::from);
let temp_file = bazel_temp_dir
.and_then(|dir| NamedTempFile::new_in(dir).ok())
.or_else(|| {
std::env::current_dir()
.ok()
.and_then(|dir| NamedTempFile::new_in(dir).ok())
})
.unwrap_or_else(|| NamedTempFile::new().expect("temp file"));
// Linux rejects exec-ing a file that is still open for writing.
let path = temp_file.into_temp_path();

View File

@@ -218,7 +218,7 @@ impl ContextManager {
/// - `num_turns == 0` is a no-op
/// - if there are no user turns, this is a no-op
/// - if `num_turns` exceeds the number of user turns, all user turns are dropped while
/// preserving any items that occurred before the first user message.
/// preserving only non-contextual items that occurred before the first user message.
///
/// If rollback trims a pre-turn developer message that mixes contextual fragments with
/// persistent developer text from `build_initial_context`, this also clears
@@ -232,20 +232,19 @@ impl ContextManager {
let snapshot = self.items.clone();
let user_positions = user_message_positions(&snapshot);
let Some(&first_instruction_turn_idx) = user_positions.first() else {
let Some(&first_user_idx) = user_positions.first() else {
self.replace(snapshot);
return;
};
let n_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);
let mut cut_idx = if n_from_end >= user_positions.len() {
first_instruction_turn_idx
first_user_idx
} else {
user_positions[user_positions.len() - n_from_end]
};
cut_idx =
self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx);
cut_idx = self.trim_pre_turn_context_updates(&snapshot, cut_idx);
self.replace(snapshot[..cut_idx].to_vec());
}
@@ -401,13 +400,10 @@ impl ContextManager {
/// Returns the adjusted cut index after removing contextual developer/user items immediately
/// above the rolled-back turn boundary.
///
/// `first_instruction_turn_idx` is the earliest rollback-eligible instruction-turn boundary
/// in `snapshot`; the trim walk never crosses it so any session-prefix items that predate the
/// first real turn survive rollback.
///
/// `cut_idx` is the tentative slice boundary after dropping the requested number of
/// instruction turns, before stripping contextual pre-turn items that sit immediately above
/// that boundary.
/// that boundary. The trim walk stops as soon as it encounters a non-contextual item, so only
/// actual per-turn scaffolding is removed.
///
/// If any trimmed developer message was a mixed `build_initial_context` bundle containing both
/// rollback-trimmable contextual fragments and persistent developer text, this also clears the
@@ -416,19 +412,16 @@ impl ContextManager {
fn trim_pre_turn_context_updates(
&mut self,
snapshot: &[ResponseItem],
first_instruction_turn_idx: usize,
mut cut_idx: usize,
) -> usize {
while cut_idx > first_instruction_turn_idx {
let mut trimmed_mixed_dev_bundle = false;
while cut_idx > 0 {
match &snapshot[cut_idx - 1] {
ResponseItem::Message { role, content, .. }
if role == "developer" && is_contextual_dev_message_content(content) =>
{
if has_non_contextual_dev_message_content(content) {
// Mixed `build_initial_context` bundles are not reconstructible from
// steady-state diffs once trimmed, so the next real turn must fully
// reinject context instead of diffing against a stale baseline.
self.reference_context_item = None;
trimmed_mixed_dev_bundle = true;
}
cut_idx -= 1;
}
@@ -440,6 +433,14 @@ impl ContextManager {
_ => break,
}
}
if trimmed_mixed_dev_bundle {
// Mixed `build_initial_context` bundles are not reconstructible from steady-state
// diffs once trimmed, so the next real turn must fully reinject context instead of
// diffing against a stale baseline.
self.reference_context_item = None;
}
cut_idx
}
}

View File

@@ -817,7 +817,7 @@ fn drop_last_n_user_turns_preserves_prefix() {
}
#[test]
fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
fn drop_last_n_user_turns_does_not_count_session_prefix_user_messages_as_turns() {
let items = vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg(
@@ -861,20 +861,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
expected_prefix_and_first_turn
);
let expected_prefix_only = vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
user_input_text_msg(
"<skill>\n<name>demo</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
),
user_input_text_msg("<user_shell_command>echo 42</user_shell_command>"),
user_input_text_msg(
"<subagent_notification>{\"agent_id\":\"a\",\"status\":\"completed\"}</subagent_notification>",
),
];
let mut history = create_history_with_items(vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg(
@@ -893,7 +879,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
assistant_msg("turn 2 assistant"),
]);
history.drop_last_n_user_turns(2);
assert_eq!(history.for_prompt(&modalities), expected_prefix_only);
assert_eq!(history.for_prompt(&modalities), Vec::<ResponseItem>::new());
let mut history = create_history_with_items(vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
@@ -913,7 +899,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
assistant_msg("turn 2 assistant"),
]);
history.drop_last_n_user_turns(3);
assert_eq!(history.for_prompt(&modalities), expected_prefix_only);
assert_eq!(history.for_prompt(&modalities), Vec::<ResponseItem>::new());
}
#[test]
@@ -954,6 +940,36 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
);
}
#[test]
fn drop_last_n_user_turns_trims_context_updates_above_first_rolled_back_turn() {
let items = vec![
assistant_msg("session prefix item"),
developer_msg("<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>"),
user_input_text_msg(
"<environment_context><cwd>PRETURN_CONTEXT_DIFF_CWD</cwd></environment_context>",
),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
];
let modalities = default_input_modalities();
let mut history = create_history_with_items(items);
let reference_context_item = reference_context_item();
history.set_reference_context_item(Some(reference_context_item.clone()));
history.drop_last_n_user_turns(1);
assert_eq!(
history.clone().for_prompt(&modalities),
vec![assistant_msg("session prefix item")]
);
assert_eq!(
serde_json::to_value(history.reference_context_item())
.expect("serialize retained reference context item"),
serde_json::to_value(Some(reference_context_item))
.expect("serialize expected reference context item")
);
}
#[test]
fn drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles() {
let items = vec![