Compare commits

...

46 Commits

Author SHA1 Message Date
Charles Cunningham
19bf2ea5a4 Fallback pre-turn compaction on context overflow 2026-02-17 17:53:46 -08:00
Charles Cunningham
b99d7265f5 Qualify Feature enum in compact suite tests 2026-02-17 17:16:49 -08:00
Charles Cunningham
ab23952482 Move compact reinjection snapshots into suite coverage 2026-02-17 17:16:49 -08:00
Charles Cunningham
8f86cb1580 Treat no-op pre-turn compaction as not-needed 2026-02-17 17:15:51 -08:00
Charles Cunningham
581066bc43 Add comment 2026-02-17 17:15:51 -08:00
Charles Cunningham
8bef5e0933 Make compact unit snapshots Bazel-stable 2026-02-17 17:15:51 -08:00
Charles Cunningham
7712ea323e Persist pre-turn updates before interrupted auto-compaction exits 2026-02-17 17:15:51 -08:00
Charles Cunningham
8fe0e8b903 Persist pre-turn updates before interrupted model-switch compact exits 2026-02-17 17:15:51 -08:00
Charles Cunningham
ace4aa5fc0 Silence interrupted pre-turn compaction failures 2026-02-17 17:15:51 -08:00
Charles Cunningham
3843a4c5c4 Strip incoming model-switch updates from remote compaction input 2026-02-17 17:15:51 -08:00
Charles Cunningham
5e4304c89e core: snapshot pre-turn model-switch compaction strip behavior 2026-02-17 17:14:19 -08:00
Charles Cunningham
fa7f0bb551 compact: strip incoming model-switch before compaction 2026-02-17 17:13:17 -08:00
Charles Cunningham
09e08a5831 compact: snapshot reinjection above last summary 2026-02-17 17:13:17 -08:00
Charles Cunningham
959cf01a31 compact: reinsert context above last summary 2026-02-17 17:13:17 -08:00
Charles Cunningham
5b737fa853 Reinject context for summary-only compacted history 2026-02-17 17:13:17 -08:00
Charles Cunningham
7107158929 Document manual compact turn-context reinjection policy 2026-02-17 17:13:17 -08:00
Charles Cunningham
d53761b33a Use explicit user-message predicate for compaction filtering 2026-02-17 17:13:17 -08:00
Charles Cunningham
fb0f3d1cd5 Comment 2026-02-17 17:13:17 -08:00
Charles Cunningham
8703a0c79c Comments 2026-02-17 17:13:17 -08:00
Charles Cunningham
781896241e Move pre-turn auto-compact error handling into helper 2026-02-17 17:13:17 -08:00
Charles Cunningham
ee34d879be Return sentinel error from model-switch compaction helper 2026-02-17 17:13:17 -08:00
Charles Cunningham
9f00db1395 Move previous-model compact failure handling into helper 2026-02-17 17:13:16 -08:00
Charles Cunningham
152602caa1 Add TODO for legacy compacted history context reinjection 2026-02-17 17:13:16 -08:00
Charles Cunningham
315ffc01df Add comment 2026-02-17 17:13:16 -08:00
Charles Cunningham
32b5f3406d Enable compaction tests and remove stale TODO markers 2026-02-17 17:13:16 -08:00
Charles Cunningham
02eb5152b3 Differentiate model-switch compaction failures from oversize input 2026-02-17 17:12:34 -08:00
Charles Cunningham
6f1f97c715 Rebase: align compaction snapshots and imports 2026-02-17 17:12:34 -08:00
Charles Cunningham
227fad55a3 Avoid wildcard pattern in ResponseEvent::Completed match 2026-02-17 17:12:34 -08:00
Charles Cunningham
8ede3474e6 Align compaction tests with incoming-item and empty-history behavior 2026-02-17 17:12:33 -08:00
Charles Cunningham
708c25cdc8 update snaps 2026-02-17 17:10:48 -08:00
Charles Cunningham
b14e40cfeb Update snaps 2026-02-17 17:10:48 -08:00
Charles Cunningham
b7be40245b Fix clippy useless_vec in compact_remote test 2026-02-17 17:10:48 -08:00
Charles Cunningham
afe5c01d3b Reinject canonical context for model-switch pre-turn compaction 2026-02-17 17:10:48 -08:00
Charles Cunningham
5a5acddcc1 Persist pre-turn updates before apps tool-list cancellation 2026-02-17 17:10:48 -08:00
Charles Cunningham
d02dfe4d0a codex: persist pre-turn updates when compaction fails 2026-02-17 17:10:48 -08:00
Charles Cunningham
c40132caf9 compact: preserve incoming items during pre-turn trim retries 2026-02-17 17:10:48 -08:00
Charles Cunningham
1e6e4c15ea compact: split core logic changes from snapshot test coverage 2026-02-17 17:10:48 -08:00
Charles Cunningham
a62b81cbbe Update pre-sampling model-switch compaction snapshot 2026-02-17 17:10:48 -08:00
Charles Cunningham
3658a2cc4d update snapshot 2026-02-17 17:10:47 -08:00
Charles Cunningham
a919cb525b Simplify 2026-02-17 17:10:47 -08:00
Charles Cunningham
107fc70bd0 Fix model switch compaction 2026-02-17 17:10:47 -08:00
Charles Cunningham
8c7e53ed8d Use shared default remote compaction mock in core tests 2026-02-17 16:59:13 -08:00
Charles Cunningham
57e68647bb Document realistic remote compact test helpers 2026-02-17 15:58:57 -08:00
Charles Cunningham
a64ec85956 Use realistic remote compact mocks in context-shape snapshots 2026-02-17 15:52:13 -08:00
Charles Cunningham
fb757b74f4 Align extracted model-switch snapshots with main behavior 2026-02-17 15:25:39 -08:00
Charles Cunningham
86b296a47d Revert "Move newly added compaction snapshot tests to follow-up PR"
This reverts commit 4fb13213e031e906a2048f0a1caaa73627fd0c96.
2026-02-17 15:21:39 -08:00
28 changed files with 2838 additions and 881 deletions

View File

