Compare commits

...

80 Commits

Author SHA1 Message Date
Charles Cunningham
8e6b114df0 Rename /compact start test for no-history semantics 2026-02-18 09:16:00 -08:00
Charles Cunningham
c085499bb0 Avoid dropping historical duplicates in remote echo stripping 2026-02-18 09:13:14 -08:00
Charles Cunningham
ca84df34b8 edge case 2026-02-18 02:22:07 -08:00
Charles Cunningham
6901781b75 Fix clippy doc list formatting for anchor helper 2026-02-18 02:18:19 -08:00
Charles Cunningham
7b4e4b5853 Remove redundant developer-role check in compaction filter 2026-02-18 02:14:54 -08:00
Charles Cunningham
0c5363772b nit 2026-02-18 02:11:25 -08:00
Charles Cunningham
16285c158f Move pre-turn context persistence to run_turn callsite 2026-02-18 02:09:49 -08:00
Charles Cunningham
b66c6e63fd Reuse summary-user helper in compaction anchoring 2026-02-18 01:54:51 -08:00
Charles Cunningham
7476184a22 Document user-anchor behavior for context reinjection 2026-02-18 01:53:05 -08:00
Charles Cunningham
5ae08bc00b Update compaction test for canonical context reinjection 2026-02-18 01:52:29 -08:00
Charles Cunningham
96b88aee15 Reinject context for summary-only mid-turn compaction 2026-02-18 01:50:58 -08:00
Charles Cunningham
0fbc4c76a8 Clarify compaction reinjection rationale comments 2026-02-18 01:42:23 -08:00
Charles Cunningham
4c0ca09d69 Rename compact callsite vars and document reinjection policy 2026-02-18 01:41:37 -08:00
Charles Cunningham
c6d63129c1 Rename compaction callsite enum and exhaustively match reinjection 2026-02-18 01:34:11 -08:00
Charles Cunningham
7ed3d3f9aa Put NotNeeded first in pre-turn compaction outcome match 2026-02-18 01:21:51 -08:00
Charles Cunningham
454fa82281 Use shared real-user matcher for initial-context insertion 2026-02-18 01:21:18 -08:00
Charles Cunningham
223eda12d3 Reinject previous-turn context for pre-sampling model-switch compaction 2026-02-18 01:17:13 -08:00
Charles Cunningham
2ede42d319 Reinsert initial context for mid-turn compaction 2026-02-18 01:00:35 -08:00
Charles Cunningham
3759abaf15 Inline compact merge item matcher 2026-02-18 00:27:44 -08:00
Charles Cunningham
25c22dcefc Unify model-switch persistence through compaction history 2026-02-18 00:26:02 -08:00
Charles Cunningham
a46bcf3b8e Inline compacted history shaping call 2026-02-18 00:23:16 -08:00
Charles Cunningham
714e0b78ba Align compaction tests with always-skip post-layout 2026-02-18 00:08:39 -08:00
Charles Cunningham
d33a1bedd4 Simplify 2026-02-17 23:48:56 -08:00
Charles Cunningham
d58204f9df Always skip reinjection during auto-compaction 2026-02-17 23:29:28 -08:00
Charles Cunningham
583dee056d Refactor remote compaction incoming-item dedup 2026-02-17 23:12:56 -08:00
Charles Cunningham
868019da9b Skip mid-turn compaction error event on interruption 2026-02-17 22:50:48 -08:00
Charles Cunningham
a9c6219d85 Append incoming pre-turn items after compaction summary 2026-02-17 22:43:21 -08:00
Charles Cunningham
0d796c73fa Keep shell command records in compacted history filter 2026-02-17 19:38:39 -08:00
Charles Cunningham
b781a15a42 Keep only user+compaction items in compacted history 2026-02-17 19:35:22 -08:00
Charles Cunningham
6663b690b7 Restrict compacted-history keep set to user and compaction items 2026-02-17 19:23:53 -08:00
Charles Cunningham
80ab62fc68 cleanup 2026-02-17 19:18:59 -08:00
Charles Cunningham
8b290e8707 Drop redundant summary check for incoming compaction items 2026-02-17 19:06:33 -08:00
Charles Cunningham
52e6a54a6d Define real user predicate independent of keep policy 2026-02-17 19:03:04 -08:00
Charles Cunningham
d53b588309 Base incoming compaction filtering on keep predicate 2026-02-17 18:58:35 -08:00
Charles Cunningham
4c2c861e15 Keep shell command records only in incoming compaction items 2026-02-17 18:51:30 -08:00
Charles Cunningham
afebfffc93 Exclude user shell command records from compaction keep set 2026-02-17 18:49:38 -08:00
Charles Cunningham
6e6e718738 Use real user predicate for compaction reinjection 2026-02-17 18:47:38 -08:00
Charles Cunningham
01794f1a46 Reuse compaction keep filter for incoming items 2026-02-17 18:46:04 -08:00
Charles Cunningham
589b0763cc Fix compact test snapshot and include bazel lock update 2026-02-17 18:29:14 -08:00
Charles Cunningham
3964becfde dd comment 2026-02-17 18:29:13 -08:00
Charles Cunningham
15aa101bf6 Qualify Feature enum in compact suite tests 2026-02-17 18:29:13 -08:00
Charles Cunningham
a38b1c9cf5 Move compact reinjection snapshots into suite coverage 2026-02-17 18:29:13 -08:00
Charles Cunningham
04b157bb4c Treat no-op pre-turn compaction as not-needed 2026-02-17 18:29:13 -08:00
Charles Cunningham
907e292ad9 Add comment 2026-02-17 18:29:13 -08:00
Charles Cunningham
3f2f817ab6 Make compact unit snapshots Bazel-stable 2026-02-17 18:29:13 -08:00
Charles Cunningham
ccc270083f Persist pre-turn updates before interrupted auto-compaction exits 2026-02-17 18:29:13 -08:00
Charles Cunningham
f7956ffabc Persist pre-turn updates before interrupted model-switch compact exits 2026-02-17 18:29:13 -08:00
Charles Cunningham
d81d89ac0b Silence interrupted pre-turn compaction failures 2026-02-17 18:29:13 -08:00
Charles Cunningham
7139784970 Strip incoming model-switch updates from remote compaction input 2026-02-17 18:29:13 -08:00
Charles Cunningham
9d34abf954 core: snapshot pre-turn model-switch compaction strip behavior 2026-02-17 18:29:13 -08:00
Charles Cunningham
b5aeb30d59 compact: strip incoming model-switch before compaction 2026-02-17 18:29:13 -08:00
Charles Cunningham
5929f058b0 compact: snapshot reinjection above last summary 2026-02-17 18:29:13 -08:00
Charles Cunningham
466c689766 compact: reinsert context above last summary 2026-02-17 18:29:12 -08:00
Charles Cunningham
b029cc2d01 Reinject context for summary-only compacted history 2026-02-17 18:29:12 -08:00
Charles Cunningham
f832de6371 Document manual compact turn-context reinjection policy 2026-02-17 18:29:12 -08:00
Charles Cunningham
3a04f2c689 Use explicit user-message predicate for compaction filtering 2026-02-17 18:29:12 -08:00
Charles Cunningham
54e606ade3 Comment 2026-02-17 18:29:12 -08:00
Charles Cunningham
de959aafa2 Comments 2026-02-17 18:29:12 -08:00
Charles Cunningham
8595664092 Move pre-turn auto-compact error handling into helper 2026-02-17 18:29:12 -08:00
Charles Cunningham
e3efe3a020 Return sentinel error from model-switch compaction helper 2026-02-17 18:29:12 -08:00
Charles Cunningham
5682f72551 Move previous-model compact failure handling into helper 2026-02-17 18:29:12 -08:00
Charles Cunningham
b7b50d6e98 Add TODO for legacy compacted history context reinjection 2026-02-17 18:29:12 -08:00
Charles Cunningham
9e6ef13d4d Add comment 2026-02-17 18:29:12 -08:00
Charles Cunningham
ce7777801d Enable compaction tests and remove stale TODO markers 2026-02-17 18:29:12 -08:00
Charles Cunningham
d9b02b6a9e Differentiate model-switch compaction failures from oversize input 2026-02-17 18:29:12 -08:00
Charles Cunningham
bddd53bae5 Rebase: align compaction snapshots and imports 2026-02-17 18:29:11 -08:00
Charles Cunningham
c92190f130 Avoid wildcard pattern in ResponseEvent::Completed match 2026-02-17 18:29:11 -08:00
Charles Cunningham
15730c2fcb Align compaction tests with incoming-item and empty-history behavior 2026-02-17 18:29:11 -08:00
Charles Cunningham
8b2f5066a5 update snaps 2026-02-17 18:29:11 -08:00
Charles Cunningham
2a804d9f4d Update snaps 2026-02-17 18:29:11 -08:00
Charles Cunningham
59d3431a15 Fix clippy useless_vec in compact_remote test 2026-02-17 18:29:11 -08:00
Charles Cunningham
36499b59f0 Reinject canonical context for model-switch pre-turn compaction 2026-02-17 18:29:11 -08:00
Charles Cunningham
8d06dd5f8b Persist pre-turn updates before apps tool-list cancellation 2026-02-17 18:29:11 -08:00
Charles Cunningham
e87455e284 codex: persist pre-turn updates when compaction fails 2026-02-17 18:29:11 -08:00
Charles Cunningham
4022de74ef compact: preserve incoming items during pre-turn trim retries 2026-02-17 18:29:11 -08:00
Charles Cunningham
e0f99ebaa4 compact: split core logic changes from snapshot test coverage 2026-02-17 18:29:11 -08:00
Charles Cunningham
5c389f9f20 Update pre-sampling model-switch compaction snapshot 2026-02-17 18:29:11 -08:00
Charles Cunningham
c7cef7dab1 update snapshot 2026-02-17 18:29:10 -08:00
Charles Cunningham
a79bebe5ca Simplify 2026-02-17 18:29:10 -08:00
Charles Cunningham
2d4123c267 Fix model switch compaction 2026-02-17 18:29:10 -08:00
28 changed files with 2157 additions and 1517 deletions

