Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 37 additions & 14 deletions crates/tui/src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool {
user_text_of(message).is_some_and(|text| is_compaction_summary_text(&text))
}

fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
message
.content
.iter()
Expand Down Expand Up @@ -340,14 +340,14 @@ pub fn estimate_tokens(messages: &[Message]) -> usize {
.sum()
}

fn message_has_tool_use(message: &Message) -> bool {
pub(crate) fn message_has_tool_use(message: &Message) -> bool {
message
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
}

pub fn estimate_text_tokens_conservative(text: &str) -> usize {
pub(crate) fn estimate_text_tokens_conservative(text: &str) -> usize {
text.chars().count().div_ceil(3)
}

Expand Down Expand Up @@ -455,10 +455,18 @@ pub fn compaction_pressure_reached_with_billed(
if !config.enabled {
return false;
}
let estimated = estimate_input_tokens_for_pressure(messages, system_prompt);
let billed = billed_input_tokens
.and_then(|tokens| usize::try_from(tokens).ok())
.unwrap_or(0);
// Billing alone proving pressure short-circuits the walk (#perf-r5):
// `estimated.max(billed) >= threshold` is unconditionally true when
// `billed >= threshold`, so estimating cannot change the answer and the
// O(transcript) pass is skipped. Over-pressure sessions pay this check
// multiple times per step (pressure gate + decision re-check).
if billed >= config.token_threshold {
return true;
}
let estimated = estimate_input_tokens_for_pressure(messages, system_prompt);
estimated.max(billed) >= config.token_threshold
}

Expand Down Expand Up @@ -525,14 +533,26 @@ pub fn compaction_decision_with_billed(
if !config.enabled {
return CompactionDecision::NotNeeded;
}
if !compaction_pressure_reached_with_billed(
messages,
system_prompt,
config,
billed_input_tokens,
) {
return CompactionDecision::NotNeeded;
}
// Pressure gate + prune projection share one estimate (#perf-r5): both
// consume `estimate_input_tokens_for_pressure` over the same
// `(messages, system_prompt)`, a pure function, so it is computed at
// most once. `billed >= threshold` proves pressure without estimating
// (max is unconditionally >= threshold then); the estimate is deferred
// until something actually needs it — the prune projection below — so
// the billed-corner still reaches the TooFew and RetainedFloor guards
// unchanged, and skips the walk entirely when no prune candidates exist.
let billed = billed_input_tokens
.and_then(|tokens| usize::try_from(tokens).ok())
.unwrap_or(0);
let estimated: Option<usize> = if billed < config.token_threshold {
let estimate = estimate_input_tokens_for_pressure(messages, system_prompt);
if estimate.max(billed) < config.token_threshold {
return CompactionDecision::NotNeeded;
}
Some(estimate)
} else {
None
};

// The execution path mechanically prunes old verbose tool results before
// asking the model for a summary. Local pruning alone may be enough to
Expand All @@ -542,9 +562,12 @@ pub fn compaction_decision_with_billed(
// without cloning a multi-megabyte transcript on every step.
let prune_plan = plan_tool_result_prunes(messages, KEEP_RECENT_MESSAGES);
if !prune_plan.is_empty() {
let estimate = match estimated {
Some(value) => value,
None => estimate_input_tokens_for_pressure(messages, system_prompt),
};
let reclaimed_tokens: usize = prune_plan.iter().map(PlannedPrune::tokens_reclaimed).sum();
let projected = estimate_input_tokens_for_pressure(messages, system_prompt)
.saturating_sub(reclaimed_tokens);
let projected = estimate.saturating_sub(reclaimed_tokens);
if projected < config.token_threshold {
return CompactionDecision::Compact;
}
Expand Down
48 changes: 38 additions & 10 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2837,6 +2837,14 @@ impl Engine {
.push(crate::compaction::compaction_checkpoint_message(checkpoint));
}
self.session.messages = restored_messages.into();
// Direct field assignment bypasses `add_message` /
// `replace_messages`, which own the messages-revision
// bump the token-estimate cache keys on (#perf-r5).
// Without this bump the first estimate after a
// session restore is computed against whatever
// history revision was current before the sync — a
// stale number can flow into capacity checkpoints.
self.session.bump_messages_revision();
self.session.compaction_summary_prompt = compaction_checkpoint;
self.session.system_prompt =
crate::compaction::strip_compaction_summaries(system_prompt.as_ref());
Expand Down Expand Up @@ -3112,17 +3120,37 @@ impl Engine {
current_text: &str,
system_prompt: Option<&SystemPrompt>,
) -> usize {
let mut messages: Vec<Message> = self.session.messages.clone().into();
if !current_text.trim().is_empty() {
messages.push(Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: current_text.to_string(),
cache_control: None,
}],
});
// Estimate the installed history IN PLACE — no full-transcript clone
// per `<turn_meta>` build (#perf-r5). `&AppendLog` deref-coerces to
// `&[Message]` exactly like the cache call site.
let base = estimate_input_tokens_conservative(&self.session.messages, system_prompt);
if current_text.trim().is_empty() {
return base;
}
// Arithmetic equivalent of pushing one more user message: `own`
// un-inflated tokens (Text block rule, `len()/4` — same as the
// estimator's per-message byte sum S) plus one framing increment.
// The estimator inflates S by ceil(3/2) as a WHOLE, so
// ceil((S+own)*3/2) − ceil(S*3/2) = floor(own*3/2) + 1 exactly when
// S is even and own is odd; pinned exhaustively (80k pairs) and per
// case by `context_pressure_delta_matches_clone_and_push_reference`.
let sum: usize = self
.session
.messages
.iter()
.map(|m| {
crate::compaction::estimate_tokens_for_message(
m,
crate::compaction::message_has_tool_use(m),
)
})
.sum();
let own = current_text.len() / 4;
let mut inflated_delta = own * 3 / 2;
if sum % 2 == 0 && own % 2 == 1 {
inflated_delta += 1;
}
estimate_input_tokens_conservative(&messages, system_prompt)
base.saturating_add(inflated_delta).saturating_add(12)
}

fn append_resource_metadata_lines(
Expand Down
208 changes: 208 additions & 0 deletions crates/tui/src/core/engine/preview/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,214 @@ fn turn_metadata_uses_planned_cross_route_limits_not_installed_limits() {
assert!(!metadata.contains("4096 tokens"), "{metadata}");
}

/// #perf-r5: the pressure-line helper must estimate the history IN PLACE and
/// add the composer text arithmetically. Guards two things at once:
///
/// 1. Equivalence — the arithmetic form must equal the naive
/// "clone + push + estimate" reference for non-trivial inputs (Unicode
/// multi-byte content included, since Text blocks count *chars* for the
/// conservative estimator but the delta path counts... the same rule as
/// `estimate_tokens_for_message`: bytes/4).
/// 2. The contract that empty/no-op composer text costs nothing extra.
#[test]
fn context_pressure_delta_matches_clone_and_push_reference() {
let config = deepseek_config();
let (mut engine, _handle, _tmp) = preview_engine(&config);
engine.api_provider = ApiProvider::Deepseek;
let installed_limits = codewhale_config::route::RouteLimits {
context_tokens: Some(64_000),
input_tokens: None,
output_tokens: Some(512),
};
engine.active_route_limits = Some(installed_limits);
// Multi-byte content on purpose: chars().count() != len() here, so an
// arity mistake between the byte rule (estimator) would surface.
engine.session.messages.push(Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "héllo wörld — ünïcode ✓ ".repeat(500),
cache_control: None,
}],
});
engine.session.messages.push(Message {
role: Role::Assistant,
content: vec![ContentBlock::Thinking {
thinking: "step".repeat(100),
signature: None,
state: None,
}],
});
// Replayed-reasoning case (#perf-r5 fresh-eyes fix): an assistant message
// carrying BOTH thinking and a tool call keeps its reasoning content in
// every subsequent request — the estimator counts those bytes, and this
// was the exact arm the delta helper originally missed. Both parity
// variants of the thinking byte-count are exercised below.
engine.session.messages.push(Message {
role: Role::Assistant,
content: vec![
ContentBlock::Thinking {
thinking: "replayed".repeat(300), // 8 bytes per unit -> even count
signature: None,
state: None,
},
ContentBlock::ToolUse {
id: "call_1".to_string(),
name: "bash".to_string(),
input: json!({"command": "echo hello"}),
caller: None,
thought_signature: None,
},
],
});
engine.session.messages.push(Message {
role: Role::Assistant,
content: vec![
ContentBlock::Thinking {
thinking: "odd replay".to_string(), // 11 bytes / 4 = 2 (even)... use odd total
signature: None,
state: None,
},
ContentBlock::ToolUse {
id: "call_2".to_string(),
name: "read".to_string(),
input: json!({"path": "x"}), // 13-byte JSON -> 3
caller: None,
thought_signature: None,
},
],
});
let _prompt_context = NextTurnPromptContext::for_planned_turn(
ApiProvider::Deepseek,
"deepseek-v4-flash".to_string(),
Some(installed_limits),
AppMode::Agent,
None,
GoalStatus::Active,
None,
false,
None,
);
let _ = &_prompt_context;

// Naive reference implementation: clone the transcript, push a
// hypothetical user message, run the full conservative estimator.
let reference = |engine: &Engine, text: &str| -> usize {
let mut messages: Vec<Message> =
crate::prompt_zones::AppendLog::clone(&engine.session.messages).into();
if !text.trim().is_empty() {
messages.push(Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
});
}
crate::compaction::estimate_input_tokens_conservative(&messages, None)
};

for text in [
"",
" ",
"short",
"a much longer composer draft with punctuation…",
] {
let via_pressure_line_input = engine.active_input_tokens_with_current_text(text, None);
assert_eq!(
via_pressure_line_input,
reference(&engine, text),
"delta arithmetic diverged from clone+push+estimate for {text:?}"
);
}
}