@@ -203,11 +203,6 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let sse = responses::sse(vec![
responses::ev_assistant_message("m1", "MANUAL_COMPACT_SUMMARY"),
responses::ev_completed_with_tokens("r1", 200),
]);
responses::mount_sse_sequence(&server, vec![sse]).await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(

View File

@@ -55,6 +55,17 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
}
if status == http::StatusCode::BAD_REQUEST {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body_text)
&& matches!(
value
.get("error")
.and_then(|error| error.get("code"))
.and_then(serde_json::Value::as_str),
Some("context_length_exceeded")
)
{
return CodexErr::ContextWindowExceeded;
}
if body_text
.contains("The image data you provided does not represent a valid image")
{
@@ -130,6 +141,24 @@ mod tests {
assert!(matches!(err, CodexErr::ServerOverloaded));
}
#[test]
fn map_api_error_maps_context_length_exceeded_from_400_body() {
let body = serde_json::json!({
"error": {
"code": "context_length_exceeded",
"message": "Your input exceeds the context window of this model."
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::BAD_REQUEST,
url: Some("http://localhost/v1/responses/compact".to_string()),
headers: Some(HeaderMap::new()),
body: Some(body),
}));
assert!(matches!(err, CodexErr::ContextWindowExceeded));
}
#[test]
fn map_api_error_maps_server_overloaded_from_503_body() {
let body = serde_json::json!({

View File

@@ -19,6 +19,8 @@ use crate::analytics_client::build_track_events_context;
use crate::apps::render_apps_section;
use crate::commit_attribution::commit_message_trailer_instruction;
use crate::compact;
use crate::compact::AutoCompactCallsite;
use crate::compact::TurnContextReinjection;
use crate::compact::run_inline_auto_compact_task;
use crate::compact::should_use_remote_compact_task;
use crate::compact_remote::run_inline_remote_auto_compact_task;
@@ -126,6 +128,7 @@ use crate::config::types::McpServerConfig;
use crate::config::types::ShellEnvironmentPolicy;
use crate::context_manager::ContextManager;
use crate::context_manager::TotalTokenUsageBreakdown;
use crate::context_manager::estimate_item_token_count;
use crate::environment_context::EnvironmentContext;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
@@ -2380,12 +2383,16 @@ impl Session {
if let Some(replacement) = &compacted.replacement_history {
history.replace(replacement.clone());
} else {
// TODO(ccunningham): When we have TurnContextItem-based legacy reconstruction,
// build historical turn context from those items and inject it at the correct
// point in compacted history instead of prepending the current initial context.
// This matters for legacy rollouts with replacement_history=None.
let user_messages = collect_user_messages(history.raw_items());
let rebuilt = compact::build_compacted_history(
self.build_initial_context(turn_context).await,
let mut rebuilt = self.build_initial_context(turn_context).await;
rebuilt.extend(compact::build_compacted_history(
&user_messages,
&compacted.message,
);
));
history.replace(rebuilt);
}
}
@@ -2402,9 +2409,14 @@ impl Session {
&self,
turn_context: &TurnContext,
compacted_history: Vec<ResponseItem>,
turn_context_reinjection: TurnContextReinjection,
) -> Vec<ResponseItem> {
let initial_context = self.build_initial_context(turn_context).await;
compact::process_compacted_history(compacted_history, &initial_context)
compact::process_compacted_history(
compacted_history,
&initial_context,
turn_context_reinjection,
)
}
/// Append ResponseItems to the in-memory conversation history only.
@@ -2493,6 +2505,11 @@ impl Session {
self.flush_rollout().await;
}
pub(crate) async fn mark_initial_context_unseeded_for_next_turn(&self) {
let mut state = self.state.lock().await;
state.initial_context_seeded = false;
}
async fn persist_rollout_response_items(&self, items: &[ResponseItem]) {
let rollout_items: Vec<RolloutItem> = items
.iter()
@@ -3394,19 +3411,29 @@ mod handlers {
if let Err(SteerInputError::NoActiveTurn(items)) = sess.steer_input(items, None).await {
sess.seed_initial_context_if_needed(&current_context).await;
let previous_model = sess.previous_model().await;
let update_items = sess.build_settings_update_items(
let pre_turn_context_items = sess.build_settings_update_items(
previous_context.as_ref(),
previous_model.as_deref(),
&current_context,
);
if !update_items.is_empty() {
sess.record_conversation_items(&current_context, &update_items)
let has_user_input = !items.is_empty();
if !has_user_input && !pre_turn_context_items.is_empty() {
// Empty-input UserTurn still needs these model-visible updates persisted now.
// Otherwise `previous_context` advances and the next non-empty turn computes no diff.
sess.record_conversation_items(&current_context, &pre_turn_context_items)
.await;
}
sess.refresh_mcp_servers_if_requested(&current_context)
.await;
let regular_task = sess.take_startup_regular_task().await.unwrap_or_default();
let regular_task = if has_user_input {
sess.take_startup_regular_task()
.await
.unwrap_or_default()
.with_pre_turn_context_items(pre_turn_context_items)
} else {
sess.take_startup_regular_task().await.unwrap_or_default()
};
sess.spawn_task(Arc::clone(&current_context), items, regular_task)
.await;
*previous_context = Some(current_context);
@@ -4235,6 +4262,7 @@ pub(crate) async fn run_turn(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
pre_turn_context_items: Vec<ResponseItem>,
prewarmed_client_session: Option<ModelClientSession>,
cancellation_token: CancellationToken,
) -> Option<String> {
@@ -4244,6 +4272,9 @@ pub(crate) async fn run_turn(
let model_info = turn_context.model_info.clone();
let auto_compact_limit = model_info.auto_compact_token_limit().unwrap_or(i64::MAX);
let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into();
let mut incoming_turn_items = pre_turn_context_items.clone();
incoming_turn_items.push(response_item.clone());
let event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
@@ -4251,14 +4282,43 @@ pub(crate) async fn run_turn(
collaboration_mode_kind: turn_context.collaboration_mode.mode,
});
sess.send_event(&turn_context, event).await;
if run_pre_sampling_compact(&sess, &turn_context)
.await
.is_err()
let total_usage_tokens_before_compaction = sess.get_total_token_usage().await;
if maybe_run_previous_model_inline_compact(
&sess,
&turn_context,
total_usage_tokens_before_compaction,
&pre_turn_context_items,
)
.await
.is_err()
{
error!("Failed to run pre-sampling compact");
// Error messaging is emitted inside maybe_run_previous_model_inline_compact.
return None;
}
let Ok(pre_turn_compaction_outcome) = run_pre_turn_auto_compaction_if_needed(
&sess,
&turn_context,
auto_compact_limit,
&incoming_turn_items,
&pre_turn_context_items,
)
.await
else {
// Error messaging is emitted inside run_pre_turn_auto_compaction_if_needed.
return None;
};
persist_pre_turn_items_for_compaction_outcome(
&sess,
&turn_context,
pre_turn_compaction_outcome,
&pre_turn_context_items,
&input,
response_item,
)
.await;
let skills_outcome = Some(
sess.services
.skills_manager
@@ -4365,12 +4425,6 @@ pub(crate) async fn run_turn(
.track_app_mentioned(tracking.clone(), mentioned_app_invocations);
sess.merge_connector_selection(explicitly_enabled_connectors.clone())
.await;
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
let response_item: ResponseItem = initial_input_for_turn.clone().into();
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
.await;
if !skill_items.is_empty() {
sess.record_conversation_items(&turn_context, &skill_items)
.await;
@@ -4473,7 +4527,19 @@ pub(crate) async fn run_turn(
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
if token_limit_reached && needs_follow_up {
if run_auto_compact(&sess, &turn_context).await.is_err() {
if let Err(err) = run_auto_compact(
&sess,
&turn_context,
AutoCompactCallsite::MidTurnContinuation,
TurnContextReinjection::ReinjectAboveLastRealUser,
None,
)
.await
{
let event = EventMsg::Error(
err.to_error_event(Some("Error running auto compact task".to_string())),
);
sess.send_event(&turn_context, event).await;
return None;
}
continue;
@@ -4575,40 +4641,16 @@ pub(crate) async fn run_turn(
last_agent_message
}
async fn run_pre_sampling_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
) -> CodexResult<()> {
let total_usage_tokens_before_compaction = sess.get_total_token_usage().await;
maybe_run_previous_model_inline_compact(
sess,
turn_context,
total_usage_tokens_before_compaction,
)
.await?;
let total_usage_tokens = sess.get_total_token_usage().await;
let auto_compact_limit = turn_context
.model_info
.auto_compact_token_limit()
.unwrap_or(i64::MAX);
// Compact if the total usage tokens are greater than the auto compact limit
if total_usage_tokens >= auto_compact_limit {
run_auto_compact(sess, turn_context).await?;
}
Ok(())
}
/// Runs pre-sampling compaction against the previous model when switching to a smaller
/// context-window model.
/// Runs the pre-sampling model-switch compaction pass when needed.
///
/// Returns `Ok(())` when compaction either completed successfully or was skipped because the
/// model/context-window preconditions were not met. Returns `Err(_)` only when compaction was
/// attempted and failed.
/// On failure this function emits any user-visible error event itself and returns `Err(())` as a
/// sentinel so callers can stop the turn without duplicating error messaging logic.
async fn maybe_run_previous_model_inline_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
total_usage_tokens: i64,
) -> CodexResult<()> {
pre_turn_context_items: &[ResponseItem],
) -> Result<(), ()> {
let Some(previous_model) = sess.previous_model().await else {
return Ok(());
};
@@ -4631,19 +4673,318 @@ async fn maybe_run_previous_model_inline_compact(
let should_run = total_usage_tokens > new_auto_compact_limit
&& previous_turn_context.model_info.slug != turn_context.model_info.slug
&& old_context_window > new_context_window;
if should_run {
run_auto_compact(sess, &previous_turn_context).await?;
if !should_run {
return Ok(());
}
Ok(())
match run_auto_compact(
sess,
// We use previous turn context here because we compact with the previous model
&previous_turn_context,
AutoCompactCallsite::PreTurnExcludingIncomingUserMessage,
// User message and turn context diff is injected in the pre-compaction NotNeeded case later
TurnContextReinjection::ReinjectAboveLastRealUser,
None,
)
.await
{
Ok(()) => Ok(()),
Err(err) => {
if !pre_turn_context_items.is_empty() {
// Preserve model-visible settings updates even when pre-turn compaction fails
// before we can persist turn input.
sess.record_conversation_items(turn_context, pre_turn_context_items)
.await;
}
if matches!(err, CodexErr::Interrupted) {
return Err(());
}
let compact_error_prefix = if should_use_remote_compact_task(&turn_context.provider) {
"Error running remote compact task"
} else {
"Error running local compact task"
};
let event = EventMsg::Error(err.to_error_event(Some(compact_error_prefix.to_string())));
sess.send_event(turn_context, event).await;
Err(())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreTurnCompactionOutcome {
/// Pre-turn input fits without compaction.
NotNeeded,
/// Pre-turn compaction succeeded with incoming turn context + user message included.
CompactedWithIncomingItems,
/// Pre-turn compaction succeeded without incoming turn items
/// (incoming user message should be appended after the compaction summary).
/// This compaction strategy is currently out of distribution for our compaction model.
CompactedWithoutIncomingItems,
}
async fn run_auto_compact(sess: &Arc<Session>, turn_context: &Arc<TurnContext>) -> CodexResult<()> {
if should_use_remote_compact_task(&turn_context.provider) {
run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?;
} else {
run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?;
async fn persist_pre_turn_items_for_compaction_outcome(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
outcome: PreTurnCompactionOutcome,
pre_turn_context_items: &[ResponseItem],
input: &[UserInput],
response_item: ResponseItem,
) {
match outcome {
PreTurnCompactionOutcome::CompactedWithIncomingItems => {
// Incoming turn items were already part of pre-turn compaction input, and the
// user prompt is already persisted in history after compaction. Emit lifecycle events
// only so UI/consumers still observe a normal user turn item transition.
let turn_item = TurnItem::UserMessage(UserMessageItem::new(input));
sess.emit_turn_item_started(turn_context.as_ref(), &turn_item)
.await;
sess.emit_turn_item_completed(turn_context.as_ref(), turn_item)
.await;
sess.ensure_rollout_materialized().await;
}
PreTurnCompactionOutcome::NotNeeded => {
if !pre_turn_context_items.is_empty() {
sess.record_conversation_items(turn_context, pre_turn_context_items)
.await;
}
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item)
.await;
}
PreTurnCompactionOutcome::CompactedWithoutIncomingItems => {
// Reseed canonical initial context above the incoming user message.
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
if !initial_context.is_empty() {
sess.record_conversation_items(turn_context, &initial_context)
.await;
}
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item)
.await;
}
}
Ok(())
}
/// Runs pre-turn auto-compaction with incoming turn context + user message included first.
///
/// If the include-incoming compaction attempt fails with `ContextWindowExceeded`, this function
/// retries once with a history-only compaction strategy that excludes incoming turn items.
///
/// On failure this function emits any user-visible error event itself and returns `Err(())` as a
/// sentinel so callers can stop the turn without duplicating error messaging logic.
async fn run_pre_turn_auto_compaction_if_needed(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
auto_compact_limit: i64,
incoming_turn_items: &[ResponseItem],
pre_turn_context_items: &[ResponseItem],
) -> Result<PreTurnCompactionOutcome, ()> {
let total_usage_tokens = sess.get_total_token_usage().await;
let incoming_items_tokens_estimate = incoming_turn_items
.iter()
.map(estimate_item_token_count)
.fold(0_i64, i64::saturating_add);
if !is_projected_submission_over_auto_compact_limit(
total_usage_tokens,
incoming_items_tokens_estimate,
auto_compact_limit,
) {
return Ok(PreTurnCompactionOutcome::NotNeeded);
}
let compact_result = run_auto_compact(
sess,
turn_context,
AutoCompactCallsite::PreTurnIncludingIncomingUserMessage,
TurnContextReinjection::ReinjectAboveLastRealUser,
Some(incoming_turn_items.to_vec()),
)
.await;
match compact_result {
Ok(()) => {}
Err(CodexErr::ContextWindowExceeded) => {
let fallback_result = run_auto_compact(
sess,
turn_context,
AutoCompactCallsite::PreTurnExcludingIncomingUserMessage,
TurnContextReinjection::Skip,
None,
)
.await;
match fallback_result {
Ok(()) => {
let has_user_turn_boundary_after_compaction = sess
.clone_history()
.await
.raw_items()
.iter()
.any(crate::context_manager::is_user_turn_boundary);
if !has_user_turn_boundary_after_compaction {
if !pre_turn_context_items.is_empty() {
// Preserve model-visible settings updates even when pre-turn
// compaction cannot proceed before we can persist turn input.
sess.record_conversation_items(turn_context, pre_turn_context_items)
.await;
}
let message = format!(
"Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})"
);
let event = EventMsg::Error(
CodexErr::ContextWindowExceeded.to_error_event(Some(message)),
);
sess.send_event(turn_context, event).await;
return Err(());
}
return Ok(PreTurnCompactionOutcome::CompactedWithoutIncomingItems);
}
Err(err) => {
if !pre_turn_context_items.is_empty() {
// Preserve model-visible settings updates even when pre-turn compaction
// fails before we can persist turn input.
sess.record_conversation_items(turn_context, pre_turn_context_items)
.await;
}
if matches!(err, CodexErr::Interrupted) {
return Err(());
}
let event = match err {
CodexErr::ContextWindowExceeded => {
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?AutoCompactCallsite::PreTurnExcludingIncomingUserMessage,
incoming_items_tokens_estimate,
auto_compact_limit,
reason = "pre-turn compaction exceeded context window after fallback",
"incoming user/context is too large for pre-turn auto-compaction flow"
);
let message = format!(
"Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})"
);
EventMsg::Error(
CodexErr::ContextWindowExceeded.to_error_event(Some(message)),
)
}
other => {
let compact_error_prefix =
if should_use_remote_compact_task(&turn_context.provider) {
"Error running remote compact task"
} else {
"Error running local compact task"
};
EventMsg::Error(
other.to_error_event(Some(compact_error_prefix.to_string())),
)
}
};
sess.send_event(turn_context, event).await;
return Err(());
}
}
}
Err(err) => {
if !pre_turn_context_items.is_empty() {
// Preserve model-visible settings updates even when pre-turn compaction fails
// before we can persist turn input.
sess.record_conversation_items(turn_context, pre_turn_context_items)
.await;
}
if matches!(err, CodexErr::Interrupted) {
return Err(());
}
let event = match err {
CodexErr::ContextWindowExceeded => {
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?AutoCompactCallsite::PreTurnIncludingIncomingUserMessage,
incoming_items_tokens_estimate,
auto_compact_limit,
reason = "pre-turn compaction exceeded context window",
"incoming user/context is too large for pre-turn auto-compaction flow"
);
let message = format!(
"Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})"
);
EventMsg::Error(CodexErr::ContextWindowExceeded.to_error_event(Some(message)))
}
other => {
let compact_error_prefix =
if should_use_remote_compact_task(&turn_context.provider) {
"Error running remote compact task"
} else {
"Error running local compact task"
};
EventMsg::Error(other.to_error_event(Some(compact_error_prefix.to_string())))
}
};
sess.send_event(turn_context, event).await;
return Err(());
}
}
// If compaction no-oped because there was no user-turn boundary even after including incoming
// items, do not treat this as "compacted with incoming". The caller must persist incoming
// items explicitly in this case.
let has_user_turn_boundary_after_compaction = sess
.clone_history()
.await
.raw_items()
.iter()
.any(crate::context_manager::is_user_turn_boundary);
if !has_user_turn_boundary_after_compaction {
return Ok(PreTurnCompactionOutcome::NotNeeded);
}
Ok(PreTurnCompactionOutcome::CompactedWithIncomingItems)
}
fn is_projected_submission_over_auto_compact_limit(
total_usage_tokens: i64,
incoming_user_tokens_estimate: i64,
auto_compact_limit: i64,
) -> bool {
if auto_compact_limit == i64::MAX {
return false;
}
total_usage_tokens.saturating_add(incoming_user_tokens_estimate) >= auto_compact_limit
}
async fn run_auto_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
auto_compact_callsite: AutoCompactCallsite,
turn_context_reinjection: TurnContextReinjection,
incoming_items: Option<Vec<ResponseItem>>,
) -> CodexResult<()> {
let result = if should_use_remote_compact_task(&turn_context.provider) {
run_inline_remote_auto_compact_task(
Arc::clone(sess),
Arc::clone(turn_context),
auto_compact_callsite,
turn_context_reinjection,
incoming_items,
)
.await
} else {
run_inline_auto_compact_task(
Arc::clone(sess),
Arc::clone(turn_context),
auto_compact_callsite,
turn_context_reinjection,
incoming_items,
)
.await
};
if let Err(err) = &result {
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?auto_compact_callsite,
compact_error = %err,
"auto compaction failed"
);
}
result
}
fn collect_explicit_app_ids_from_skill_items(
@@ -5590,9 +5931,9 @@ async fn try_run_sampling_request(
sess.services.models_manager.refresh_if_new_etag(etag).await;
}
ResponseEvent::Completed {
response_id: _,
response_id: _response_id,
token_usage,
can_append: _,
can_append: _can_append,
} => {
if let Some(state) = plan_mode_state.as_mut() {
flush_proposed_plan_segments_all(&sess, &turn_context, state).await;
@@ -5823,6 +6164,238 @@ mod tests {
}
}
#[test]
fn pre_turn_projection_uses_incoming_user_tokens_for_compaction() {
assert!(is_projected_submission_over_auto_compact_limit(90, 15, 100));
assert!(!is_projected_submission_over_auto_compact_limit(90, 9, 100));
}
#[test]
fn pre_turn_projection_does_not_compact_with_unbounded_limit() {
assert!(!is_projected_submission_over_auto_compact_limit(
i64::MAX - 1,
100,
i64::MAX,
));
}
#[test]
fn post_compaction_projection_triggers_error_when_still_over_limit() {
assert!(is_projected_submission_over_auto_compact_limit(95, 10, 100));
assert!(is_projected_submission_over_auto_compact_limit(
100, 10, 100
));
assert!(!is_projected_submission_over_auto_compact_limit(
80, 10, 100
));
}
#[tokio::test]
async fn reserved_compacted_without_incoming_items_records_initial_context_and_prompt() {
let (session, turn_context) = make_session_and_context().await;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into();
let stale_pre_turn_context_items = vec![ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "stale context diff".to_string(),
}],
end_turn: None,
phase: None,
}];
persist_pre_turn_items_for_compaction_outcome(
&session,
&turn_context,
PreTurnCompactionOutcome::CompactedWithoutIncomingItems,
&stale_pre_turn_context_items,
&input,
response_item.clone(),
)
.await;
let mut expected = session.build_initial_context(turn_context.as_ref()).await;
expected.push(response_item);
let actual = session.clone_history().await.raw_items().to_vec();
assert_eq!(actual, expected);
}
#[tokio::test]
async fn compacted_with_incoming_items_emits_lifecycle_without_history_writes() {
let (session, turn_context) = make_session_and_context().await;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into();
let stale_pre_turn_context_items = vec![ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "stale context diff".to_string(),
}],
end_turn: None,
phase: None,
}];
persist_pre_turn_items_for_compaction_outcome(
&session,
&turn_context,
PreTurnCompactionOutcome::CompactedWithIncomingItems,
&stale_pre_turn_context_items,
&input,
response_item,
)
.await;
let actual = session.clone_history().await.raw_items().to_vec();
assert_eq!(actual, Vec::<ResponseItem>::new());
}
#[tokio::test]
async fn run_turn_persists_pre_turn_context_before_apps_tool_listing_cancellation() {
let (session, mut turn_context) = make_session_and_context().await;
let session = Arc::new(session);
let mut config = (*turn_context.config).clone();
config.features.enable(Feature::Apps);
turn_context.features = config.features.clone();
turn_context.config = Arc::new(config);
let turn_context = Arc::new(turn_context);
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into();
let pre_turn_context_items = vec![ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "model-visible setting diff".to_string(),
}],
end_turn: None,
phase: None,
}];
let cancellation_token = CancellationToken::new();
cancellation_token.cancel();
let result = run_turn(
Arc::clone(&session),
Arc::clone(&turn_context),
input,
pre_turn_context_items.clone(),
None,
cancellation_token,
)
.await;
assert_eq!(result, None);
let mut expected_history = pre_turn_context_items;
expected_history.push(response_item);
let actual_history = session.clone_history().await.raw_items().to_vec();
assert_eq!(actual_history, expected_history);
}
#[tokio::test]
async fn pre_turn_auto_compaction_noop_without_user_turn_boundary_returns_not_needed() {
let (session, turn_context) = make_session_and_context().await;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
{
let mut state = session.state.lock().await;
state.set_token_info(Some(TokenUsageInfo {
total_token_usage: TokenUsage {
total_tokens: 1_000,
..TokenUsage::default()
},
last_token_usage: TokenUsage {
total_tokens: 1_000,
..TokenUsage::default()
},
model_context_window: turn_context.model_context_window(),
}));
}
let incoming_turn_items = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<user_shell_command>\necho hi\n</user_shell_command>".to_string(),
}],
end_turn: None,
phase: None,
}];
let outcome = run_pre_turn_auto_compaction_if_needed(
&session,
&turn_context,
10,
&incoming_turn_items,
&[],
)
.await
.expect("pre-turn compaction no-op should not fail");
assert_eq!(outcome, PreTurnCompactionOutcome::NotNeeded);
assert_eq!(session.clone_history().await.raw_items(), &[]);
}
#[test]
fn estimate_user_input_token_count_is_positive_for_text_input() {
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
let response_input_item = ResponseInputItem::from(input);
let response_item: ResponseItem = response_input_item.into();
let estimated_tokens = estimate_item_token_count(&response_item);
assert!(estimated_tokens > 0);
}
#[test]
fn estimate_user_input_token_count_ignores_skill_and_mention_payload_lengths() {
let short = vec![
UserInput::Skill {
name: "s".to_string(),
path: PathBuf::from("/s"),
},
UserInput::Mention {
name: "m".to_string(),
path: "app://m".to_string(),
},
];
let long = vec![
UserInput::Skill {
name: "very-long-skill-name-that-should-not-affect-prompt-serialization"
.to_string(),
path: PathBuf::from(
"/very/long/skill/path/that/should/not/affect/prompt/serialization/SKILL.md",
),
},
UserInput::Mention {
name: "very-long-mention-name-that-should-not-affect-prompt-serialization"
.to_string(),
path: "app://very-long-connector-path-that-should-not-affect-prompt-serialization"
.to_string(),
},
];
let short_response_input_item = ResponseInputItem::from(short);
let long_response_input_item = ResponseInputItem::from(long);
let short_response_item: ResponseItem = short_response_input_item.into();
let long_response_item: ResponseItem = long_response_input_item.into();
let short_tokens = estimate_item_token_count(&short_response_item);
let long_tokens = estimate_item_token_count(&long_response_item);
assert_eq!(short_tokens, long_tokens);
}
fn make_connector(id: &str, name: &str) -> AppInfo {
AppInfo {
id: id.to_string(),
@@ -8077,8 +8650,8 @@ mod tests {
.clone()
.for_prompt(&reconstruction_turn.model_info.input_modalities);
let user_messages1 = collect_user_messages(&snapshot1);
let rebuilt1 =
compact::build_compacted_history(initial_context.clone(), &user_messages1, summary1);
let mut rebuilt1 = initial_context.clone();
rebuilt1.extend(compact::build_compacted_history(&user_messages1, summary1));
live_history.replace(rebuilt1);
rollout_items.push(RolloutItem::Compacted(CompactedItem {
message: summary1.to_string(),
@@ -8120,8 +8693,8 @@ mod tests {
.clone()
.for_prompt(&reconstruction_turn.model_info.input_modalities);
let user_messages2 = collect_user_messages(&snapshot2);
let rebuilt2 =
compact::build_compacted_history(initial_context.clone(), &user_messages2, summary2);
let mut rebuilt2 = initial_context.clone();
rebuilt2.extend(compact::build_compacted_history(&user_messages2, summary2));
live_history.replace(rebuilt2);
rollout_items.push(RolloutItem::Compacted(CompactedItem {
message: summary2.to_string(),

View File

@@ -8,6 +8,7 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::codex::get_last_assistant_message_from_turn;
use crate::context_manager::ContextManager;
use crate::context_manager::is_user_turn_boundary;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::protocol::CompactedItem;
@@ -32,6 +33,32 @@ pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt
pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md");
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutoCompactCallsite {
/// Pre-turn auto-compaction where the incoming turn context + user message are included in
/// the compaction request.
PreTurnIncludingIncomingUserMessage,
/// Reserved pre-turn auto-compaction strategy that compacts from the end of the previous turn
/// only, excluding incoming turn context + user message. This is currently unused by the
/// default pre-turn flow and retained for future model-specific strategies.
#[allow(dead_code)]
PreTurnExcludingIncomingUserMessage,
/// Mid-turn compaction between assistant responses in a follow-up loop.
MidTurnContinuation,
}
/// Controls whether compacted-history processing should reinsert canonical turn context.
///
/// When callers exclude incoming user/context from the compaction request, they should typically
/// set reinjection to `Skip` and append canonical context together with the next user message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TurnContextReinjection {
/// Insert canonical context immediately above the last real user message in compacted history.
ReinjectAboveLastRealUser,
/// Do not reinsert canonical context while processing compacted history.
Skip,
}
pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool {
provider.is_openai()
}
@@ -61,9 +88,23 @@ pub(crate) fn extract_trailing_model_switch_update_for_compaction_request(
Some(model_switch_item)
}
pub(crate) fn extract_latest_model_switch_update_from_items(
items: &mut Vec<ResponseItem>,
) -> Option<ResponseItem> {
let model_switch_index = items
.iter()
.enumerate()
.rev()
.find_map(|(i, item)| Session::is_model_switch_developer_message(item).then_some(i))?;
Some(items.remove(model_switch_index))
}
pub(crate) async fn run_inline_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
auto_compact_callsite: AutoCompactCallsite,
turn_context_reinjection: TurnContextReinjection,
incoming_items: Option<Vec<ResponseItem>>,
) -> CodexResult<()> {
let prompt = turn_context.compact_prompt().to_string();
let input = vec![UserInput::Text {
@@ -72,7 +113,15 @@ pub(crate) async fn run_inline_auto_compact_task(
text_elements: Vec::new(),
}];
run_compact_task_inner(sess, turn_context, input).await?;
run_compact_task_inner(
sess,
turn_context,
input,
Some(auto_compact_callsite),
turn_context_reinjection,
incoming_items,
)
.await?;
Ok(())
}
@@ -87,13 +136,26 @@ pub(crate) async fn run_compact_task(
collaboration_mode_kind: turn_context.collaboration_mode.mode,
});
sess.send_event(&turn_context, start_event).await;
run_compact_task_inner(sess.clone(), turn_context, input).await
run_compact_task_inner(
sess,
turn_context,
input,
None,
// Manual `/compact` should not reinsert turn context into compacted history; we reseed
// canonical initial context before the next user turn.
TurnContextReinjection::Skip,
None,
)
.await
}
async fn run_compact_task_inner(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
auto_compact_callsite: Option<AutoCompactCallsite>,
turn_context_reinjection: TurnContextReinjection,
incoming_items: Option<Vec<ResponseItem>>,
) -> CodexResult<()> {
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
sess.emit_turn_item_started(&turn_context, &compaction_item)
@@ -101,14 +163,33 @@ async fn run_compact_task_inner(
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
let mut history = sess.clone_history().await;
let mut incoming_items = incoming_items;
// Keep compaction prompts in-distribution: if a model-switch update was injected at the
// tail of history (between turns), exclude it from the compaction request payload.
let stripped_model_switch_item =
extract_trailing_model_switch_update_for_compaction_request(&mut history);
// tail of incoming turn items (pre-turn path) or between turns in history, exclude it from
// the compaction request payload.
let stripped_model_switch_item = incoming_items
.as_mut()
.and_then(extract_latest_model_switch_update_from_items)
.or_else(|| extract_trailing_model_switch_update_for_compaction_request(&mut history));
if let Some(incoming_items) = incoming_items.as_ref() {
history.record_items(incoming_items.iter(), turn_context.truncation_policy);
}
if !history.raw_items().iter().any(is_user_turn_boundary) {
// Nothing to compact: do not rewrite history when there is no user-turn boundary.
sess.emit_turn_item_completed(&turn_context, compaction_item)
.await;
return Ok(());
}
history.record_items(
&[initial_input_for_turn.into()],
turn_context.truncation_policy,
);
// Keep incoming turn items and the compaction prompt pinned at the tail while trimming.
// Pre-turn compaction should fail with ContextWindowExceeded rather than dropping incoming
// items to force compaction to succeed.
let protected_tail_items = incoming_items
.as_ref()
.map_or(1_usize, |items| items.len().saturating_add(1));
let mut truncated_count = 0usize;
@@ -166,9 +247,12 @@ async fn run_compact_task_inner(
return Err(CodexErr::Interrupted);
}
Err(e @ CodexErr::ContextWindowExceeded) => {
if turn_input_len > 1 {
// Trim from the beginning to preserve cache (prefix-based) and keep recent messages intact.
if turn_input_len > 1 && history.raw_items().len() > protected_tail_items {
// Trim from the beginning to preserve cache (prefix-based) and keep recent
// messages intact.
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?auto_compact_callsite,
"Context window exceeded while compacting; removing oldest history item. Error: {e}"
);
history.remove_first_item();
@@ -177,8 +261,12 @@ async fn run_compact_task_inner(
continue;
}
sess.set_total_tokens_full(turn_context.as_ref()).await;
let event = EventMsg::Error(e.to_error_event(None));
sess.send_event(&turn_context, event).await;
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?auto_compact_callsite,
compact_error = %e,
"compaction failed after history truncation could not proceed"
);
return Err(e);
}
Err(e) => {
@@ -193,11 +281,16 @@ async fn run_compact_task_inner(
.await;
tokio::time::sleep(delay).await;
continue;
} else {
let event = EventMsg::Error(e.to_error_event(None));
sess.send_event(&turn_context, event).await;
return Err(e);
}
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?auto_compact_callsite,
retries,
max_retries,
compact_error = %e,
"compaction failed after retry exhaustion"
);
return Err(e);
}
}
}
@@ -207,9 +300,32 @@ async fn run_compact_task_inner(
let summary_suffix = get_last_assistant_message_from_turn(history_items).unwrap_or_default();
let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}");
let user_messages = collect_user_messages(history_items);
let incoming_user_items = match incoming_items.as_ref() {
Some(items) => items
.iter()
.filter(|item| is_non_summary_user_message(item))
.cloned()
.collect(),
None => Vec::new(),
};
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
let initial_context = match turn_context_reinjection {
TurnContextReinjection::ReinjectAboveLastRealUser => {
sess.build_initial_context(turn_context.as_ref()).await
}
TurnContextReinjection::Skip => Vec::new(),
};
let compacted_history = build_compacted_history_with_limit(
&user_messages,
&incoming_user_items,
&summary_text,
COMPACT_USER_MESSAGE_MAX_TOKENS,
);
let mut new_history = process_compacted_history(
compacted_history,
&initial_context,
turn_context_reinjection,
);
// Reattach the stripped model-switch update only after successful compaction so the model
// still sees the switch instructions on the next real sampling request.
if let Some(model_switch_item) = stripped_model_switch_item {
@@ -226,7 +342,7 @@ async fn run_compact_task_inner(
let rollout_item = RolloutItem::Compacted(CompactedItem {
message: summary_text.clone(),
replacement_history: None,
replacement_history: Some(sess.clone_history().await.raw_items().to_vec()),
});
sess.persist_rollout_items(&[rollout_item]).await;
@@ -281,28 +397,51 @@ pub(crate) fn is_summary_message(message: &str) -> bool {
pub(crate) fn process_compacted_history(
mut compacted_history: Vec<ResponseItem>,
initial_context: &[ResponseItem],
turn_context_reinjection: TurnContextReinjection,
) -> Vec<ResponseItem> {
// Keep only model-visible transcript items that we allow from remote compaction output.
compacted_history.retain(should_keep_compacted_history_item);
let initial_context = initial_context.to_vec();
// Re-inject canonical context from the current session since we stripped it
// from the pre-compaction history. Keep it right before the last user
// message so older user messages remain earlier in the transcript.
if let Some(last_user_index) = compacted_history.iter().rposition(|item| {
matches!(
crate::event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(_))
)
}) {
compacted_history.splice(last_user_index..last_user_index, initial_context);
} else {
compacted_history.extend(initial_context);
match turn_context_reinjection {
TurnContextReinjection::ReinjectAboveLastRealUser => {
// Prefer inserting immediately above the last real user message so turn context
// applies to that user input rather than an earlier turn. If compaction output has no
// real user messages, insert before the last summary user message to keep canonical
// context present for the next sampling request.
let insertion_index = if let Some(last_real_user_index) = compacted_history
.iter()
.rposition(is_non_summary_user_message)
{
last_real_user_index
} else if let Some(last_summary_index) = compacted_history.iter().rposition(|item| {
matches!(
crate::event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(user_message))
if is_summary_message(&user_message.message())
)
}) {
last_summary_index
} else {
compacted_history.len()
};
compacted_history.splice(insertion_index..insertion_index, initial_context.to_vec());
}
TurnContextReinjection::Skip => {}
}
compacted_history
}
fn is_non_summary_user_message(item: &ResponseItem) -> bool {
match crate::event_mapping::parse_turn_item(item) {
Some(TurnItem::UserMessage(user_message)) => {
let message = user_message.message();
!is_summary_message(&message)
}
_ => false,
}
}
/// Returns whether an item from remote compaction output should be preserved.
///
/// Called while processing the model-provided compacted transcript, before we
@@ -328,24 +467,24 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
}
pub(crate) fn build_compacted_history(
initial_context: Vec<ResponseItem>,
user_messages: &[String],
summary_text: &str,
) -> Vec<ResponseItem> {
build_compacted_history_with_limit(
initial_context,
user_messages,
&[],
summary_text,
COMPACT_USER_MESSAGE_MAX_TOKENS,
)
}
fn build_compacted_history_with_limit(
mut history: Vec<ResponseItem>,
user_messages: &[String],
incoming_user_items: &[ResponseItem],
summary_text: &str,
max_tokens: usize,
) -> Vec<ResponseItem> {
let mut history = Vec::new();
let mut selected_messages: Vec<String> = Vec::new();
if max_tokens > 0 {
let mut remaining = max_tokens;
@@ -378,6 +517,8 @@ fn build_compacted_history_with_limit(
});
}
history.extend(incoming_user_items.iter().cloned());
let summary_text = if summary_text.is_empty() {
"(no summary available)".to_string()
} else {
@@ -444,7 +585,6 @@ async fn drain_to_completed(
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
@@ -579,6 +719,95 @@ mod tests {
);
}
#[test]
fn extract_model_switch_update_for_compaction_request_prefers_incoming_items() {
let mut history = ContextManager::new();
history.replace(vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "USER_MESSAGE".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "ASSISTANT_REPLY".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "<model_switch>\nHISTORY_MODEL_INSTRUCTIONS".to_string(),
}],
end_turn: None,
phase: None,
},
]);
let mut incoming_items = vec![
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "<model_switch>\nINCOMING_MODEL_INSTRUCTIONS".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "INCOMING_USER_MESSAGE".to_string(),
}],
end_turn: None,
phase: None,
},
];
let model_switch_item = Some(&mut incoming_items)
.and_then(extract_latest_model_switch_update_from_items)
.or_else(|| extract_trailing_model_switch_update_for_compaction_request(&mut history));
assert_eq!(
model_switch_item,
Some(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "<model_switch>\nINCOMING_MODEL_INSTRUCTIONS".to_string(),
}],
end_turn: None,
phase: None,
})
);
assert_eq!(
incoming_items,
vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "INCOMING_USER_MESSAGE".to_string(),
}],
end_turn: None,
phase: None,
}]
);
assert!(
history
.raw_items()
.iter()
.any(Session::is_model_switch_developer_message)
);
}
#[test]
fn collect_user_messages_extracts_user_text_only() {
let items = vec![
@@ -657,8 +886,8 @@ do things
let max_tokens = 16;
let big = "word ".repeat(200);
let history = super::build_compacted_history_with_limit(
Vec::new(),
std::slice::from_ref(&big),
&[],
"SUMMARY",
max_tokens,
);
@@ -694,11 +923,10 @@ do things
#[test]
fn build_token_limited_compacted_history_appends_summary_message() {
let initial_context: Vec<ResponseItem> = Vec::new();
let user_messages = vec!["first user message".to_string()];
let summary_text = "summary text";
let history = build_compacted_history(initial_context, &user_messages, summary_text);
let history = build_compacted_history(&user_messages, summary_text);
assert!(
!history.is_empty(),
"expected compacted history to include summary"
@@ -714,6 +942,70 @@ do things
assert_eq!(summary, summary_text);
}
#[test]
fn build_compacted_history_preserves_incoming_user_item_structure() {
let preserved_user_item = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputImage {
image_url: "data:image/png;base64,AAAA".to_string(),
},
ContentItem::InputText {
text: "latest user with image".to_string(),
},
],
end_turn: None,
phase: None,
};
let history = super::build_compacted_history_with_limit(
&["older user".to_string()],
std::slice::from_ref(&preserved_user_item),
"SUMMARY",
128,
);
let expected = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
preserved_user_item,
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "SUMMARY".to_string(),
}],
end_turn: None,
phase: None,
},
];
assert_eq!(history, expected);
}
#[test]
fn non_summary_user_message_includes_image_only_user_messages() {
let image_only_user = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,AAAA".to_string(),
}],
end_turn: None,
phase: None,
};
assert!(super::is_non_summary_user_message(&image_only_user));
}
#[test]
fn process_compacted_history_replaces_developer_messages() {
let compacted_history = vec![
@@ -779,7 +1071,11 @@ do things
},
];
let refreshed = process_compacted_history(compacted_history, &initial_context);
let refreshed = process_compacted_history(
compacted_history,
&initial_context,
TurnContextReinjection::ReinjectAboveLastRealUser,
);
let expected = vec![
ResponseItem::Message {
id: None,
@@ -888,7 +1184,11 @@ keep me updated
},
];
let refreshed = process_compacted_history(compacted_history, &initial_context);
let refreshed = process_compacted_history(
compacted_history,
&initial_context,
TurnContextReinjection::ReinjectAboveLastRealUser,
);
let expected = vec![
ResponseItem::Message {
id: None,
@@ -1024,7 +1324,11 @@ keep me updated
phase: None,
}];
let refreshed = process_compacted_history(compacted_history, &initial_context);
let refreshed = process_compacted_history(
compacted_history,
&initial_context,
TurnContextReinjection::ReinjectAboveLastRealUser,
);
let expected = vec![
ResponseItem::Message {
id: None,
@@ -1089,7 +1393,11 @@ keep me updated
phase: None,
}];
let refreshed = process_compacted_history(compacted_history, &initial_context);
let refreshed = process_compacted_history(
compacted_history,
&initial_context,
TurnContextReinjection::ReinjectAboveLastRealUser,
);
let expected = vec![
ResponseItem::Message {
id: None,
@@ -1130,4 +1438,282 @@ keep me updated
];
assert_eq!(refreshed, expected);
}
#[test]
fn process_compacted_history_pre_turn_places_summary_last() {
let compacted_history = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
},
];
let initial_context = vec![ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "fresh permissions".to_string(),
}],
end_turn: None,
phase: None,
}];
let refreshed = process_compacted_history(
compacted_history,
&initial_context,
TurnContextReinjection::ReinjectAboveLastRealUser,
);
let expected = vec![
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "fresh permissions".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
},
];
assert_eq!(refreshed, expected);
}
#[test]
fn process_compacted_history_preserves_summary_order() {
let compacted_history = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nolder summary"),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "newer user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nlatest summary"),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "assistant after latest summary".to_string(),
}],
end_turn: None,
phase: None,
},
];
let refreshed =
process_compacted_history(compacted_history, &[], TurnContextReinjection::Skip);
let expected = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nolder summary"),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "newer user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nlatest summary"),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "assistant after latest summary".to_string(),
}],
end_turn: None,
phase: None,
},
];
assert_eq!(refreshed, expected);
}
#[test]
fn process_compacted_history_skips_context_insertion_without_real_user_message() {
let compacted_history = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
}];
let initial_context = vec![ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "fresh permissions".to_string(),
}],
end_turn: None,
phase: None,
}];
let refreshed = process_compacted_history(
compacted_history,
&initial_context,
TurnContextReinjection::Skip,
);
let expected = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
}];
assert_eq!(refreshed, expected);
}
#[test]
fn process_compacted_history_mid_turn_without_orphan_user_places_summary_last() {
let compacted_history = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
},
];
let initial_context = vec![ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "fresh permissions".to_string(),
}],
end_turn: None,
phase: None,
}];
let refreshed = process_compacted_history(
compacted_history,
&initial_context,
TurnContextReinjection::ReinjectAboveLastRealUser,
);
let expected = vec![
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "fresh permissions".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "older user".to_string(),
}],
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{SUMMARY_PREFIX}\nsummary text"),
}],
end_turn: None,
phase: None,
},
];
assert_eq!(refreshed, expected);
}
}