View File

@@ -199,15 +199,10 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -> Result<()> {
async fn thread_compact_start_without_history_emits_started_and_completed_items() -> Result<()> {
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

@@ -19,6 +19,7 @@ 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::CompactCallsite;
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 +127,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;
@@ -2385,11 +2387,11 @@ impl Session {
history.replace(replacement.clone());
} else {
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,15 +2404,6 @@ impl Session {
history.raw_items().to_vec()
}
pub(crate) async fn process_compacted_history(
&self,
turn_context: &TurnContext,
compacted_history: Vec<ResponseItem>,
) -> Vec<ResponseItem> {
let initial_context = self.build_initial_context(turn_context).await;
compact::process_compacted_history(compacted_history, &initial_context)
}
/// Append ResponseItems to the in-memory conversation history only.
pub(crate) async fn record_into_history(
&self,
@@ -2499,6 +2492,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()
@@ -3400,19 +3398,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);
@@ -4241,6 +4249,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> {
@@ -4250,6 +4259,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(),
@@ -4257,14 +4269,51 @@ 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 pre_turn_compaction_outcome = match run_pre_turn_auto_compaction_if_needed(
&sess,
&turn_context,
auto_compact_limit,
&incoming_turn_items,
)
.await
{
Ok(outcome) => outcome,
Err(()) => {
if !pre_turn_context_items.is_empty() {
// Preserve model-visible settings updates even when pre-turn compaction fails
// before turn input persistence can run.
sess.record_conversation_items(&turn_context, &pre_turn_context_items)
.await;
}
// 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
@@ -4371,12 +4420,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;
@@ -4479,7 +4522,21 @@ 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,
CompactCallsite::MidTurnContinuation,
None,
)
.await
{
if matches!(err, CodexErr::Interrupted) {
return None;
}
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;
@@ -4581,40 +4638,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(());
};
@@ -4637,19 +4670,229 @@ 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,
CompactCallsite::PreSamplingModelSwitch,
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,
/// but is planned to be trained on in the future.
#[cfg(test)]
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::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::CompactedWithIncomingItems => {
// Pre-turn compaction includes incoming items only for the compaction request itself.
// Persist canonical turn context directly above the incoming user item so context
// applies to the latest user message in post-compaction history.
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;
}
// TODO(ccunningham): Followup PR will use compacting excluding incoming items as a fallback
// (even though it is out of distribution for current models).
// Also future models may prefer compacting pre-turn history without incoming turn items.
#[cfg(test)]
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.
///
/// 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],
) -> 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);
}
match run_auto_compact(
sess,
turn_context,
CompactCallsite::PreTurnIncludingIncomingUserMessage,
Some(incoming_turn_items.to_vec()),
)
.await
{
Ok(()) => {
// 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)
}
Err(err) => {
if matches!(err, CodexErr::Interrupted) {
return Err(());
}
let event = match err {
CodexErr::ContextWindowExceeded => {
error!(
turn_id = %turn_context.sub_id,
compact_callsite = ?CompactCallsite::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;
Err(())
}
}
}
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>,
compact_callsite: CompactCallsite,
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),
compact_callsite,
incoming_items,
)
.await
} else {
run_inline_auto_compact_task(
Arc::clone(sess),
Arc::clone(turn_context),
compact_callsite,
incoming_items,
)
.await
};
if let Err(err) = &result {
error!(
turn_id = %turn_context.sub_id,
compact_callsite = ?compact_callsite,
compact_error = %err,
"auto compaction failed"
);
}
result
}
fn collect_explicit_app_ids_from_skill_items(
@@ -5596,9 +5839,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;
@@ -5829,6 +6072,239 @@ 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_persists_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::CompactedWithIncomingItems,
&stale_pre_turn_context_items,
&input,
response_item.clone(),
)
.await;
let actual = session.clone_history().await.raw_items().to_vec();
let mut expected = session.build_initial_context(turn_context.as_ref()).await;
expected.push(response_item);
assert_eq!(actual, expected);
}
#[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(),
@@ -8083,8 +8559,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(),
@@ -8126,8 +8602,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(),

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,18 @@ use std::sync::Arc;
use crate::Prompt;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::compact::CompactCallsite;
use crate::compact::extract_latest_model_switch_update_from_items;
use crate::compact::extract_trailing_model_switch_update_for_compaction_request;
use crate::compact::insert_initial_context_before_last_user_anchor;
use crate::compact::process_compacted_history;
use crate::compact::should_keep_compacted_history_item;
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 +32,10 @@ use tracing::info;
pub(crate) async fn run_inline_remote_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
compact_callsite: CompactCallsite,
incoming_items: Option<Vec<ResponseItem>>,
) -> CodexResult<()> {
run_remote_compact_task_inner(&sess, &turn_context).await?;
run_remote_compact_task_inner(&sess, &turn_context, compact_callsite, incoming_items).await?;
Ok(())
}
@@ -41,18 +50,25 @@ 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, CompactCallsite::ManualCompact, None).await
}
async fn run_remote_compact_task_inner(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
compact_callsite: CompactCallsite,
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, compact_callsite, incoming_items)
.await
{
error!(
turn_id = %turn_context.sub_id,
compact_callsite = ?compact_callsite,
compact_error = %err,
"remote compaction task failed"
);
sess.send_event(turn_context, event).await;
return Err(err);
}
Ok(())
@@ -61,24 +77,42 @@ async fn run_remote_compact_task_inner(
async fn run_remote_compact_task_inner_impl(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
compact_callsite: CompactCallsite,
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(),
);
let historical_items_before_incoming = history.raw_items().to_vec();
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(());
}
if deleted_items > 0 {
info!(
turn_id = %turn_context.sub_id,
compact_callsite = ?compact_callsite,
deleted_items,
"trimmed history items before remote compaction"
);
@@ -115,6 +149,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,
compact_callsite,
&compact_request_log_data,
total_usage_breakdown,
&err,
@@ -122,11 +157,38 @@ async fn run_remote_compact_task_inner_impl(
Err(err)
})
.await?;
new_history = sess
.process_compacted_history(turn_context, new_history)
.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.
new_history = process_compacted_history(new_history);
match compact_callsite {
CompactCallsite::MidTurnContinuation | CompactCallsite::PreSamplingModelSwitch => {
// These callsites do not get a later post-compaction canonical-context write in
// `run_turn`, so replacement history must carry canonical context directly.
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
insert_initial_context_before_last_user_anchor(&mut new_history, initial_context);
}
CompactCallsite::ManualCompact => {
// Manual `/compact` intentionally rewrites transcript history without reseeding turn
// context here; the task marks initial context unseeded for the next user turn.
}
CompactCallsite::PreTurnIncludingIncomingUserMessage
| CompactCallsite::PreTurnExcludingIncomingUserMessage => {
// Pre-turn compaction persists canonical context directly above the incoming user
// message in `run_turn`, not inside compacted replacement history.
}
}
if let Some(incoming_items) = incoming_items.as_ref() {
let incoming_history_items: Vec<ResponseItem> = incoming_items
.iter()
.filter(|item| should_keep_compacted_history_item(item))
.cloned()
.collect();
remove_incoming_echoes_from_compacted_history(
&mut new_history,
&incoming_history_items,
&historical_items_before_incoming,
);
}
// Reattach stripped model-switch updates into replacement history so post-compaction
// sampling keeps model-switch guidance regardless of compaction callsite.
if let Some(model_switch_item) = stripped_model_switch_item {
new_history.push(model_switch_item);
}
@@ -171,14 +233,70 @@ fn build_compact_request_log_data(
}
}
/// Remote compaction may echo incoming items in `new_history`. Because incoming items are
/// appended after compaction at the caller, remove one semantic duplicate for each incoming item
/// so turn-context/user entries do not appear twice.
fn remove_incoming_echoes_from_compacted_history(
new_history: &mut Vec<ResponseItem>,
incoming_history_items: &[ResponseItem],
historical_items_before_incoming: &[ResponseItem],
) {
let items_match =
|candidate: &ResponseItem, incoming_item: &ResponseItem| match (candidate, incoming_item) {
(
ResponseItem::Message {
role: candidate_role,
content: candidate_content,
..
},
ResponseItem::Message {
role: incoming_role,
content: incoming_content,
..
},
) => candidate_role == incoming_role && candidate_content == incoming_content,
(
ResponseItem::Compaction {
encrypted_content: candidate_content,
},
ResponseItem::Compaction {
encrypted_content: incoming_content,
},
) => candidate_content == incoming_content,
_ => candidate == incoming_item,
};
for incoming_item in incoming_history_items {
let historical_count = historical_items_before_incoming
.iter()
.filter(|candidate| items_match(candidate, incoming_item))
.count();
let compacted_count = new_history
.iter()
.filter(|candidate| items_match(candidate, incoming_item))
.count();
if compacted_count <= historical_count {
continue;
}
if let Some(index) = new_history
.iter()
.rposition(|candidate| items_match(candidate, incoming_item))
{
new_history.remove(index);
}
}
}
fn log_remote_compact_failure(
turn_context: &TurnContext,
compact_callsite: CompactCallsite,
log_data: &CompactRequestLogData,
total_usage_breakdown: TotalTokenUsageBreakdown,
err: &CodexErr,
) {
error!(
turn_id = %turn_context.sub_id,
compact_callsite = ?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 +312,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 +358,139 @@ 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,
}
}
fn summary_user_message(text: &str) -> ResponseItem {
user_message(&format!("{}\n{text}", crate::compact::SUMMARY_PREFIX))
}
#[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")]);
}
#[test]
fn remove_incoming_echoes_preserves_historical_duplicates_when_not_echoed() {
let mut compacted_history = vec![
user_message("REPEAT_MESSAGE"),
summary_user_message("LATEST_SUMMARY"),
];
let incoming_items = vec![user_message("REPEAT_MESSAGE")];
let historical_items_before_incoming = vec![user_message("REPEAT_MESSAGE")];
remove_incoming_echoes_from_compacted_history(
&mut compacted_history,
&incoming_items,
&historical_items_before_incoming,
);
let expected = vec![
user_message("REPEAT_MESSAGE"),
summary_user_message("LATEST_SUMMARY"),
];
assert_eq!(compacted_history, expected);
}
#[test]
fn remove_incoming_echoes_removes_net_new_echoes_only() {
let mut compacted_history = vec![
user_message("REPEAT_MESSAGE"),
user_message("REPEAT_MESSAGE"),
summary_user_message("LATEST_SUMMARY"),
];
let incoming_items = vec![user_message("REPEAT_MESSAGE")];
let historical_items_before_incoming = vec![user_message("REPEAT_MESSAGE")];
remove_incoming_echoes_from_compacted_history(
&mut compacted_history,
&incoming_items,
&historical_items_before_incoming,
);
let expected = vec![
user_message("REPEAT_MESSAGE"),
summary_user_message("LATEST_SUMMARY"),
];
assert_eq!(compacted_history, expected);
}
}

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,
)