/// #perf-r5 guard: billed input above the threshold must report pressure with
/// a provably-empty history — proving the short-circuit answers from billing
/// alone without consulting message contents.
#[test]
fn billed_pressure_above_threshold_answers_from_billing_alone() {
let config = CompactionConfig {
enabled: true,
token_threshold: 1_000,
..Default::default()
};
let pressure = crate::compaction::compaction_pressure_reached_with_billed(
&[], // empty history: only billing can prove pressure
None,
&config,
Some(2_000),
);
assert!(pressure, "billed 2000 >= threshold 1000 must be pressure");
}

/// #perf-r5 guard: under-threshold billing keeps the old max() semantics —
/// an estimate above the trigger still fires even when billing is quiet.
#[test]
fn billed_below_threshold_still_fires_on_estimate() {
let config = CompactionConfig {
enabled: true,
token_threshold: 100,
..Default::default()
};
let big = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "x".repeat(4 * 200),
cache_control: None,
}],
};
let pressure = crate::compaction::compaction_pressure_reached_with_billed(
std::slice::from_ref(&big),
None,
&config,
Some(10), // below threshold; must not short-circuit to false either
);
assert!(pressure, "estimate 200 (+1.0 framing) >= 100 must fire");
}

/// #perf-r5 guard: a direct `session.messages` overwrite (the SyncSession
/// restore path) must advance `messages_revision` so the token-estimate
/// cache invalidates instead of serving the pre-sync value.
#[test]
fn sync_restore_bumps_messages_revision_for_estimate_cache() {
use crate::core::engine::token_estimate_cache::TokenEstimateCache;

let config = deepseek_config();
let (mut engine, _handle, _tmp) = preview_engine(&config);
engine.session.add_message(Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "before restore".to_string(),
cache_control: None,
}],
});
let revision_before = engine.session.messages_revision;
let mut cache = TokenEstimateCache::new();
let stale = cache.lookup_or_compute(
revision_before,
engine.session.system_prompt.as_ref(),
&engine.session.messages,
);

// Simulate the restore's direct field assignment.
engine.session.messages = Vec::new().into();
engine.session.bump_messages_revision();

assert_ne!(
engine.session.messages_revision, revision_before,
"restore must bump the revision the estimate cache keys on"
);
let fresh = cache.lookup_or_compute(
engine.session.messages_revision,
engine.session.system_prompt.as_ref(),
&engine.session.messages,
);
assert_ne!(
fresh, stale,
"cache must recompute after a restore-driven revision bump"
);
}

#[tokio::test]
async fn compaction_preview_uses_the_planned_routes_system_prompt() {
let config = deepseek_config();
Expand Down
Loading
Loading