View File

@@ -3,11 +3,16 @@ use std::sync::Arc;
use crate::Prompt;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::compact::AutoCompactCallsite;
use crate::compact::TurnContextReinjection;
use crate::compact::extract_latest_model_switch_update_from_items;
use crate::compact::extract_trailing_model_switch_update_for_compaction_request;
use crate::context_manager::ContextManager;
use crate::context_manager::TotalTokenUsageBreakdown;
use crate::context_manager::estimate_item_token_count;
use crate::context_manager::estimate_response_item_model_visible_bytes;
use crate::context_manager::is_codex_generated_item;
use crate::context_manager::is_user_turn_boundary;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::protocol::CompactedItem;
@@ -25,8 +30,19 @@ use tracing::info;
pub(crate) async fn run_inline_remote_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
auto_compact_callsite: AutoCompactCallsite,
// Controls whether canonical turn context should be reinserted into compacted history.
turn_context_reinjection: TurnContextReinjection,
incoming_items: Option<Vec<ResponseItem>>,
) -> CodexResult<()> {
run_remote_compact_task_inner(&sess, &turn_context).await?;
run_remote_compact_task_inner(
&sess,
&turn_context,
auto_compact_callsite,
turn_context_reinjection,
incoming_items,
)
.await?;
Ok(())
}
@@ -41,18 +57,40 @@ pub(crate) async fn run_remote_compact_task(
});
sess.send_event(&turn_context, start_event).await;
run_remote_compact_task_inner(&sess, &turn_context).await
run_remote_compact_task_inner(
&sess,
&turn_context,
AutoCompactCallsite::PreTurnExcludingIncomingUserMessage,
// Manual `/compact` should not reinsert turn context into compacted history; we reseed
// canonical initial context before the next user turn.
TurnContextReinjection::Skip,
None,
)
.await
}
async fn run_remote_compact_task_inner(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
auto_compact_callsite: AutoCompactCallsite,
turn_context_reinjection: TurnContextReinjection,
incoming_items: Option<Vec<ResponseItem>>,
) -> CodexResult<()> {
if let Err(err) = run_remote_compact_task_inner_impl(sess, turn_context).await {
let event = EventMsg::Error(
err.to_error_event(Some("Error running remote compact task".to_string())),
if let Err(err) = run_remote_compact_task_inner_impl(
sess,
turn_context,
auto_compact_callsite,
turn_context_reinjection,
incoming_items,
)
.await
{
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?auto_compact_callsite,
compact_error = %err,
"remote compaction task failed"
);
sess.send_event(turn_context, event).await;
return Err(err);
}
Ok(())
@@ -61,24 +99,42 @@ async fn run_remote_compact_task_inner(
async fn run_remote_compact_task_inner_impl(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
auto_compact_callsite: AutoCompactCallsite,
turn_context_reinjection: TurnContextReinjection,
incoming_items: Option<Vec<ResponseItem>>,
) -> CodexResult<()> {
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
sess.emit_turn_item_started(turn_context, &compaction_item)
.await;
let mut history = sess.clone_history().await;
let mut incoming_items = incoming_items;
// Keep compaction prompts in-distribution: if a model-switch update was injected at the
// tail of history (between turns), exclude it from the compaction request payload.
let stripped_model_switch_item =
extract_trailing_model_switch_update_for_compaction_request(&mut history);
// tail of incoming turn items (pre-turn path) or between turns in history, exclude it from
// the compaction request payload.
let stripped_model_switch_item = incoming_items
.as_mut()
.and_then(extract_latest_model_switch_update_from_items)
.or_else(|| extract_trailing_model_switch_update_for_compaction_request(&mut history));
let base_instructions = sess.get_base_instructions().await;
let deleted_items = trim_function_call_history_to_fit_context_window(
&mut history,
turn_context.as_ref(),
&base_instructions,
incoming_items.as_deref(),
);
if let Some(incoming_items) = incoming_items {
history.record_items(incoming_items.iter(), turn_context.truncation_policy);
}
if !history.raw_items().iter().any(is_user_turn_boundary) {
// Nothing to compact: do not rewrite history when there is no user-turn boundary.
sess.emit_turn_item_completed(turn_context, compaction_item)
.await;
return Ok(());
}
if deleted_items > 0 {
info!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?auto_compact_callsite,
deleted_items,
"trimmed history items before remote compaction"
);
@@ -115,6 +171,7 @@ async fn run_remote_compact_task_inner_impl(
build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text);
log_remote_compact_failure(
turn_context,
auto_compact_callsite,
&compact_request_log_data,
total_usage_breakdown,
&err,
@@ -123,7 +180,7 @@ async fn run_remote_compact_task_inner_impl(
})
.await?;
new_history = sess
.process_compacted_history(turn_context, new_history)
.process_compacted_history(turn_context, new_history, turn_context_reinjection)
.await;
// Reattach the stripped model-switch update only after successful compaction so the model
// still sees the switch instructions on the next real sampling request.
@@ -173,12 +230,14 @@ fn build_compact_request_log_data(
fn log_remote_compact_failure(
turn_context: &TurnContext,
auto_compact_callsite: AutoCompactCallsite,
log_data: &CompactRequestLogData,
total_usage_breakdown: TotalTokenUsageBreakdown,
err: &CodexErr,
) {
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?auto_compact_callsite,
last_api_response_total_tokens = total_usage_breakdown.last_api_response_total_tokens,
all_history_items_model_visible_bytes = total_usage_breakdown.all_history_items_model_visible_bytes,
estimated_tokens_of_items_added_since_last_successful_api_response = total_usage_breakdown.estimated_tokens_of_items_added_since_last_successful_api_response,
@@ -194,15 +253,37 @@ fn trim_function_call_history_to_fit_context_window(
history: &mut ContextManager,
turn_context: &TurnContext,
base_instructions: &BaseInstructions,
incoming_items: Option<&[ResponseItem]>,
) -> usize {
let Some(context_window) = turn_context.model_context_window() else {
return 0;
};
let incoming_items_tokens = incoming_items
.unwrap_or_default()
.iter()
.map(estimate_item_token_count)
.fold(0_i64, i64::saturating_add);
trim_codex_generated_tail_items_to_fit_context_window(
history,
context_window,
base_instructions,
incoming_items_tokens,
)
}
fn trim_codex_generated_tail_items_to_fit_context_window(
history: &mut ContextManager,
context_window: i64,
base_instructions: &BaseInstructions,
incoming_items_tokens: i64,
) -> usize {
let mut deleted_items = 0usize;
let Some(context_window) = turn_context.model_context_window() else {
return deleted_items;
};
while history
.estimate_token_count_with_base_instructions(base_instructions)
.is_some_and(|estimated_tokens| estimated_tokens > context_window)
.is_some_and(|estimated_tokens| {
estimated_tokens.saturating_add(incoming_items_tokens) > context_window
})
{
let Some(last_item) = history.raw_items().last() else {
break;
@@ -218,3 +299,90 @@ fn trim_function_call_history_to_fit_context_window(
deleted_items
}
#[cfg(test)]
mod tests {
use super::*;
use crate::truncate::TruncationPolicy;
use codex_protocol::models::ContentItem;
use pretty_assertions::assert_eq;
fn user_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
fn developer_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
phase: None,
}
}
#[test]
fn trim_accounts_for_incoming_items_tokens() {
let base_instructions = BaseInstructions {
text: String::new(),
};
let incoming_items = [user_message(
"INCOMING_USER_MESSAGE_THAT_TIPS_OVER_THE_WINDOW",
)];
let incoming_items_tokens = incoming_items
.iter()
.map(estimate_item_token_count)
.fold(0_i64, i64::saturating_add);
assert!(
incoming_items_tokens > 0,
"expected incoming item token estimate to be positive"
);
let mut history = ContextManager::new();
let history_items = [
user_message("USER_ONE"),
developer_message("TRAILING_CODEX_GENERATED_CONTEXT"),
];
history.record_items(history_items.iter(), TruncationPolicy::Tokens(10_000));
let history_tokens = history
.estimate_token_count_with_base_instructions(&base_instructions)
.unwrap_or_default();
let context_window = history_tokens
.saturating_add(incoming_items_tokens)
.saturating_sub(1);
let mut without_incoming_projection = history.clone();
let deleted_without_incoming = trim_codex_generated_tail_items_to_fit_context_window(
&mut without_incoming_projection,
context_window,
&base_instructions,
0,
);
assert_eq!(
deleted_without_incoming, 0,
"history-only projection should not trim when currently under the limit"
);
let deleted_with_incoming = trim_codex_generated_tail_items_to_fit_context_window(
&mut history,
context_window,
&base_instructions,
incoming_items_tokens,
);
assert_eq!(
deleted_with_incoming, 1,
"incoming projection should trim trailing codex-generated history to fit pre-turn request"
);
assert_eq!(history.raw_items(), vec![user_message("USER_ONE")]);
}
}

View File

@@ -395,7 +395,7 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize {
.saturating_sub(650)
}
fn estimate_item_token_count(item: &ResponseItem) -> i64 {
pub(crate) fn estimate_item_token_count(item: &ResponseItem) -> i64 {
let model_visible_bytes = estimate_response_item_model_visible_bytes(item);
approx_tokens_from_byte_count_i64(model_visible_bytes)
}

View File

@@ -4,6 +4,7 @@ pub(crate) mod updates;
pub(crate) use history::ContextManager;
pub(crate) use history::TotalTokenUsageBreakdown;
pub(crate) use history::estimate_item_token_count;
pub(crate) use history::estimate_response_item_model_visible_bytes;
pub(crate) use history::is_codex_generated_item;
pub(crate) use history::is_user_turn_boundary;

View File

@@ -3,6 +3,8 @@ use std::sync::Arc;
use super::SessionTask;
use super::SessionTaskContext;
use crate::codex::TurnContext;
use crate::context_manager::is_user_turn_boundary;
use crate::protocol::EventMsg;
use crate::state::TaskKind;
use async_trait::async_trait;
use codex_protocol::user_input::UserInput;
@@ -25,20 +27,50 @@ impl SessionTask for CompactTask {
_cancellation_token: CancellationToken,
) -> Option<String> {
let session = session.clone_session();
let has_user_turn_boundary = session
.clone_history()
.await
.raw_items()
.iter()
.any(is_user_turn_boundary);
if crate::compact::should_use_remote_compact_task(&ctx.provider) {
let _ = session.services.otel_manager.counter(
"codex.task.compact",
1,
&[("type", "remote")],
);
let _ = crate::compact_remote::run_remote_compact_task(session, ctx).await;
if let Err(err) =
crate::compact_remote::run_remote_compact_task(session.clone(), ctx.clone()).await
{
let event = EventMsg::Error(
err.to_error_event(Some("Error running remote compact task".to_string())),
);
session.send_event(&ctx, event).await;
} else if has_user_turn_boundary {
// Manual `/compact` rewrites history to compacted transcript items and drops
// per-turn context entries. Force initial-context reseeding on the next user turn.
// TODO(ccunningham): Eliminate this when we have better TurnContextItem diffing (compaction aware)
session.mark_initial_context_unseeded_for_next_turn().await;
}
} else {
let _ = session.services.otel_manager.counter(
"codex.task.compact",
1,
&[("type", "local")],
);
let _ = crate::compact::run_compact_task(session, ctx, input).await;
if let Err(err) =
crate::compact::run_compact_task(session.clone(), ctx.clone(), input).await
{
let event = EventMsg::Error(
err.to_error_event(Some("Error running local compact task".to_string())),
);
session.send_event(&ctx, event).await;
} else if has_user_turn_boundary {
// Manual `/compact` rewrites history to compacted transcript items and drops
// per-turn context entries. Force initial-context reseeding on the next user turn.
// TODO(ccunningham): Eliminate this when we have better TurnContextItem diffing (compaction aware)
session.mark_initial_context_unseeded_for_next_turn().await;
}
}
None

View File

@@ -8,6 +8,7 @@ use crate::codex::run_turn;
use crate::state::TaskKind;
use async_trait::async_trait;
use codex_otel::OtelManager;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::user_input::UserInput;
use tokio::task::JoinHandle;
@@ -23,12 +24,14 @@ type PrewarmedSessionTask = JoinHandle<Option<ModelClientSession>>;
pub(crate) struct RegularTask {
prewarmed_session_task: Mutex<Option<PrewarmedSessionTask>>,
pre_turn_context_items: Vec<ResponseItem>,
}
impl Default for RegularTask {
fn default() -> Self {
Self {
prewarmed_session_task: Mutex::new(None),
pre_turn_context_items: Vec::new(),
}
}
}
@@ -55,9 +58,15 @@ impl RegularTask {
Self {
prewarmed_session_task: Mutex::new(Some(prewarmed_session_task)),
pre_turn_context_items: Vec::new(),
}
}
pub(crate) fn with_pre_turn_context_items(mut self, items: Vec<ResponseItem>) -> Self {
self.pre_turn_context_items = items;
self
}
async fn take_prewarmed_session(&self) -> Option<ModelClientSession> {
let prewarmed_session_task = self
.prewarmed_session_task
@@ -101,6 +110,7 @@ impl SessionTask for RegularTask {
sess,
ctx,
input,
self.pre_turn_context_items.clone(),
prewarmed_client_session,
cancellation_token,
)

View File

@@ -865,6 +865,88 @@ pub async fn mount_compact_json_once(server: &MockServer, body: serde_json::Valu
.await
}
/// Mount a `/responses/compact` mock that mirrors the default remote compaction shape:
/// keep user+developer messages from the request, drop assistant/tool artifacts, and append one
/// summary user message.
pub async fn mount_compact_user_history_with_summary_once(
server: &MockServer,
summary_text: &str,
) -> ResponseMock {
mount_compact_user_history_with_summary_sequence(server, vec![summary_text.to_string()]).await
}
/// Same as [`mount_compact_user_history_with_summary_once`], but for multiple compact calls.
/// Each incoming compact request receives the next summary text in order.
pub async fn mount_compact_user_history_with_summary_sequence(
server: &MockServer,
summary_texts: Vec<String>,
) -> ResponseMock {
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
#[derive(Debug)]
struct UserHistorySummaryResponder {
num_calls: AtomicUsize,
summary_texts: Vec<String>,
}
impl Respond for UserHistorySummaryResponder {
fn respond(&self, request: &wiremock::Request) -> ResponseTemplate {
let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst);
let Some(summary_text) = self.summary_texts.get(call_num) else {
panic!("no summary text for compact request {call_num}");
};
let body_bytes = decode_body_bytes(
&request.body,
request
.headers
.get("content-encoding")
.and_then(|value| value.to_str().ok()),
);
let body_json: Value = serde_json::from_slice(&body_bytes)
.unwrap_or_else(|err| panic!("failed to parse compact request body: {err}"));
let mut output = body_json
.get("input")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
// Match current remote compaction behavior: keep user/developer messages and
// omit assistant/tool history entries.
.filter(|item| {
item.get("type").and_then(Value::as_str) == Some("message")
&& matches!(
item.get("role").and_then(Value::as_str),
Some("user") | Some("developer")
)
})
.collect::<Vec<Value>>();
// Append the synthetic summary message as the newest user item.
output.push(serde_json::json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": summary_text}],
}));
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({ "output": output }))
}
}
let num_calls = summary_texts.len();
let responder = UserHistorySummaryResponder {
num_calls: AtomicUsize::new(0),
summary_texts,
};
let (mock, response_mock) = compact_mock();
mock.respond_with(responder)
.up_to_n_times(num_calls as u64)
.expect(num_calls as u64)
.mount(server)
.await;
response_mock
}
pub async fn mount_compact_response_once(
server: &MockServer,
response: ResponseTemplate,

View File

@@ -132,11 +132,20 @@ fn assert_pre_sampling_switch_compaction_requests(
!compact_body.contains("<model_switch>"),
"pre-sampling compact request should strip trailing model-switch update item"
);
let first_body = first.to_string();
assert!(
body_contains_text(&first_body, "<environment_context>"),
"first request should include canonical environment context"
);
let follow_up_body = follow_up.to_string();
assert!(
follow_up_body.contains("<model_switch>"),
"follow-up request after successful model-switch compaction should include model-switch update item"
);
assert!(
body_contains_text(&follow_up_body, "<environment_context>"),
"follow-up request should preserve canonical environment context after pre-sampling compaction"
);
}
async fn assert_compaction_uses_turn_lifecycle_id(codex: &std::sync::Arc<codex_core::CodexThread>) {
@@ -501,6 +510,10 @@ async fn manual_compact_emits_api_and_local_token_usage_events() {
let server = start_mock_server().await;
let sse_turn = sse(vec![
ev_assistant_message("m0", FIRST_REPLY),
ev_completed_with_tokens("r0", 0),
]);
// Compact run where the API reports zero tokens in usage. Our local
// estimator should still compute a non-zero context size for the compacted
// history.
@@ -508,7 +521,7 @@ async fn manual_compact_emits_api_and_local_token_usage_events() {
ev_assistant_message("m1", SUMMARY_TEXT),
ev_completed_with_tokens("r1", 0),
]);
mount_sse_once(&server, sse_compact).await;
mount_sse_sequence(&server, vec![sse_turn, sse_compact]).await;
let model_provider = non_openai_model_provider(&server);
let mut builder = test_codex().with_config(move |config| {
@@ -517,39 +530,42 @@ async fn manual_compact_emits_api_and_local_token_usage_events() {
});
let codex = builder.build(&server).await.unwrap().codex;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "seed compact history".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
// Trigger manual compact and collect TokenCount events for the compact turn.
codex.submit(Op::Compact).await.unwrap();
// First TokenCount: from the compact API call (usage.total_tokens = 0).
let first = wait_for_event_match(&codex, |ev| match ev {
EventMsg::TokenCount(tc) => tc
.info
.as_ref()
.map(|info| info.last_token_usage.total_tokens),
_ => None,
})
.await;
let mut compact_turn_token_totals = Vec::new();
loop {
let event = wait_for_event(&codex, |_| true).await;
match event {
EventMsg::TokenCount(tc) => {
if let Some(info) = tc.info {
compact_turn_token_totals.push(info.last_token_usage.total_tokens);
}
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
// Second TokenCount: from the local post-compaction estimate.
let last = wait_for_event_match(&codex, |ev| match ev {
EventMsg::TokenCount(tc) => tc
.info
.as_ref()
.map(|info| info.last_token_usage.total_tokens),
_ => None,
})
.await;
// Ensure the compact task itself completes.
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_eq!(
first, 0,
"expected first TokenCount from compact API usage to be zero"
assert!(
compact_turn_token_totals.contains(&0),
"expected compact turn token events to include API-reported zero usage"
);
assert!(
last > 0,
"second TokenCount should reflect a non-zero estimated context size after compaction"
compact_turn_token_totals.iter().any(|total| *total > 0),
"expected compact turn token events to include a non-zero local estimate"
);
}
@@ -1784,6 +1800,132 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_sampling_compact_context_window_failure_surfaces_compact_task_error() {
skip_if_no_network!();
let server = MockServer::start().await;
let previous_model = "gpt-5.2-codex";
let next_model = "gpt-5.1-codex-max";
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![
model_info_with_context_window(previous_model, 273_000),
model_info_with_context_window(next_model, 125_000),
],
},
)
.await;
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_assistant_message("m1", "before switch"),
ev_completed_with_tokens("r1", 120_000),
]),
sse_failed(
"compact-failed",
"context_length_exceeded",
CONTEXT_LIMIT_MESSAGE,
),
],
)
.await;
let mut model_provider = non_openai_model_provider(&server);
model_provider.stream_max_retries = Some(0);
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_model(previous_model)
.with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config
.features
.enable(codex_core::features::Feature::RemoteModels);
});
let test = builder.build(&server).await.expect("build test codex");
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "before switch".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: previous_model.to_string(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await
.expect("submit first user turn");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "after switch".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: next_model.to_string(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await
.expect("submit second user turn");
let error_message = wait_for_event_match(&test.codex, |event| match event {
EventMsg::Error(err) => Some(err.message.clone()),
_ => None,
})
.await;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
assert_eq!(models_mock.requests().len(), 1);
assert!(
error_message.contains("Error running local compact task"),
"expected local compact-task failure prefix, got {error_message}"
);
assert!(
!error_message.contains(
"Incoming user message and/or turn context is too large to fit in context window"
),
"model-switch pre-sampling compaction failure should not be misclassified as incoming-input oversize: {error_message}"
);
let requests = request_log.requests();
assert_eq!(
requests.len(),
2,
"expected first user turn and one pre-sampling compaction request"
);
let compact_request_body = requests[1].body_json().to_string();
assert!(
body_contains_text(&compact_request_body, SUMMARIZATION_PROMPT),
"second request should be the pre-sampling compaction request"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() {
skip_if_no_network!();
@@ -2172,9 +2314,6 @@ async fn manual_compact_retries_after_context_window_error() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands.
// Current main behavior around non-context manual /compact failures is known-incorrect.
#[ignore = "behavior change covered in follow-up compaction PR"]
async fn manual_compact_non_context_failure_retries_then_emits_task_error() {
skip_if_no_network!();
@@ -2405,35 +2544,33 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
"compact requests should consistently include or omit the summarization prompt"
);
let first_request_user_texts = requests[0].message_input_texts("user");
let first_turn_user_index = first_request_user_texts
.len()
.checked_sub(1)
.unwrap_or_else(|| panic!("first turn request missing user messages"));
assert_eq!(
first_request_user_texts[first_turn_user_index], first_user_message,
"first turn request should end with the submitted user message"
);
let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index];
let final_request_user_texts = requests
.last()
.unwrap_or_else(|| panic!("final turn request missing for {final_user_message}"))
.message_input_texts("user");
assert!(
final_request_user_texts
.as_slice()
.starts_with(seeded_user_prefix),
"final request should start with seeded user prefix from first request: {seeded_user_prefix:?}"
);
let final_output = &final_request_user_texts[seeded_user_prefix.len()..];
let Some(first_user_index) = final_request_user_texts
.iter()
.position(|text| text == first_user_message)
else {
panic!("final request missing first user message: {final_request_user_texts:?}");
};
let final_output = &final_request_user_texts[first_user_index..];
let expected = vec![
first_user_message.to_string(),
second_user_message.to_string(),
expected_second_summary,
final_user_message.to_string(),
];
assert_eq!(final_output, expected.as_slice());
let mut final_output_iter = final_output.iter();
for expected_text in &expected {
final_output_iter
.position(|text| text == expected_text)
.unwrap_or_else(|| {
panic!(
"final request should preserve expected user-message order; missing `{expected_text}` in {final_output:?}"
)
});
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -2704,8 +2841,8 @@ async fn auto_compact_clamps_config_limit_to_context_window() {
let auto_compact_body = auto_compact_mock.single_request().body_json().to_string();
assert!(
body_contains_text(&auto_compact_body, SUMMARIZATION_PROMPT),
"auto compact should run with the summarization prompt when config limit exceeds context"
body_contains_text(&auto_compact_body, "OVER_LIMIT_TURN"),
"auto compact should run when the configured limit clamps to the model context window"
);
}
@@ -2919,7 +3056,6 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// TODO(ccunningham): Update once pre-turn compaction includes incoming user input.
async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_message() {
skip_if_no_network!();
@@ -3007,7 +3143,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess
insta::assert_snapshot!(
"pre_turn_compaction_including_incoming_shapes",
format_labeled_requests_snapshot(
"Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.",
"Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.",
&[
("Local Compaction Request", &requests[2]),
("Local Post-Compaction History Layout", &requests[3]),
@@ -3016,10 +3152,17 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess
);
let compact_request_user_texts = requests[2].message_input_texts("user");
assert!(
!compact_request_user_texts
compact_request_user_texts
.iter()
.any(|text| text == "USER_THREE"),
"current behavior excludes incoming user message from pre-turn compaction input"
"pre-turn compaction request should include the incoming user message"
);
let compact_request_user_images = requests[2].message_input_image_urls("user");
assert!(
compact_request_user_images
.iter()
.any(|url| url == image_url.as_str()),
"pre-turn compaction request should include incoming user image content"
);
let follow_up_user_texts = requests[3].message_input_texts("user");
assert!(
@@ -3036,8 +3179,131 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// TODO(ccunningham): Update once pre-turn compaction context-overflow handling includes incoming
// user input and emits richer oversized-input messaging.
async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch() {
skip_if_no_network!();
let server = start_mock_server().await;
let previous_model = "gpt-5.1-codex-max";
let next_model = "gpt-5.2-codex";
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_assistant_message("m1", "BEFORE_SWITCH_REPLY"),
ev_completed_with_tokens("r1", 500),
]),
sse(vec![
ev_assistant_message("m2", "PRETURN_SWITCH_SUMMARY"),
ev_completed_with_tokens("r2", 100),
]),
sse(vec![
ev_assistant_message("m3", "AFTER_SWITCH_REPLY"),
ev_completed_with_tokens("r3", 100),
]),
],
)
.await;
let model_provider = non_openai_model_provider(&server);
let test = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_model(previous_model)
.with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config
.features
.enable(codex_core::features::Feature::RemoteModels);
config.model_auto_compact_token_limit = Some(200);
})
.build(&server)
.await
.expect("build codex");
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "BEFORE_SWITCH_USER".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: previous_model.to_string(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await
.expect("submit first user turn");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "AFTER_SWITCH_USER".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: next_model.to_string(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await
.expect("submit second user turn");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = request_log.requests();
assert_eq!(
requests.len(),
3,
"expected first turn, pre-turn compact, and post-compact follow-up requests"
);
let compact_body = requests[1].body_json().to_string();
assert!(
body_contains_text(&compact_body, SUMMARIZATION_PROMPT),
"pre-turn compaction request should include summarization prompt"
);
assert!(
!compact_body.contains("<model_switch>"),
"pre-turn compaction request should strip incoming model-switch update item"
);
let follow_up_body = requests[2].body_json().to_string();
assert!(
follow_up_body.contains("<model_switch>"),
"post-compaction follow-up should include model-switch update item"
);
insta::assert_snapshot!(
"pre_turn_compaction_strips_incoming_model_switch_shapes",
format_labeled_requests_snapshot(
"Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming <model_switch> from the compact request and restores it in the post-compaction follow-up request.",
&[
("Initial Request (Previous Model)", &requests[0]),
("Local Compaction Request", &requests[1]),
("Local Post-Compaction History Layout", &requests[2]),
]
)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() {
skip_if_no_network!();
@@ -3049,7 +3315,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() {
]);
let mut responses = vec![first_turn];
responses.extend(
(0..6).map(|_| {
(0..12).map(|_| {
sse_failed(
"compact-failed",
"context_length_exceeded",
@@ -3106,13 +3372,12 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() {
requests.len() >= 2,
"expected first turn and at least one compaction request"
);
insta::assert_snapshot!(
"pre_turn_compaction_context_window_exceeded_shapes",
format_labeled_requests_snapshot(
"Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.",
"Pre-turn auto-compaction context-window failure: compaction request includes the incoming user message and the turn errors.",
&[(
"Local Compaction Request (Incoming User Excluded)",
"Local Compaction Request (Incoming User Included)",
&requests[1]
),]
)
@@ -3124,21 +3389,231 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_turn_local_compaction_trim_retries_keep_incoming_items() {
skip_if_no_network!();
let server = start_mock_server().await;
let compact_failed = sse_failed(
"compact-failed",
"context_length_exceeded",
CONTEXT_LIMIT_MESSAGE,
);
let request_log = mount_sse_sequence(
&server,
vec![
compact_failed.clone(),
compact_failed.clone(),
compact_failed.clone(),
compact_failed,
],
)
.await;
let model_provider = non_openai_model_provider(&server);
let codex = test_codex()
.with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_auto_compact_token_limit = Some(1);
})
.build(&server)
.await
.expect("build codex")
.codex;
let incoming_text = "PRETURN_INCOMING_USER";
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: incoming_text.to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await
.expect("submit user input");
let error_message = wait_for_event_match(&codex, |event| match event {
EventMsg::Error(err) => Some(err.message.clone()),
_ => None,
})
.await;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert!(
error_message.contains(
"Incoming user message and/or turn context is too large to fit in context window"
),
"expected oversized incoming-items error, got {error_message}"
);
let compact_attempts = request_log.requests();
assert_eq!(
compact_attempts.len(),
4,
"expected pre-turn compaction to retry while trimming seeded history only"
);
for (index, request) in compact_attempts.iter().enumerate() {
let body = request.body_json().to_string();
assert!(
body_contains_text(&body, SUMMARIZATION_PROMPT),
"request {index} should be a compaction attempt"
);
assert!(
request
.message_input_texts("user")
.iter()
.any(|text| text == incoming_text),
"request {index} dropped incoming user text during trim retries"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_turn_compaction_failure_persists_context_updates_for_next_turn() {
skip_if_no_network!();
let server = start_mock_server().await;
let compact_failed = sse_failed(
"compact-failed",
"context_length_exceeded",
CONTEXT_LIMIT_MESSAGE,
);
let third_turn = sse(vec![
ev_assistant_message("m3", FINAL_REPLY),
ev_completed_with_tokens("r3", 80),
]);
let request_log = mount_sse_sequence(
&server,
vec![
compact_failed.clone(),
compact_failed.clone(),
compact_failed.clone(),
compact_failed,
third_turn,
],
)
.await;
let mut model_provider = non_openai_model_provider(&server);
model_provider.stream_max_retries = Some(0);
let codex = test_codex()
.with_config(move |config| {
config.model_provider = model_provider;
set_test_compact_prompt(config);
config.model_auto_compact_token_limit = Some(300);
})
.build(&server)
.await
.expect("build codex")
.codex;
// Seed `previous_context` without adding a user turn to history.
codex
.submit(Op::UserTurn {
items: Vec::new(),
final_output_json_schema: None,
cwd: PathBuf::from("."),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: "gpt-5.2-codex".to_string(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await
.expect("submit empty settings-only turn");
let oversized_input = "X".repeat(2_000);
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: oversized_input,
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: PathBuf::from("."),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: "gpt-5.2-codex".to_string(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await
.expect("submit oversized turn that triggers pre-turn compaction failure");
let error_message = wait_for_event_match(&codex, |event| match event {
EventMsg::Error(err) => Some(err.message.clone()),
_ => None,
})
.await;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert!(
error_message.contains(
"Incoming user message and/or turn context is too large to fit in context window"
),
"expected oversized incoming-items error, got {error_message}"
);
let follow_up_text = "after failed pre-turn compact";
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: follow_up_text.to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: PathBuf::from("."),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: "gpt-5.2-codex".to_string(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await
.expect("submit follow-up turn");
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let requests = request_log.requests();
assert_eq!(
requests.len(),
5,
"expected four failed pre-turn compaction attempts and one follow-up model request"
);
let follow_up_request = requests.last().expect("missing follow-up request");
let follow_up_developer_texts = follow_up_request.message_input_texts("developer");
assert!(
follow_up_developer_texts
.iter()
.any(|text| text.contains("sandbox_mode` is `danger-full-access`")),
"expected danger-full-access permissions update in follow-up turn after failed pre-turn compaction: {follow_up_developer_texts:?}"
);
assert!(
follow_up_request
.message_input_texts("user")
.iter()
.any(|text| text == follow_up_text),
"expected follow-up request to include follow-up user message"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_request_shape_manual_compact_without_previous_user_messages() {
skip_if_no_network!();
let server = start_mock_server().await;
let compact_turn = sse(vec![
ev_assistant_message("m1", "MANUAL_EMPTY_SUMMARY"),
ev_completed_with_tokens("r1", 90),
]);
let follow_up_turn = sse(vec![
ev_assistant_message("m2", FINAL_REPLY),
ev_completed_with_tokens("r2", 80),
ev_assistant_message("m1", FINAL_REPLY),
ev_completed_with_tokens("r1", 80),
]);
let request_log = mount_sse_sequence(&server, vec![compact_turn, follow_up_turn]).await;
let request_log = mount_sse_once(&server, follow_up_turn).await;
let model_provider = non_openai_model_provider(&server);
let codex = test_codex()
@@ -3169,18 +3644,15 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages()
let requests = request_log.requests();
assert_eq!(
requests.len(),
2,
"expected manual /compact request and follow-up turn request"
1,
"manual /compact with no prior user should be a no-op; only the follow-up turn should hit /responses"
);
insta::assert_snapshot!(
"manual_compact_without_prev_user_shapes",
format_labeled_requests_snapshot(
"Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.",
&[
("Local Compaction Request", &requests[0]),
("Local Post-Compaction History Layout", &requests[1]),
]
"Manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.",
&[("Local Post-Compaction History Layout", &requests[0]),]
)
);
}

View File

@@ -216,15 +216,9 @@ async fn remote_compact_runs_automatically() -> Result<()> {
)
.await;
let compacted_history = vec![
responses::user_message_item("REMOTE_COMPACTED_SUMMARY"),
ResponseItem::Compaction {
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
},
];
let compact_mock = responses::mount_compact_json_once(
let compact_mock = responses::mount_compact_user_history_with_summary_once(
harness.server(),
serde_json::json!({ "output": compacted_history.clone() }),
"REMOTE_COMPACTED_SUMMARY",
)
.await;
@@ -249,7 +243,6 @@ async fn remote_compact_runs_automatically() -> Result<()> {
let follow_up_request = responses_mock.single_request();
let follow_up_body = follow_up_request.body_json().to_string();
assert!(follow_up_body.contains("REMOTE_COMPACTED_SUMMARY"));
assert!(follow_up_body.contains("ENCRYPTED_COMPACTION_SUMMARY"));
Ok(())
}
@@ -318,9 +311,11 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let compact_mock =
responses::mount_compact_json_once(harness.server(), serde_json::json!({ "output": [] }))
.await;
let compact_mock = responses::mount_compact_user_history_with_summary_once(
harness.server(),
"REMOTE_COMPACT_SUMMARY",
)
.await;
codex.submit(Op::Compact).await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
@@ -436,9 +431,11 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window()
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let compact_mock =
responses::mount_compact_json_once(harness.server(), serde_json::json!({ "output": [] }))
.await;
let compact_mock = responses::mount_compact_user_history_with_summary_once(
harness.server(),
"REMOTE_AUTO_COMPACT_SUMMARY",
)
.await;
codex
.submit(Op::UserInput {
@@ -582,9 +579,9 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> {
insta::assert_snapshot!(
"remote_pre_turn_compaction_failure_shapes",
format_labeled_requests_snapshot(
"Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.",
"Remote pre-turn auto-compaction parse failure: compaction request includes incoming user content and the turn stops.",
&[(
"Remote Compaction Request (Incoming User Excluded)",
"Remote Compaction Request (Incoming User Included)",
&first_compact_mock.single_request()
),]
)
@@ -667,9 +664,9 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result
})
.await;
let baseline_compact_mock = responses::mount_compact_json_once(
let baseline_compact_mock = responses::mount_compact_user_history_with_summary_once(
baseline_harness.server(),
serde_json::json!({ "output": [] }),
"REMOTE_BASELINE_SUMMARY",
)
.await;
@@ -766,9 +763,9 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result
})
.await;
let override_compact_mock = responses::mount_compact_json_once(
let override_compact_mock = responses::mount_compact_user_history_with_summary_once(
override_harness.server(),
serde_json::json!({ "output": [] }),
"REMOTE_OVERRIDE_SUMMARY",
)
.await;
@@ -814,15 +811,9 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> {
)
.await;
let compacted_history = vec![
responses::user_message_item("REMOTE_COMPACTED_SUMMARY"),
ResponseItem::Compaction {
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
},
];
let compact_mock = responses::mount_compact_json_once(
let compact_mock = responses::mount_compact_user_history_with_summary_once(
harness.server(),
serde_json::json!({ "output": compacted_history.clone() }),
"REMOTE_COMPACTED_SUMMARY",
)
.await;
@@ -939,9 +930,6 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands.
// Current main behavior for rollout replacement-history persistence is known-incorrect.
#[ignore = "behavior change covered in follow-up compaction PR"]
async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1319,7 +1307,6 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// TODO(ccunningham): Update once remote pre-turn compaction includes incoming user input.
async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_user_message()
-> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1353,14 +1340,9 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us
)
.await;
let compacted_history = vec![
responses::user_message_item("USER_ONE"),
responses::user_message_item("USER_TWO"),
responses::user_message_item(&summary_with_prefix("REMOTE_PRE_TURN_SUMMARY")),
];
let compact_mock = responses::mount_compact_json_once(
let compact_mock = responses::mount_compact_user_history_with_summary_once(
harness.server(),
serde_json::json!({ "output": compacted_history }),
&summary_with_prefix("REMOTE_PRE_TURN_SUMMARY"),
)
.await;
@@ -1404,13 +1386,20 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us
insta::assert_snapshot!(
"remote_pre_turn_compaction_including_incoming_shapes",
format_labeled_requests_snapshot(
"Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.",
"Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.",
&[
("Remote Compaction Request", &compact_request),
("Remote Post-Compaction History Layout", &requests[2]),
]
)
);
assert!(
compact_request
.message_input_texts("user")
.iter()
.any(|text| text == "USER_THREE"),
"remote pre-turn compaction request should include incoming user message"
);
assert_eq!(
requests[2]
.message_input_texts("user")
@@ -1425,8 +1414,131 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// TODO(ccunningham): Update once remote pre-turn compaction context-overflow handling includes
// incoming user input and emits richer oversized-input messaging.
async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model_switch()
-> Result<()> {
skip_if_no_network!(Ok(()));
let previous_model = "gpt-5.1-codex-max";
let next_model = "gpt-5.2-codex";
let harness = TestCodexHarness::with_builder(
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_model(previous_model)
.with_config(|config| {
config.model_auto_compact_token_limit = Some(200);
}),
)
.await?;
let codex = harness.test().codex.clone();
let responses_mock = responses::mount_sse_sequence(
harness.server(),
vec![
responses::sse(vec![
responses::ev_assistant_message("m1", "BEFORE_SWITCH_REPLY"),
responses::ev_completed_with_tokens("r1", 500),
]),
responses::sse(vec![
responses::ev_assistant_message("m2", "AFTER_SWITCH_REPLY"),
responses::ev_completed_with_tokens("r2", 80),
]),
],
)
.await;
let compact_mock = responses::mount_compact_user_history_with_summary_once(
harness.server(),
&summary_with_prefix("REMOTE_SWITCH_SUMMARY"),
)
.await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "BEFORE_SWITCH_USER".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
codex
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
windows_sandbox_level: None,
model: Some(next_model.to_string()),
effort: None,
summary: None,
collaboration_mode: None,
personality: None,
})
.await?;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "AFTER_SWITCH_USER".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_eq!(
compact_mock.requests().len(),
1,
"expected a single remote pre-turn compaction request"
);
let requests = responses_mock.requests();
assert_eq!(
requests.len(),
2,
"expected initial turn request and post-compaction follow-up request"
);
let compact_request = compact_mock.single_request();
let compact_body = compact_request.body_json().to_string();
assert!(
compact_body.contains("AFTER_SWITCH_USER"),
"pre-turn remote compaction request should include incoming user message"
);
assert!(
!compact_body.contains("<model_switch>"),
"pre-turn remote compaction request should strip incoming model-switch update item"
);
let follow_up_body = requests[1].body_json().to_string();
assert!(
follow_up_body.contains("BEFORE_SWITCH_USER"),
"post-compaction follow-up should preserve older user messages when they fit"
);
assert!(
follow_up_body.contains("AFTER_SWITCH_USER"),
"post-compaction follow-up should preserve incoming user message"
);
assert!(
follow_up_body.contains("<model_switch>"),
"post-compaction follow-up should include the model-switch update item"
);
insta::assert_snapshot!(
"remote_pre_turn_compaction_strips_incoming_model_switch_shapes",
format_labeled_requests_snapshot(
"Remote pre-turn compaction during model switch strips incoming <model_switch> from the compact request and restores it in the post-compaction follow-up request.",
&[
("Initial Request (Previous Model)", &requests[0]),
("Remote Compaction Request", &compact_request),
("Remote Post-Compaction History Layout", &requests[1]),
]
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceeded() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1449,7 +1561,17 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed
)
.await;
let compact_mock = responses::mount_compact_response_once(
let include_attempt_mock = responses::mount_compact_response_once(
harness.server(),
ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
"code": "context_length_exceeded",
"message": "Your input exceeds the context window of this model. Please adjust your input and try again."
}
})),
)
.await;
let fallback_attempt_mock = responses::mount_compact_response_once(
harness.server(),
ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
@@ -1495,7 +1617,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed
.await;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_eq!(compact_mock.requests().len(), 1);
let requests = responses_mock.requests();
assert_eq!(
requests.len(),
@@ -1507,15 +1628,45 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed
"expected turn to stop after compaction failure"
);
let include_attempt_request = compact_mock.single_request();
let mut compact_requests = include_attempt_mock.requests();
compact_requests.extend(fallback_attempt_mock.requests());
assert_eq!(
compact_requests.len(),
2,
"expected include-incoming and fallback compact attempts"
);
let include_attempt_request = compact_requests
.iter()
.find(|request| request.body_contains_text("USER_TWO"))
.cloned()
.expect("expected one compact attempt containing incoming user message");
let fallback_attempt_request = compact_requests
.iter()
.find(|request| !request.body_contains_text("USER_TWO"))
.cloned()
.expect("expected one fallback compact attempt excluding incoming user message");
assert!(
include_attempt_request.body_contains_text("USER_TWO"),
"include-incoming pre-turn remote compaction attempt should include incoming user message"
);
assert!(
!fallback_attempt_request.body_contains_text("USER_TWO"),
"fallback pre-turn remote compaction attempt should exclude incoming user message"
);
insta::assert_snapshot!(
"remote_pre_turn_compaction_context_window_exceeded_shapes",
format_labeled_requests_snapshot(
"Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.",
&[(
"Remote Compaction Request (Incoming User Excluded)",
&include_attempt_request
),]
"Remote pre-turn auto-compaction context-window failure: initial compaction includes incoming user content, fallback compaction excludes incoming user content, and the turn errors.",
&[
(
"Remote Compaction Request (Incoming User Included)",
&include_attempt_request
),
(
"Remote Compaction Request (Fallback Incoming User Excluded)",
&fallback_attempt_request
),
]
)
);
assert!(
@@ -1555,13 +1706,9 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res
)
.await;
let compacted_history = vec![
responses::user_message_item("USER_ONE"),
responses::user_message_item(&summary_with_prefix("REMOTE_MID_TURN_SUMMARY")),
];
let compact_mock = responses::mount_compact_json_once(
let compact_mock = responses::mount_compact_user_history_with_summary_once(
harness.server(),
serde_json::json!({ "output": compacted_history }),
&summary_with_prefix("REMOTE_MID_TURN_SUMMARY"),
)
.await;
@@ -1600,7 +1747,179 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// TODO(ccunningham): Update once manual remote /compact with no prior user turn becomes a no-op.
async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinjects_context()
-> Result<()> {
skip_if_no_network!(Ok(()));
let harness = TestCodexHarness::with_builder(
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
config.model_auto_compact_token_limit = Some(200);
}),
)
.await?;
let codex = harness.test().codex.clone();
let responses_mock = responses::mount_sse_sequence(
harness.server(),
vec![
responses::sse(vec![
responses::ev_function_call("call-remote-summary-only", DUMMY_FUNCTION_NAME, "{}"),
responses::ev_completed_with_tokens("r1", 500),
]),
responses::sse(vec![
responses::ev_assistant_message("m2", "REMOTE_SUMMARY_ONLY_FINAL_REPLY"),
responses::ev_completed_with_tokens("r2", 80),
]),
],
)
.await;
let compacted_history = vec![responses::user_message_item(&summary_with_prefix(
"REMOTE_SUMMARY_ONLY",
))];
let compact_mock = responses::mount_compact_json_once(
harness.server(),
serde_json::json!({ "output": compacted_history }),
)
.await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "USER_ONE".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_eq!(compact_mock.requests().len(), 1);
let requests = responses_mock.requests();
assert_eq!(
requests.len(),
2,
"expected initial and post-compact requests"
);
let compact_request = compact_mock.single_request();
insta::assert_snapshot!(
"remote_mid_turn_compaction_summary_only_reinjects_context_shapes",
format_labeled_requests_snapshot(
"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.",
&[
("Remote Compaction Request", &compact_request),
("Remote Post-Compaction History Layout", &requests[1]),
]
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary()
-> Result<()> {
skip_if_no_network!(Ok(()));
let harness = TestCodexHarness::with_builder(
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
config.model_auto_compact_token_limit = Some(200);
}),
)
.await?;
let codex = harness.test().codex.clone();
let responses_mock = responses::mount_sse_sequence(
harness.server(),
vec![
responses::sse(vec![
responses::ev_assistant_message("setup", "REMOTE_SETUP_REPLY"),
responses::ev_completed_with_tokens("setup-response", 60),
]),
responses::sse(vec![
responses::ev_shell_command_call("call-remote-multi-summary", "echo multi-summary"),
responses::ev_completed_with_tokens("r1", 1_000),
]),
],
)
.await;
let compact_mock = responses::mount_compact_user_history_with_summary_sequence(
harness.server(),
vec![
summary_with_prefix("REMOTE_OLDER_SUMMARY"),
summary_with_prefix("REMOTE_LATEST_SUMMARY"),
],
)
.await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "USER_ONE".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
codex.submit(Op::Compact).await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "USER_TWO".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_eq!(compact_mock.requests().len(), 2);
let requests = responses_mock.requests();
assert_eq!(
requests.len(),
2,
"expected setup turn request and second-turn pre-compaction request"
);
let compact_requests = compact_mock.requests();
assert_eq!(
compact_requests.len(),
2,
"expected one setup compact and one mid-turn compact request"
);
let compact_request = compact_requests[1].clone();
assert!(
compact_request.body_contains_text("REMOTE_OLDER_SUMMARY"),
"older summary should round-trip from conversation history into the next compact request"
);
insta::assert_snapshot!(
"remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes",
format_labeled_requests_snapshot(
"Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.",
&[
(
"Second Turn Request (Before Mid-Turn Compaction)",
&requests[1]
),
("Remote Compaction Request", &compact_request),
]
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_request_shape_remote_manual_compact_without_previous_user_messages() -> Result<()>
{
skip_if_no_network!(Ok(()));
@@ -1620,10 +1939,6 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess
)
.await;
let compact_mock =
responses::mount_compact_json_once(harness.server(), serde_json::json!({ "output": [] }))
.await;
codex.submit(Op::Compact).await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
@@ -1638,21 +1953,12 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_eq!(
compact_mock.requests().len(),
1,
"current behavior still issues remote compaction for manual /compact without prior user"
);
let compact_request = compact_mock.single_request();
let follow_up_request = responses_mock.single_request();
insta::assert_snapshot!(
"remote_manual_compact_without_prev_user_shapes",
format_labeled_requests_snapshot(
"Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.",
&[
("Remote Compaction Request", &compact_request),
("Remote Post-Compaction History Layout", &follow_up_request),
]
"Remote manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.",
&[("Remote Post-Compaction History Layout", &follow_up_request),]
)
);

View File

@@ -200,24 +200,11 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
&fork_arr[..compact_arr.len()]
);
let expected_model = requests[0]["model"]
.as_str()
.unwrap_or_default()
.to_string();
let prompt = requests[0]["instructions"]
.as_str()
.unwrap_or_default()
.to_string();
let permissions_message = requests[0]["input"][0].clone();
let user_instructions = requests[0]["input"][1]["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string();
let environment_context = requests[0]["input"][2]["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string();
let tool_calls = json!(requests[0]["tools"].as_array());
assert_eq!(requests.len(), 5);
let expected_model = requests[0]["model"].as_str();
for request in &requests {
assert_eq!(request["model"].as_str(), expected_model);
}
let prompt_cache_key = requests[0]["prompt_cache_key"]
.as_str()
.unwrap_or_default()
@@ -226,433 +213,46 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
.as_str()
.unwrap_or_default()
.to_string();
assert_ne!(
prompt_cache_key, fork_prompt_cache_key,
"forked request should use a new prompt cache key"
);
let summary_after_compact = extract_summary_message(&requests[2], SUMMARY_TEXT);
let summary_after_resume = extract_summary_message(&requests[3], SUMMARY_TEXT);
let summary_after_fork = extract_summary_message(&requests[4], SUMMARY_TEXT);
let user_turn_1 = json!(
{
"model": expected_model,
"instructions": prompt,
"input": [
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_context
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "hello world"
}
]
}
],
"tools": tool_calls,
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"store": false,
"stream": true,
"include": [
"reasoning.encrypted_content"
],
"prompt_cache_key": prompt_cache_key
});
let compact_1 = json!(
{
"model": expected_model,
"instructions": prompt,
"input": [
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_context
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "hello world"
}
]
},
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "FIRST_REPLY"
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": SUMMARIZATION_PROMPT
}
]
}
],
"tools": [],
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"store": false,
"stream": true,
"include": [
"reasoning.encrypted_content"
],
"prompt_cache_key": prompt_cache_key
});
let user_turn_2_after_compact = json!(
{
"model": expected_model,
"instructions": prompt,
"input": [
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_context
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "hello world"
}
]
},
summary_after_compact,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "AFTER_COMPACT"
}
]
}
],
"tools": tool_calls,
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"store": false,
"stream": true,
"include": [
"reasoning.encrypted_content"
],
"prompt_cache_key": prompt_cache_key
});
let usert_turn_3_after_resume = json!(
{
"model": expected_model,
"instructions": prompt,
"input": [
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_context
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "hello world"
}
]
},
summary_after_resume,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "AFTER_COMPACT"
}
]
},
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "AFTER_COMPACT_REPLY"
}
]
},
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_context
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "AFTER_RESUME"
}
]
}
],
"tools": tool_calls,
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"store": false,
"stream": true,
"include": [
"reasoning.encrypted_content"
],
"prompt_cache_key": prompt_cache_key
});
let user_turn_3_after_fork = json!(
{
"model": expected_model,
"instructions": prompt,
"input": [
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_context
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "hello world"
}
]
},
summary_after_fork,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "AFTER_COMPACT"
}
]
},
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "AFTER_COMPACT_REPLY"
}
]
},
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_context
}
]
},
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_context
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "AFTER_FORK"
}
]
}
],
"tools": tool_calls,
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"store": false,
"stream": true,
"include": [
"reasoning.encrypted_content"
],
"prompt_cache_key": fork_prompt_cache_key
});
let mut expected = json!([
user_turn_1,
compact_1,
user_turn_2_after_compact,
usert_turn_3_after_resume,
user_turn_3_after_fork
]);
normalize_line_endings(&mut expected);
if let Some(arr) = expected.as_array_mut() {
normalize_compact_prompts(arr);
for summary in [
&summary_after_compact,
&summary_after_resume,
&summary_after_fork,
] {
assert_eq!(summary["role"].as_str(), Some("user"));
assert!(
summary["content"][0]["text"]
.as_str()
.unwrap_or_default()
.contains(SUMMARY_TEXT),
"summary message should include compacted summary text"
);
}
assert_eq!(requests.len(), 5);
assert_eq!(json!(requests), expected);
let request_2_body = requests[2].to_string();
assert!(
request_2_body.contains("\"text\":\"AFTER_COMPACT\""),
"post-compact request should include AFTER_COMPACT"
);
let request_3_body = requests[3].to_string();
assert!(
request_3_body.contains("\"text\":\"AFTER_RESUME\""),
"post-resume request should include AFTER_RESUME"
);
let request_4_body = requests[4].to_string();
assert!(
request_4_body.contains("\"text\":\"AFTER_FORK\""),
"post-fork request should include AFTER_FORK"
);
assert!(
!request_4_body.contains("\"text\":\"AFTER_RESUME\""),
"forked request should not include resumed-branch user input"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -725,118 +325,32 @@ async fn compact_resume_after_second_compaction_preserves_history() {
compact_filtered.as_slice(),
&resume_filtered[..compact_filtered.len()]
);
// hard coded test
let prompt = requests[0]["instructions"]
.as_str()
.unwrap_or_default()
.to_string();
let permissions_message = requests[0]["input"][0].clone();
let user_instructions = requests[0]["input"][1]["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string();
let environment_instructions = requests[0]["input"][2]["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string();
// Build expected final request input: initial context + forked user message +
// compacted summary + post-compact user message + resumed user message.
// Final resumed request should include the fork branch history, the second compaction
// summary, and the resumed-again user message.
let summary_after_second_compact =
extract_summary_message(&requests[requests.len() - 3], SUMMARY_TEXT);
let mut expected = json!([
{
"instructions": prompt,
"input": [
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "AFTER_FORK"
}
]
},
summary_after_second_compact,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "AFTER_COMPACT_2"
}
]
},
permissions_message,
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": user_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": environment_instructions
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "AFTER_SECOND_RESUME"
}
]
}
],
}
]);
normalize_line_endings(&mut expected);
let mut last_request_after_2_compacts = json!([{
"instructions": requests[requests.len() -1]["instructions"],
"input": requests[requests.len() -1]["input"],
}]);
if let Some(arr) = expected.as_array_mut() {
normalize_compact_prompts(arr);
}
if let Some(arr) = last_request_after_2_compacts.as_array_mut() {
normalize_compact_prompts(arr);
}
assert_eq!(expected, last_request_after_2_compacts);
assert_eq!(summary_after_second_compact["role"].as_str(), Some("user"));
assert!(
summary_after_second_compact["content"][0]["text"]
.as_str()
.unwrap_or_default()
.contains(SUMMARY_TEXT),
"second compaction summary should include compacted summary text"
);
let last_request_after_two_compacts = &requests[requests.len() - 1];
let last_request_body = last_request_after_two_compacts.to_string();
assert!(
last_request_body.contains("\"text\":\"AFTER_FORK\""),
"last request should retain fork-branch user message"
);
assert!(
last_request_body.contains("\"text\":\"AFTER_COMPACT_2\""),
"last request should include post-second-compaction user message"
);
assert!(
last_request_body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\"")),
"last request should include resumed-again user message"
);
}
fn normalize_line_endings(value: &mut Value) {

View File

@@ -115,6 +115,94 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn settings_only_empty_turn_persists_updates_for_next_non_empty_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let resp_mock = mount_sse_sequence(
&server,
vec![sse_completed("resp-1"), sse_completed("resp-2")],
)
.await;
let mut builder = test_codex().with_model("gpt-5.2-codex");
let test = builder.build(&server).await?;
let model = test.session_configured.model.clone();
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "first".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
// Settings-only turn with no user message.
test.codex
.submit(Op::UserTurn {
items: Vec::new(),
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: model.clone(),
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await?;
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "after settings-only turn".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model,
effort: test.config.model_reasoning_effort,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let requests = resp_mock.requests();
assert_eq!(
requests.len(),
2,
"expected only first and third turns to hit the model"
);
let third_turn_request = requests.last().expect("expected third turn request");
let developer_texts = third_turn_request.message_input_texts("developer");
assert!(
developer_texts
.iter()
.any(|text| text.contains("sandbox_mode` is `danger-full-access`")),
"expected danger-full-access permissions update in next non-empty turn: {developer_texts:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_and_personality_change_only_appends_model_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -13,9 +13,9 @@ Scenario: Manual /compact with prior user history compacts existing history and
05:message/user:<SUMMARIZATION_PROMPT>
## Local Post-Compaction History Layout
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:first manual turn
04:message/user:<COMPACTION_SUMMARY>\nFIRST_MANUAL_SUMMARY
00:message/user:first manual turn
01:message/user:<COMPACTION_SUMMARY>\nFIRST_MANUAL_SUMMARY
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:<AGENTS_MD>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
05:message/user:second manual turn

View File

@@ -1,18 +1,11 @@
---
source: core/tests/suite/compact.rs
expression: "format_labeled_requests_snapshot(\"Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.\",\n&[(\"Local Compaction Request\", &requests[0]),\n(\"Local Post-Compaction History Layout\", &requests[1]),])"
expression: "format_labeled_requests_snapshot(\"Manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.\",\n&[(\"Local Post-Compaction History Layout\", &requests[0]),])"
---
Scenario: Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.
## Local Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:<SUMMARIZATION_PROMPT>
Scenario: Manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.
## Local Post-Compaction History Layout
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:<COMPACTION_SUMMARY>\nMANUAL_EMPTY_SUMMARY
04:message/user:AFTER_MANUAL_EMPTY_COMPACT
03:message/user:AFTER_MANUAL_EMPTY_COMPACT

View File

@@ -1,13 +1,14 @@
---
source: core/tests/suite/compact.rs
expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Local Compaction Request (Incoming User Excluded)\", &requests[1]),])"
expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction context-window failure: compaction request includes the incoming user message and the turn errors.\",\n&[(\"Local Compaction Request (Incoming User Included)\", &requests[1]),])"
---
Scenario: Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.
Scenario: Pre-turn auto-compaction context-window failure: compaction request includes the incoming user message and the turn errors.
## Local Compaction Request (Incoming User Excluded)
## Local Compaction Request (Incoming User Included)
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:USER_ONE
04:message/assistant:FIRST_REPLY
05:message/user:<SUMMARIZATION_PROMPT>
05:message/user:USER_TWO
06:message/user:<SUMMARIZATION_PROMPT>

View File

@@ -1,8 +1,8 @@
---
source: core/tests/suite/compact.rs
expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])"
expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])"
---
Scenario: Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.
Scenario: Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.
## Local Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
@@ -13,13 +13,14 @@ Scenario: Pre-turn auto-compaction with a context override emits the context dif
05:message/user:USER_TWO
06:message/assistant:SECOND_REPLY
07:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
08:message/user:<SUMMARIZATION_PROMPT>
08:message/user:<image> | <input_image:image_url> | </image> | USER_THREE
09:message/user:<SUMMARIZATION_PROMPT>
## Local Post-Compaction History Layout
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
03:message/user:USER_ONE
04:message/user:USER_TWO
05:message/user:<COMPACTION_SUMMARY>\nPRE_TURN_SUMMARY
06:message/user:<image> | <input_image:image_url> | </image> | USER_THREE
00:message/user:USER_ONE
01:message/user:USER_TWO
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:<AGENTS_MD>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
05:message/user:<image> | <input_image:image_url> | </image> | USER_THREE
06:message/user:<COMPACTION_SUMMARY>\nPRE_TURN_SUMMARY