File diff suppressed because it is too large Load Diff

View File

@@ -579,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()
),]
)
@@ -930,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(()));
@@ -1054,11 +1051,15 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()>
)
});
if has_compacted_user_summary && has_compaction_item && has_compacted_assistant_note {
if has_compacted_user_summary && has_compaction_item {
assert!(
!has_permissions_developer_message,
"manual remote compact rollout replacement history should not inject permissions context"
);
assert!(
!has_compacted_assistant_note,
"manual remote compact rollout replacement history should drop assistant notes"
);
saw_compacted_history = true;
break;
}
@@ -1310,7 +1311,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(()));
@@ -1390,13 +1390,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")
@@ -1506,8 +1513,8 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model
let post_compact_turn_request = post_compact_turn_request_mock.single_request();
let compact_body = compact_request.body_json().to_string();
assert!(
!compact_body.contains("AFTER_SWITCH_USER"),
"current behavior excludes incoming user from the pre-turn remote compaction request"
compact_body.contains("AFTER_SWITCH_USER"),
"pre-turn remote compaction request should include incoming user message"
);
assert!(
!compact_body.contains("<model_switch>"),
@@ -1521,7 +1528,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model
);
assert!(
follow_up_body.contains("AFTER_SWITCH_USER"),
"post-compaction follow-up should preserve incoming user message via runtime append"
"post-compaction follow-up should preserve incoming user message"
);
assert!(
follow_up_body.contains("<model_switch>"),
@@ -1531,7 +1538,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model
insta::assert_snapshot!(
"remote_pre_turn_compaction_strips_incoming_model_switch_shapes",
format_labeled_requests_snapshot(
"Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming <model_switch> from the compact request payload, and restores it in the post-compaction follow-up request.",
"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)", &initial_turn_request),
("Remote Compaction Request", &compact_request),
@@ -1547,8 +1554,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model
}
#[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_context_window_exceeded() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1718,8 +1723,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinjects_context()
-> Result<()> {
async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_layout() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = TestCodexHarness::with_builder(
@@ -1784,9 +1788,9 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject
let compact_request = compact_mock.single_request();
let post_compact_turn_request = post_compact_turn_request_mock.single_request();
insta::assert_snapshot!(
"remote_mid_turn_compaction_summary_only_reinjects_context_shapes",
"remote_mid_turn_compaction_summary_only_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 mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before the latest summary.",
&[
("Remote Compaction Request", &compact_request),
(
@@ -1801,8 +1805,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary()
-> Result<()> {
async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_layout() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = TestCodexHarness::with_builder(
@@ -1891,7 +1894,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec
"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",
"remote_mid_turn_compaction_multi_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.",
&[
@@ -1908,7 +1911,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec
}
#[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_manual_compact_without_previous_user_messages() -> Result<()>
{
skip_if_no_network!(Ok(()));
@@ -1928,10 +1930,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;
@@ -1946,21 +1944,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,6 +1,6 @@
---
source: core/tests/suite/compact.rs
assertion_line: 2646
assertion_line: 2434
expression: "format_labeled_requests_snapshot(\"True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.\",\n&[(\"Local Compaction Request\", &auto_compact_mock.single_request()),\n(\"Local Post-Compaction History Layout\",\n&post_auto_compact_mock.single_request()),])"
---
Scenario: True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.

View File

@@ -1,6 +1,5 @@
---
source: core/tests/suite/compact.rs
assertion_line: 1773
expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])"
---
Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.

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
00:message/user:USER_ONE
01:message/user:USER_TWO
02:message/user:<COMPACTION_SUMMARY>\nPRE_TURN_SUMMARY
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/user:<AGENTS_MD>
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
06:message/user:<image> | <input_image:image_url> | </image> | USER_THREE

View File

@@ -1,6 +1,6 @@
---
source: core/tests/suite/compact.rs
assertion_line: 3152
assertion_line: 2949
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.
@@ -19,14 +19,15 @@ Scenario: Pre-turn compaction during model switch (without pre-sampling model-sw
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/user:BEFORE_SWITCH_USER
05:message/assistant:BEFORE_SWITCH_REPLY
06:message/user:<SUMMARIZATION_PROMPT>
06:message/user:AFTER_SWITCH_USER
07:message/user:<SUMMARIZATION_PROMPT>
## Local Post-Compaction History Layout
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/developer:<personality_spec> The user has requested a new communication st...
02:message/user:<AGENTS_MD>
03:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
04:message/user:BEFORE_SWITCH_USER
05:message/user:<COMPACTION_SUMMARY>\nPRETURN_SWITCH_SUMMARY
06:message/developer:<model_switch>\nThe user was previously using a different model....
00:message/user:BEFORE_SWITCH_USER
01:message/user:<COMPACTION_SUMMARY>\nPRETURN_SWITCH_SUMMARY
02:message/developer:<model_switch>\nThe user was previously using a different model....
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/developer:<personality_spec> The user has requested a new communication st...
05:message/user:<AGENTS_MD>
06:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
07:message/user:AFTER_SWITCH_USER

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

@@ -1,21 +1,23 @@
---
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),])"
assertion_line: 1896
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)\", &second_turn_request),\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
02:message/user:<COMPACTION_SUMMARY>\nREMOTE_LATEST_SUMMARY
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/user:<AGENTS_MD>
05:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
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
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:USER_TWO

View File

@@ -1,5 +1,6 @@
---
source: core/tests/suite/compact_remote.rs
assertion_line: 1711
expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])"
---
Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.
@@ -13,8 +14,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

@@ -1,8 +1,8 @@
---
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]),])"
expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before the latest summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])"
---
Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.
Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before the latest summary.
## Remote Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>

