Files
codex/codex-rs/core/src/context_manager/updates.rs
T
Charley Cunningham bb0ac5be70 Fix compaction context reinjection and model baselines (#12252)
## Summary
- move regular-turn context diff/full-context persistence into
`run_turn` so pre-turn compaction runs before incoming context updates
are recorded
- after successful pre-turn compaction, rely on a cleared
`reference_context_item` to trigger full context reinjection on the
follow-up regular turn (manual `/compact` keeps replacement history
summary-only and also clears the baseline)
- preserve `<model_switch>` when full context is reinjected, and inject
it *before* the rest of the full-context items
- scope `reference_context_item` and `previous_model` to regular user
turns only so standalone tasks (`/compact`, shell, review, undo) cannot
suppress future reinjection or `<model_switch>` behavior
- make context-diff persistence + `reference_context_item` updates
explicit in the regular-turn path, with clearer docs/comments around the
invariant
- stop persisting local `/compact` `RolloutItem::TurnContext` snapshots
(only regular turns persist `TurnContextItem` now)
- simplify resume/fork previous-model/reference-baseline hydration by
looking up the last surviving turn context from rollout lifecycle
events, including rollback and compaction-crossing handling
- remove the legacy fallback that guessed from bare `TurnContext`
rollouts without lifecycle events
- update compaction/remote-compaction/model-visible snapshots and
compact test assertions (including remote compaction mock response
shape)

## Why
We were persisting incoming context items before spawning the regular
turn task, which let pre-turn compaction requests accidentally include
incoming context diffs without the new user message. Fixing that exposed
follow-on baseline issues around `/compact`, resume/fork, and standalone
tasks that could cause duplicate context injection or suppress
`<model_switch>` instructions.

This PR re-centers the invariants around regular turns:
- regular turns persist model-visible context diffs/full reinjection and
update the `reference_context_item`
- standalone tasks do not advance those regular-turn baselines
- compaction clears the baseline when replacement history may have
stripped the referenced context diffs

## Follow-ups (TODOs left in code)
- `TODO(ccunningham)`: fix rollback/backtracking baseline handling more
comprehensively
- `TODO(ccunningham)`: include pending incoming context items in
pre-turn compaction threshold estimation
- `TODO(ccunningham)`: inject updated personality spec alongside
`<model_switch>` so some model-switch paths can avoid forced full
reinjection
- `TODO(ccunningham)`: review task turn lifecycle
(`TurnStarted`/`TurnComplete`) behavior and emit task-start context
diffs for task types that should have them (excluding `/compact`)

## Validation
- `just fmt`
- CI should cover the updated compaction/resume/model-visible snapshot
expectations and rollout-hydration behavior
- I did **not** rerun the full local test suite after the latest
resume-lookup / rollout-persistence simplifications
2026-02-20 23:13:08 -08:00

150 lines
4.6 KiB
Rust

use crate::codex::TurnContext;
use crate::environment_context::EnvironmentContext;
use crate::shell::Shell;
use codex_execpolicy::Policy;
use codex_protocol::config_types::Personality;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::TurnContextItem;
fn build_environment_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
shell: &Shell,
) -> Option<ResponseItem> {
let prev = previous?;
let prev_context = EnvironmentContext::from_turn_context_item(prev, shell);
let next_context = EnvironmentContext::from_turn_context(next, shell);
if prev_context.equals_except_shell(&next_context) {
return None;
}
Some(ResponseItem::from(
EnvironmentContext::diff_from_turn_context_item(prev, next, shell),
))
}
fn build_permissions_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
exec_policy: &Policy,
) -> Option<ResponseItem> {
let prev = previous?;
if prev.sandbox_policy == next.sandbox_policy && prev.approval_policy == next.approval_policy {
return None;
}
Some(
DeveloperInstructions::from_policy(
&next.sandbox_policy,
next.approval_policy,
exec_policy,
&next.cwd,
)
.into(),
)
}
fn build_collaboration_mode_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
) -> Option<ResponseItem> {
let prev = previous?;
if prev.collaboration_mode.as_ref() != Some(&next.collaboration_mode) {
// If the next mode has empty developer instructions, this returns None and we emit no
// update, so prior collaboration instructions remain in the prompt history.
Some(DeveloperInstructions::from_collaboration_mode(&next.collaboration_mode)?.into())
} else {
None
}
}
fn build_personality_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
personality_feature_enabled: bool,
) -> Option<ResponseItem> {
if !personality_feature_enabled {
return None;
}
let previous = previous?;
if next.model_info.slug != previous.model {
return None;
}
if let Some(personality) = next.personality
&& next.personality != previous.personality
{
let model_info = &next.model_info;
let personality_message = personality_message_for(model_info, personality);
personality_message
.map(|message| DeveloperInstructions::personality_spec_message(message).into())
} else {
None
}
}
pub(crate) fn personality_message_for(
model_info: &ModelInfo,
personality: Personality,
) -> Option<String> {
model_info
.model_messages
.as_ref()
.and_then(|spec| spec.get_personality_message(Some(personality)))
.filter(|message| !message.is_empty())
}
pub(crate) fn build_model_instructions_update_item(
previous_user_turn_model: Option<&str>,
next: &TurnContext,
) -> Option<ResponseItem> {
let previous_model = previous_user_turn_model?;
if previous_model == next.model_info.slug {
return None;
}
let model_instructions = next.model_info.get_model_instructions(next.personality);
if model_instructions.is_empty() {
return None;
}
Some(DeveloperInstructions::model_switch_message(model_instructions).into())
}
pub(crate) fn build_settings_update_items(
previous: Option<&TurnContextItem>,
previous_user_turn_model: Option<&str>,
next: &TurnContext,
shell: &Shell,
exec_policy: &Policy,
personality_feature_enabled: bool,
) -> Vec<ResponseItem> {
let mut update_items = Vec::new();
// Keep model-switch instructions first so model-specific guidance is read before
// any other context diffs on this turn.
if let Some(model_instructions_item) =
build_model_instructions_update_item(previous_user_turn_model, next)
{
update_items.push(model_instructions_item);
}
if let Some(env_item) = build_environment_update_item(previous, next, shell) {
update_items.push(env_item);
}
if let Some(permissions_item) = build_permissions_update_item(previous, next, exec_policy) {
update_items.push(permissions_item);
}
if let Some(collaboration_mode_item) = build_collaboration_mode_update_item(previous, next) {
update_items.push(collaboration_mode_item);
}
if let Some(personality_item) =
build_personality_update_item(previous, next, personality_feature_enabled)
{
update_items.push(personality_item);
}
update_items
}