View File

@@ -0,0 +1,32 @@
---
source: core/tests/suite/compact.rs
expression: "format_labeled_requests_snapshot(\"Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming <model_switch> from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])"
---
Scenario: Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming <model_switch> from the compact request and restores it in the post-compaction follow-up request.
## Initial Request (Previous Model)
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/user:BEFORE_SWITCH_USER
## Local Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/user:BEFORE_SWITCH_USER
05:message/assistant:BEFORE_SWITCH_REPLY
06:message/user:AFTER_SWITCH_USER
07:message/user:<SUMMARIZATION_PROMPT>
## Local Post-Compaction History Layout
00:message/user:BEFORE_SWITCH_USER
01:message/developer:<PERMISSIONS_INSTRUCTIONS>
02:message/developer:<personality_spec> The user has requested a new communication st...
03:message/user:<AGENTS_MD>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
05:message/user:AFTER_SWITCH_USER
06:message/user:<COMPACTION_SUMMARY>\nPRETURN_SWITCH_SUMMARY
07:message/developer:<model_switch>\nThe user was previously using a different model....

View File

@@ -13,9 +13,9 @@ Scenario: Remote manual /compact where remote compact output is summary-only: fo
04:message/assistant:FIRST_REMOTE_REPLY
## Remote Post-Compaction History Layout
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:REMOTE_COMPACTED_SUMMARY
04:compaction:encrypted=true
00:message/user:REMOTE_COMPACTED_SUMMARY
01:compaction:encrypted=true
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:<AGENTS_MD>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
05:message/user:after compact