View File

@@ -10,3 +10,4 @@ Scenario: Remote pre-turn auto-compaction context-window failure: compaction req
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:USER_ONE
04:message/assistant:REMOTE_FIRST_REPLY
05:message/user:USER_TWO

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,12 +13,13 @@ 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
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:<COMPACTION_SUMMARY>\nREMOTE_PRE_TURN_SUMMARY
02:message/user:<COMPACTION_SUMMARY>\nREMOTE_PRE_TURN_SUMMARY
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/user:<AGENTS_MD>
05:message/user:<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>
06:message/user:USER_THREE

View File

@@ -1,8 +1,9 @@
---
source: core/tests/suite/compact_remote.rs
expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming <model_switch> from the compact request payload, 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]),])"
assertion_line: 1538
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)\", &initial_turn_request),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])"
---
Scenario: Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming <model_switch> from the compact request payload, and restores it in the post-compaction follow-up request.
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>
@@ -16,13 +17,14 @@ Scenario: Remote pre-turn compaction during model switch currently excludes inco
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:<COMPACTION_SUMMARY>\nREMOTE_SWITCH_SUMMARY
06:message/developer:<model_switch>\nThe user was previously using a different model....
01:message/user:<COMPACTION_SUMMARY>\nREMOTE_SWITCH_SUMMARY
02:message/developer:<model_switch>\nThe user was previously using a different model....
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/developer:<personality_spec> The user has requested a new communication st...
05:message/user:<AGENTS_MD>
06:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
07:message/user:AFTER_SWITCH_USER