Skip to content
Closed
27 changes: 24 additions & 3 deletions codex-rs/core/src/context_manager/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,32 @@ impl ContextManager {
ResponseItem::Reasoning {
encrypted_content: Some(content),
..
}
| ResponseItem::Compaction {
encrypted_content: content,
} => {
let reasoning_bytes = estimate_reasoning_length(content.len());
i64::try_from(approx_tokens_from_byte_count(reasoning_bytes))
.unwrap_or(i64::MAX)
}
ResponseItem::Compaction { encrypted_content } => {
// `compaction_summary.encrypted_content` is an opaque server artifact and does
// not represent user-controllable prompt text. Counting its full encoded
// length grossly overestimates prompt size and can incorrectly pin the UI to
// `0% context left` immediately after a successful `/compact`.
//
// Keep a small bounded estimate for the payload to avoid making auto-compaction
// decisions wildly optimistic while still reflecting that the compacted summary
// item exists in the transcript.
let compaction_tokens = i64::try_from(approx_tokens_from_byte_count(
estimate_reasoning_length(encrypted_content.len()),
))
.unwrap_or(i64::MAX);
let cap_tokens = turn_context
.client
.get_model_context_window()
.map(|window| window.saturating_div(COMPACTION_ENCRYPTED_TOKENS_FRACTION))
.unwrap_or(MAX_COMPACTION_ENCRYPTED_TOKENS_ESTIMATE)
.clamp(0, MAX_COMPACTION_ENCRYPTED_TOKENS_ESTIMATE);
compaction_tokens.min(cap_tokens)
}
item => {
let serialized = serde_json::to_string(item).unwrap_or_default();
i64::try_from(approx_token_count(&serialized)).unwrap_or(i64::MAX)
Expand Down Expand Up @@ -332,6 +350,9 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize {
.saturating_sub(650)
}

const COMPACTION_ENCRYPTED_TOKENS_FRACTION: i64 = 10;
const MAX_COMPACTION_ENCRYPTED_TOKENS_ESTIMATE: i64 = 25_000;

pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
let ResponseItem::Message { role, content, .. } = item else {
return false;
Expand Down
93 changes: 93 additions & 0 deletions codex-rs/core/src/context_manager/history_tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
use super::*;
use crate::client::ModelClient;
use crate::config::types::ShellEnvironmentPolicy;
use crate::features::Features;
use crate::models_manager::manager::ModelsManager;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::ToolsConfigParams;
use crate::truncate;
use crate::truncate::TruncationPolicy;
use codex_git::GhostCommit;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
Expand All @@ -10,8 +18,14 @@ use codex_protocol::models::LocalShellExecAction;
use codex_protocol::models::LocalShellStatus;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_utils_readiness::ReadinessFlag;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use std::path::PathBuf;
use std::sync::Arc;

const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
const EXEC_FORMAT_MAX_TOKENS: usize = 2_500;
Expand Down Expand Up @@ -81,6 +95,61 @@ fn reasoning_with_encrypted_content(len: usize) -> ResponseItem {
}
}

fn test_turn_context() -> TurnContext {
let mut config = crate::config::test_config();
config.model_context_window = Some(200_000);
let config = Arc::new(config);
let model = "gpt-5.2".to_string();
let model_info = ModelsManager::construct_model_info_offline(&model, config.as_ref());
let conversation_id = ThreadId::default();
let otel_manager = OtelManager::new(
conversation_id,
model_info.slug.as_str(),
model_info.slug.as_str(),
None,
None,
None,
false,
"test".to_string(),
SessionSource::Cli,
);
let client = ModelClient::new(
Arc::clone(&config),
None,
model_info.clone(),
otel_manager,
config.model_provider.clone(),
None,
Default::default(),
conversation_id,
SessionSource::Cli,
);
let features = Features::with_defaults();
TurnContext {
sub_id: "test".to_string(),
client,
cwd: PathBuf::from("/"),
developer_instructions: None,
compact_prompt: None,
user_instructions: None,
personality: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
shell_environment_policy: ShellEnvironmentPolicy::default(),
tools_config: ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: config.web_search_mode,
}),
ghost_snapshot: Default::default(),
final_output_json_schema: None,
codex_linux_sandbox_exe: None,
tool_call_gate: Arc::new(ReadinessFlag::new()),
truncation_policy: TruncationPolicy::Tokens(10_000),
dynamic_tools: vec![],
}
}

fn truncate_exec_output(content: &str) -> String {
truncate::truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS))
}
Expand Down Expand Up @@ -172,6 +241,30 @@ fn get_history_for_prompt_drops_ghost_commits() {
assert_eq!(filtered, vec![]);
}

#[test]
fn estimate_token_count_does_not_pin_context_left_to_zero_after_compaction() {
let turn_context = test_turn_context();
let history = create_history_with_items(vec![
user_input_text_msg("REMOTE_COMPACTED_SUMMARY"),
ResponseItem::Compaction {
encrypted_content: "a".repeat(2_000_000),
},
]);

let estimated = history
.estimate_token_count(&turn_context)
.expect("expected token estimate");
let context_window = turn_context
.client
.get_model_context_window()
.expect("expected context window");

assert!(
estimated < context_window,
"expected estimated tokens ({estimated}) to be less than context window ({context_window})"
);
}

#[test]
fn remove_first_item_removes_matching_output_for_function_call() {
let items = vec![
Expand Down