View File

@@ -1,13 +1,8 @@
---
source: core/tests/suite/compact_remote.rs
expression: "format_labeled_requests_snapshot(\"Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &follow_up_request),])"
expression: "format_labeled_requests_snapshot(\"Remote manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.\",\n&[(\"Remote Post-Compaction History Layout\", &follow_up_request),])"
---
Scenario: Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.
## Remote Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
Scenario: Remote manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.
## Remote Post-Compaction History Layout
00:message/developer:<PERMISSIONS_INSTRUCTIONS>

View File

@@ -0,0 +1,21 @@
---
source: core/tests/suite/compact_remote.rs
expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.\",\n&[(\"Second Turn Request (Before Mid-Turn Compaction)\", &requests[1]),\n(\"Remote Compaction Request\", &compact_request),])"
---
Scenario: Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.
## Second Turn Request (Before Mid-Turn Compaction)
00:message/user:USER_ONE
01:message/user:<COMPACTION_SUMMARY>\nREMOTE_OLDER_SUMMARY
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:<AGENTS_MD>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
05:message/user:<COMPACTION_SUMMARY>\nREMOTE_LATEST_SUMMARY
06:message/user:USER_TWO
## Remote Compaction Request
00:message/user:USER_ONE
01:message/developer:<PERMISSIONS_INSTRUCTIONS>
02:message/user:<AGENTS_MD>
03:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
04:message/user:<COMPACTION_SUMMARY>\nREMOTE_OLDER_SUMMARY

View File

@@ -13,8 +13,8 @@ Scenario: Remote mid-turn continuation compaction after tool output: compact req
05:function_call_output:unsupported call: test_tool
## Remote Post-Compaction History Layout
00:message/user:USER_ONE
01:message/developer:<PERMISSIONS_INSTRUCTIONS>
02:message/user:<AGENTS_MD>
03:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:USER_ONE
04:message/user:<COMPACTION_SUMMARY>\nREMOTE_MID_TURN_SUMMARY

View File

@@ -0,0 +1,19 @@
---
source: core/tests/suite/compact_remote.rs
expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])"
---
Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.
## Remote Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:USER_ONE
04:function_call/test_tool
05:function_call_output:unsupported call: test_tool
## Remote Post-Compaction History Layout
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:<COMPACTION_SUMMARY>\nREMOTE_SUMMARY_ONLY

View File

@@ -1,10 +1,18 @@
---
source: core/tests/suite/compact_remote.rs
expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&include_attempt_request),])"
expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction context-window failure: initial compaction includes incoming user content, fallback compaction excludes incoming user content, and the turn errors.\",\n&[(\"Remote Compaction Request (Incoming User Included)\",\n&include_attempt_request),\n(\"Remote Compaction Request (Fallback Incoming User Excluded)\",\n&fallback_attempt_request),])"
---
Scenario: Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.
Scenario: Remote pre-turn auto-compaction context-window failure: initial compaction includes incoming user content, fallback compaction excludes incoming user content, and the turn errors.
## Remote Compaction Request (Incoming User Excluded)
## Remote Compaction Request (Incoming User Included)
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:USER_ONE
04:message/assistant:REMOTE_FIRST_REPLY
05:message/user:USER_TWO
## Remote Compaction Request (Fallback Incoming User Excluded)
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>

View File

@@ -1,12 +1,13 @@
---
source: core/tests/suite/compact_remote.rs
expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&first_compact_mock.single_request()),])"
expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction parse failure: compaction request includes incoming user content and the turn stops.\",\n&[(\"Remote Compaction Request (Incoming User Included)\",\n&first_compact_mock.single_request()),])"
---
Scenario: Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.
Scenario: Remote pre-turn auto-compaction parse failure: compaction request includes incoming user content and the turn stops.
## Remote Compaction Request (Incoming User Excluded)
## Remote Compaction Request (Incoming User Included)
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:turn that exceeds token threshold
04:message/assistant:initial turn complete
05:message/user:turn that triggers auto compact

View File

@@ -1,8 +1,8 @@
---
source: core/tests/suite/compact_remote.rs
expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])"
expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])"
---
Scenario: Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.
Scenario: Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.
## Remote Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
@@ -13,6 +13,7 @@ Scenario: Remote pre-turn auto-compaction with a context override emits the cont
05:message/user:USER_TWO
06:message/assistant:REMOTE_SECOND_REPLY
07:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
08:message/user:USER_THREE
## Remote Post-Compaction History Layout
00:message/user:USER_ONE
@@ -20,5 +21,5 @@ Scenario: Remote pre-turn auto-compaction with a context override emits the cont
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:<AGENTS_MD>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
05:message/user:<COMPACTION_SUMMARY>\nREMOTE_PRE_TURN_SUMMARY
06:message/user:USER_THREE
05:message/user:USER_THREE
06:message/user:<COMPACTION_SUMMARY>\nREMOTE_PRE_TURN_SUMMARY

View File

@@ -0,0 +1,29 @@
---
source: core/tests/suite/compact_remote.rs
expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch strips incoming <model_switch> from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])"
---
Scenario: Remote pre-turn compaction during model switch strips incoming <model_switch> from the compact request and restores it in the post-compaction follow-up request.
## Initial Request (Previous Model)
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:BEFORE_SWITCH_USER
## Remote Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:BEFORE_SWITCH_USER
04:message/assistant:BEFORE_SWITCH_REPLY
05:message/user:AFTER_SWITCH_USER
## Remote Post-Compaction History Layout
00:message/user:BEFORE_SWITCH_USER
01:message/developer:<PERMISSIONS_INSTRUCTIONS>
02:message/developer:<personality_spec> The user has requested a new communication st...
03:message/user:<AGENTS_MD>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
05:message/user:AFTER_SWITCH_USER
06:message/user:<COMPACTION_SUMMARY>\nREMOTE_SWITCH_SUMMARY
07:message/developer:<model_switch>\nThe user was previously using a different model....